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

@@ -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,
};
}