Files
sheetmusictrainer/src/audio/pitchDetection.test.ts
Jens Mohrmann 079d574b66 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).
2026-09-13 18:52:21 +02:00

157 lines
5.8 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
chooseAnalysisWindowSize,
selectDetector,
normalizeSignal,
} from './PitchAnalyzer';
import {
PITCH_BUFFER_SIZE,
PITCH_MIN_WINDOW,
PITCH_BELOW_FLOOR_REJECT_FACTOR,
} from '../AppConfig';
/**
* Regression tests for the pitch detection pipeline.
*
* pitchfinder's YIN returns ~20 kHz garbage for low fundamentals (its
* forced yinBuffer[1] = 1 breaks the absolute-threshold search below
* ~100 Hz) and for quiet input. The app therefore uses ACF2PLUS for normal
* instruments, YIN only for high registers, and normalizes the analysis
* buffer to a constant RMS. These tests synthesize instrument-like signals
* (fundamental + harmonics + pluck decay, deterministic) and run them
* through the exact helpers PitchAnalyzer uses.
*/
/** Deterministic instrument-like test signal (no RNG → no flaky tests). */
function synth(freq: number, sampleRate: number, length: number, amp: number): Float32Array {
const buf = new Float32Array(length);
for (let i = 0; i < length; i++) {
const t = i / sampleRate;
const env = Math.exp(-t * 3); // pluck-like decay
buf[i] = amp * env * (
Math.sin(2 * Math.PI * freq * t) +
0.5 * Math.sin(2 * Math.PI * freq * 2 * t) +
0.25 * Math.sin(2 * Math.PI * freq * 3 * t) +
0.1 * Math.sin(2 * Math.PI * freq * 4 * t) +
0.02 * Math.sin(2 * Math.PI * 7.13 * i / length) // deterministic "noise" dither
);
}
return buf;
}
/** Mirror of PitchAnalyzer.getPitch, minus microphone plumbing. */
function detect(freq: number, sampleRate: number, lowestHz: number, amp = 0.3): number | null {
const size = chooseAnalysisWindowSize(sampleRate, lowestHz);
const buffer = synth(freq, sampleRate, size, amp);
const { detector, normalize } = selectDetector(sampleRate, lowestHz);
if (normalize) normalizeSignal(buffer);
const pitch = detector(buffer);
if (pitch === null || pitch < 25 || pitch > 8000) return null;
if (pitch < lowestHz * PITCH_BELOW_FLOOR_REJECT_FACTOR) return null;
return pitch;
}
function expectCloseTo(actual: number | null, expected: number) {
expect(actual).not.toBeNull();
expect(Math.abs(actual! - expected) / expected).toBeLessThan(0.03);
}
describe('chooseAnalysisWindowSize', () => {
it('adapts to the instrument floor', () => {
// Guitar low E2 (~78 Hz w/ margin) -> half window at 48k
expect(chooseAnalysisWindowSize(48000, 77.8)).toBe(2048);
// Bass low E1 (~39 Hz w/ margin) -> full window
expect(chooseAnalysisWindowSize(48000, 38.9)).toBe(PITCH_BUFFER_SIZE);
// Whistle register -> minimum window
expect(chooseAnalysisWindowSize(48000, 622)).toBe(PITCH_MIN_WINDOW);
});
});
describe('pitch detection across instruments (sample rate 48000)', () => {
const SR = 48000;
it('guitar (low E2)', () => {
const floor = 77.8; // E2 minus one semitone
expectCloseTo(detect(82.4, SR, floor), 82.4); // E2 — the regression case
expectCloseTo(detect(110.0, SR, floor), 110); // A2
expectCloseTo(detect(329.6, SR, floor), 329.6); // E4
});
it('guitar at low input level (quiet pluck tail)', () => {
expectCloseTo(detect(82.4, SR, 77.8, 0.05), 82.4);
expectCloseTo(detect(440, SR, 77.8, 0.05), 440);
});
it('bass (low E1)', () => {
const floor = 38.9;
expectCloseTo(detect(41.2, SR, floor), 41.2); // E1 — broken even before the rewrite
expectCloseTo(detect(55.0, SR, floor), 55); // A1
});
it('piano', () => {
const floor = 61.7; // C2 minus one semitone
expectCloseTo(detect(65.4, SR, floor), 65.4); // C2
expectCloseTo(detect(261.6, SR, floor), 261.6); // C4
expectCloseTo(detect(1046.5, SR, floor), 1046.5); // C6
});
it('whistle register (uses YIN, small window)', () => {
const floor = 622; // E5 minus one semitone
expectCloseTo(detect(659.3, SR, floor), 659.3); // E5
expectCloseTo(detect(2093.0, SR, floor), 2093); // C7
expectCloseTo(detect(4186.0, SR, floor), 4186); // C8
});
});
describe('pitch detection (sample rate 44100)', () => {
it('still works', () => {
expectCloseTo(detect(82.4, 44100, 77.8), 82.4);
expectCloseTo(detect(41.2, 44100, 38.9), 41.2);
expectCloseTo(detect(440, 44100, 77.8), 440);
});
});
describe('normalizeSignal', () => {
it('scales to the target RMS', () => {
const buf = synth(440, 48000, 2048, 0.02);
normalizeSignal(buf, 0.3);
let rms = 0;
for (const v of buf) rms += v * v;
rms = Math.sqrt(rms / buf.length);
expect(rms).toBeGreaterThan(0.2); // ~0.3 modulo decay envelope shape
});
it('leaves silence untouched', () => {
const buf = new Float32Array(1024);
normalizeSignal(buf);
expect(buf.every(v => v === 0)).toBe(true);
});
});
describe('noise-floor rejection (tuner freeze regression)', () => {
const SR = 48000;
it('rejects 50 Hz mains hum for guitar (floor ~78 Hz)', () => {
expect(detect(50, SR, 77.8)).toBeNull();
});
it('rejects 60 Hz mains hum for guitar', () => {
expect(detect(60, SR, 77.8)).toBeNull();
});
it('rejects hum for the whistle register too', () => {
expect(detect(50, SR, 622)).toBeNull();
expect(detect(120, SR, 622)).toBeNull();
});
it('returns null for very quiet decayed signal instead of a confident wrong pitch', () => {
// Raw RMS ~0.003: below ACF2PLUS's internal unpitched gate.
// (Normalization must NOT rescue this into a bogus reading.)
expect(detect(82.4, SR, 77.8, 0.005)).toBeNull();
});
it('still detects the string when it rings at moderate level', () => {
expectCloseTo(detect(82.4, SR, 77.8, 0.05), 82.4);
});
});