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).
85 lines
3.0 KiB
TypeScript
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();
|