mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode: audio engine (groups, clicks, blanking), raw frame bus, trainer hook
This commit is contained in:
546
src/hooks/usePhraseTrainer.ts
Normal file
546
src/hooks/usePhraseTrainer.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user