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:
@@ -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)
|
{
|
||||||
|
float score = ScoreChord(chroma, r, chordType);
|
||||||
|
if (score > bestScore)
|
||||||
{
|
{
|
||||||
secondBestScore = bestScore;
|
secondBestScore = bestScore;
|
||||||
bestScore = major;
|
bestScore = score;
|
||||||
bestRoot = r;
|
bestRoot = r;
|
||||||
bestType = 0;
|
bestType = chordType;
|
||||||
}
|
}
|
||||||
else if (major > secondBestScore)
|
else if (score > secondBestScore)
|
||||||
{
|
{
|
||||||
secondBestScore = major;
|
secondBestScore = score;
|
||||||
}
|
}
|
||||||
|
|
||||||
float minor = ScoreChord(chroma, r, 1);
|
|
||||||
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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user