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.
This commit is contained in:
Michal Vrba
2026-02-13 20:43:29 +01:00
parent 8bf3978354
commit 6a1bdfb935
5 changed files with 221 additions and 5 deletions
+46
View File
@@ -168,6 +168,8 @@ public sealed class AudioAnalyzer : IDisposable
float bassEnergy = 0f; float bassEnergy = 0f;
float midEnergy = 0f; float midEnergy = 0f;
float highEnergy = 0f; float highEnergy = 0f;
float dominantMagnitude = 0f;
float dominantFrequency = 0f;
for (int bin = 1; bin < half; bin++) for (int bin = 1; bin < half; bin++)
{ {
@@ -195,6 +197,12 @@ public sealed class AudioAnalyzer : IDisposable
{ {
highEnergy += magnitude; highEnergy += magnitude;
} }
if (frequency >= 80f && frequency <= 2000f && magnitude > dominantMagnitude)
{
dominantMagnitude = magnitude;
dominantFrequency = frequency;
}
} }
float[] spectrum = new float[_spectrumBins]; float[] spectrum = new float[_spectrumBins];
@@ -248,6 +256,9 @@ public sealed class AudioAnalyzer : IDisposable
float bpmInstant = CalculateBpm(); float bpmInstant = CalculateBpm();
_smoothedBpm = Smooth(_smoothedBpm, bpmInstant, 0.18f, 0.04f); _smoothedBpm = Smooth(_smoothedBpm, bpmInstant, 0.18f, 0.04f);
int noteIndex = GetNoteIndexFromFrequency(dominantFrequency);
float dominantStrength = Clamp01(dominantMagnitude * 0.02f);
AudioFeatures features = new() AudioFeatures features = new()
{ {
Level = _smoothedLevel, Level = _smoothedLevel,
@@ -256,6 +267,9 @@ public sealed class AudioAnalyzer : IDisposable
High = _smoothedHigh, High = _smoothedHigh,
Beat = _smoothedBeat, Beat = _smoothedBeat,
Bpm = _smoothedBpm, Bpm = _smoothedBpm,
DominantFrequencyHz = dominantFrequency,
DetectedNoteIndex = noteIndex,
DetectedNoteStrength = dominantStrength,
Spectrum = spectrum, Spectrum = spectrum,
TimeSeconds = now TimeSeconds = now
}; };
@@ -319,6 +333,38 @@ public sealed class AudioAnalyzer : IDisposable
return Math.Clamp(bpm, 40f, 220f); 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() private void ThrowIfDisposed()
{ {
if (_disposed) if (_disposed)
+27 -5
View File
@@ -145,7 +145,7 @@ public class Game1 : Game
if (IsPressed(keyboard, Keys.Right)) if (IsPressed(keyboard, Keys.Right))
{ {
_fractalPresetIndex = Math.Min(19, _fractalPresetIndex + 1); _fractalPresetIndex = Math.Min(20, _fractalPresetIndex + 1);
} }
if (IsPressed(keyboard, Keys.M)) 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, "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, "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, "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, "NOTE " + GetDetectedNoteName(_latestFeatures.DetectedNoteIndex), 12, 170, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 190, 2, Color.White); _hudFont.DrawText(_spriteBatch, _pixel, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 190, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, "QUALITY " + _renderQuality + " 1 2 3", 12, 210, 2, Color.White); _hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 210, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE", 12, 230, 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(); _spriteBatch.End();
} }
@@ -247,10 +248,31 @@ public class Game1 : Game
17 => "MOSAIC", 17 => "MOSAIC",
18 => "RIBBON", 18 => "RIBBON",
19 => "GRAINBARS", 19 => "GRAINBARS",
20 => "NOTEDROP",
_ => "JULIA" _ => "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() private void ToggleFullScreen()
{ {
_graphics.ToggleFullScreen(); _graphics.ToggleFullScreen();
@@ -8,6 +8,9 @@ public struct AudioFeatures
public float High; public float High;
public float Beat; public float Beat;
public float Bpm; public float Bpm;
public float DominantFrequencyHz;
public int DetectedNoteIndex;
public float DetectedNoteStrength;
public float[] Spectrum; public float[] Spectrum;
public float TimeSeconds; public float TimeSeconds;
@@ -18,11 +18,28 @@ public sealed class FractalRenderer : IDisposable
private Color[] _postBuffer; private Color[] _postBuffer;
private Color[] _previousFrame; private Color[] _previousFrame;
private readonly List<NoteParticle> _notes = []; private readonly List<NoteParticle> _notes = [];
private readonly List<DetectedNoteParticle> _detectedNotes = [];
private readonly List<FireworkRocket> _fireworkRockets = []; private readonly List<FireworkRocket> _fireworkRockets = [];
private readonly List<FireworkSpark> _fireworkSparks = []; private readonly List<FireworkSpark> _fireworkSparks = [];
private readonly Random _rng = new(); 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<char, string[]> 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 _noteSpawnAccumulator;
private float _detectedNoteAccumulator;
private float _fireworkSpawnAccumulator; private float _fireworkSpawnAccumulator;
private float _runtimePerformanceScale = 1f; private float _runtimePerformanceScale = 1f;
private RenderQuality _quality = RenderQuality.Medium; private RenderQuality _quality = RenderQuality.Medium;
@@ -114,9 +131,11 @@ public sealed class FractalRenderer : IDisposable
_postBuffer = new Color[_internalWidth * _internalHeight]; _postBuffer = new Color[_internalWidth * _internalHeight];
_previousFrame = new Color[_internalWidth * _internalHeight]; _previousFrame = new Color[_internalWidth * _internalHeight];
_notes.Clear(); _notes.Clear();
_detectedNotes.Clear();
_fireworkRockets.Clear(); _fireworkRockets.Clear();
_fireworkSparks.Clear(); _fireworkSparks.Clear();
_noteSpawnAccumulator = 0f; _noteSpawnAccumulator = 0f;
_detectedNoteAccumulator = 0f;
_fireworkSpawnAccumulator = 0f; _fireworkSpawnAccumulator = 0f;
} }
@@ -201,6 +220,9 @@ public sealed class FractalRenderer : IDisposable
case 19: case 19:
RenderGrainBars(features, timeSeconds); RenderGrainBars(features, timeSeconds);
break; break;
case 20:
RenderDetectedNotes(features, timeSeconds);
break;
default: default:
RenderJulia(features, timeSeconds); RenderJulia(features, timeSeconds);
break; 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) private void SpawnFireworkRocket(float intensity)
{ {
if (_fireworkRockets.Count >= ScaleCount(42, 12)) if (_fireworkRockets.Count >= ScaleCount(42, 12))
@@ -1478,6 +1610,18 @@ public sealed class FractalRenderer : IDisposable
public bool HasFlag; 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 private struct FireworkRocket
{ {
public float X; public float X;
+1
View File
@@ -44,6 +44,7 @@ public sealed class HudBitmapFont
['7'] = ["11111", "00001", "00010", "00100", "01000", "01000", "01000"], ['7'] = ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
['8'] = ["01110", "10001", "10001", "01110", "10001", "10001", "01110"], ['8'] = ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
['9'] = ["01110", "10001", "10001", "01111", "00001", "00001", "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", "00000", "00000", "00110", "00110"],
['-'] = ["00000", "00000", "00000", "01110", "00000", "00000", "00000"], ['-'] = ["00000", "00000", "00000", "01110", "00000", "00000", "00000"],
[':'] = ["00000", "00110", "00110", "00000", "00110", "00110", "00000"], [':'] = ["00000", "00110", "00110", "00000", "00110", "00110", "00000"],