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

@@ -2,10 +2,73 @@
* Global application configuration
*/
// How long (in ms) a note must be held to be accepted as correct
// How long (in ms) a note must be held to be accepted as correct
// How long (in ms) the target note must be detected to count as correct.
// Protects against spurious single-frame detections.
export const NOTE_MATCH_THRESHOLD_MS = 50;
// Grace period (in ms): brief detection dropouts or wrong frames while holding
// a note do NOT reset the match timer. Pitch detectors (YIN) routinely flicker
// for a frame or two during note attacks and decays; a hard reset on every
// flicker makes the threshold above feel broken ("shows green but won't register").
export const NOTE_MATCH_GRACE_MS = 150;
// How long (in ms) after speaker playback ends that mic-based matching stays
// gated, so the app doesn't "hear itself" (ear-training auto-play, Play Note
// button, virtual instruments).
export const PLAYBACK_GATE_TAIL_MS = 400;
// Safety cap for the "wait for silence" option: if the mic never goes quiet
// (continuously detectable sound in the room), the next reference note plays
// anyway after this delay so the app can't stall indefinitely.
export const REFERENCE_QUIET_MAX_WAIT_MS = 8000;
// Display smoothing (exponential moving average factor, 0-1) applied to the
// cents reading and the audio level in the detection loop (~60 fps). Keeps
// the tuner needle and level meter readable without CSS transitions — a
// transition on a value that changes every frame is interrupted 60x per
// second, which Safari/WebKit handles by leaving the element stuck.
// 0.25 ≈ 80ms effective smoothing: damps jitter but stays very responsive.
export const DISPLAY_EMA_FACTOR = 0.25;
// How many consecutive detection frames a different note must persist before
// the displayed note name switches (~50ms at 60fps). Prevents the note name
// from flickering between adjacent notes when playing right at a semitone
// boundary. Matching is unaffected (it uses the raw midi value).
export const NOTE_DISPLAY_STABLE_FRAMES = 3;
// How long (in ms) the last detected pitch is kept on screen after the signal
// disappears, before the readout is cleared. Prevents flicker between frames
// without leaving stale notes hanging forever.
export const PITCH_READING_HOLD_MS = 200;
// Pitch analysis window (samples @ AudioContext rate).
// The analyzer adapts the window down from BUFFER_SIZE based on the lowest
// frequency that must be detected (~2 periods minimum). Smaller window =
// lower latency and 4x less CPU per analysis. 4096 covers bass E1 (~41 Hz).
export const PITCH_BUFFER_SIZE = 4096;
export const PITCH_MIN_WINDOW = 1024;
// Fallback for the lowest detectable frequency before the app configures
// the actual instrument range. ~70 Hz covers guitar low E with margin.
export const PITCH_DEFAULT_LOWEST_HZ = 70;
// Instruments whose lowest note is at/above this frequency use YIN instead of
// ACF2PLUS: ACF2PLUS picks subharmonics above ~2 kHz, while YIN's weakness
// below ~100 Hz is irrelevant for high registers (e.g. whistle).
export const PITCH_HIGH_REGISTER_HZ = 300;
// Target RMS for normalizing the analysis buffer before pitch detection
// (YIN/high-register path only — see selectDetector in PitchAnalyzer).
// pitchfinder's YIN force-sets yinBuffer[1] = 1 before normalization, so at
// low absolute amplitudes its difference function gets dwarfed and it latches
// onto tau=2 (~20 kHz garbage). A constant operating level fixes this.
export const PITCH_NORMALIZE_TARGET_RMS = 0.3;
// Detected pitches below (instrument floor × this factor) are rejected as
// implausible — e.g. 50/60 Hz mains hum showing up as a confident wrong note
// while a guitar (floor ~78 Hz) is not playing. ~4 semitones of margin.
export const PITCH_BELOW_FLOOR_REJECT_FACTOR = 2 ** (-4 / 12); // ≈ 0.794
// Microphone Sensitivity Configuration
// Sensitivity Scale: 0.0 (Low) to 1.0 (High)
// Linear mapping to dB thresholds