mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
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:
84
src/game/MatchTracker.test.ts
Normal file
84
src/game/MatchTracker.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MatchTracker } from './MatchTracker';
|
||||
|
||||
const THRESHOLD = 50;
|
||||
const GRACE = 150;
|
||||
|
||||
describe('MatchTracker', () => {
|
||||
it('does not trigger before the threshold', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
expect(t.update(true, 0)).toBe(false);
|
||||
expect(t.update(true, 49)).toBe(false);
|
||||
});
|
||||
|
||||
it('triggers once the threshold is reached', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
expect(t.update(true, 0)).toBe(false);
|
||||
expect(t.update(true, 50)).toBe(true);
|
||||
});
|
||||
|
||||
it('triggers exactly once, then starts fresh', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
t.update(true, 0);
|
||||
expect(t.update(true, 60)).toBe(true);
|
||||
// After triggering, tracker is reset: next frame must not re-trigger
|
||||
expect(t.update(true, 70)).toBe(false);
|
||||
});
|
||||
|
||||
it('survives a brief dropout within the grace period', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
t.update(true, 0); // start matching
|
||||
t.update(false, 30); // one flickered frame
|
||||
expect(t.update(true, 60)).toBe(true); // 60ms elapsed > 50ms threshold
|
||||
});
|
||||
|
||||
it('survives several short dropouts', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
t.update(true, 0);
|
||||
t.update(false, 16);
|
||||
t.update(true, 32);
|
||||
t.update(false, 48);
|
||||
expect(t.update(true, 64)).toBe(true);
|
||||
});
|
||||
|
||||
it('resets when the interruption exceeds the grace period', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
t.update(true, 0);
|
||||
t.update(false, 10);
|
||||
// 200ms of silence later the user plays the note again:
|
||||
// must NOT inherit the 200ms-old start time (that would fire instantly)
|
||||
expect(t.update(true, 210)).toBe(false);
|
||||
expect(t.update(true, 259)).toBe(false);
|
||||
expect(t.update(true, 260)).toBe(true);
|
||||
});
|
||||
|
||||
it('resets when a wrong note persists beyond grace', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
t.update(true, 0);
|
||||
t.update(false, 20);
|
||||
t.update(false, 200); // wrong note held: grace exceeded
|
||||
expect(t.update(true, 210)).toBe(false); // restart from ~210
|
||||
expect(t.update(true, 261)).toBe(true);
|
||||
});
|
||||
|
||||
it('never triggers on non-matching frames alone', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
expect(t.update(false, 0)).toBe(false);
|
||||
expect(t.update(false, 1000)).toBe(false);
|
||||
});
|
||||
|
||||
it('handles frames arriving long after the last one (throttled tab)', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
// No frames for a while, then a single matching frame: fresh start.
|
||||
expect(t.update(true, 5000)).toBe(false);
|
||||
});
|
||||
|
||||
it('reset() clears accumulated progress', () => {
|
||||
const t = new MatchTracker(THRESHOLD, GRACE);
|
||||
t.update(true, 0);
|
||||
t.update(true, 40);
|
||||
t.reset();
|
||||
expect(t.update(true, 50)).toBe(false); // restarted at 50
|
||||
expect(t.update(true, 100)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user