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:
@@ -20,10 +20,40 @@ export function createAudioContext(): AudioContext {
|
|||||||
* app is audible — otherwise the app hears its own reference notes through
|
* app is audible — otherwise the app hears its own reference notes through
|
||||||
* the speaker and scores them as correct (ear-training feedback loop).
|
* 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 {
|
class AudioEngine {
|
||||||
private ctx: AudioContext | null = null;
|
private ctx: AudioContext | null = null;
|
||||||
/** ctx.currentTime (seconds) until which output may still be audible, incl. gate tail */
|
/** ctx.currentTime (seconds) until which output may still be audible, incl. gate tail */
|
||||||
private audibleUntil = 0;
|
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 {
|
private ensureContext(): AudioContext {
|
||||||
if (!this.ctx) {
|
if (!this.ctx) {
|
||||||
@@ -35,38 +65,108 @@ class AudioEngine {
|
|||||||
return this.ctx;
|
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 ctx = this.ensureContext();
|
||||||
|
|
||||||
const osc = ctx.createOscillator();
|
const osc = ctx.createOscillator();
|
||||||
const gainNode = ctx.createGain();
|
const gainNode = ctx.createGain();
|
||||||
|
|
||||||
osc.frequency.value = midiToFrequency(midi);
|
osc.frequency.value = midiToFrequency(midi);
|
||||||
// Triangle wave is often nicer than sine for music training
|
|
||||||
osc.type = 'triangle';
|
osc.type = 'triangle';
|
||||||
|
|
||||||
osc.connect(gainNode);
|
osc.connect(gainNode);
|
||||||
gainNode.connect(ctx.destination);
|
gainNode.connect(ctx.destination);
|
||||||
|
|
||||||
const now = ctx.currentTime;
|
|
||||||
|
|
||||||
// Attack
|
// Attack
|
||||||
gainNode.gain.setValueAtTime(0, now);
|
gainNode.gain.setValueAtTime(0, startAt);
|
||||||
gainNode.gain.linearRampToValueAtTime(volume, now + 0.02);
|
gainNode.gain.linearRampToValueAtTime(volume, startAt + 0.02);
|
||||||
// Sustain -> Release
|
// Sustain -> Release
|
||||||
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
|
gainNode.gain.exponentialRampToValueAtTime(0.001, startAt + duration);
|
||||||
|
|
||||||
osc.start(now);
|
osc.start(startAt);
|
||||||
osc.stop(now + duration + 0.1); // Stop slightly after fade out
|
osc.stop(startAt + duration + 0.1);
|
||||||
|
|
||||||
// The note decays to -60dB at `now + duration`; keep the mic gated for
|
this.active.push({ osc, gain: gainNode, endsAt: startAt + duration, groupId });
|
||||||
// an additional tail so room reverb / speaker bleed can't self-trigger.
|
// 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 = Math.max(
|
||||||
this.audibleUntil,
|
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
|
* True while speaker output (plus gate tail) may still be audible at the
|
||||||
* microphone. Mic-based note matching should be ignored during this time.
|
* microphone. Mic-based note matching should be ignored during this time.
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ export class PhraseMatcher {
|
|||||||
return this.statuses[idx] ?? 'pending';
|
return this.statuses[idx] ?? 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Snapshot of all statuses (for React state mirroring). */
|
||||||
|
allStatuses(): NoteStatus[] {
|
||||||
|
return [...this.statuses];
|
||||||
|
}
|
||||||
|
|
||||||
setStatus(idx: number, status: NoteStatus): void {
|
setStatus(idx: number, status: NoteStatus): void {
|
||||||
// One-way latch: a resolved note never changes.
|
// One-way latch: a resolved note never changes.
|
||||||
if (this.statuses[idx] === 'pending' || this.statuses[idx] === 'rest') {
|
if (this.statuses[idx] === 'pending' || this.statuses[idx] === 'rest') {
|
||||||
|
|||||||
31
src/hooks/rawFrameBus.ts
Normal file
31
src/hooks/rawFrameBus.ts
Normal 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); };
|
||||||
|
}
|
||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer';
|
import { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer';
|
||||||
import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils';
|
import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils';
|
||||||
|
import { emitRawFrame } from './rawFrameBus';
|
||||||
import {
|
import {
|
||||||
MIC_DEFAULT_SENSITIVITY,
|
MIC_DEFAULT_SENSITIVITY,
|
||||||
PITCH_READING_HOLD_MS,
|
PITCH_READING_HOLD_MS,
|
||||||
@@ -91,6 +92,11 @@ export function usePitchDetector(
|
|||||||
const freq = analyzer.getPitch();
|
const freq = analyzer.getPitch();
|
||||||
const now = performance.now();
|
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) {
|
if (freq) {
|
||||||
lastPitchAt = now;
|
lastPitchAt = now;
|
||||||
const midi = frequencyToMidi(freq);
|
const midi = frequencyToMidi(freq);
|
||||||
|
|||||||
Reference in New Issue
Block a user