diff --git a/src/audio/AudioEngine.ts b/src/audio/AudioEngine.ts index fa04eed..8467f50 100644 --- a/src/audio/AudioEngine.ts +++ b/src/audio/AudioEngine.ts @@ -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 { + 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. diff --git a/src/game/PhraseMatcher.ts b/src/game/PhraseMatcher.ts index a7e43c0..848d8dd 100644 --- a/src/game/PhraseMatcher.ts +++ b/src/game/PhraseMatcher.ts @@ -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') { diff --git a/src/hooks/rawFrameBus.ts b/src/hooks/rawFrameBus.ts new file mode 100644 index 0000000..0368cf7 --- /dev/null +++ b/src/hooks/rawFrameBus.ts @@ -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(); + +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); }; +} diff --git a/src/hooks/usePhraseTrainer.ts b/src/hooks/usePhraseTrainer.ts new file mode 100644 index 0000000..f695305 --- /dev/null +++ b/src/hooks/usePhraseTrainer.ts @@ -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('ready'); + const [statuses, setStatuses] = useState([]); + const [currentIdx, setCurrentIdx] = useState(-1); + const [countInLeft, setCountInLeft] = useState(0); + const [error, setError] = useState(null); + const [accumulated, setAccumulated] = useState({ total: 0, matched: 0, missed: 0, skipped: 0 }); + + // ---- Active slice (resume tails replace it) --------------------------- + const [activeScore, setActiveScore] = useState(() => sliceScore(fullScore, selection.startBar, selection.barCount)); + + const matcherRef = useRef(null); + const phaseRef = useRef('ready'); + const paceRef = useRef(config.pace); + const configRef = useRef(config); + const micActiveRef = useRef(micActive); + + const scheduleRef = useRef([]); + 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 | null>(null); + const previewTimerRef = useRef | 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(() => { + 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, + }; +} diff --git a/src/hooks/usePitchDetector.ts b/src/hooks/usePitchDetector.ts index 81ceae1..da65228 100644 --- a/src/hooks/usePitchDetector.ts +++ b/src/hooks/usePitchDetector.ts @@ -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);