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"],