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.
This commit is contained in:
@@ -19,6 +19,7 @@ public sealed class AudioAnalyzer : IDisposable
|
|||||||
private readonly Complex[] _fftBuffer;
|
private readonly Complex[] _fftBuffer;
|
||||||
private readonly float[] _spectrumAccumulator;
|
private readonly float[] _spectrumAccumulator;
|
||||||
private readonly int[] _spectrumAccumulatorCounts;
|
private readonly int[] _spectrumAccumulatorCounts;
|
||||||
|
private readonly float[] _chroma = new float[12];
|
||||||
private readonly Stopwatch _clock = new();
|
private readonly Stopwatch _clock = new();
|
||||||
|
|
||||||
private readonly object _lifecycleSync = new();
|
private readonly object _lifecycleSync = new();
|
||||||
@@ -38,6 +39,8 @@ public sealed class AudioAnalyzer : IDisposable
|
|||||||
private bool _beatGateOpen = true;
|
private bool _beatGateOpen = true;
|
||||||
private readonly Queue<float> _beatIntervals = new();
|
private readonly Queue<float> _beatIntervals = new();
|
||||||
private const int MaxBeatIntervals = 8;
|
private const int MaxBeatIntervals = 8;
|
||||||
|
private int _lastChordRoot = -1;
|
||||||
|
private int _lastChordType = -1;
|
||||||
|
|
||||||
private Snapshot _latestSnapshot;
|
private Snapshot _latestSnapshot;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
@@ -170,6 +173,7 @@ public sealed class AudioAnalyzer : IDisposable
|
|||||||
float highEnergy = 0f;
|
float highEnergy = 0f;
|
||||||
float dominantMagnitude = 0f;
|
float dominantMagnitude = 0f;
|
||||||
float dominantFrequency = 0f;
|
float dominantFrequency = 0f;
|
||||||
|
Array.Clear(_chroma);
|
||||||
|
|
||||||
for (int bin = 1; bin < half; bin++)
|
for (int bin = 1; bin < half; bin++)
|
||||||
{
|
{
|
||||||
@@ -203,6 +207,15 @@ public sealed class AudioAnalyzer : IDisposable
|
|||||||
dominantMagnitude = magnitude;
|
dominantMagnitude = magnitude;
|
||||||
dominantFrequency = frequency;
|
dominantFrequency = frequency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (frequency >= 60f && frequency <= 5000f)
|
||||||
|
{
|
||||||
|
int pitchClass = GetNoteIndexFromFrequency(frequency);
|
||||||
|
if (pitchClass >= 0)
|
||||||
|
{
|
||||||
|
_chroma[pitchClass] += magnitude;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
float[] spectrum = new float[_spectrumBins];
|
float[] spectrum = new float[_spectrumBins];
|
||||||
@@ -258,6 +271,7 @@ public sealed class AudioAnalyzer : IDisposable
|
|||||||
|
|
||||||
int noteIndex = GetNoteIndexFromFrequency(dominantFrequency);
|
int noteIndex = GetNoteIndexFromFrequency(dominantFrequency);
|
||||||
float dominantStrength = Clamp01(dominantMagnitude * 0.02f);
|
float dominantStrength = Clamp01(dominantMagnitude * 0.02f);
|
||||||
|
DetectChord(_chroma, out int chordRoot, out int chordType, out float chordConfidence);
|
||||||
|
|
||||||
AudioFeatures features = new()
|
AudioFeatures features = new()
|
||||||
{
|
{
|
||||||
@@ -270,6 +284,9 @@ public sealed class AudioAnalyzer : IDisposable
|
|||||||
DominantFrequencyHz = dominantFrequency,
|
DominantFrequencyHz = dominantFrequency,
|
||||||
DetectedNoteIndex = noteIndex,
|
DetectedNoteIndex = noteIndex,
|
||||||
DetectedNoteStrength = dominantStrength,
|
DetectedNoteStrength = dominantStrength,
|
||||||
|
DetectedChordRootIndex = chordRoot,
|
||||||
|
DetectedChordType = chordType,
|
||||||
|
DetectedChordConfidence = chordConfidence,
|
||||||
Spectrum = spectrum,
|
Spectrum = spectrum,
|
||||||
TimeSeconds = now
|
TimeSeconds = now
|
||||||
};
|
};
|
||||||
@@ -350,6 +367,83 @@ public sealed class AudioAnalyzer : IDisposable
|
|||||||
return note;
|
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)
|
private static float Clamp01(float value)
|
||||||
{
|
{
|
||||||
if (value < 0f)
|
if (value < 0f)
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ public class Game1 : Game
|
|||||||
|
|
||||||
if (IsPressed(keyboard, Keys.Right))
|
if (IsPressed(keyboard, Keys.Right))
|
||||||
{
|
{
|
||||||
_fractalPresetIndex = Math.Min(20, _fractalPresetIndex + 1);
|
_fractalPresetIndex = Math.Min(21, _fractalPresetIndex + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsPressed(keyboard, Keys.M))
|
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, "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, "NOTE " + GetDetectedNoteName(_latestFeatures.DetectedNoteIndex), 12, 170, 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, "CHORD " + GetDetectedChordName(_latestFeatures.DetectedChordRootIndex, _latestFeatures.DetectedChordType), 12, 190, 2, Color.White);
|
||||||
_hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 210, 2, Color.White);
|
_hudFont.DrawText(_spriteBatch, _pixel, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 210, 2, Color.White);
|
||||||
_hudFont.DrawText(_spriteBatch, _pixel, "QUALITY " + _renderQuality + " 1 2 3", 12, 230, 2, Color.White);
|
_hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 230, 2, Color.White);
|
||||||
_hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE", 12, 250, 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();
|
_spriteBatch.End();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,6 +250,7 @@ public class Game1 : Game
|
|||||||
18 => "RIBBON",
|
18 => "RIBBON",
|
||||||
19 => "GRAINBARS",
|
19 => "GRAINBARS",
|
||||||
20 => "NOTEDROP",
|
20 => "NOTEDROP",
|
||||||
|
21 => "CHORDS",
|
||||||
_ => "JULIA"
|
_ => "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()
|
private void ToggleFullScreen()
|
||||||
{
|
{
|
||||||
_graphics.ToggleFullScreen();
|
_graphics.ToggleFullScreen();
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ public struct AudioFeatures
|
|||||||
public float DominantFrequencyHz;
|
public float DominantFrequencyHz;
|
||||||
public int DetectedNoteIndex;
|
public int DetectedNoteIndex;
|
||||||
public float DetectedNoteStrength;
|
public float DetectedNoteStrength;
|
||||||
|
public int DetectedChordRootIndex;
|
||||||
|
public int DetectedChordType;
|
||||||
|
public float DetectedChordConfidence;
|
||||||
public float[] Spectrum;
|
public float[] Spectrum;
|
||||||
public float TimeSeconds;
|
public float TimeSeconds;
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ public sealed class FractalRenderer : IDisposable
|
|||||||
[
|
[
|
||||||
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
|
"C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
|
||||||
];
|
];
|
||||||
private static readonly Dictionary<char, string[]> NoteGlyphs = new()
|
private static readonly Dictionary<char, string[]> TextGlyphs = new()
|
||||||
{
|
{
|
||||||
['A'] = ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
['A'] = ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
|
||||||
['B'] = ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
|
['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"],
|
['E'] = ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
|
||||||
['F'] = ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
|
['F'] = ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
|
||||||
['G'] = ["01111", "10000", "10000", "10111", "10001", "10001", "01111"],
|
['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"]
|
['#'] = ["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 _noteSpawnAccumulator;
|
||||||
private float _detectedNoteAccumulator;
|
private float _detectedNoteAccumulator;
|
||||||
private float _fireworkSpawnAccumulator;
|
private float _fireworkSpawnAccumulator;
|
||||||
|
private int _lastChordCode = -1;
|
||||||
|
private float _chordFlash;
|
||||||
private float _runtimePerformanceScale = 1f;
|
private float _runtimePerformanceScale = 1f;
|
||||||
private RenderQuality _quality = RenderQuality.Medium;
|
private RenderQuality _quality = RenderQuality.Medium;
|
||||||
private int _lastViewportWidth;
|
private int _lastViewportWidth;
|
||||||
@@ -223,6 +231,9 @@ public sealed class FractalRenderer : IDisposable
|
|||||||
case 20:
|
case 20:
|
||||||
RenderDetectedNotes(features, timeSeconds);
|
RenderDetectedNotes(features, timeSeconds);
|
||||||
break;
|
break;
|
||||||
|
case 21:
|
||||||
|
RenderChordCenter(features, timeSeconds);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
RenderJulia(features, timeSeconds);
|
RenderJulia(features, timeSeconds);
|
||||||
break;
|
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)
|
private void SpawnDetectedNote(AudioFeatures features)
|
||||||
{
|
{
|
||||||
if (features.DetectedNoteIndex < 0 || features.DetectedNoteIndex > 11)
|
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)
|
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;
|
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)
|
private void SpawnFireworkRocket(float intensity)
|
||||||
{
|
{
|
||||||
if (_fireworkRockets.Count >= ScaleCount(42, 12))
|
if (_fireworkRockets.Count >= ScaleCount(42, 12))
|
||||||
|
|||||||
Reference in New Issue
Block a user