Expanded chord detection, real-time tuning, improved FFT

Expanded chord detection to support 9 chord types (maj, min, sus2, sus4, dim, aug, 7, maj7, min7) with updated scoring and voting logic. Added pitch-filtered FFT path for improved tonal analysis. Introduced real-time, per-mode tuning of detection thresholds (harmonicity, separation, vote consensus) via keyboard shortcuts, with HUD display of current values. Refactored chord display and voting logic to handle expanded chord space. Added thread-safe tuning adjustment and helper methods. Overall, improved flexibility, accuracy, and user control for audio analysis.
This commit is contained in:
Michal Vrba
2026-02-13 21:43:24 +01:00
parent b784e91b22
commit 79c6267196
3 changed files with 343 additions and 61 deletions
+257 -57
View File
@@ -21,7 +21,7 @@ public sealed class AudioAnalyzer : IDisposable
private readonly int[] _spectrumAccumulatorCounts; private readonly int[] _spectrumAccumulatorCounts;
private readonly float[] _chroma = new float[12]; private readonly float[] _chroma = new float[12];
private readonly float[] _noteVoteScores = new float[12]; private readonly float[] _noteVoteScores = new float[12];
private readonly float[] _chordVoteScores = new float[24]; private readonly float[] _chordVoteScores = new float[12 * ChordTypeCount];
private readonly float[][] _spectrumRefsByMode; private readonly float[][] _spectrumRefsByMode;
private readonly float[] _levelRefsByMode = new float[2]; private readonly float[] _levelRefsByMode = new float[2];
private readonly float[] _bassRefsByMode = new float[2]; private readonly float[] _bassRefsByMode = new float[2];
@@ -30,6 +30,7 @@ public sealed class AudioAnalyzer : IDisposable
private readonly Stopwatch _clock = new(); private readonly Stopwatch _clock = new();
private readonly object _lifecycleSync = new(); private readonly object _lifecycleSync = new();
private readonly object _tuningSync = new();
private CancellationTokenSource? _cts; private CancellationTokenSource? _cts;
private Task? _analysisTask; private Task? _analysisTask;
@@ -49,6 +50,11 @@ public sealed class AudioAnalyzer : IDisposable
private readonly Queue<ChordVote> _chordVotes = new(); private readonly Queue<ChordVote> _chordVotes = new();
private const float TemporalVoteWindowSeconds = 0.30f; private const float TemporalVoteWindowSeconds = 0.30f;
private const int MaxBeatIntervals = 8; private const int MaxBeatIntervals = 8;
private const int ChordTypeCount = 9;
private readonly float[] _chordHarmonicityOffsetByMode = new float[2];
private readonly float[] _chordSeparationOffsetByMode = new float[2];
private readonly float[] _noteVoteConsensusOffsetByMode = new float[2];
private readonly float[] _chordVoteConsensusOffsetByMode = new float[2];
private int _lastChordRoot = -1; private int _lastChordRoot = -1;
private int _lastChordType = -1; private int _lastChordType = -1;
private float _lastChordChangeTimeSeconds; private float _lastChordChangeTimeSeconds;
@@ -57,6 +63,11 @@ public sealed class AudioAnalyzer : IDisposable
private float _lastNoteChangeTimeSeconds; private float _lastNoteChangeTimeSeconds;
private bool _noteIsActive; private bool _noteIsActive;
private int _activeNoteIndex = -1; private int _activeNoteIndex = -1;
private readonly float[] _pitchSamples;
private readonly Complex[] _pitchFftBuffer;
private float _hpPrevInput;
private float _hpPrevOutput;
private float _lpPrevOutput;
private Snapshot _latestSnapshot; private Snapshot _latestSnapshot;
private bool _disposed; private bool _disposed;
@@ -80,7 +91,9 @@ public sealed class AudioAnalyzer : IDisposable
_hannWindow = BuildHannWindow(fftSize); _hannWindow = BuildHannWindow(fftSize);
_timeSamples = new float[fftSize]; _timeSamples = new float[fftSize];
_pitchSamples = new float[fftSize];
_fftBuffer = new Complex[fftSize]; _fftBuffer = new Complex[fftSize];
_pitchFftBuffer = new Complex[fftSize];
_spectrumAccumulator = new float[spectrumBins]; _spectrumAccumulator = new float[spectrumBins];
_spectrumAccumulatorCounts = new int[spectrumBins]; _spectrumAccumulatorCounts = new int[spectrumBins];
_spectrumRefsByMode = _spectrumRefsByMode =
@@ -151,6 +164,40 @@ public sealed class AudioAnalyzer : IDisposable
return Volatile.Read(ref _latestSnapshot).Features; return Volatile.Read(ref _latestSnapshot).Features;
} }
public DetectionTuningSnapshot GetTuning(AudioInputMode mode)
{
int modeIndex = mode == AudioInputMode.Microphone ? 0 : 1;
SensitivityProfile baseProfile = mode == AudioInputMode.Microphone ? SensitivityProfile.Microphone : SensitivityProfile.SystemOutput;
lock (_tuningSync)
{
return new DetectionTuningSnapshot(
Clamp(baseProfile.ChordHarmonicityThreshold + _chordHarmonicityOffsetByMode[modeIndex], 0.05f, 0.95f),
Clamp(baseProfile.ChordSeparationThreshold + _chordSeparationOffsetByMode[modeIndex], 0.01f, 0.30f),
Clamp(baseProfile.NoteVoteConsensus + _noteVoteConsensusOffsetByMode[modeIndex], 0.15f, 0.90f),
Clamp(baseProfile.ChordVoteConsensus + _chordVoteConsensusOffsetByMode[modeIndex], 0.15f, 0.90f));
}
}
public void AdjustChordHarmonicity(AudioInputMode mode, float delta)
{
AdjustTuning(mode, _chordHarmonicityOffsetByMode, delta, -0.35f, 0.35f);
}
public void AdjustChordSeparation(AudioInputMode mode, float delta)
{
AdjustTuning(mode, _chordSeparationOffsetByMode, delta, -0.10f, 0.20f);
}
public void AdjustNoteVoteConsensus(AudioInputMode mode, float delta)
{
AdjustTuning(mode, _noteVoteConsensusOffsetByMode, delta, -0.35f, 0.35f);
}
public void AdjustChordVoteConsensus(AudioInputMode mode, float delta)
{
AdjustTuning(mode, _chordVoteConsensusOffsetByMode, delta, -0.35f, 0.35f);
}
private void AnalysisLoop(CancellationToken cancellationToken) private void AnalysisLoop(CancellationToken cancellationToken)
{ {
while (!cancellationToken.IsCancellationRequested) while (!cancellationToken.IsCancellationRequested)
@@ -172,13 +219,23 @@ public sealed class AudioAnalyzer : IDisposable
int modeIndex = mode == AudioInputMode.Microphone ? 0 : 1; int modeIndex = mode == AudioInputMode.Microphone ? 0 : 1;
int sampleRate = Volatile.Read(ref _sampleRate); int sampleRate = Volatile.Read(ref _sampleRate);
int half = _fftSize / 2; int half = _fftSize / 2;
GetEffectiveThresholds(
modeIndex,
profile,
out float chordHarmonicityThreshold,
out float chordSeparationThreshold,
out float noteVoteConsensusThreshold,
out float chordVoteConsensusThreshold);
for (int i = 0; i < _fftSize; i++) for (int i = 0; i < _fftSize; i++)
{ {
_fftBuffer[i] = new Complex(_timeSamples[i] * _hannWindow[i], 0.0); _fftBuffer[i] = new Complex(_timeSamples[i] * _hannWindow[i], 0.0);
} }
BuildPitchFilteredBuffer(sampleRate);
Fourier.Forward(_fftBuffer, FourierOptions.Matlab); Fourier.Forward(_fftBuffer, FourierOptions.Matlab);
Fourier.Forward(_pitchFftBuffer, FourierOptions.Matlab);
Array.Clear(_spectrumAccumulator); Array.Clear(_spectrumAccumulator);
Array.Clear(_spectrumAccumulatorCounts); Array.Clear(_spectrumAccumulatorCounts);
@@ -203,6 +260,7 @@ public sealed class AudioAnalyzer : IDisposable
{ {
float frequency = (sampleRate * bin) / (float)_fftSize; float frequency = (sampleRate * bin) / (float)_fftSize;
float magnitude = (float)_fftBuffer[bin].Magnitude; float magnitude = (float)_fftBuffer[bin].Magnitude;
float pitchMagnitude = (float)_pitchFftBuffer[bin].Magnitude;
int spectrumIndex = (int)((bin / (float)half) * _spectrumBins); int spectrumIndex = (int)((bin / (float)half) * _spectrumBins);
if (spectrumIndex >= _spectrumBins) if (spectrumIndex >= _spectrumBins)
@@ -226,9 +284,9 @@ public sealed class AudioAnalyzer : IDisposable
highEnergy += magnitude; highEnergy += magnitude;
} }
if (frequency >= 120f && frequency <= 6000f && magnitude > dominantMagnitude) if (frequency >= 120f && frequency <= 6000f && pitchMagnitude > dominantMagnitude)
{ {
dominantMagnitude = magnitude; dominantMagnitude = pitchMagnitude;
dominantFrequency = frequency; dominantFrequency = frequency;
} }
@@ -237,7 +295,7 @@ public sealed class AudioAnalyzer : IDisposable
int pitchClass = GetNoteIndexFromFrequency(frequency); int pitchClass = GetNoteIndexFromFrequency(frequency);
if (pitchClass >= 0) if (pitchClass >= 0)
{ {
_chroma[pitchClass] += magnitude; _chroma[pitchClass] += pitchMagnitude;
} }
} }
} }
@@ -299,17 +357,28 @@ 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);
EstimateFundamentalFrequencyHps(sampleRate, out float hpsFrequency, out float hpsMagnitude, out float harmonicity); EstimateFundamentalFrequencyHps(sampleRate, _pitchFftBuffer, out float hpsFrequency, out float hpsMagnitude, out float harmonicity);
float noteFrequency = hpsFrequency > 0f ? hpsFrequency : dominantFrequency; float noteFrequency = hpsFrequency > 0f ? hpsFrequency : dominantFrequency;
float noteMagnitude = hpsFrequency > 0f ? hpsMagnitude : dominantMagnitude; float noteMagnitude = hpsFrequency > 0f ? hpsMagnitude : dominantMagnitude;
int noteIndex = GetNoteIndexFromFrequency(noteFrequency); int noteIndex = GetNoteIndexFromFrequency(noteFrequency);
float whistleBoost = GetWhistleBoost(noteFrequency); float whistleBoost = GetWhistleBoost(noteFrequency);
float dominantStrength = Clamp01(noteMagnitude * profile.NoteStrengthGain * (1f + whistleBoost * 0.65f) * (0.58f + (0.42f * harmonicity))); float dominantStrength = Clamp01(noteMagnitude * profile.NoteStrengthGain * (1f + whistleBoost * 0.65f) * (0.58f + (0.42f * harmonicity)));
DetectChord(_chroma, _smoothedLevel, profile, harmonicity, out int chordRoot, out int chordType, out float chordConfidence); DetectChord(
_chroma,
_smoothedLevel,
profile,
harmonicity,
chordHarmonicityThreshold,
chordSeparationThreshold,
out int chordRoot,
out int chordType,
out float chordConfidence);
ApplyTemporalVoting( ApplyTemporalVoting(
now, now,
profile, profile,
noteVoteConsensusThreshold,
chordVoteConsensusThreshold,
noteIndex, noteIndex,
dominantStrength, dominantStrength,
chordRoot, chordRoot,
@@ -360,6 +429,9 @@ public sealed class AudioAnalyzer : IDisposable
private void OnSampleRateChanged(int sampleRate) private void OnSampleRateChanged(int sampleRate)
{ {
Volatile.Write(ref _sampleRate, sampleRate); Volatile.Write(ref _sampleRate, sampleRate);
_hpPrevInput = 0f;
_hpPrevOutput = 0f;
_lpPrevOutput = 0f;
} }
private static float[] BuildHannWindow(int length) private static float[] BuildHannWindow(int length)
@@ -430,9 +502,18 @@ public sealed class AudioAnalyzer : IDisposable
return note; return note;
} }
private void DetectChord(float[] chroma, float level, SensitivityProfile profile, float harmonicity, out int root, out int type, out float confidence) private void DetectChord(
float[] chroma,
float level,
SensitivityProfile profile,
float harmonicity,
float chordHarmonicityThreshold,
float chordSeparationThreshold,
out int root,
out int type,
out float confidence)
{ {
if (level < profile.ChordLevelThreshold || harmonicity < profile.ChordHarmonicityThreshold) if (level < profile.ChordLevelThreshold || harmonicity < chordHarmonicityThreshold)
{ {
root = -1; root = -1;
type = -1; type = -1;
@@ -471,7 +552,7 @@ public sealed class AudioAnalyzer : IDisposable
float geometricMean = MathF.Exp(logSum / chroma.Length); float geometricMean = MathF.Exp(logSum / chroma.Length);
float flatness = geometricMean / (arithmeticMean + 1e-9f); float flatness = geometricMean / (arithmeticMean + 1e-9f);
float chromaHarmonicity = Clamp01(1f - (flatness * 1.25f)); float chromaHarmonicity = Clamp01(1f - (flatness * 1.25f));
if (chromaHarmonicity < profile.ChordHarmonicityThreshold) if (chromaHarmonicity < chordHarmonicityThreshold)
{ {
root = -1; root = -1;
type = -1; type = -1;
@@ -494,36 +575,26 @@ public sealed class AudioAnalyzer : IDisposable
int bestType = -1; int bestType = -1;
for (int r = 0; r < 12; r++) for (int r = 0; r < 12; r++)
{ {
float major = ScoreChord(chroma, r, 0); for (int chordType = 0; chordType < ChordTypeCount; chordType++)
if (major > bestScore)
{ {
secondBestScore = bestScore; float score = ScoreChord(chroma, r, chordType);
bestScore = major; if (score > bestScore)
bestRoot = r; {
bestType = 0; secondBestScore = bestScore;
} bestScore = score;
else if (major > secondBestScore) bestRoot = r;
{ bestType = chordType;
secondBestScore = major; }
} else if (score > secondBestScore)
{
float minor = ScoreChord(chroma, r, 1); secondBestScore = score;
if (minor > bestScore) }
{
secondBestScore = bestScore;
bestScore = minor;
bestRoot = r;
bestType = 1;
}
else if (minor > secondBestScore)
{
secondBestScore = minor;
} }
} }
float conf = Clamp01(bestScore / (total + 1e-6f)); float conf = Clamp01(bestScore / (total + 1e-6f));
float separation = (bestScore - secondBestScore) / (total + 1e-6f); float separation = (bestScore - secondBestScore) / (total + 1e-6f);
if (conf < 0.17f || separation < profile.ChordSeparationThreshold) if (conf < 0.17f || separation < chordSeparationThreshold)
{ {
root = -1; root = -1;
type = -1; type = -1;
@@ -548,7 +619,7 @@ public sealed class AudioAnalyzer : IDisposable
confidence = Clamp01(conf * (0.55f + (0.45f * harmonicity))); confidence = Clamp01(conf * (0.55f + (0.45f * harmonicity)));
} }
private void EstimateFundamentalFrequencyHps(int sampleRate, out float frequencyHz, out float magnitude, out float harmonicity) private void EstimateFundamentalFrequencyHps(int sampleRate, Complex[] frequencyBuffer, out float frequencyHz, out float magnitude, out float harmonicity)
{ {
frequencyHz = 0f; frequencyHz = 0f;
magnitude = 0f; magnitude = 0f;
@@ -568,10 +639,10 @@ public sealed class AudioAnalyzer : IDisposable
const float epsilon = 1e-12f; const float epsilon = 1e-12f;
for (int bin = minBin; bin <= maxBin; bin++) for (int bin = minBin; bin <= maxBin; bin++)
{ {
float m1 = (float)_fftBuffer[bin].Magnitude; float m1 = (float)frequencyBuffer[bin].Magnitude;
float m2 = (float)_fftBuffer[bin * 2].Magnitude; float m2 = (float)frequencyBuffer[bin * 2].Magnitude;
float m3 = (float)_fftBuffer[bin * 3].Magnitude; float m3 = (float)frequencyBuffer[bin * 3].Magnitude;
float m4 = (float)_fftBuffer[bin * 4].Magnitude; float m4 = (float)frequencyBuffer[bin * 4].Magnitude;
float logScore = float logScore =
MathF.Log(m1 + epsilon) + MathF.Log(m1 + epsilon) +
(0.90f * MathF.Log(m2 + epsilon)) + (0.90f * MathF.Log(m2 + epsilon)) +
@@ -589,10 +660,10 @@ public sealed class AudioAnalyzer : IDisposable
return; return;
} }
float fundamental = (float)_fftBuffer[bestBin].Magnitude; float fundamental = (float)frequencyBuffer[bestBin].Magnitude;
float second = (float)_fftBuffer[bestBin * 2].Magnitude; float second = (float)frequencyBuffer[bestBin * 2].Magnitude;
float third = (float)_fftBuffer[bestBin * 3].Magnitude; float third = (float)frequencyBuffer[bestBin * 3].Magnitude;
float fourth = (float)_fftBuffer[bestBin * 4].Magnitude; float fourth = (float)frequencyBuffer[bestBin * 4].Magnitude;
frequencyHz = (sampleRate * bestBin) / (float)_fftSize; frequencyHz = (sampleRate * bestBin) / (float)_fftSize;
magnitude = fundamental; magnitude = fundamental;
@@ -601,12 +672,68 @@ public sealed class AudioAnalyzer : IDisposable
private static float ScoreChord(float[] chroma, int root, int type) private static float ScoreChord(float[] chroma, int root, int type)
{ {
int third = type == 0 ? (root + 4) % 12 : (root + 3) % 12; int i0 = root;
int fifth = (root + 7) % 12; int i1 = root;
int second = (root + 2) % 12; int i2 = root;
int fourth = (root + 5) % 12; int i3 = -1;
float score = chroma[root] * 1.0f + chroma[third] * 0.9f + chroma[fifth] * 0.8f;
score -= (chroma[second] + chroma[fourth]) * 0.15f; switch (type)
{
case 0: // maj
i1 = (root + 4) % 12;
i2 = (root + 7) % 12;
break;
case 1: // min
i1 = (root + 3) % 12;
i2 = (root + 7) % 12;
break;
case 2: // sus2
i1 = (root + 2) % 12;
i2 = (root + 7) % 12;
break;
case 3: // sus4
i1 = (root + 5) % 12;
i2 = (root + 7) % 12;
break;
case 4: // dim
i1 = (root + 3) % 12;
i2 = (root + 6) % 12;
break;
case 5: // aug
i1 = (root + 4) % 12;
i2 = (root + 8) % 12;
break;
case 6: // 7
i1 = (root + 4) % 12;
i2 = (root + 7) % 12;
i3 = (root + 10) % 12;
break;
case 7: // maj7
i1 = (root + 4) % 12;
i2 = (root + 7) % 12;
i3 = (root + 11) % 12;
break;
case 8: // min7
i1 = (root + 3) % 12;
i2 = (root + 7) % 12;
i3 = (root + 10) % 12;
break;
default:
i1 = (root + 4) % 12;
i2 = (root + 7) % 12;
break;
}
float score = chroma[i0] * 1.00f + chroma[i1] * 0.92f + chroma[i2] * 0.84f;
if (i3 >= 0)
{
score += chroma[i3] * 0.72f;
}
int b2 = (root + 1) % 12;
int tritone = (root + 6) % 12;
int clash = (root + 11) % 12;
score -= (chroma[b2] * 0.20f) + (chroma[tritone] * 0.14f) + (chroma[clash] * 0.08f);
return Math.Max(0f, score); return Math.Max(0f, score);
} }
@@ -625,6 +752,21 @@ public sealed class AudioAnalyzer : IDisposable
return value; return value;
} }
private static float Clamp(float value, float min, float max)
{
if (value < min)
{
return min;
}
if (value > max)
{
return max;
}
return value;
}
private static float GetWhistleBoost(float frequencyHz) private static float GetWhistleBoost(float frequencyHz)
{ {
if (frequencyHz <= 0f) if (frequencyHz <= 0f)
@@ -667,9 +809,61 @@ public sealed class AudioAnalyzer : IDisposable
return Clamp01((v / reference) * gain); return Clamp01((v / reference) * gain);
} }
private void BuildPitchFilteredBuffer(int sampleRate)
{
// Pitch/chord path: remove low rumble and very high hiss for clearer tonal detection.
float dt = 1f / MathF.Max(1f, sampleRate);
float hpCut = 70f;
float lpCut = 5000f;
float hpRc = 1f / (2f * MathF.PI * hpCut);
float lpRc = 1f / (2f * MathF.PI * lpCut);
float hpAlpha = hpRc / (hpRc + dt);
float lpAlpha = dt / (lpRc + dt);
for (int i = 0; i < _fftSize; i++)
{
float x = _timeSamples[i];
float hp = hpAlpha * (_hpPrevOutput + x - _hpPrevInput);
_hpPrevInput = x;
_hpPrevOutput = hp;
float lp = _lpPrevOutput + (lpAlpha * (hp - _lpPrevOutput));
_lpPrevOutput = lp;
_pitchSamples[i] = lp;
_pitchFftBuffer[i] = new Complex(lp * _hannWindow[i], 0.0);
}
}
private void GetEffectiveThresholds(
int modeIndex,
SensitivityProfile baseProfile,
out float chordHarmonicityThreshold,
out float chordSeparationThreshold,
out float noteVoteConsensusThreshold,
out float chordVoteConsensusThreshold)
{
lock (_tuningSync)
{
chordHarmonicityThreshold = Clamp(baseProfile.ChordHarmonicityThreshold + _chordHarmonicityOffsetByMode[modeIndex], 0.05f, 0.95f);
chordSeparationThreshold = Clamp(baseProfile.ChordSeparationThreshold + _chordSeparationOffsetByMode[modeIndex], 0.01f, 0.30f);
noteVoteConsensusThreshold = Clamp(baseProfile.NoteVoteConsensus + _noteVoteConsensusOffsetByMode[modeIndex], 0.15f, 0.90f);
chordVoteConsensusThreshold = Clamp(baseProfile.ChordVoteConsensus + _chordVoteConsensusOffsetByMode[modeIndex], 0.15f, 0.90f);
}
}
private void AdjustTuning(AudioInputMode mode, float[] offsets, float delta, float min, float max)
{
int modeIndex = mode == AudioInputMode.Microphone ? 0 : 1;
lock (_tuningSync)
{
offsets[modeIndex] = Clamp(offsets[modeIndex] + delta, min, max);
}
}
private void ApplyTemporalVoting( private void ApplyTemporalVoting(
float now, float now,
SensitivityProfile profile, SensitivityProfile profile,
float noteVoteConsensusThreshold,
float chordVoteConsensusThreshold,
int noteIndex, int noteIndex,
float noteStrength, float noteStrength,
int chordRoot, int chordRoot,
@@ -684,16 +878,16 @@ public sealed class AudioAnalyzer : IDisposable
float noteWeight = noteIndex >= 0 ? MathF.Max(0f, noteStrength) : profile.SilenceVoteWeight; float noteWeight = noteIndex >= 0 ? MathF.Max(0f, noteStrength) : profile.SilenceVoteWeight;
_noteVotes.Enqueue(new NoteVote(now, noteIndex, noteWeight)); _noteVotes.Enqueue(new NoteVote(now, noteIndex, noteWeight));
PruneOldVotes(_noteVotes, now); PruneOldVotes(_noteVotes, now);
ResolveNoteVote(profile, out votedNoteIndex, out votedNoteStrength); ResolveNoteVote(profile, noteVoteConsensusThreshold, out votedNoteIndex, out votedNoteStrength);
bool hasChordCandidate = chordRoot >= 0 && chordConfidence >= (profile.ChordConfidenceThreshold * 0.55f); bool hasChordCandidate = chordRoot >= 0 && chordConfidence >= (profile.ChordConfidenceThreshold * 0.55f);
float chordWeight = hasChordCandidate ? chordConfidence : profile.SilenceVoteWeight; float chordWeight = hasChordCandidate ? chordConfidence : profile.SilenceVoteWeight;
_chordVotes.Enqueue(new ChordVote(now, hasChordCandidate ? chordRoot : -1, hasChordCandidate ? chordType : -1, chordWeight)); _chordVotes.Enqueue(new ChordVote(now, hasChordCandidate ? chordRoot : -1, hasChordCandidate ? chordType : -1, chordWeight));
PruneOldVotes(_chordVotes, now); PruneOldVotes(_chordVotes, now);
ResolveChordVote(profile, out votedChordRoot, out votedChordType, out votedChordConfidence); ResolveChordVote(profile, chordVoteConsensusThreshold, out votedChordRoot, out votedChordType, out votedChordConfidence);
} }
private void ResolveNoteVote(SensitivityProfile profile, out int noteIndex, out float noteStrength) private void ResolveNoteVote(SensitivityProfile profile, float noteVoteConsensusThreshold, out int noteIndex, out float noteStrength)
{ {
Array.Clear(_noteVoteScores); Array.Clear(_noteVoteScores);
float totalWeight = 0f; float totalWeight = 0f;
@@ -742,7 +936,7 @@ public sealed class AudioAnalyzer : IDisposable
float separation = (best - second) / (totalWeight + 1e-6f); float separation = (best - second) / (totalWeight + 1e-6f);
if (bestIndex < 0 || if (bestIndex < 0 ||
best < profile.NoteVoteMinScore || best < profile.NoteVoteMinScore ||
consensus < profile.NoteVoteConsensus || consensus < noteVoteConsensusThreshold ||
separation < profile.NoteVoteSeparation || separation < profile.NoteVoteSeparation ||
silenceWeight > best * 1.08f) silenceWeight > best * 1.08f)
{ {
@@ -755,7 +949,7 @@ public sealed class AudioAnalyzer : IDisposable
noteStrength = Clamp01((consensus * 0.72f) + (separation * 0.28f)); noteStrength = Clamp01((consensus * 0.72f) + (separation * 0.28f));
} }
private void ResolveChordVote(SensitivityProfile profile, out int chordRoot, out int chordType, out float chordConfidence) private void ResolveChordVote(SensitivityProfile profile, float chordVoteConsensusThreshold, out int chordRoot, out int chordType, out float chordConfidence)
{ {
Array.Clear(_chordVoteScores); Array.Clear(_chordVoteScores);
float totalWeight = 0f; float totalWeight = 0f;
@@ -767,7 +961,7 @@ public sealed class AudioAnalyzer : IDisposable
totalWeight += weight; totalWeight += weight;
if (vote.Root >= 0 && vote.Type >= 0) if (vote.Root >= 0 && vote.Type >= 0)
{ {
int idx = (vote.Root * 2) + vote.Type; int idx = (vote.Root * ChordTypeCount) + vote.Type;
_chordVoteScores[idx] += weight; _chordVoteScores[idx] += weight;
} }
else else
@@ -806,7 +1000,7 @@ public sealed class AudioAnalyzer : IDisposable
float separation = (best - second) / (totalWeight + 1e-6f); float separation = (best - second) / (totalWeight + 1e-6f);
if (bestIndex < 0 || if (bestIndex < 0 ||
best < profile.ChordVoteMinScore || best < profile.ChordVoteMinScore ||
consensus < profile.ChordVoteConsensus || consensus < chordVoteConsensusThreshold ||
separation < profile.ChordVoteSeparation || separation < profile.ChordVoteSeparation ||
silenceWeight > best * 1.10f) silenceWeight > best * 1.10f)
{ {
@@ -816,8 +1010,8 @@ public sealed class AudioAnalyzer : IDisposable
return; return;
} }
chordRoot = bestIndex / 2; chordRoot = bestIndex / ChordTypeCount;
chordType = bestIndex % 2; chordType = bestIndex % ChordTypeCount;
chordConfidence = Clamp01((consensus * 0.75f) + (separation * 0.25f)); chordConfidence = Clamp01((consensus * 0.75f) + (separation * 0.25f));
} }
@@ -1049,6 +1243,12 @@ public sealed class AudioAnalyzer : IDisposable
private readonly record struct ChordVote(float Time, int Root, int Type, float Weight); private readonly record struct ChordVote(float Time, int Root, int Type, float Weight);
public readonly record struct DetectionTuningSnapshot(
float ChordHarmonicityThreshold,
float ChordSeparationThreshold,
float NoteVoteConsensus,
float ChordVoteConsensus);
private void ThrowIfDisposed() private void ThrowIfDisposed()
{ {
if (_disposed) if (_disposed)
+70 -2
View File
@@ -26,6 +26,7 @@ public class Game1 : Game
private AudioFeatures _latestFeatures; private AudioFeatures _latestFeatures;
private bool _isPaused; private bool _isPaused;
private bool _showHud = true; private bool _showHud = true;
private bool _showLegend = true;
private int _fractalPresetIndex; private int _fractalPresetIndex;
private int _fps; private int _fps;
private int _frameCounter; private int _frameCounter;
@@ -138,6 +139,11 @@ public class Game1 : Game
_showHud = !_showHud; _showHud = !_showHud;
} }
if (IsPressed(keyboard, Keys.H))
{
_showLegend = !_showLegend;
}
if (IsPressed(keyboard, Keys.Left)) if (IsPressed(keyboard, Keys.Left))
{ {
_fractalPresetIndex = Math.Max(0, _fractalPresetIndex - 1); _fractalPresetIndex = Math.Max(0, _fractalPresetIndex - 1);
@@ -156,6 +162,46 @@ public class Game1 : Game
_audioCapture.SwitchMode(_currentInputMode); _audioCapture.SwitchMode(_currentInputMode);
} }
if (IsPressed(keyboard, Keys.OemOpenBrackets))
{
_audioAnalyzer.AdjustChordHarmonicity(_currentInputMode, -0.01f);
}
if (IsPressed(keyboard, Keys.OemCloseBrackets))
{
_audioAnalyzer.AdjustChordHarmonicity(_currentInputMode, +0.01f);
}
if (IsPressed(keyboard, Keys.OemSemicolon))
{
_audioAnalyzer.AdjustChordSeparation(_currentInputMode, -0.005f);
}
if (IsPressed(keyboard, Keys.OemQuotes))
{
_audioAnalyzer.AdjustChordSeparation(_currentInputMode, +0.005f);
}
if (IsPressed(keyboard, Keys.OemComma))
{
_audioAnalyzer.AdjustNoteVoteConsensus(_currentInputMode, -0.01f);
}
if (IsPressed(keyboard, Keys.OemPeriod))
{
_audioAnalyzer.AdjustNoteVoteConsensus(_currentInputMode, +0.01f);
}
if (IsPressed(keyboard, Keys.OemMinus))
{
_audioAnalyzer.AdjustChordVoteConsensus(_currentInputMode, -0.01f);
}
if (IsPressed(keyboard, Keys.OemPlus))
{
_audioAnalyzer.AdjustChordVoteConsensus(_currentInputMode, +0.01f);
}
_latestFeatures = _audioAnalyzer.GetLatestFeatures(); _latestFeatures = _audioAnalyzer.GetLatestFeatures();
_fpsElapsed += gameTime.ElapsedGameTime; _fpsElapsed += gameTime.ElapsedGameTime;
_frameCounter++; _frameCounter++;
@@ -184,6 +230,7 @@ public class Game1 : Game
if (_showHud && _spriteBatch is not null && _hudFont is not null && _pixel is not null) if (_showHud && _spriteBatch is not null && _hudFont is not null && _pixel is not null)
{ {
var tuning = _audioAnalyzer.GetTuning(_currentInputMode);
_spriteBatch.Begin(samplerState: SamplerState.PointClamp, blendState: BlendState.AlphaBlend); _spriteBatch.Begin(samplerState: SamplerState.PointClamp, blendState: BlendState.AlphaBlend);
string modeText = _currentInputMode == AudioInputMode.Microphone ? "MIC" : "SYS"; string modeText = _currentInputMode == AudioInputMode.Microphone ? "MIC" : "SYS";
_hudFont.DrawText(_spriteBatch, _pixel, "MODE " + modeText + " M SWITCH", 12, 10, 2, Color.White); _hudFont.DrawText(_spriteBatch, _pixel, "MODE " + modeText + " M SWITCH", 12, 10, 2, Color.White);
@@ -199,7 +246,16 @@ public class Game1 : Game
_hudFont.DrawText(_spriteBatch, _pixel, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 210, 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, "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, "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); _hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE H HELP", 12, 270, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, "[ ] HARM " + tuning.ChordHarmonicityThreshold.ToString("0.000"), 12, 290, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, "; ' SEP " + tuning.ChordSeparationThreshold.ToString("0.000"), 12, 310, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, ", . NCONS " + tuning.NoteVoteConsensus.ToString("0.000"), 12, 330, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, "- + CCONS " + tuning.ChordVoteConsensus.ToString("0.000"), 12, 350, 2, Color.White);
if (_showLegend)
{
_hudFont.DrawText(_spriteBatch, _pixel, "LEGEND SPACE PAUSE TAB HUD M INPUT <- -> PRESET", 12, 370, 2, Color.White);
_hudFont.DrawText(_spriteBatch, _pixel, "TUNING [ ] HARM ; ' SEP , . NOTE CONS - + CHORD CONS", 12, 390, 2, Color.White);
}
_spriteBatch.End(); _spriteBatch.End();
} }
@@ -290,7 +346,19 @@ public class Game1 : Game
return "-"; return "-";
} }
return chordType == 1 ? root + " MIN" : root + " MAJ"; return chordType switch
{
0 => root + " MAJ",
1 => root + " MIN",
2 => root + " SUS2",
3 => root + " SUS4",
4 => root + " DIM",
5 => root + " AUG",
6 => root + " 7",
7 => root + " MAJ7",
8 => root + " MIN7",
_ => root + " MAJ"
};
} }
private void ToggleFullScreen() private void ToggleFullScreen()
+16 -2
View File
@@ -1253,7 +1253,9 @@ public sealed class FractalRenderer : IDisposable
} }
} }
int chordCode = features.DetectedChordRootIndex < 0 ? -1 : (features.DetectedChordRootIndex * 2 + (features.DetectedChordType == 1 ? 1 : 0)); int chordCode = features.DetectedChordRootIndex < 0
? -1
: (features.DetectedChordRootIndex * 9 + Math.Clamp(features.DetectedChordType, 0, 8));
if (chordCode != _lastChordCode) if (chordCode != _lastChordCode)
{ {
_lastChordCode = chordCode; _lastChordCode = chordCode;
@@ -1609,7 +1611,19 @@ public sealed class FractalRenderer : IDisposable
} }
string root = NoteNames[features.DetectedChordRootIndex]; string root = NoteNames[features.DetectedChordRootIndex];
return features.DetectedChordType == 1 ? root + " MIN" : root + " MAJ"; return features.DetectedChordType switch
{
0 => root + " MAJ",
1 => root + " MIN",
2 => root + " SUS2",
3 => root + " SUS4",
4 => root + " DIM",
5 => root + " AUG",
6 => root + " 7",
7 => root + " MAJ7",
8 => root + " MIN7",
_ => root + " MAJ"
};
} }
private void SpawnFireworkRocket(float intensity) private void SpawnFireworkRocket(float intensity)