Files
sheetmusictrainer/src/audio/AudioEngine.ts
OpenClaw 9acf32a32f UI consolidation round: pace checkbox, unified Limit notes section, fixes
- Pace dropdown REMOVED from Phrase setup — replaced by a 'Sync to
  exercise' checkbox in the Metronome widget (analog to 'Auto' in
  single-note modes). pace is derived from the checkbox (checked = tempo,
  unchecked = step).
- Preview clicks now registered in PREVIEW_GROUP: stopping/abandoning a
  preview cancels its scheduled clicks — no more parallel ghost
  metronomes after preview->stop->next exercise.
- Chord pool replaces the chord dropdown entirely (single chord AND
  sequences): togglable degree list, empty = all 7.
- Giuliani figures corrected to NOTE sequences (p always takes the bass/
  root): p-i-m, p-i-m-a, p-a-m-i, a-i-m, p-i-m-a-m-i, p-m-a-m-i-m,
  p-e-a-m-a-m (replaced the fingering-driven patterns that made no
  musical sense).
- 'Limit notes' is now one bordered section like Tuner/Metronome, with a
  header, Clear-all link, and Strings behind a toggle (row hidden until
  restricted).
2026-09-14 21:08:15 +00:00

192 lines
7.2 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).
*/
interface ActiveNote {
osc: OscillatorNode;
gain: GainNode;
/** Audio-clock time after which this note is no longer sounding. */
endsAt: number;
groupId: string;
}
class AudioEngine {
private ctx: AudioContext | null = null;
/** ctx.currentTime (seconds) until which output may still be audible, incl. gate tail */
private audibleUntil = 0;
/** Currently scheduled/playing notes, for cancellation (preview groups). */
private active: ActiveNote[] = [];
/** Mic blanking after non-tonal metronome clicks (seconds, audio clock). */
private clickBlankUntil = 0;
private noiseBuffer: AudioBuffer | null = null;
/** Audio-clock time; 0 when no context exists yet. */
now(): number {
return this.ctx ? this.ctx.currentTime : 0;
}
/** Create/resume the context (must be called from a user gesture). */
async ensureRunning(): Promise<void> {
this.ensureContext();
if (this.ctx && this.ctx.state === 'suspended') {
await this.ctx.resume();
}
}
isRunning(): boolean {
return !!this.ctx && this.ctx.state === 'running';
}
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, groupId = 'oneshot'): void {
const ctx = this.ensureContext();
const now = ctx.currentTime;
this.scheduleNote(midi, now, duration, volume, groupId);
}
/**
* Schedule a pitched note at an absolute audio-clock time (phrase preview).
* Registers the node so the group can be cancelled; the mic gate covers
* the whole scheduled span (see isAudible).
*/
scheduleNote(midi: number, startAt: number, duration: number, volume = 0.3, groupId = 'preview'): void {
const ctx = this.ensureContext();
const osc = ctx.createOscillator();
const gainNode = ctx.createGain();
osc.frequency.value = midiToFrequency(midi);
osc.type = 'triangle';
osc.connect(gainNode);
gainNode.connect(ctx.destination);
// Attack
gainNode.gain.setValueAtTime(0, startAt);
gainNode.gain.linearRampToValueAtTime(volume, startAt + 0.02);
// Sustain -> Release
gainNode.gain.exponentialRampToValueAtTime(0.001, startAt + duration);
osc.start(startAt);
osc.stop(startAt + duration + 0.1);
this.active.push({ osc, gain: gainNode, endsAt: startAt + duration, groupId });
// The note decays to -60dB at `startAt + duration`; keep the mic gated
// for an additional tail so room reverb / speaker bleed can't self-trigger.
this.audibleUntil = Math.max(
this.audibleUntil,
startAt + duration + PLAYBACK_GATE_TAIL_MS / 1000
);
}
/**
* Cancel a playback group (preview, metronome clicks). Stops scheduled
* nodes, removes them, and recomputes the mic gate from what remains —
* a cancelled long preview must not keep the mic gated until its former
* end time.
*/
cancelGroup(groupId: string): void {
if (!this.ctx) return;
const now = this.ctx.currentTime;
this.active = this.active.filter(n => {
if (n.groupId !== groupId) return true;
try {
n.gain.gain.cancelScheduledValues(now);
n.gain.gain.setValueAtTime(0, now);
n.osc.stop(now + 0.01);
} catch {
// Already stopped — nothing to do.
}
return false;
});
this.recomputeGate();
}
private recomputeGate(): void {
this.audibleUntil = this.active.reduce(
(max, n) => Math.max(max, n.endsAt + PLAYBACK_GATE_TAIL_MS / 1000),
0
);
}
/**
* Short NON-TONAL metronome click (~10 ms noise burst). Not registered in
* the pitched-audio gate; instead the mic is blanked for 30 ms so the
* click itself can't be scored as a pitch (contract E2).
* Optional groupId: the click becomes cancellable via cancelGroup (used
* by preview clicks so stopping a preview also stops its clicks).
*/
playClickAt(time: number, volume = 0.5, accent = false, groupId?: string): void {
const ctx = this.ensureContext();
if (!this.noiseBuffer) {
const len = Math.floor(ctx.sampleRate * 0.01);
this.noiseBuffer = ctx.createBuffer(1, len, ctx.sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len);
}
const src = ctx.createBufferSource();
src.buffer = this.noiseBuffer;
const gainNode = ctx.createGain();
const v = accent ? Math.min(1, volume * 1.6) : volume;
gainNode.gain.setValueAtTime(v, time);
gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.01);
src.connect(gainNode);
gainNode.connect(ctx.destination);
src.start(time);
src.stop(time + 0.02);
this.clickBlankUntil = Math.max(this.clickBlankUntil, time + 0.03);
if (groupId) {
// Register as a cancellable node (cast: clicks are buffer sources,
// cancelGroup only calls stop() on them, which BufferSource has).
this.active.push({ osc: src as unknown as OscillatorNode, gain: gainNode, endsAt: time + 0.02, groupId });
}
}
/** True for ~30 ms after a scheduled click (mic frames should be skipped). */
isMicBlanked(): boolean {
return !!this.ctx && this.ctx.state === 'running' && this.ctx.currentTime < this.clickBlankUntil;
}
/**
* 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();