Phrase mode: audio engine (groups, clicks, blanking), raw frame bus, trainer hook

This commit is contained in:
2026-09-14 00:50:47 +02:00
parent 7c5632fab6
commit c7f1fa170b
5 changed files with 700 additions and 12 deletions

View File

@@ -20,10 +20,40 @@ export function createAudioContext(): AudioContext {
* 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) {
@@ -35,38 +65,108 @@ class AudioEngine {
return this.ctx;
}
playNote(midi: number, duration = 0.5, volume = 0.3): void {
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);
// 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);
gainNode.gain.setValueAtTime(0, startAt);
gainNode.gain.linearRampToValueAtTime(volume, startAt + 0.02);
// Sustain -> Release
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
gainNode.gain.exponentialRampToValueAtTime(0.001, startAt + duration);
osc.start(now);
osc.stop(now + duration + 0.1); // Stop slightly after fade out
osc.start(startAt);
osc.stop(startAt + duration + 0.1);
// 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.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,
now + duration + PLAYBACK_GATE_TAIL_MS / 1000
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).
*/
playClickAt(time: number, volume = 0.5, accent = false): 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);
}
/** 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.

View File

@@ -73,6 +73,11 @@ export class PhraseMatcher {
return this.statuses[idx] ?? 'pending';
}
/** Snapshot of all statuses (for React state mirroring). */
allStatuses(): NoteStatus[] {
return [...this.statuses];
}
setStatus(idx: number, status: NoteStatus): void {
// One-way latch: a resolved note never changes.
if (this.statuses[idx] === 'pending' || this.statuses[idx] === 'rest') {

31
src/hooks/rawFrameBus.ts Normal file
View File

@@ -0,0 +1,31 @@
/**
* Minimal pub/sub for RAW pitch-detection frames (contract D1).
*
* The detector's rAF loop emits one frame per analysis tick — including
* silence (midi === null) — which is what phrase matching needs. The
* smoothed React state (pitchData) is display-only and keeps its 200 ms
* hold; it must never be used as a release signal.
*
* A tiny module-level bus is appropriate here: exactly one detector and at
* most one trainer exist at any time.
*/
export interface RawPitchFrame {
/** Raw detected midi (no smoothing, no hysteresis), or null. */
readonly midi: number | null;
/** performance.now() at frame emission. */
readonly at: number;
}
type Listener = (frame: RawPitchFrame) => void;
const listeners = new Set<Listener>();
export function emitRawFrame(frame: RawPitchFrame): void {
for (const l of listeners) l(frame);
}
export function subscribeRawFrames(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}

View File

@@ -0,0 +1,546 @@
/**
* Phrase-mode trainer: the state machine from the implementation contract
* (sections D/E). Pure logic lives in PhraseMatcher / PhraseTransport; this
* hook wires them to the audio clock, the raw frame bus, and React state.
*
* Phases: ready → countIn (tempo only) → playing → done, with paused/preview
* reachable from playing. Structural changes (score/selection/pace/bpm/…)
* cancel the run and return to ready — never retime a half-played note.
*
* The hook receives the FULL score plus a bar selection; it slices internally
* so a tempo resume can restart the current bar (ties entering the slice
* become fresh attacks — sliceScore does exactly that).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { scoreEvents, sliceScore, type Score } from '../score/model';
import { PhraseMatcher, type NoteStatus } from '../game/PhraseMatcher';
import { buildSchedule, sanitizeBpm, type ScheduleEntry } from '../game/PhraseTransport';
import { PPQ } from '../score/model';
import { audioEngine } from '../audio/AudioEngine';
import { subscribeRawFrames } from './rawFrameBus';
export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'done';
export interface PhraseSelection {
readonly startBar: number;
readonly barCount: number;
}
export interface PhraseTrainerConfig {
readonly pace: 'step' | 'tempo';
readonly bpm: number;
readonly clickSound: boolean;
readonly repeat: boolean;
readonly inputMode: 'mic' | 'virtual';
readonly previewVolume?: number;
}
export interface PhraseSummary {
readonly total: number;
readonly matched: number;
readonly missed: number;
readonly skipped: number;
}
const TICK_MS = 25;
const LOOKAHEAD_S = 0.1;
const CLICK_VOLUME = 0.4;
const PREVIEW_GROUP = 'phrase-preview';
const CLICK_GROUP = 'phrase-clicks';
const REPEAT_DELAY_MS = 1400;
export interface PhraseTrainerApi {
readonly phase: PhrasePhase;
/** Active slice the user is currently working through (may be a resume tail). */
readonly activeScore: Score;
readonly statuses: NoteStatus[];
/** Event index currently up (cursor for renderer/hints). */
readonly currentIdx: number;
readonly countInLeft: number;
readonly error: string | null;
readonly summary: PhraseSummary;
start: () => void;
pauseToggle: () => void;
retry: () => void;
skip: () => void;
previewToggle: () => void;
virtualTap: (midi: number) => void;
/** External pause (settings dialog, visibility change handled internally). */
pause: () => void;
stop: () => void;
}
export function usePhraseTrainer(
fullScore: Score,
selection: PhraseSelection,
config: PhraseTrainerConfig,
micActive: boolean,
micError: string | null,
): PhraseTrainerApi {
const [phase, setPhase] = useState<PhrasePhase>('ready');
const [statuses, setStatuses] = useState<NoteStatus[]>([]);
const [currentIdx, setCurrentIdx] = useState(-1);
const [countInLeft, setCountInLeft] = useState(0);
const [error, setError] = useState<string | null>(null);
const [accumulated, setAccumulated] = useState<PhraseSummary>({ total: 0, matched: 0, missed: 0, skipped: 0 });
// ---- Active slice (resume tails replace it) ---------------------------
const [activeScore, setActiveScore] = useState<Score>(() => sliceScore(fullScore, selection.startBar, selection.barCount));
const matcherRef = useRef<PhraseMatcher | null>(null);
const phaseRef = useRef<PhrasePhase>('ready');
const paceRef = useRef(config.pace);
const configRef = useRef(config);
const micActiveRef = useRef(micActive);
const scheduleRef = useRef<ScheduleEntry[]>([]);
const boundaryIdxRef = useRef(0);
const clickIdxRef = useRef(0);
const windowIdxRef = useRef(0);
const scoreStartRef = useRef(0);
const spbRef = useRef(1);
const runTokenRef = useRef(0);
const repeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const events = useMemo(() => scoreEvents(activeScore), [activeScore]);
const setPhaseBoth = useCallback((p: PhrasePhase) => {
phaseRef.current = p;
setPhase(p);
}, []);
useEffect(() => { paceRef.current = config.pace; }, [config.pace]);
useEffect(() => { configRef.current = config; }, [config]);
useEffect(() => { micActiveRef.current = micActive; }, [micActive]);
// ---- Matcher lifecycle ------------------------------------------------
const resetMatcher = useCallback((score: Score) => {
matcherRef.current = new PhraseMatcher(scoreEvents(score).map(e => (e.pitch ? e.pitch.midi : null)));
setStatuses(matcherRef.current.allStatuses());
}, []);
const makeSlice = useCallback(() =>
sliceScore(fullScore, selection.startBar, selection.barCount), [fullScore, selection.startBar, selection.barCount]);
// Structural change: new score or selection → full reset to ready.
useEffect(() => {
const slice = makeSlice();
setActiveScore(slice);
resetMatcher(slice);
setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 });
audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP);
setPhaseBoth('ready');
setCurrentIdx(-1);
setCountInLeft(0);
setError(null);
runTokenRef.current++;
}, [makeSlice, resetMatcher, setPhaseBoth]);
// Config change while running → pause (never retime mid-note).
const prevConfigRef = useRef(config);
useEffect(() => {
const prev = prevConfigRef.current;
prevConfigRef.current = config;
if (
(prev.pace !== config.pace || prev.bpm !== config.bpm ||
prev.clickSound !== config.clickSound || prev.inputMode !== config.inputMode) &&
(phaseRef.current === 'playing' || phaseRef.current === 'countIn')
) {
pauseInternalRef.current?.();
}
}, [config]);
// ---- Transport tick ----------------------------------------------------
const tickRef = useRef<() => void>(() => { });
const finishRun = useCallback(() => {
audioEngine.cancelGroup(CLICK_GROUP);
const m = matcherRef.current;
if (m) setStatuses(m.allStatuses());
setCurrentIdx(-1);
setPhaseBoth('done');
if (configRef.current.repeat) {
const token = runTokenRef.current;
repeatTimerRef.current = setTimeout(() => {
if (runTokenRef.current === token) startRef.current?.();
}, REPEAT_DELAY_MS);
}
}, [setPhaseBoth]);
const processBoundaries = useCallback((now: number) => {
const schedule = scheduleRef.current;
const token = runTokenRef.current;
if (!schedule) return;
// Schedule click audio ahead of time (no state change yet).
while (
clickIdxRef.current < schedule.length &&
schedule[clickIdxRef.current].at < now + LOOKAHEAD_S
) {
const e = schedule[clickIdxRef.current];
if ((e.kind === 'count-in-beat' || e.kind === 'beat') && configRef.current.clickSound) {
audioEngine.playClickAt(e.at, CLICK_VOLUME, e.kind === 'count-in-beat' ? e.beat === 0 : e.beat !== undefined);
}
clickIdxRef.current++;
}
// Fire state boundaries only when the clock reaches them.
while (boundaryIdxRef.current < schedule.length && schedule[boundaryIdxRef.current].at <= now) {
if (runTokenRef.current !== token) return;
const e = schedule[boundaryIdxRef.current];
if (e.kind === 'count-in-beat') {
if (phaseRef.current === 'countIn') {
const left = Math.max(0, Math.ceil((scoreStartRef.current - now) / spbRef.current));
setCountInLeft(left);
}
} else if (e.kind === 'note-end' && e.index !== undefined) {
matcherRef.current?.expireWindow(e.index);
windowIdxRef.current = e.index + 1;
const n = events.length;
const nextIdx = Math.min(windowIdxRef.current, Math.max(0, n - 1));
matcherRef.current?.setCurrent(nextIdx);
if (n > 0) setCurrentIdx(nextIdx);
const m = matcherRef.current;
if (m) setStatuses(m.allStatuses());
} else if (e.kind === 'done') {
finishRun();
return;
}
boundaryIdxRef.current++;
}
if (phaseRef.current === 'countIn' && now >= scoreStartRef.current) {
setPhaseBoth('playing');
setCountInLeft(0);
}
}, [events.length, finishRun, setPhaseBoth]);
useEffect(() => {
tickRef.current = () => {
const now = audioEngine.now();
processBoundaries(now);
};
}, [processBoundaries]);
// Interval only while time is flowing.
useEffect(() => {
if (phase !== 'countIn' && phase !== 'playing') return;
const id = setInterval(() => tickRef.current(), TICK_MS);
return () => clearInterval(id);
}, [phase]);
const advanceStep = useCallback((m: PhraseMatcher) => {
const next = m.firstPending;
if (next === -1) {
finishRun();
} else {
setCurrentIdx(next);
}
}, [finishRun]);
// ---- Frame handling ----------------------------------------------------
useEffect(() => {
const unsubscribe = subscribeRawFrames(frame => {
if (configRef.current.inputMode !== 'mic') return;
const p = phaseRef.current;
if (p !== 'countIn' && p !== 'playing') return;
// Pitched speaker output (preview, virtual instruments) must not
// be scored; while audible the matcher sees no frames at all.
if (audioEngine.isAudible()) return;
if (paceRef.current === 'tempo') {
// Finish expired windows before applying the frame.
processBoundaries(audioEngine.now());
if (phaseRef.current !== 'countIn' && phaseRef.current !== 'playing') return;
}
// Click blanking: skip the frame entirely (no episode update).
if (audioEngine.isMicBlanked()) return;
const m = matcherRef.current;
if (!m) return;
let idx: number;
if (paceRef.current === 'step') {
idx = m.firstPending;
if (idx === -1) return;
} else {
idx = Math.min(windowIdxRef.current, Math.max(0, events.length - 1));
}
const out = m.feed(frame.midi, frame.at, idx);
if (out) {
setStatuses(m.allStatuses());
if (paceRef.current === 'step') advanceStep(m);
}
});
return unsubscribe;
}, [events.length, processBoundaries, advanceStep]);
// ---- Actions -----------------------------------------------------------
const startRef = useRef<(() => void) | null>(null);
const start = useCallback(() => {
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP);
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
runTokenRef.current++;
const token = runTokenRef.current;
setError(null);
const score = activeScore;
const m = matcherRef.current;
if (!m) return;
m.reset();
setStatuses(m.allStatuses());
setAccumulated(prev => (prev.total === 0 ? prev : prev)); // keep; retry resets separately
const bpm = sanitizeBpm(configRef.current.bpm);
spbRef.current = 60 / bpm;
if (paceRef.current === 'tempo') {
void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token) return;
if (!audioEngine.isRunning()) {
setError('Audio could not start — try again.');
setPhaseBoth('ready');
return;
}
const now = audioEngine.now();
const countIn = score.meter.numerator;
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
scheduleRef.current = buildSchedule(score, bpm, now + 0.15, countIn);
boundaryIdxRef.current = 0;
clickIdxRef.current = 0;
windowIdxRef.current = 0;
m.setCurrent(scoreEvents(score).length > 0 ? 0 : -1);
setCurrentIdx(0);
setCountInLeft(countIn);
setPhaseBoth('countIn');
tickRef.current();
});
} else {
// At-your-pace needs no audio context at all.
scheduleRef.current = [];
const first = m.firstPending;
if (first === -1) { finishRun(); return; }
m.setCurrent(first);
setCurrentIdx(first);
setPhaseBoth('playing');
}
}, [activeScore, finishRun, setPhaseBoth]);
startRef.current = start;
const pauseInternalRef = useRef<(() => void) | null>(null);
const pause = useCallback(() => {
if (phaseRef.current !== 'playing' && phaseRef.current !== 'countIn') return;
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
audioEngine.cancelGroup(CLICK_GROUP);
setPhaseBoth('paused');
}, [setPhaseBoth]);
pauseInternalRef.current = pause;
const resumeTempo = useCallback(() => {
const m = matcherRef.current;
if (!m) return;
const evts = events;
if (evts.length === 0) { start(); return; }
// Current bar = bar of the window we were in (or the last note's bar).
const idx = Math.min(windowIdxRef.current, evts.length - 1);
const ev = evts[Math.max(0, idx)];
const bar = activeScore.measures.find(
m2 => ev.startTick >= m2.startTick && ev.startTick < m2.startTick + m2.durationTicks
) ?? activeScore.measures[activeScore.measures.length - 1];
// Accumulate resolved results of the DROPPED part (events fully
// before the bar we restart from).
const barStart = bar.startTick;
let droppedTotal = 0, droppedMatched = 0, droppedMissed = 0, droppedSkipped = 0;
evts.forEach((e, i) => {
if (e.startTick + e.durationTicks <= barStart) {
const st = m.status(i);
if (!e.pitch) return;
droppedTotal++;
if (st === 'matched') droppedMatched++;
else if (st === 'missed') droppedMissed++;
else if (st === 'skipped') droppedSkipped++;
}
});
const dropped: PhraseSummary = { total: droppedTotal, matched: droppedMatched, missed: droppedMissed, skipped: droppedSkipped };
setAccumulated(prev => ({
total: prev.total + dropped.total,
matched: prev.matched + dropped.matched,
missed: prev.missed + dropped.missed,
skipped: prev.skipped + dropped.skipped,
}));
// Re-slice from that bar: ties entering the slice become fresh attacks.
const remainingBarCount = selection.startBar + selection.barCount - bar.number;
const tail = sliceScore(fullScore, bar.number, Math.max(1, remainingBarCount));
setActiveScore(tail);
resetMatcher(tail);
runTokenRef.current++;
const token = runTokenRef.current;
const bpm = sanitizeBpm(configRef.current.bpm);
spbRef.current = 60 / bpm;
void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token) return;
const now = audioEngine.now();
const countIn = tail.meter.numerator;
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
scheduleRef.current = buildSchedule(tail, bpm, now + 0.15, countIn);
boundaryIdxRef.current = 0;
clickIdxRef.current = 0;
windowIdxRef.current = 0;
matcherRef.current?.setCurrent(scoreEvents(tail).length > 0 ? 0 : -1);
setCurrentIdx(0);
setCountInLeft(countIn);
setPhaseBoth('countIn');
tickRef.current();
});
}, [activeScore, events, fullScore, resetMatcher, selection.barCount, selection.startBar, setPhaseBoth, start]);
const pauseToggle = useCallback(() => {
const p = phaseRef.current;
if (p === 'playing' || p === 'countIn') pause();
else if (p === 'paused') {
if (paceRef.current === 'tempo') resumeTempo();
else setPhaseBoth('playing');
} else if (p === 'ready' || p === 'done') start();
// preview: handled by previewToggle
}, [pause, resumeTempo, setPhaseBoth, start]);
const retry = useCallback(() => {
const score = makeSlice();
setActiveScore(score);
resetMatcher(score);
setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 });
start();
}, [makeSlice, resetMatcher, start]);
const skip = useCallback(() => {
if (phaseRef.current !== 'playing') return;
if (paceRef.current !== 'step') return; // skip is at-your-pace only
const m = matcherRef.current;
if (!m) return;
const idx = m.firstPending;
if (idx === -1) return;
m.setStatus(idx, 'skipped');
setStatuses(m.allStatuses());
advanceStep(m);
}, [advanceStep]);
const previewToggle = useCallback(() => {
if (phaseRef.current === 'preview') {
audioEngine.cancelGroup(PREVIEW_GROUP);
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
setPhaseBoth('ready');
return;
}
pause();
runTokenRef.current++;
const token = runTokenRef.current;
void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token) return;
const bpm = sanitizeBpm(configRef.current.bpm);
const spb = 60 / bpm;
const t0 = audioEngine.now() + 0.15;
const vol = configRef.current.previewVolume ?? 0.4;
for (const e of events) {
if (!e.pitch) continue;
audioEngine.scheduleNote(e.pitch.midi, t0 + (e.startTick / PPQ) * spb, (e.durationTicks / PPQ) * spb, vol, PREVIEW_GROUP);
}
setPhaseBoth('preview');
const totalSec = (events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0) / PPQ) * spb;
previewTimerRef.current = setTimeout(() => {
if (runTokenRef.current === token) {
setPhaseBoth('ready');
}
}, 150 + totalSec * 1000 + 200);
});
}, [events, pause, setPhaseBoth]);
const virtualTap = useCallback((midi: number) => {
const p = phaseRef.current;
if (p !== 'playing') return; // no scoring during count-in/preview/done
const m = matcherRef.current;
if (!m) return;
let idx: number;
if (paceRef.current === 'step') {
idx = m.firstPending;
if (idx === -1) return;
} else {
idx = Math.min(windowIdxRef.current, Math.max(0, events.length - 1));
}
const out = m.tap(midi, idx);
if (out) {
setStatuses(m.allStatuses());
if (paceRef.current === 'step') advanceStep(m);
}
}, [advanceStep, events.length]);
const stop = useCallback(() => {
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP);
runTokenRef.current++;
setPhaseBoth('ready');
}, [setPhaseBoth]);
// ---- Lifecycle guards ---------------------------------------------------
// Pause when the tab hides or the microphone fails mid-run.
useEffect(() => {
const onVisibility = () => {
if (document.hidden) pause();
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, [pause]);
useEffect(() => {
if (micError && (phaseRef.current === 'playing' || phaseRef.current === 'countIn')) pause();
}, [micError, pause]);
useEffect(() => {
if (phaseRef.current !== 'ready' && !micActive && configRef.current.inputMode === 'mic') pause();
}, [micActive, pause]);
useEffect(() => () => {
// Unmount: cancel all scheduled audio and timers.
if (repeatTimerRef.current) clearTimeout(repeatTimerRef.current);
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP);
}, []);
const summary = useMemo<PhraseSummary>(() => {
let total = accumulated.total;
let matched = accumulated.matched;
let missed = accumulated.missed;
let skipped = accumulated.skipped;
for (let i = 0; i < statuses.length; i++) {
if (events[i]?.pitch === null || events[i]?.pitch === undefined) continue;
if (statuses[i] === 'pending' || statuses[i] === 'rest') continue;
total++;
if (statuses[i] === 'matched') matched++;
else if (statuses[i] === 'missed') missed++;
else if (statuses[i] === 'skipped') skipped++;
}
return { total, matched, missed, skipped };
}, [accumulated, statuses, events]);
return {
phase,
activeScore,
statuses,
currentIdx,
countInLeft,
error,
summary,
start,
pauseToggle,
retry,
skip,
previewToggle,
virtualTap,
pause,
stop,
};
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer';
import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils';
import { emitRawFrame } from './rawFrameBus';
import {
MIC_DEFAULT_SENSITIVITY,
PITCH_READING_HOLD_MS,
@@ -91,6 +92,11 @@ export function usePitchDetector(
const freq = analyzer.getPitch();
const now = performance.now();
// RAW frame for sequence matching (phrase mode): one per tick,
// silence included. Not batched through React state — matching
// must see nulls immediately and must not depend on render rate.
emitRawFrame({ midi: freq ? frequencyToMidi(freq) : null, at: now });
if (freq) {
lastPitchAt = now;
const midi = frequencyToMidi(freq);