From 2d535addd0c664a4f369168bd19090d7045a5a10 Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Fri, 13 Feb 2026 20:52:02 +0100 Subject: [PATCH] Add real-time chord detection and visualization mode Implemented chroma-based major/minor chord detection in AudioAnalyzer and exposed results in AudioFeatures. Updated HUD to display detected chord names. Added a new "CHORDS" fractal preset that visualizes the current chord with large pixel text, modulated by confidence and beat. Extended pixel font for chord display. Ensured graceful handling when no chord is detected. These changes enhance both analysis and visual feedback for harmonic content. --- FractalVisualizer/Audio/AudioAnalyzer.cs | 94 +++++++++++++++++++ FractalVisualizer/Game1.cs | 23 ++++- FractalVisualizer/Models/AudioFeatures.cs | 3 + .../Rendering/FractalRenderer.cs | 75 ++++++++++++++- 4 files changed, 188 insertions(+), 7 deletions(-) diff --git a/FractalVisualizer/Audio/AudioAnalyzer.cs b/FractalVisualizer/Audio/AudioAnalyzer.cs index 5a55acb..e42c736 100644 --- a/FractalVisualizer/Audio/AudioAnalyzer.cs +++ b/FractalVisualizer/Audio/AudioAnalyzer.cs @@ -19,6 +19,7 @@ public sealed class AudioAnalyzer : IDisposable private readonly Complex[] _fftBuffer; private readonly float[] _spectrumAccumulator; private readonly int[] _spectrumAccumulatorCounts; + private readonly float[] _chroma = new float[12]; private readonly Stopwatch _clock = new(); private readonly object _lifecycleSync = new(); @@ -38,6 +39,8 @@ public sealed class AudioAnalyzer : IDisposable private bool _beatGateOpen = true; private readonly Queue _beatIntervals = new(); private const int MaxBeatIntervals = 8; + private int _lastChordRoot = -1; + private int _lastChordType = -1; private Snapshot _latestSnapshot; private bool _disposed; @@ -170,6 +173,7 @@ public sealed class AudioAnalyzer : IDisposable float highEnergy = 0f; float dominantMagnitude = 0f; float dominantFrequency = 0f; + Array.Clear(_chroma); for (int bin = 1; bin < half; bin++) { @@ -203,6 +207,15 @@ public sealed class AudioAnalyzer : IDisposable dominantMagnitude = magnitude; dominantFrequency = frequency; } + + if (frequency >= 60f && frequency <= 5000f) + { + int pitchClass = GetNoteIndexFromFrequency(frequency); + if (pitchClass >= 0) + { + _chroma[pitchClass] += magnitude; + } + } } float[] spectrum = new float[_spectrumBins]; @@ -258,6 +271,7 @@ public sealed class AudioAnalyzer : IDisposable int noteIndex = GetNoteIndexFromFrequency(dominantFrequency); float dominantStrength = Clamp01(dominantMagnitude * 0.02f); + DetectChord(_chroma, out int chordRoot, out int chordType, out float chordConfidence); AudioFeatures features = new() { @@ -270,6 +284,9 @@ public sealed class AudioAnalyzer : IDisposable DominantFrequencyHz = dominantFrequency, DetectedNoteIndex = noteIndex, DetectedNoteStrength = dominantStrength, + DetectedChordRootIndex = chordRoot, + DetectedChordType = chordType, + DetectedChordConfidence = chordConfidence, Spectrum = spectrum, TimeSeconds = now }; @@ -350,6 +367,83 @@ public sealed class AudioAnalyzer : IDisposable return note; } + private void DetectChord(float[] chroma, out int root, out int type, out float confidence) + { + float total = 0f; + for (int i = 0; i < chroma.Length; i++) + { + total += chroma[i]; + } + + if (total <= 1e-6f) + { + root = -1; + type = -1; + confidence = 0f; + return; + } + + float bestScore = 0f; + int bestRoot = -1; + int bestType = -1; + for (int r = 0; r < 12; r++) + { + float major = ScoreChord(chroma, r, 0); + if (major > bestScore) + { + bestScore = major; + bestRoot = r; + bestType = 0; + } + + float minor = ScoreChord(chroma, r, 1); + if (minor > bestScore) + { + bestScore = minor; + bestRoot = r; + bestType = 1; + } + } + + float conf = Clamp01(bestScore / (total + 1e-6f)); + if (conf < 0.17f) + { + root = -1; + type = -1; + confidence = conf; + return; + } + + if (_lastChordRoot >= 0 && _lastChordType >= 0) + { + float held = ScoreChord(chroma, _lastChordRoot, _lastChordType); + if ((bestRoot != _lastChordRoot || bestType != _lastChordType) && held * 1.08f > bestScore) + { + bestRoot = _lastChordRoot; + bestType = _lastChordType; + bestScore = held; + conf = Clamp01(bestScore / (total + 1e-6f)); + } + } + + _lastChordRoot = bestRoot; + _lastChordType = bestType; + root = bestRoot; + type = bestType; + confidence = conf; + } + + private static float ScoreChord(float[] chroma, int root, int type) + { + int third = type == 0 ? (root + 4) % 12 : (root + 3) % 12; + int fifth = (root + 7) % 12; + int second = (root + 2) % 12; + int fourth = (root + 5) % 12; + float score = chroma[root] * 1.0f + chroma[third] * 0.9f + chroma[fifth] * 0.8f; + score -= (chroma[second] + chroma[fourth]) * 0.15f; + return Math.Max(0f, score); + } + private static float Clamp01(float value) { if (value < 0f) diff --git a/FractalVisualizer/Game1.cs b/FractalVisualizer/Game1.cs index 2f11c9c..426e775 100644 --- a/FractalVisualizer/Game1.cs +++ b/FractalVisualizer/Game1.cs @@ -145,7 +145,7 @@ public class Game1 : Game if (IsPressed(keyboard, Keys.Right)) { - _fractalPresetIndex = Math.Min(20, _fractalPresetIndex + 1); + _fractalPresetIndex = Math.Min(21, _fractalPresetIndex + 1); } if (IsPressed(keyboard, Keys.M)) @@ -195,10 +195,11 @@ public class Game1 : Game _hudFont.DrawText(_spriteBatch, _pixel, "BEAT " + _latestFeatures.Beat.ToString("0.000"), 12, 130, 2, Color.White); _hudFont.DrawText(_spriteBatch, _pixel, "BPM " + _latestFeatures.Bpm.ToString("0.0"), 12, 150, 2, Color.White); _hudFont.DrawText(_spriteBatch, _pixel, "NOTE " + GetDetectedNoteName(_latestFeatures.DetectedNoteIndex), 12, 170, 2, Color.White); - _hudFont.DrawText(_spriteBatch, _pixel, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 190, 2, Color.White); - _hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 210, 2, Color.White); - _hudFont.DrawText(_spriteBatch, _pixel, "QUALITY " + _renderQuality + " 1 2 3", 12, 230, 2, Color.White); - _hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE", 12, 250, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "CHORD " + GetDetectedChordName(_latestFeatures.DetectedChordRootIndex, _latestFeatures.DetectedChordType), 12, 190, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 210, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 230, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "QUALITY " + _renderQuality + " 1 2 3", 12, 250, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE", 12, 270, 2, Color.White); _spriteBatch.End(); } @@ -249,6 +250,7 @@ public class Game1 : Game 18 => "RIBBON", 19 => "GRAINBARS", 20 => "NOTEDROP", + 21 => "CHORDS", _ => "JULIA" }; } @@ -273,6 +275,17 @@ public class Game1 : Game }; } + private static string GetDetectedChordName(int rootIndex, int chordType) + { + string root = GetDetectedNoteName(rootIndex); + if (root == "-") + { + return "-"; + } + + return chordType == 1 ? root + " MIN" : root + " MAJ"; + } + private void ToggleFullScreen() { _graphics.ToggleFullScreen(); diff --git a/FractalVisualizer/Models/AudioFeatures.cs b/FractalVisualizer/Models/AudioFeatures.cs index 76ef26f..fd5be7d 100644 --- a/FractalVisualizer/Models/AudioFeatures.cs +++ b/FractalVisualizer/Models/AudioFeatures.cs @@ -11,6 +11,9 @@ public struct AudioFeatures public float DominantFrequencyHz; public int DetectedNoteIndex; public float DetectedNoteStrength; + public int DetectedChordRootIndex; + public int DetectedChordType; + public float DetectedChordConfidence; public float[] Spectrum; public float TimeSeconds; diff --git a/FractalVisualizer/Rendering/FractalRenderer.cs b/FractalVisualizer/Rendering/FractalRenderer.cs index 684b89d..ef72bba 100644 --- a/FractalVisualizer/Rendering/FractalRenderer.cs +++ b/FractalVisualizer/Rendering/FractalRenderer.cs @@ -26,7 +26,7 @@ public sealed class FractalRenderer : IDisposable [ "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" ]; - private static readonly Dictionary NoteGlyphs = new() + private static readonly Dictionary TextGlyphs = new() { ['A'] = ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], ['B'] = ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], @@ -35,12 +35,20 @@ public sealed class FractalRenderer : IDisposable ['E'] = ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], ['F'] = ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], ['G'] = ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + ['I'] = ["01110", "00100", "00100", "00100", "00100", "00100", "01110"], + ['J'] = ["00001", "00001", "00001", "00001", "10001", "10001", "01110"], + ['M'] = ["10001", "11011", "10101", "10001", "10001", "10001", "10001"], + ['N'] = ["10001", "11001", "10101", "10011", "10001", "10001", "10001"], ['#'] = ["01010", "11111", "01010", "01010", "11111", "01010", "00000"] + ,['-'] = ["00000", "00000", "00000", "01110", "00000", "00000", "00000"] + ,[' '] = ["00000", "00000", "00000", "00000", "00000", "00000", "00000"] }; private float _noteSpawnAccumulator; private float _detectedNoteAccumulator; private float _fireworkSpawnAccumulator; + private int _lastChordCode = -1; + private float _chordFlash; private float _runtimePerformanceScale = 1f; private RenderQuality _quality = RenderQuality.Medium; private int _lastViewportWidth; @@ -223,6 +231,9 @@ public sealed class FractalRenderer : IDisposable case 20: RenderDetectedNotes(features, timeSeconds); break; + case 21: + RenderChordCenter(features, timeSeconds); + break; default: RenderJulia(features, timeSeconds); break; @@ -1220,6 +1231,47 @@ public sealed class FractalRenderer : IDisposable } } + private void RenderChordCenter(AudioFeatures features, float timeSeconds) + { + for (int y = 0; y < _internalHeight; y++) + { + float t = y / (float)(_internalHeight - 1); + Color bg = new(0.02f + 0.03f * t, 0.02f + 0.02f * t, 0.05f + 0.08f * (1f - t), 1f); + int row = y * _internalWidth; + for (int x = 0; x < _internalWidth; x++) + { + _pixelBuffer[row + x] = bg; + } + } + + int chordCode = features.DetectedChordRootIndex < 0 ? -1 : (features.DetectedChordRootIndex * 2 + (features.DetectedChordType == 1 ? 1 : 0)); + if (chordCode != _lastChordCode) + { + _lastChordCode = chordCode; + _chordFlash = 1f; + } + else + { + _chordFlash *= 0.94f; + } + + string chordText = GetChordText(features); + float strength = Clamp01(features.DetectedChordConfidence * 1.8f); + int scale = 4 + (int)MathF.Round(strength * 5f + _chordFlash * 2f); + int textWidth = chordText.Length * 6 * scale; + int x0 = (_internalWidth - textWidth) / 2; + int y0 = (_internalHeight - 7 * scale) / 2; + + float pulse = 0.25f + _chordFlash * 0.45f + Clamp01(features.Beat) * 0.2f; + Vector3 textColor = new( + Clamp01(0.45f + strength * 0.55f + pulse * 0.2f), + Clamp01(0.5f + strength * 0.45f), + Clamp01(0.8f + pulse * 0.3f)); + + DrawRect(x0 - 8, y0 - 8, textWidth + 16, 7 * scale + 16, new Vector3(0.08f, 0.1f, 0.16f), 0.8f); + DrawTextPixels(chordText, x0, y0, scale, textColor, 0.95f); + } + private void SpawnDetectedNote(AudioFeatures features) { if (features.DetectedNoteIndex < 0 || features.DetectedNoteIndex > 11) @@ -1270,7 +1322,7 @@ public sealed class FractalRenderer : IDisposable private void DrawGlyph(char c, int x, int y, int scale, Vector3 color, float alpha) { - if (!NoteGlyphs.TryGetValue(c, out string[]? glyph)) + if (!TextGlyphs.TryGetValue(c, out string[]? glyph)) { return; } @@ -1290,6 +1342,25 @@ public sealed class FractalRenderer : IDisposable } } + private void DrawTextPixels(string text, int x, int y, int scale, Vector3 color, float alpha) + { + for (int i = 0; i < text.Length; i++) + { + DrawGlyph(text[i], x + i * (6 * scale), y, scale, color, alpha); + } + } + + private static string GetChordText(AudioFeatures features) + { + if (features.DetectedChordRootIndex < 0 || features.DetectedChordRootIndex > 11) + { + return "--"; + } + + string root = NoteNames[features.DetectedChordRootIndex]; + return features.DetectedChordType == 1 ? root + " MIN" : root + " MAJ"; + } + private void SpawnFireworkRocket(float intensity) { if (_fireworkRockets.Count >= ScaleCount(42, 12))