Files
sheetmusictrainer/src/audio/AudioEngine.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

85 lines
3.0 KiB
TypeScript

import { midiToFrequency } from '../music/NoteUtils';
import { PLAYBACK_GATE_TAIL_MS } from '../AppConfig';
/**
* Create an AudioContext with a typed webkit fallback (older Safari).
*/
export function createAudioContext(): AudioContext {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
return new Ctor({ latencyHint: 'interactive' });
}
/**
* Shared audio playback engine (singleton).
*
* All speaker output goes through a single AudioContext (previously App and
* useGameLogic each created their own via separate useAudioPlayer instances).
* Playback is tracked so pitch detection can ignore the microphone while the
* app is audible — otherwise the app hears its own reference notes through
* the speaker and scores them as correct (ear-training feedback loop).
*/
class AudioEngine {
private ctx: AudioContext | null = null;
/** ctx.currentTime (seconds) until which output may still be audible, incl. gate tail */
private audibleUntil = 0;
private ensureContext(): AudioContext {
if (!this.ctx) {
this.ctx = createAudioContext();
}
if (this.ctx.state === 'suspended') {
void this.ctx.resume();
}
return this.ctx;
}
playNote(midi: number, duration = 0.5, volume = 0.3): void {
const ctx = this.ensureContext();
const osc = ctx.createOscillator();
const gainNode = ctx.createGain();
osc.frequency.value = midiToFrequency(midi);
// Triangle wave is often nicer than sine for music training
osc.type = 'triangle';
osc.connect(gainNode);
gainNode.connect(ctx.destination);
const now = ctx.currentTime;
// Attack
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(volume, now + 0.02);
// Sustain -> Release
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.start(now);
osc.stop(now + duration + 0.1); // Stop slightly after fade out
// The note decays to -60dB at `now + duration`; keep the mic gated for
// an additional tail so room reverb / speaker bleed can't self-trigger.
this.audibleUntil = Math.max(
this.audibleUntil,
now + duration + PLAYBACK_GATE_TAIL_MS / 1000
);
}
/**
* True while speaker output (plus gate tail) may still be audible at the
* microphone. Mic-based note matching should be ignored during this time.
*
* A suspended AudioContext (autoplay policy, no user gesture yet) reports
* currentTime = 0 forever — without the state check the gate would be
* stuck "audible" permanently and matching would never engage.
*/
isAudible(): boolean {
if (!this.ctx || this.ctx.state !== 'running') return false;
return this.ctx.currentTime < this.audibleUntil;
}
}
export const audioEngine = new AudioEngine();