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))