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,4 +1,5 @@
import { useRef, useEffect, useCallback } from 'react';
import { createAudioContext } from '../audio/AudioEngine';
interface MetronomeOptions {
bpm: number;
@@ -53,6 +54,10 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
onTickRef.current(currentBeat.current);
}, [volume]);
// Indirection so the scheduler can reschedule itself without referencing
// its own variable during initialization
const schedulerRef = useRef<() => void>(() => { });
const scheduler = useCallback(() => {
if (!audioContext.current) return;
@@ -62,9 +67,13 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
playClick(nextNoteTime.current);
nextNote();
}
timerID.current = window.setTimeout(scheduler, lookahead);
timerID.current = window.setTimeout(() => schedulerRef.current(), lookahead);
}, [nextNote, playClick]);
useEffect(() => {
schedulerRef.current = scheduler;
}, [scheduler]);
const restart = useCallback(() => {
if (audioContext.current) {
// Reset to play one beat interval from NOW
@@ -78,7 +87,7 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
useEffect(() => {
if (playing) {
if (!audioContext.current) {
audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)();
audioContext.current = createAudioContext();
}
// Resume if suspended (browser autoplay policy)