mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
- 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).
157 lines
5.8 KiB
TypeScript
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);
|
|
});
|
|
});
|