Overhaul pitch detection and note matching

- Pick detector per instrument range: ACF2PLUS (robust down to ~40 Hz) for
  normal instruments, YIN only for the whistle register. pitchfinder's YIN
  latches onto tau=2 (~20 kHz garbage) below ~100 Hz and for quiet input;
  the old fixed 4096 window masked this, the smaller window exposed it.
- RMS-normalize analysis input for the YIN path only; ACF2PLUS keeps the raw
  signal so its internal unpitched gate rejects quiet decayed audio instead
  of amplifying noise into confident wrong pitches (tuner stuck on 'a note').
- Adaptive analysis window sized to the instrument's lowest note (~2 periods):
  half the latency, a quarter of the CPU for guitar/piano/voice; bass keeps
  the full window for E1.
- Reject detections implausibly below the instrument floor (mains hum).
- Clear stale pitch readings after 200 ms instead of keeping them forever.
- Shared AudioEngine singleton for all playback (was: two AudioContexts);
  tracks audibility so the app stops hearing its own speaker (ear-training
  self-scoring) and gates matching plus display during playback.
- MatchTracker: grace-period based note hold instead of reset-on-every-flicker
  (YIN flickers during attacks/decays; the old hard reset made the 50 ms
  threshold fire unreliably).
- Display damping in the producer (EMA for cents/level, 3-frame note-name
  hysteresis); display components render directly without CSS transitions.
- Regression tests: synthetic signals through the exact pipeline helpers
  (MatchTracker + pitchDetection test suites).
This commit is contained in:
2026-09-13 18:52:21 +02:00
parent d8602e0bd8
commit 079d574b66
12 changed files with 873 additions and 182 deletions

View File

@@ -1,6 +1,12 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState } from 'react';
import { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer';
import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils';
import {
MIC_DEFAULT_SENSITIVITY,
PITCH_READING_HOLD_MS,
DISPLAY_EMA_FACTOR,
NOTE_DISPLAY_STABLE_FRAMES,
} from '../AppConfig';
interface PitchData {
frequency: number;
@@ -10,98 +16,186 @@ interface PitchData {
clarity: number; // Placeholder for now, maybe uses probability if YIN exposes it
}
import { MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
export type { MicrophoneDebugInfo };
export function usePitchDetector(active: boolean, sensitivity: number = MIC_DEFAULT_SENSITIVITY) {
export function usePitchDetector(
active: boolean,
sensitivity: number = MIC_DEFAULT_SENSITIVITY,
minFrequencyHz?: number
) {
const analyzerRef = useRef<PitchAnalyzer | null>(null);
const [pitchData, setPitchData] = useState<PitchData | null>(null);
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const [audioLevel, setAudioLevel] = useState<number>(0);
const [debugInfo, setDebugInfo] = useState<MicrophoneDebugInfo | null>(null);
const animationRef = useRef<number | null>(null);
// Update sensitivity when it changes
// Latest values for the capture effect (refs keep its dep list minimal so
// changing them does not restart the microphone)
const sensitivityRef = useRef(sensitivity);
const minFrequencyRef = useRef(minFrequencyHz);
// Apply sensitivity live without restarting capture
useEffect(() => {
if (analyzerRef.current) {
analyzerRef.current.setSensitivity(sensitivity);
}
sensitivityRef.current = sensitivity;
analyzerRef.current?.setSensitivity(sensitivity);
}, [sensitivity]);
const updatePitch = useCallback(() => {
if (!analyzerRef.current) return;
const freq = analyzerRef.current.getPitch();
if (freq) {
const midi = frequencyToMidi(freq);
const { scientific } = getNoteDetails(midi);
const cents = getCentDifference(freq, midi);
setPitchData({
frequency: freq,
midi,
note: scientific,
cents,
clarity: 1
});
// Adapt the analysis window to the instrument's lowest note, live
useEffect(() => {
minFrequencyRef.current = minFrequencyHz;
if (minFrequencyHz) {
analyzerRef.current?.setLowestFrequency(minFrequencyHz);
}
// Always update level and debug info
setAudioLevel(analyzerRef.current.getCurrentLevel());
setDebugInfo(analyzerRef.current.getDebugInfo());
animationRef.current = requestAnimationFrame(updatePitch);
}, []);
}, [minFrequencyHz]);
useEffect(() => {
if (active) {
if (!analyzerRef.current) {
analyzerRef.current = new PitchAnalyzer();
analyzerRef.current.setSensitivity(sensitivity);
if (!active) {
setIsListening(false);
setPitchData(null);
setAudioLevel(0);
setDebugInfo(null);
return;
}
let cancelled = false;
let rafId = 0;
let frameCount = 0;
let lastPitchAt = 0;
// Display smoothing: pitch/cents/level arrive on every detection frame
// (~60 fps) and jitter slightly. A short exponential moving average keeps
// the tuner needle and level meter readable. Done here (in the producer)
// so the display components can render directly without CSS transitions —
// transitions on values that change every frame get interrupted 60x/sec,
// which some engines (Safari/WebKit) punish with a visually stuck needle.
let smoothedCents: number | null = null;
let smoothedLevel = 0;
// Note-name stability: the displayed note only switches after the raw
// detection reports a different note for several consecutive frames.
// Prevents D#2/E2-style flicker at semitone boundaries. The raw midi
// (used for matching) is unaffected.
let stableMidi: number | null = null;
let candidateMidi: number | null = null;
let candidateCount = 0;
const analyzer = new PitchAnalyzer();
analyzer.setSensitivity(sensitivityRef.current);
if (minFrequencyRef.current) {
analyzer.setLowestFrequency(minFrequencyRef.current);
}
analyzerRef.current = analyzer;
function tick() {
if (cancelled) return;
const freq = analyzer.getPitch();
const now = performance.now();
if (freq) {
lastPitchAt = now;
const midi = frequencyToMidi(freq);
// Note-name hysteresis: only switch the displayed note after
// the new one persists for a few consecutive frames.
if (midi === stableMidi) {
candidateMidi = null;
candidateCount = 0;
} else if (midi === candidateMidi) {
if (++candidateCount >= NOTE_DISPLAY_STABLE_FRAMES) {
stableMidi = midi;
candidateMidi = null;
candidateCount = 0;
// Re-anchor the cents average: a new note means a new
// reference (the needle must not sweep across the scale).
smoothedCents = null;
}
} else {
candidateMidi = midi;
candidateCount = 1;
}
// Cents are displayed relative to the STABLE note (like real
// tuners): the needle sweeps to ±50 and only then the note flips.
const displayMidi = stableMidi ?? midi;
const { scientific } = getNoteDetails(displayMidi);
const cents = getCentDifference(freq, displayMidi);
smoothedCents = smoothedCents === null
? cents
: smoothedCents + (cents - smoothedCents) * DISPLAY_EMA_FACTOR;
setPitchData({
frequency: freq,
midi, // raw — matching uses this and must stay unsmoothed
note: scientific,
cents: smoothedCents, // smoothed — display only
clarity: 1
});
} else if (lastPitchAt !== 0 && now - lastPitchAt > PITCH_READING_HOLD_MS) {
// Clear stale readings so the UI doesn't keep displaying
// (and matching against) a note that stopped sounding long ago.
lastPitchAt = 0;
smoothedCents = null;
stableMidi = null;
candidateMidi = null;
candidateCount = 0;
setPitchData(null);
}
analyzerRef.current.start()
.then(() => {
setIsListening(true);
setError(null);
updatePitch();
})
.catch(err => {
console.error(err);
// Provide more specific error messages
if (err.name === 'NotAllowedError') {
setError("Microphone access denied. Please allow microphone access.");
} else if (err.name === 'NotFoundError') {
setError("No microphone found. Please connect a microphone.");
} else if (err.name === 'NotReadableError') {
setError("Microphone is in use by another app.");
} else {
setError("Could not access microphone.");
}
setIsListening(false);
});
} else {
if (analyzerRef.current) {
analyzerRef.current.stop();
// Always update level (smoothed)
smoothedLevel += (analyzer.getCurrentLevel() - smoothedLevel) * DISPLAY_EMA_FACTOR;
setAudioLevel(smoothedLevel);
// Debug info is only shown in the settings modal; ~4 Hz is plenty
// and avoids a third state update (full re-render) every frame.
if (++frameCount % 15 === 0) {
setDebugInfo(analyzer.getDebugInfo());
}
rafId = requestAnimationFrame(tick);
}
analyzer.start()
.then(() => {
if (cancelled) {
// StrictMode double-mount or quick toggle-off while
// getUserMedia was pending: don't leave the mic running.
analyzer.stop();
return;
}
setIsListening(true);
setError(null);
tick();
})
.catch((err: Error) => {
if (cancelled) return;
console.error(err);
// Provide more specific error messages
if (err.name === 'NotAllowedError') {
setError("Microphone access denied. Please allow microphone access.");
} else if (err.name === 'NotFoundError') {
setError("No microphone found. Please connect a microphone.");
} else if (err.name === 'NotReadableError') {
setError("Microphone is in use by another app.");
} else {
setError("Could not access microphone.");
}
setIsListening(false);
});
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
analyzer.stop();
if (analyzerRef.current === analyzer) {
analyzerRef.current = null;
}
setIsListening(false);
setPitchData(null);
setAudioLevel(0);
setDebugInfo(null);
if (animationRef.current) cancelAnimationFrame(animationRef.current);
}
return () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
if (analyzerRef.current) {
analyzerRef.current.stop();
}
};
}, [active, updatePitch]);
}, [active]);
return { pitchData, isListening, error, audioLevel, debugInfo };
}