diff --git a/FractalVisualizer/Audio/AudioAnalyzer.cs b/FractalVisualizer/Audio/AudioAnalyzer.cs index 1a8cec2..0a3eb9d 100644 --- a/FractalVisualizer/Audio/AudioAnalyzer.cs +++ b/FractalVisualizer/Audio/AudioAnalyzer.cs @@ -20,6 +20,11 @@ public sealed class AudioAnalyzer : IDisposable private readonly float[] _spectrumAccumulator; private readonly int[] _spectrumAccumulatorCounts; private readonly float[] _chroma = new float[12]; + private readonly float[] _bassChroma = new float[12]; + private readonly float[] _integratedChroma = new float[12]; + private readonly float[] _integratedBassChroma = new float[12]; + private readonly float[] _chordChromaSum = new float[12]; + private readonly float[] _chordBassSum = new float[12]; private readonly float[] _noteVoteScores = new float[12]; private readonly float[] _chordVoteScores = new float[12 * ChordTypeCount]; private readonly float[][] _spectrumRefsByMode; @@ -48,7 +53,10 @@ public sealed class AudioAnalyzer : IDisposable private readonly Queue _beatIntervals = new(); private readonly Queue _noteVotes = new(); private readonly Queue _chordVotes = new(); + private readonly Queue _chordFrames = new(); private const float TemporalVoteWindowSeconds = 0.30f; + private const float ChordIntegrationWindowSeconds = 0.34f; + private const int MaxNotePeakCandidates = 5; private const int MaxBeatIntervals = 8; private const int ChordTypeCount = 9; private readonly float[] _chordHarmonicityOffsetByMode = new float[2]; @@ -58,9 +66,12 @@ public sealed class AudioAnalyzer : IDisposable private int _lastChordRoot = -1; private int _lastChordType = -1; private float _lastChordChangeTimeSeconds; + private float _chordLockUntilSeconds; private int _stableNoteIndex = -1; private float _stableNoteStrength; private float _lastNoteChangeTimeSeconds; + private int _lastRawNoteIndex = -1; + private float _lastRawNoteChangeTimeSeconds; private bool _noteIsActive; private int _activeNoteIndex = -1; private readonly float[] _pitchSamples; @@ -198,6 +209,31 @@ public sealed class AudioAnalyzer : IDisposable AdjustTuning(mode, _chordVoteConsensusOffsetByMode, delta, -0.35f, 0.35f); } + public void ResetTuning(AudioInputMode mode) + { + int modeIndex = mode == AudioInputMode.Microphone ? 0 : 1; + lock (_tuningSync) + { + _chordHarmonicityOffsetByMode[modeIndex] = 0f; + _chordSeparationOffsetByMode[modeIndex] = 0f; + _noteVoteConsensusOffsetByMode[modeIndex] = 0f; + _chordVoteConsensusOffsetByMode[modeIndex] = 0f; + } + } + + public void SetTuning(AudioInputMode mode, DetectionTuningSnapshot snapshot) + { + int modeIndex = mode == AudioInputMode.Microphone ? 0 : 1; + SensitivityProfile baseProfile = mode == AudioInputMode.Microphone ? SensitivityProfile.Microphone : SensitivityProfile.SystemOutput; + lock (_tuningSync) + { + _chordHarmonicityOffsetByMode[modeIndex] = Clamp(snapshot.ChordHarmonicityThreshold - baseProfile.ChordHarmonicityThreshold, -0.35f, 0.35f); + _chordSeparationOffsetByMode[modeIndex] = Clamp(snapshot.ChordSeparationThreshold - baseProfile.ChordSeparationThreshold, -0.10f, 0.20f); + _noteVoteConsensusOffsetByMode[modeIndex] = Clamp(snapshot.NoteVoteConsensus - baseProfile.NoteVoteConsensus, -0.35f, 0.35f); + _chordVoteConsensusOffsetByMode[modeIndex] = Clamp(snapshot.ChordVoteConsensus - baseProfile.ChordVoteConsensus, -0.35f, 0.35f); + } + } + private void AnalysisLoop(CancellationToken cancellationToken) { while (!cancellationToken.IsCancellationRequested) @@ -255,6 +291,7 @@ public sealed class AudioAnalyzer : IDisposable float dominantMagnitude = 0f; float dominantFrequency = 0f; Array.Clear(_chroma); + Array.Clear(_bassChroma); for (int bin = 1; bin < half; bin++) { @@ -296,6 +333,10 @@ public sealed class AudioAnalyzer : IDisposable if (pitchClass >= 0) { _chroma[pitchClass] += pitchMagnitude; + if (frequency <= 260f) + { + _bassChroma[pitchClass] += pitchMagnitude; + } } } } @@ -357,15 +398,34 @@ public sealed class AudioAnalyzer : IDisposable float bpmInstant = CalculateBpm(); _smoothedBpm = Smooth(_smoothedBpm, bpmInstant, 0.18f, 0.04f); - EstimateFundamentalFrequencyHps(sampleRate, _pitchFftBuffer, out float hpsFrequency, out float hpsMagnitude, out float harmonicity); - float noteFrequency = hpsFrequency > 0f ? hpsFrequency : dominantFrequency; - float noteMagnitude = hpsFrequency > 0f ? hpsMagnitude : dominantMagnitude; + EstimateFundamentalFrequencyHps(sampleRate, _pitchFftBuffer, out float hpsFrequency, out float hpsMagnitude, out float hpsHarmonicity); + EstimateNoteFromPeaks(sampleRate, _pitchFftBuffer, out float peakFrequency, out float peakStrength, out float peakHarmonicity); + float noteFrequency; + float noteMagnitude; + float harmonicity; + if (peakStrength >= hpsMagnitude * 0.82f && peakFrequency > 0f) + { + noteFrequency = peakFrequency; + noteMagnitude = peakStrength; + harmonicity = MathF.Max(peakHarmonicity, hpsHarmonicity * 0.85f); + } + else + { + noteFrequency = hpsFrequency > 0f ? hpsFrequency : dominantFrequency; + noteMagnitude = hpsFrequency > 0f ? hpsMagnitude : dominantMagnitude; + harmonicity = hpsHarmonicity; + } + int noteIndex = GetNoteIndexFromFrequency(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.52f + (0.48f * harmonicity))); + ApplyRawNoteContinuity(now, harmonicity, dominantStrength, ref noteIndex); + + AccumulateChordFrames(now, _chroma, _bassChroma, MathF.Max(dominantStrength, _smoothedLevel * 0.75f)); DetectChord( - _chroma, + _integratedChroma, + _integratedBassChroma, _smoothedLevel, profile, harmonicity, @@ -432,6 +492,18 @@ public sealed class AudioAnalyzer : IDisposable _hpPrevInput = 0f; _hpPrevOutput = 0f; _lpPrevOutput = 0f; + _lastRawNoteIndex = -1; + _lastRawNoteChangeTimeSeconds = 0f; + _chordLockUntilSeconds = 0f; + while (_chordFrames.Count > 0) + { + _chordFrames.Dequeue(); + } + + Array.Clear(_chordChromaSum); + Array.Clear(_chordBassSum); + Array.Clear(_integratedChroma); + Array.Clear(_integratedBassChroma); } private static float[] BuildHannWindow(int length) @@ -502,8 +574,46 @@ public sealed class AudioAnalyzer : IDisposable return note; } + private void ApplyRawNoteContinuity(float now, float harmonicity, float noteStrength, ref int noteIndex) + { + if (noteIndex < 0) + { + if (noteStrength <= 0.04f) + { + _lastRawNoteIndex = -1; + } + + return; + } + + if (_lastRawNoteIndex >= 0) + { + int dist = CircularSemitoneDistance(_lastRawNoteIndex, noteIndex); + float sinceLast = now - _lastRawNoteChangeTimeSeconds; + bool unstableJump = dist >= 5 && sinceLast < 0.20f && harmonicity < 0.44f && noteStrength < 0.28f; + if (unstableJump) + { + noteIndex = _lastRawNoteIndex; + return; + } + } + + if (_lastRawNoteIndex != noteIndex) + { + _lastRawNoteIndex = noteIndex; + _lastRawNoteChangeTimeSeconds = now; + } + } + + private static int CircularSemitoneDistance(int a, int b) + { + int d = Math.Abs(a - b) % 12; + return Math.Min(d, 12 - d); + } + private void DetectChord( float[] chroma, + float[] bassChroma, float level, SensitivityProfile profile, float harmonicity, @@ -577,7 +687,7 @@ public sealed class AudioAnalyzer : IDisposable { for (int chordType = 0; chordType < ChordTypeCount; chordType++) { - float score = ScoreChord(chroma, r, chordType); + float score = ScoreChord(chroma, bassChroma, r, chordType); if (score > bestScore) { secondBestScore = bestScore; @@ -604,7 +714,7 @@ public sealed class AudioAnalyzer : IDisposable if (_lastChordRoot >= 0 && _lastChordType >= 0) { - float held = ScoreChord(chroma, _lastChordRoot, _lastChordType); + float held = ScoreChord(chroma, bassChroma, _lastChordRoot, _lastChordType); if ((bestRoot != _lastChordRoot || bestType != _lastChordType) && held * 1.08f > bestScore) { bestRoot = _lastChordRoot; @@ -670,7 +780,186 @@ public sealed class AudioAnalyzer : IDisposable harmonicity = Clamp01(((second + third + fourth) / 3f) / (fundamental + 1e-6f)); } - private static float ScoreChord(float[] chroma, int root, int type) + private void EstimateNoteFromPeaks(int sampleRate, Complex[] frequencyBuffer, out float frequencyHz, out float strength, out float harmonicity) + { + frequencyHz = 0f; + strength = 0f; + harmonicity = 0f; + + int half = _fftSize / 2; + int minBin = Math.Max(2, (int)(70f * _fftSize / sampleRate)); + int maxBin = Math.Min(half - 1, (int)(4200f * _fftSize / sampleRate)); + if (maxBin <= minBin + 3) + { + return; + } + + Span candidateBins = stackalloc int[MaxNotePeakCandidates]; + Span candidateScores = stackalloc float[MaxNotePeakCandidates]; + for (int i = 0; i < MaxNotePeakCandidates; i++) + { + candidateBins[i] = -1; + candidateScores[i] = 0f; + } + + for (int bin = minBin + 1; bin < maxBin - 1; bin++) + { + float m = (float)frequencyBuffer[bin].Magnitude; + if (m <= 1e-8f) + { + continue; + } + + float prev = (float)frequencyBuffer[bin - 1].Magnitude; + float next = (float)frequencyBuffer[bin + 1].Magnitude; + if (m < prev || m < next) + { + continue; + } + + float h2 = (bin * 2 < half) ? (float)frequencyBuffer[bin * 2].Magnitude : 0f; + float h3 = (bin * 3 < half) ? (float)frequencyBuffer[bin * 3].Magnitude : 0f; + float localScore = m + (0.62f * h2) + (0.44f * h3); + + int insert = -1; + for (int i = 0; i < MaxNotePeakCandidates; i++) + { + if (localScore > candidateScores[i]) + { + insert = i; + break; + } + } + + if (insert < 0) + { + continue; + } + + for (int j = MaxNotePeakCandidates - 1; j > insert; j--) + { + candidateScores[j] = candidateScores[j - 1]; + candidateBins[j] = candidateBins[j - 1]; + } + + candidateScores[insert] = localScore; + candidateBins[insert] = bin; + } + + Span classScores = stackalloc float[12]; + float bestCandidateScore = 0f; + int bestBin = -1; + float bestHarm = 0f; + for (int i = 0; i < MaxNotePeakCandidates; i++) + { + int bin = candidateBins[i]; + if (bin < 0) + { + continue; + } + + float freq = (sampleRate * bin) / (float)_fftSize; + int cls = GetNoteIndexFromFrequency(freq); + if (cls < 0) + { + continue; + } + + float score = candidateScores[i]; + classScores[cls] += score; + if (score > bestCandidateScore) + { + float m = (float)frequencyBuffer[bin].Magnitude; + float h2 = (bin * 2 < half) ? (float)frequencyBuffer[bin * 2].Magnitude : 0f; + float h3 = (bin * 3 < half) ? (float)frequencyBuffer[bin * 3].Magnitude : 0f; + bestCandidateScore = score; + bestBin = bin; + bestHarm = Clamp01(((h2 + h3) * 0.5f) / (m + 1e-6f)); + } + } + + int bestClass = -1; + float bestClassScore = 0f; + for (int i = 0; i < classScores.Length; i++) + { + if (classScores[i] > bestClassScore) + { + bestClassScore = classScores[i]; + bestClass = i; + } + } + + if (bestBin < 0 || bestClass < 0 || bestClassScore <= 1e-8f) + { + return; + } + + float bestClassFreq = (sampleRate * bestBin) / (float)_fftSize; + frequencyHz = bestClassFreq; + strength = bestClassScore; + harmonicity = bestHarm; + } + + private void AccumulateChordFrames(float now, float[] chroma, float[] bassChroma, float weight) + { + float w = Clamp01(weight); + float[] chromaFrame = new float[12]; + float[] bassFrame = new float[12]; + for (int i = 0; i < 12; i++) + { + chromaFrame[i] = chroma[i] * w; + bassFrame[i] = bassChroma[i] * w; + _chordChromaSum[i] += chromaFrame[i]; + _chordBassSum[i] += bassFrame[i]; + } + + _chordFrames.Enqueue(new ChordFrame(now, chromaFrame, bassFrame)); + float cutoff = now - ChordIntegrationWindowSeconds; + while (_chordFrames.Count > 0 && _chordFrames.Peek().Time < cutoff) + { + ChordFrame old = _chordFrames.Dequeue(); + for (int i = 0; i < 12; i++) + { + _chordChromaSum[i] -= old.Chroma[i]; + _chordBassSum[i] -= old.BassChroma[i]; + } + } + + float sum = 0f; + float bassSum = 0f; + for (int i = 0; i < 12; i++) + { + if (_chordChromaSum[i] < 0f) + { + _chordChromaSum[i] = 0f; + } + + if (_chordBassSum[i] < 0f) + { + _chordBassSum[i] = 0f; + } + + sum += _chordChromaSum[i]; + bassSum += _chordBassSum[i]; + } + + if (sum <= 1e-8f || bassSum <= 1e-8f) + { + Array.Clear(_integratedChroma); + Array.Clear(_integratedBassChroma); + return; + } + + float inv = 1f / sum; + float bassInv = 1f / bassSum; + for (int i = 0; i < 12; i++) + { + _integratedChroma[i] = _chordChromaSum[i] * inv; + _integratedBassChroma[i] = _chordBassSum[i] * bassInv; + } + } + + private static float ScoreChord(float[] chroma, float[] bassChroma, int root, int type) { int i0 = root; int i1 = root; @@ -724,12 +1013,26 @@ public sealed class AudioAnalyzer : IDisposable break; } - float score = chroma[i0] * 1.00f + chroma[i1] * 0.92f + chroma[i2] * 0.84f; + float rootPresence = chroma[i0]; + float thirdPresence = chroma[i1]; + float fifthPresence = chroma[i2]; + float supportingPresence = MathF.Max(thirdPresence, fifthPresence); + float primary = (rootPresence * 0.72f) + (supportingPresence * 0.28f); + float triadBlend = (rootPresence * 0.45f) + (thirdPresence * 0.30f) + (fifthPresence * 0.25f); + + // Lower root reliance slightly so first/second inversions still score well. + float score = (primary * 0.82f) + (triadBlend * 1.05f); if (i3 >= 0) { score += chroma[i3] * 0.72f; } + // Bass chroma helps prefer plausible roots for piano voicings and slash chords. + float bassRoot = bassChroma[i0]; + float bassThird = bassChroma[i1]; + float bassFifth = bassChroma[i2]; + score += (bassRoot * 0.26f) + (MathF.Max(bassThird, bassFifth) * 0.16f); + int b2 = (root + 1) % 12; int tritone = (root + 6) % 12; int clash = (root + 11) % 12; @@ -1079,14 +1382,20 @@ public sealed class AudioAnalyzer : IDisposable if (chordRoot >= 0 && chordConfidence >= profile.ChordConfidenceThreshold) { bool changed = _lastChordRoot != chordRoot || _lastChordType != chordType; - bool canSwitch = (now - _lastChordChangeTimeSeconds) >= profile.SwitchHoldSeconds; - if (!changed || canSwitch || chordConfidence > stableChordConfidence * 1.2f) + bool lockActive = now < _chordLockUntilSeconds; + bool canSwitch = !lockActive && (now - _lastChordChangeTimeSeconds) >= profile.SwitchHoldSeconds; + bool strongOverride = chordConfidence > stableChordConfidence * 1.35f; + if (!changed || canSwitch || strongOverride) { _lastChordRoot = chordRoot; _lastChordType = chordType; if (changed) { _lastChordChangeTimeSeconds = now; + if (noteStrength >= profile.ChordLockNoteThreshold) + { + _chordLockUntilSeconds = now + profile.ChordLockSeconds; + } } } @@ -1101,6 +1410,7 @@ public sealed class AudioAnalyzer : IDisposable { _lastChordRoot = -1; _lastChordType = -1; + _chordLockUntilSeconds = 0f; stableChordRoot = -1; stableChordType = -1; stableChordConfidence = 0f; @@ -1146,6 +1456,8 @@ public sealed class AudioAnalyzer : IDisposable float chordConfidenceThreshold, float noteOnsetThreshold, float noteReleaseThreshold, + float chordLockNoteThreshold, + float chordLockSeconds, float chordLevelThreshold, float chordHarmonicityThreshold, float chordPeakRatioThreshold, @@ -1172,6 +1484,8 @@ public sealed class AudioAnalyzer : IDisposable public float ChordConfidenceThreshold { get; } = chordConfidenceThreshold; public float NoteOnsetThreshold { get; } = noteOnsetThreshold; public float NoteReleaseThreshold { get; } = noteReleaseThreshold; + public float ChordLockNoteThreshold { get; } = chordLockNoteThreshold; + public float ChordLockSeconds { get; } = chordLockSeconds; public float ChordLevelThreshold { get; } = chordLevelThreshold; public float ChordHarmonicityThreshold { get; } = chordHarmonicityThreshold; public float ChordPeakRatioThreshold { get; } = chordPeakRatioThreshold; @@ -1199,6 +1513,8 @@ public sealed class AudioAnalyzer : IDisposable chordConfidenceThreshold: 0.18f, noteOnsetThreshold: 0.20f, noteReleaseThreshold: 0.08f, + chordLockNoteThreshold: 0.19f, + chordLockSeconds: 0.12f, chordLevelThreshold: 0.10f, chordHarmonicityThreshold: 0.24f, chordPeakRatioThreshold: 0.12f, @@ -1226,6 +1542,8 @@ public sealed class AudioAnalyzer : IDisposable chordConfidenceThreshold: 0.14f, noteOnsetThreshold: 0.18f, noteReleaseThreshold: 0.07f, + chordLockNoteThreshold: 0.16f, + chordLockSeconds: 0.11f, chordLevelThreshold: 0.08f, chordHarmonicityThreshold: 0.18f, chordPeakRatioThreshold: 0.10f, @@ -1243,6 +1561,8 @@ public sealed class AudioAnalyzer : IDisposable private readonly record struct ChordVote(float Time, int Root, int Type, float Weight); + private readonly record struct ChordFrame(float Time, float[] Chroma, float[] BassChroma); + public readonly record struct DetectionTuningSnapshot( float ChordHarmonicityThreshold, float ChordSeparationThreshold, diff --git a/FractalVisualizer/Game1.cs b/FractalVisualizer/Game1.cs index 1b36427..8ed6185 100644 --- a/FractalVisualizer/Game1.cs +++ b/FractalVisualizer/Game1.cs @@ -1,4 +1,6 @@ using System; +using System.IO; +using System.Text.Json; using FractalVisualizer.Audio; using FractalVisualizer.Models; using FractalVisualizer.Rendering; @@ -40,6 +42,7 @@ public class Game1 : Game (1920, 1080) ]; private int _resolutionIndex = 0; + private const string TuningProfilePath = "detection-tuning.json"; public Game1() { @@ -144,6 +147,21 @@ public class Game1 : Game _showLegend = !_showLegend; } + if (IsPressed(keyboard, Keys.Back)) + { + _audioAnalyzer.ResetTuning(_currentInputMode); + } + + if (IsPressed(keyboard, Keys.F5)) + { + SaveTuningProfiles(); + } + + if (IsPressed(keyboard, Keys.F6)) + { + LoadTuningProfiles(); + } + if (IsPressed(keyboard, Keys.Left)) { _fractalPresetIndex = Math.Max(0, _fractalPresetIndex - 1); @@ -243,19 +261,24 @@ public class Game1 : Game _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, "CHORD " + GetDetectedChordName(_latestFeatures.DetectedChordRootIndex, _latestFeatures.DetectedChordType, _latestFeatures.DetectedChordConfidence), 12, 190, 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, "QUALITY " + _renderQuality + " 1 2 3", 12, 250, 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); + _hudFont.DrawText(_spriteBatch, _pixel, "NOCHORD " + (_latestFeatures.DetectedChordRootIndex < 0 ? "YES" : "NO"), 12, 210, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "PAUSED " + (_isPaused ? "YES" : "NO"), 12, 230, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "FPS " + _fps, 12, 250, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "QUALITY " + _renderQuality + " 1 2 3", 12, 270, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "F OR F11 FULLSCREEN R RESIZE H HELP", 12, 290, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "[ ] HARM " + tuning.ChordHarmonicityThreshold.ToString("0.000"), 12, 310, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "; ' SEP " + tuning.ChordSeparationThreshold.ToString("0.000"), 12, 330, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, ", . NCONS " + tuning.NoteVoteConsensus.ToString("0.000"), 12, 350, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "- + CCONS " + tuning.ChordVoteConsensus.ToString("0.000"), 12, 370, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "BACK RESET MODE F5 SAVE F6 LOAD", 12, 390, 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); + _hudFont.DrawText(_spriteBatch, _pixel, "LEGEND SPACE PAUSE TAB HUD M INPUT <- -> PRESET", 12, 410, 2, Color.White); + _hudFont.DrawText(_spriteBatch, _pixel, "TUNING [ ] HARM ; ' SEP , . NOTE CONS - + CHORD CONS", 12, 430, 2, Color.White); } + + DrawConfidenceBar(820, 14, 420, 12, _latestFeatures.DetectedNoteStrength, new Color(48, 72, 118), new Color(120, 220, 255), "NOTE CONF", 798, 12); + DrawConfidenceBar(820, 40, 420, 12, _latestFeatures.DetectedChordConfidence, new Color(64, 52, 48), new Color(255, 182, 92), "CHORD CONF", 790, 38); _spriteBatch.End(); } @@ -385,4 +408,57 @@ public class Game1 : Game _graphics.PreferredBackBufferHeight = height; _graphics.ApplyChanges(); } + + private void DrawConfidenceBar(int x, int y, int width, int height, float value, Color bg, Color fg, string label, int labelX, int labelY) + { + if (_spriteBatch is null || _pixel is null || _hudFont is null) + { + return; + } + + float clamped = Math.Clamp(value, 0f, 1f); + int fillWidth = (int)MathF.Round(width * clamped); + _spriteBatch.Draw(_pixel, new Rectangle(x, y, width, height), bg); + if (fillWidth > 0) + { + _spriteBatch.Draw(_pixel, new Rectangle(x, y, fillWidth, height), fg); + } + + _spriteBatch.Draw(_pixel, new Rectangle(x - 1, y - 1, width + 2, 1), Color.White * 0.45f); + _spriteBatch.Draw(_pixel, new Rectangle(x - 1, y + height, width + 2, 1), Color.White * 0.45f); + _spriteBatch.Draw(_pixel, new Rectangle(x - 1, y - 1, 1, height + 2), Color.White * 0.45f); + _spriteBatch.Draw(_pixel, new Rectangle(x + width, y - 1, 1, height + 2), Color.White * 0.45f); + _hudFont.DrawText(_spriteBatch, _pixel, label + " " + clamped.ToString("0.000"), labelX, labelY, 2, Color.White); + } + + private void SaveTuningProfiles() + { + TuningProfiles profile = new( + _audioAnalyzer.GetTuning(AudioInputMode.Microphone), + _audioAnalyzer.GetTuning(AudioInputMode.SystemOutput)); + string json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(TuningProfilePath, json); + } + + private void LoadTuningProfiles() + { + if (!File.Exists(TuningProfilePath)) + { + return; + } + + string json = File.ReadAllText(TuningProfilePath); + TuningProfiles? profile = JsonSerializer.Deserialize(json); + if (profile is null) + { + return; + } + + _audioAnalyzer.SetTuning(AudioInputMode.Microphone, profile.Microphone); + _audioAnalyzer.SetTuning(AudioInputMode.SystemOutput, profile.SystemOutput); + } + + private readonly record struct TuningProfiles( + AudioAnalyzer.DetectionTuningSnapshot Microphone, + AudioAnalyzer.DetectionTuningSnapshot SystemOutput); }