From 6a1bdfb935c928291da6a9b1660096eb6e96ac27 Mon Sep 17 00:00:00 2001 From: Michal Vrba Date: Fri, 13 Feb 2026 20:43:29 +0100 Subject: [PATCH] Add real-time note detection and NOTEDROP visualization Implemented musical note detection in AudioAnalyzer, exposing dominant note and strength in AudioFeatures. Updated HUD to display detected note. Added "NOTEDROP" fractal preset that animates detected notes as colored glyphs. Extended bitmap font to support sharps. Increased fractal preset count and adjusted HUD layout. --- FractalVisualizer/Audio/AudioAnalyzer.cs | 46 ++++++ FractalVisualizer/Game1.cs | 32 +++- FractalVisualizer/Models/AudioFeatures.cs | 3 + .../Rendering/FractalRenderer.cs | 144 ++++++++++++++++++ FractalVisualizer/UI/HudBitmapFont.cs | 1 + 5 files changed, 221 insertions(+), 5 deletions(-) diff --git a/FractalVisualizer/Audio/AudioAnalyzer.cs b/FractalVisualizer/Audio/AudioAnalyzer.cs index e449038..5a55acb 100644 --- a/FractalVisualizer/Audio/AudioAnalyzer.cs +++ b/FractalVisualizer/Audio/AudioAnalyzer.cs @@ -168,6 +168,8 @@ public sealed class AudioAnalyzer : IDisposable float bassEnergy = 0f; float midEnergy = 0f; float highEnergy = 0f; + float dominantMagnitude = 0f; + float dominantFrequency = 0f; for (int bin = 1; bin < half; bin++) { @@ -195,6 +197,12 @@ public sealed class AudioAnalyzer : IDisposable { highEnergy += magnitude; } + + if (frequency >= 80f && frequency <= 2000f && magnitude > dominantMagnitude) + { + dominantMagnitude = magnitude; + dominantFrequency = frequency; + } } float[] spectrum = new float[_spectrumBins]; @@ -248,6 +256,9 @@ public sealed class AudioAnalyzer : IDisposable float bpmInstant = CalculateBpm(); _smoothedBpm = Smooth(_smoothedBpm, bpmInstant, 0.18f, 0.04f); + int noteIndex = GetNoteIndexFromFrequency(dominantFrequency); + float dominantStrength = Clamp01(dominantMagnitude * 0.02f); + AudioFeatures features = new() { Level = _smoothedLevel, @@ -256,6 +267,9 @@ public sealed class AudioAnalyzer : IDisposable High = _smoothedHigh, Beat = _smoothedBeat, Bpm = _smoothedBpm, + DominantFrequencyHz = dominantFrequency, + DetectedNoteIndex = noteIndex, + DetectedNoteStrength = dominantStrength, Spectrum = spectrum, TimeSeconds = now }; @@ -319,6 +333,38 @@ public sealed class AudioAnalyzer : IDisposable return Math.Clamp(bpm, 40f, 220f); } + private static int GetNoteIndexFromFrequency(float frequencyHz) + { + if (frequencyHz <= 0f) + { + return -1; + } + + float midi = 69f + (12f * MathF.Log2(frequencyHz / 440f)); + int note = ((int)MathF.Round(midi)) % 12; + if (note < 0) + { + note += 12; + } + + return note; + } + + private static float Clamp01(float value) + { + if (value < 0f) + { + return 0f; + } + + if (value > 1f) + { + return 1f; + } + + return value; + } + private void ThrowIfDisposed() { if (_disposed) diff --git a/FractalVisualizer/Game1.cs b/FractalVisualizer/Game1.cs index 89026f3..2f11c9c 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(19, _fractalPresetIndex + 1); + _fractalPresetIndex = Math.Min(20, _fractalPresetIndex + 1); } if (IsPressed(keyboard, Keys.M)) @@ -194,10 +194,11 @@ public class Game1 : Game _hudFont.DrawText(_spriteBatch, _pixel, "HIGH " + _latestFeatures.High.ToString("0.000"), 12, 110, 2, Color.White); _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, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 170, 2, Color.White); - _hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 190, 2, Color.White); - _hudFont.DrawText(_spriteBatch, _pixel, "QUALITY " + _renderQuality + " 1 2 3", 12, 210, 2, Color.White); - _hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE", 12, 230, 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); _spriteBatch.End(); } @@ -247,10 +248,31 @@ public class Game1 : Game 17 => "MOSAIC", 18 => "RIBBON", 19 => "GRAINBARS", + 20 => "NOTEDROP", _ => "JULIA" }; } + private static string GetDetectedNoteName(int noteIndex) + { + return noteIndex switch + { + 0 => "C", + 1 => "C#", + 2 => "D", + 3 => "D#", + 4 => "E", + 5 => "F", + 6 => "F#", + 7 => "G", + 8 => "G#", + 9 => "A", + 10 => "A#", + 11 => "B", + _ => "-" + }; + } + private void ToggleFullScreen() { _graphics.ToggleFullScreen(); diff --git a/FractalVisualizer/Models/AudioFeatures.cs b/FractalVisualizer/Models/AudioFeatures.cs index fbd3475..76ef26f 100644 --- a/FractalVisualizer/Models/AudioFeatures.cs +++ b/FractalVisualizer/Models/AudioFeatures.cs @@ -8,6 +8,9 @@ public struct AudioFeatures public float High; public float Beat; public float Bpm; + public float DominantFrequencyHz; + public int DetectedNoteIndex; + public float DetectedNoteStrength; public float[] Spectrum; public float TimeSeconds; diff --git a/FractalVisualizer/Rendering/FractalRenderer.cs b/FractalVisualizer/Rendering/FractalRenderer.cs index ba06c9b..684b89d 100644 --- a/FractalVisualizer/Rendering/FractalRenderer.cs +++ b/FractalVisualizer/Rendering/FractalRenderer.cs @@ -18,11 +18,28 @@ public sealed class FractalRenderer : IDisposable private Color[] _postBuffer; private Color[] _previousFrame; private readonly List _notes = []; + private readonly List _detectedNotes = []; private readonly List _fireworkRockets = []; private readonly List _fireworkSparks = []; private readonly Random _rng = new(); + private static readonly string[] NoteNames = + [ + "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" + ]; + private static readonly Dictionary NoteGlyphs = new() + { + ['A'] = ["01110", "10001", "10001", "11111", "10001", "10001", "10001"], + ['B'] = ["11110", "10001", "10001", "11110", "10001", "10001", "11110"], + ['C'] = ["01111", "10000", "10000", "10000", "10000", "10000", "01111"], + ['D'] = ["11110", "10001", "10001", "10001", "10001", "10001", "11110"], + ['E'] = ["11111", "10000", "10000", "11110", "10000", "10000", "11111"], + ['F'] = ["11111", "10000", "10000", "11110", "10000", "10000", "10000"], + ['G'] = ["01111", "10000", "10000", "10111", "10001", "10001", "01111"], + ['#'] = ["01010", "11111", "01010", "01010", "11111", "01010", "00000"] + }; private float _noteSpawnAccumulator; + private float _detectedNoteAccumulator; private float _fireworkSpawnAccumulator; private float _runtimePerformanceScale = 1f; private RenderQuality _quality = RenderQuality.Medium; @@ -114,9 +131,11 @@ public sealed class FractalRenderer : IDisposable _postBuffer = new Color[_internalWidth * _internalHeight]; _previousFrame = new Color[_internalWidth * _internalHeight]; _notes.Clear(); + _detectedNotes.Clear(); _fireworkRockets.Clear(); _fireworkSparks.Clear(); _noteSpawnAccumulator = 0f; + _detectedNoteAccumulator = 0f; _fireworkSpawnAccumulator = 0f; } @@ -201,6 +220,9 @@ public sealed class FractalRenderer : IDisposable case 19: RenderGrainBars(features, timeSeconds); break; + case 20: + RenderDetectedNotes(features, timeSeconds); + break; default: RenderJulia(features, timeSeconds); break; @@ -1158,6 +1180,116 @@ public sealed class FractalRenderer : IDisposable } } + private void RenderDetectedNotes(AudioFeatures features, float timeSeconds) + { + for (int y = 0; y < _internalHeight; y++) + { + float t = y / (float)(_internalHeight - 1); + Color bg = new(0.02f, 0.02f + 0.04f * (1f - t), 0.05f + 0.10f * (1f - t), 1f); + int row = y * _internalWidth; + for (int x = 0; x < _internalWidth; x++) + { + _pixelBuffer[row + x] = bg; + } + } + + float spawnRate = features.DetectedNoteStrength * (0.3f + Clamp01(features.Beat) * 1.3f); + _detectedNoteAccumulator += spawnRate * _runtimePerformanceScale; + while (_detectedNoteAccumulator >= 1f) + { + SpawnDetectedNote(features); + _detectedNoteAccumulator -= 1f; + } + + for (int i = _detectedNotes.Count - 1; i >= 0; i--) + { + DetectedNoteParticle p = _detectedNotes[i]; + p.X += p.Vx; + p.Y += p.Vy; + p.Vy -= 0.00002f; + p.Life -= 0.008f + (1f - features.DetectedNoteStrength) * 0.006f; + + if (p.Life <= 0f || p.X < -0.2f || p.X > 1.2f || p.Y < -0.2f) + { + _detectedNotes.RemoveAt(i); + continue; + } + + _detectedNotes[i] = p; + DrawDetectedNoteText(p); + } + } + + private void SpawnDetectedNote(AudioFeatures features) + { + if (features.DetectedNoteIndex < 0 || features.DetectedNoteIndex > 11) + { + return; + } + + if (_detectedNotes.Count >= ScaleCount(96, 20)) + { + return; + } + + string text = NoteNames[features.DetectedNoteIndex]; + float x = 0.1f + (float)_rng.NextDouble() * 0.8f; + float y = 0.25f + (float)_rng.NextDouble() * 0.65f; + float vx = ((float)_rng.NextDouble() - 0.5f) * 0.0022f; + float vy = -(0.0014f + (float)_rng.NextDouble() * 0.0025f); + float scale = 1.2f + (float)_rng.NextDouble() * (1.8f + features.DetectedNoteStrength * 1.6f); + float hue = features.DetectedNoteIndex / 12f; + Vector3 color = new( + 0.5f + 0.5f * MathF.Sin(hue * MathF.Tau), + 0.5f + 0.5f * MathF.Sin(hue * MathF.Tau + 2.09f), + 0.5f + 0.5f * MathF.Sin(hue * MathF.Tau + 4.18f)); + + _detectedNotes.Add(new DetectedNoteParticle + { + X = x, + Y = y, + Vx = vx, + Vy = vy, + Life = 1f, + Scale = scale, + Text = text, + Color = color + }); + } + + private void DrawDetectedNoteText(DetectedNoteParticle p) + { + int px = (int)(p.X * _internalWidth); + int py = (int)(p.Y * _internalHeight); + int scale = Math.Max(1, (int)MathF.Round(p.Scale)); + for (int i = 0; i < p.Text.Length; i++) + { + DrawGlyph(p.Text[i], px + i * (6 * scale), py, scale, p.Color, p.Life); + } + } + + private void DrawGlyph(char c, int x, int y, int scale, Vector3 color, float alpha) + { + if (!NoteGlyphs.TryGetValue(c, out string[]? glyph)) + { + return; + } + + for (int row = 0; row < glyph.Length; row++) + { + string line = glyph[row]; + for (int col = 0; col < line.Length; col++) + { + if (line[col] != '1') + { + continue; + } + + DrawRect(x + col * scale, y + row * scale, scale, scale, color, alpha); + } + } + } + private void SpawnFireworkRocket(float intensity) { if (_fireworkRockets.Count >= ScaleCount(42, 12)) @@ -1478,6 +1610,18 @@ public sealed class FractalRenderer : IDisposable public bool HasFlag; } + private struct DetectedNoteParticle + { + public float X; + public float Y; + public float Vx; + public float Vy; + public float Life; + public float Scale; + public string Text; + public Vector3 Color; + } + private struct FireworkRocket { public float X; diff --git a/FractalVisualizer/UI/HudBitmapFont.cs b/FractalVisualizer/UI/HudBitmapFont.cs index 99853b9..9a13ec2 100644 --- a/FractalVisualizer/UI/HudBitmapFont.cs +++ b/FractalVisualizer/UI/HudBitmapFont.cs @@ -44,6 +44,7 @@ public sealed class HudBitmapFont ['7'] = ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], ['8'] = ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], ['9'] = ["01110", "10001", "10001", "01111", "00001", "00001", "01110"], + ['#'] = ["01010", "11111", "01010", "01010", "11111", "01010", "00000"], ['.'] = ["00000", "00000", "00000", "00000", "00000", "00110", "00110"], ['-'] = ["00000", "00000", "00000", "01110", "00000", "00000", "00000"], [':'] = ["00000", "00110", "00110", "00000", "00110", "00110", "00000"],