2 Commits

Author SHA1 Message Date
OpenClaw
672cb341a7 Preview: pause/resume with cursor, stepping only while paused, indicator follows playback
- New phase 'previewPaused': pause stops scheduled audio but keeps the
  preview cursor; resume continues from the cursor position.
- Step buttons (and arrow keys) are active ONLY while the preview is
  paused; each step moves the cursor one event and plays that single
  note as a find-it-on-the-neck cue.
- Preview indicator now follows playback in real time: a cursor
  interval advances previewIdx by audio time (event start ticks), so
  the notation caret moves with the sounding note — also while
  paused at the stepped position.
- Main Start button shares the semantics: while previewing it shows
  Pause/Resume and routes to the preview controls (exercise and
  preview transport behave consistently).
- External pause (settings dialog, tab hide) during preview now keeps
  the cursor (previewPaused) instead of dropping to ready.
2026-09-15 22:37:29 +00:00
OpenClaw
75ad4613bb Phrase mode: player-style transport, separate display toggles, preview cursor + stepping
- Transport restructured like a music player: Start/Pause, step
  back/forward buttons (while previewing), Preview, Retry, Next.
- Virtual instrument and Hint moved out of the transport row into a
  separate display-toggles row using switch-style toggles (same visual
  language as tuner/metronome switches) — pure show/hide, no run state.
- Preview now tracks a cursor: the current preview event is marked in
  the notation (caret + color via existing currentIdx pipeline).
- Preview stepping: pause-free stepping forward/backward one event at
  a time; each step replays from the cursor position so notes can be
  located on the fretboard. Arrow keys ←/→ work while previewing.
- With Hint active, the fretboard/piano marks the preview cursor note
  (displayIdx follows the preview cursor).
- previewIdx resets on structural changes and when the preview ends.
2026-09-15 22:27:04 +00:00
4 changed files with 239 additions and 52 deletions

View File

@@ -1172,3 +1172,37 @@ button {
margin: 0; margin: 0;
text-align: center; text-align: center;
} }
/* ---- Transport step group (preview stepping) ---------------------------- */
.transport-step-group {
display: flex;
gap: 6px;
}
.step-button {
padding: 0.4rem 0.55rem;
}
/* ---- Display toggles (virtual instrument / hint show-hide) -------------- */
/* Separate row of pure show/hide switches, styled like the tuner and
metronome switches — visually distinct from transport actions. */
.display-toggles {
display: flex;
justify-content: center;
gap: var(--spacing-lg);
margin-top: 10px;
}
.display-toggle {
display: flex;
align-items: center;
gap: 8px;
}
.display-toggle-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--color-text-muted);
}

View File

@@ -165,6 +165,12 @@ function App() {
case 'n': case 'n':
phraseRef.current?.next(); phraseRef.current?.next();
break; break;
case 'arrowleft':
phraseRef.current?.previewStep(-1);
break;
case 'arrowright':
phraseRef.current?.previewStep(1);
break;
case 's': case 's':
phraseRef.current?.skip(); phraseRef.current?.skip();
break; break;
@@ -430,6 +436,7 @@ function App() {
<> <>
<div className="help-item"><span>Start / Pause / Resume</span><span className="shortcut-key">Space</span></div> <div className="help-item"><span>Start / Pause / Resume</span><span className="shortcut-key">Space</span></div>
<div className="help-item"><span>Preview phrase (Play / Stop)</span><span className="shortcut-key">P</span></div> <div className="help-item"><span>Preview phrase (Play / Stop)</span><span className="shortcut-key">P</span></div>
<div className="help-item"><span>While previewing: step note forward / back</span><span className="shortcut-key">→ / ←</span></div>
<div className="help-item"><span>Retry phrase</span><span className="shortcut-key">R</span></div> <div className="help-item"><span>Retry phrase</span><span className="shortcut-key">R</span></div>
<div className="help-item"><span>New melody / next bars</span><span className="shortcut-key">N</span></div> <div className="help-item"><span>New melody / next bars</span><span className="shortcut-key">N</span></div>
<div className="help-item"><span>Skip note (at your pace)</span><span className="shortcut-key">S</span></div> <div className="help-item"><span>Skip note (at your pace)</span><span className="shortcut-key">S</span></div>

View File

@@ -35,11 +35,12 @@ import { TUNINGS, getFretboardPositions } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs'; import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
const AUTO_CONTINUE_DELAY_MS = 1500; const AUTO_CONTINUE_DELAY_MS = 1500;
import { Play, Pause, RotateCcw, SkipForward, Volume2, Square, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react'; import { Play, Pause, RotateCcw, SkipForward, SkipBack, Volume2, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react';
export interface PhraseHandle { export interface PhraseHandle {
pauseToggle: () => void; pauseToggle: () => void;
previewToggle: () => void; previewToggle: () => void;
previewStep: (dir: 1 | -1) => void;
retry: () => void; retry: () => void;
skip: () => void; skip: () => void;
next: () => void; next: () => void;
@@ -328,6 +329,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
pauseToggle: () => trainer?.pauseToggle(), pauseToggle: () => trainer?.pauseToggle(),
previewToggle: () => trainer?.previewToggle(), previewToggle: () => trainer?.previewToggle(),
previewStep: (dir) => trainer?.previewStep(dir),
retry: () => trainer?.retry(), retry: () => trainer?.retry(),
skip: () => trainer?.skip(), skip: () => trainer?.skip(),
pause: () => trainer?.pause(), pause: () => trainer?.pause(),
@@ -337,8 +339,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
// ---- Hints -------------------------------------------------------------- // ---- Hints --------------------------------------------------------------
const activeScore = trainer.activeScore; const activeScore = trainer.activeScore;
const events = scoreEvents(activeScore); const events = scoreEvents(activeScore);
const currentMidi = trainer.currentIdx >= 0 && events[trainer.currentIdx]?.pitch // While previewing, the hint instruments follow the preview cursor so
? events[trainer.currentIdx]!.pitch!.midi // the user can find notes on the fretboard while listening.
const displayIdx = trainer.phase === 'preview' || trainer.phase === 'previewPaused'
? trainer.previewIdx
: trainer.currentIdx;
const currentMidi = displayIdx >= 0 && events[displayIdx]?.pitch
? events[displayIdx]!.pitch!.midi
: null; : null;
const hintPositions = useMemo(() => { const hintPositions = useMemo(() => {
@@ -368,7 +375,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const statusText = useMemo(() => { const statusText = useMemo(() => {
if (!scoreOk) return materialError ?? 'No material.'; if (!scoreOk) return materialError ?? 'No material.';
const p = trainer.phase; const p = trainer.phase;
if (p === 'preview') return 'Listen …'; if (p === 'preview') return 'Listen … (pause to step note by note)';
if (p === 'previewPaused') return 'Paused — step with ⏮ ⏭ or ← →, resume with P';
if (p === 'countIn') return `Count-in: ${trainer.countInLeft}`; if (p === 'countIn') return `Count-in: ${trainer.countInLeft}`;
if (p === 'paused') return 'Paused'; if (p === 'paused') return 'Paused';
if (p === 'done') { if (p === 'done') {
@@ -387,12 +395,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const p = trainer.phase; const p = trainer.phase;
if (p === 'playing' || p === 'countIn') return 'Pause'; if (p === 'playing' || p === 'countIn') return 'Pause';
if (p === 'paused') return 'Resume'; if (p === 'paused') return 'Resume';
if (p === 'preview') return 'Stop preview'; if (p === 'preview') return 'Pause';
if (p === 'previewPaused') return 'Resume';
return 'Start'; return 'Start';
}, [trainer.phase]); }, [trainer.phase]);
const startAction = useMemo(() => { const startAction = useMemo(() => {
if (trainer.phase === 'preview') return trainer.previewToggle; if (trainer.phase === 'preview' || trainer.phase === 'previewPaused') return trainer.previewToggle;
return trainer.pauseToggle; return trainer.pauseToggle;
}, [trainer]); }, [trainer]);
@@ -714,7 +723,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
{scoreOk ? ( {scoreOk ? (
<PhraseSheetMusic <PhraseSheetMusic
score={trainer.activeScore} score={trainer.activeScore}
currentIdx={trainer.currentIdx} currentIdx={trainer.phase === 'preview' || trainer.phase === 'previewPaused' ? trainer.previewIdx : trainer.currentIdx}
statuses={trainer.statuses} statuses={trainer.statuses}
clef={activeClef} clef={activeClef}
transpose={activeTranspose} transpose={activeTranspose}
@@ -758,13 +767,36 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</button> </button>
{!settings.zenMode && ( {!settings.zenMode && (
<> <>
{trainer.phase === 'previewPaused' && (
<div className="transport-step-group" role="group" aria-label="Preview stepping">
<button <button
className="hint-button" className="hint-button step-button"
onClick={trainer.previewToggle} onClick={() => trainer.previewStep(-1)}
title="Preview the phrase (P)" title="One event back (while previewing)"
aria-label="Step back"
> >
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />} <SkipBack size={16} />
{trainer.phase === 'preview' ? 'Stop' : 'Preview'} </button>
<button
className="hint-button step-button"
onClick={() => trainer.previewStep(1)}
title="One event forward (while previewing)"
aria-label="Step forward"
>
<SkipForward size={16} />
</button>
</div>
)}
<button
className={`hint-button ${trainer.phase === 'preview' || trainer.phase === 'previewPaused' ? 'active' : ''}`}
onClick={trainer.previewToggle}
title="Preview: play / pause / resume (P)"
>
{trainer.phase === 'preview'
? <><Pause size={18} /> Pause</>
: trainer.phase === 'previewPaused'
? <><Play size={18} /> Resume</>
: <><Volume2 size={18} /> Preview</>}
</button> </button>
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)"> <button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} /> <RotateCcw size={18} />
@@ -799,27 +831,44 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
)} )}
</> </>
)} )}
{!settings.zenMode && currentInstrumentDef.showTuning && ( </div>
{/* Display toggles — separate row, switch style (like the
tuner/metronome switches): pure show/hide, no run state. */}
{!settings.zenMode && (
<div className="display-toggles">
{currentInstrumentDef.showTuning && (
<div className="display-toggle">
<span className="display-toggle-label">
<Guitar size={15} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</span>
<button <button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`} className={`switch-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))} onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`} title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
aria-label={`Show virtual ${currentInstrumentDef.displayName}`}
> >
<Guitar size={18} /> <div className="switch-thumb" />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</button> </button>
</div>
)} )}
{!settings.zenMode && ( <div className="display-toggle">
<span className="display-toggle-label">
<HelpCircle size={15} />
Hint
</span>
<button <button
className={`hint-button ${settings.showHint ? 'active' : ''}`} className={`switch-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))} onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H" title="Keyboard Shortcut: H"
aria-label="Show hint"
> >
<HelpCircle size={18} /> <div className="switch-thumb" />
{settings.showHint ? "Hide Hint" : "Show Hint"}
</button> </button>
)}
</div> </div>
</div>
)}
{/* Hint instruments */} {/* Hint instruments */}

View File

@@ -21,7 +21,7 @@ import { subscribeRawFrames } from './rawFrameBus';
import { phraseRunBus } from './phraseRunBus'; import { phraseRunBus } from './phraseRunBus';
import { phraseBeatBus } from './phraseBeatBus'; import { phraseBeatBus } from './phraseBeatBus';
export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'done'; export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'previewPaused' | 'done';
export interface PhraseSelection { export interface PhraseSelection {
readonly startBar: number; readonly startBar: number;
@@ -56,6 +56,8 @@ const REARM_AFTER_SUSTAINED_MS = 2500;
export interface PhraseTrainerApi { export interface PhraseTrainerApi {
readonly phase: PhrasePhase; readonly phase: PhrasePhase;
/** Event index the preview cursor points at (-1 when not previewing). */
readonly previewIdx: number;
/** Active slice the user is currently working through (may be a resume tail). */ /** Active slice the user is currently working through (may be a resume tail). */
readonly activeScore: Score; readonly activeScore: Score;
readonly statuses: NoteStatus[]; readonly statuses: NoteStatus[];
@@ -76,6 +78,9 @@ export interface PhraseTrainerApi {
retry: () => void; retry: () => void;
skip: () => void; skip: () => void;
previewToggle: () => void; previewToggle: () => void;
/** While previewing: step one event forward/backward (player-style).
* Plays the note at the new position and moves the preview indicator. */
previewStep: (dir: 1 | -1) => void;
virtualTap: (midi: number) => void; virtualTap: (midi: number) => void;
/** External pause (settings dialog, visibility change handled internally). */ /** External pause (settings dialog, visibility change handled internally). */
pause: () => void; pause: () => void;
@@ -95,6 +100,7 @@ export function usePhraseTrainer(
const [countInLeft, setCountInLeft] = useState(0); const [countInLeft, setCountInLeft] = useState(0);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [repeatedNoteBlocked, setRepeatedNoteBlocked] = useState(false); const [repeatedNoteBlocked, setRepeatedNoteBlocked] = useState(false);
const [previewIdx, setPreviewIdx] = useState(-1);
const [accumulated, setAccumulated] = useState<PhraseSummary>({ total: 0, matched: 0, missed: 0, skipped: 0 }); const [accumulated, setAccumulated] = useState<PhraseSummary>({ total: 0, matched: 0, missed: 0, skipped: 0 });
// ---- Active slice (resume tails replace it) --------------------------- // ---- Active slice (resume tails replace it) ---------------------------
@@ -102,6 +108,8 @@ export function usePhraseTrainer(
const matcherRef = useRef<PhraseMatcher | null>(null); const matcherRef = useRef<PhraseMatcher | null>(null);
const repeatBlockedRef = useRef(false); const repeatBlockedRef = useRef(false);
const previewIdxRef = useRef(-1);
const previewToggleRef = useRef<(() => void) | null>(null);
const phaseRef = useRef<PhrasePhase>('ready'); const phaseRef = useRef<PhrasePhase>('ready');
const paceRef = useRef(config.pace); const paceRef = useRef(config.pace);
const configRef = useRef(config); const configRef = useRef(config);
@@ -117,6 +125,10 @@ export function usePhraseTrainer(
const runTokenRef = useRef(0); const runTokenRef = useRef(0);
const repeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const repeatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const previewTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
/** Audio time the preview cursor is scheduled to reach (for pause). */
const previewEndAtRef = useRef(0);
/** Audio-time interval that advances the preview cursor. */
const previewTickRef = useRef<ReturnType<typeof setInterval> | null>(null);
const events = useMemo(() => scoreEvents(activeScore), [activeScore]); const events = useMemo(() => scoreEvents(activeScore), [activeScore]);
@@ -157,6 +169,9 @@ export function usePhraseTrainer(
setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 }); setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 });
audioEngine.cancelGroup(PREVIEW_GROUP); audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP); audioEngine.cancelGroup(CLICK_GROUP);
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
previewIdxRef.current = -1;
setPreviewIdx(-1);
setPhaseBoth('ready'); setPhaseBoth('ready');
setCurrentIdx(-1); setCurrentIdx(-1);
setCountInLeft(0); setCountInLeft(0);
@@ -388,6 +403,15 @@ export function usePhraseTrainer(
const pauseInternalRef = useRef<(() => void) | null>(null); const pauseInternalRef = useRef<(() => void) | null>(null);
const pause = useCallback(() => { const pause = useCallback(() => {
if (phaseRef.current === 'preview') {
// External pause during preview behaves like preview pause
// (cursor kept, stepping enabled) but without phase recursion.
audioEngine.cancelGroup(PREVIEW_GROUP);
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
setPhaseBoth('previewPaused');
return;
}
if (phaseRef.current !== 'playing' && phaseRef.current !== 'countIn') return; if (phaseRef.current !== 'playing' && phaseRef.current !== 'countIn') return;
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; } if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
audioEngine.cancelGroup(CLICK_GROUP); audioEngine.cancelGroup(CLICK_GROUP);
@@ -462,7 +486,7 @@ export function usePhraseTrainer(
if (paceRef.current === 'tempo') resumeTempo(); if (paceRef.current === 'tempo') resumeTempo();
else setPhaseBoth('playing'); else setPhaseBoth('playing');
} else if (p === 'ready' || p === 'done') start(); } else if (p === 'ready' || p === 'done') start();
// preview: handled by previewToggle else if (p === 'preview' || p === 'previewPaused') previewToggleRef.current?.();
}, [pause, resumeTempo, setPhaseBoth, start]); }, [pause, resumeTempo, setPhaseBoth, start]);
const retry = useCallback(() => { const retry = useCallback(() => {
@@ -485,46 +509,115 @@ export function usePhraseTrainer(
advanceStep(m); advanceStep(m);
}, [advanceStep]); }, [advanceStep]);
const previewToggle = useCallback(() => { /** Preview from event `fromIdx` to the end. A cursor interval mirrors
if (phaseRef.current === 'preview') { * playback time so the notation indicator follows the sounding note. */
audioEngine.cancelGroup(PREVIEW_GROUP); const playPreviewFrom = useCallback((fromIdx: number, token: number) => {
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
setPhaseBoth('ready');
return;
}
pause();
runTokenRef.current++;
const token = runTokenRef.current;
void audioEngine.ensureRunning().then(() => { void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token) return; if (runTokenRef.current !== token) return;
const bpm = sanitizeBpm(configRef.current.bpm); const bpm = sanitizeBpm(configRef.current.bpm);
const spb = 60 / bpm; const spb = 60 / bpm;
const t0 = audioEngine.now() + 0.15; const t0 = audioEngine.now() + 0.15;
const vol = configRef.current.previewVolume ?? 0.4; const vol = configRef.current.previewVolume ?? 0.4;
const startTick = fromIdx > 0 ? events[fromIdx].startTick : 0;
const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0);
const secPerTick = spb / PPQ;
for (const e of events) { for (const e of events) {
if (!e.pitch) continue; if (!e.pitch) continue;
audioEngine.scheduleNote(e.pitch.midi, t0 + (e.startTick / PPQ) * spb, (e.durationTicks / PPQ) * spb, vol, PREVIEW_GROUP); if (e.startTick < startTick) continue;
audioEngine.scheduleNote(e.pitch.midi, t0 + ((e.startTick - startTick) / PPQ) * spb, (e.durationTicks / PPQ) * spb, vol, PREVIEW_GROUP);
} }
// Tempo pace: click along with the preview so the rhythm is // Tempo pace: click along with the preview so the rhythm is
// audible while listening (quarter beats over the full span). // audible while listening (quarter beats over the full span).
// Registered in PREVIEW_GROUP so stopping the preview also stops // Registered in PREVIEW_GROUP so stopping the preview also stops
// its clicks (no ghost metronomes after an aborted preview). // its clicks (no ghost metronomes after an aborted preview).
if (paceRef.current === 'tempo' && configRef.current.clickSound) { if (paceRef.current === 'tempo' && configRef.current.clickSound) {
const totalTicks = events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0); const beats = Math.floor((totalTicks - startTick) / PPQ);
const beats = Math.floor(totalTicks / PPQ);
for (let i = 0; i < beats; i++) { for (let i = 0; i < beats; i++) {
audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, i % 4 === 0, PREVIEW_GROUP); audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, (fromIdx + i) % 4 === 0, PREVIEW_GROUP);
} }
} }
setPhaseBoth('preview'); setPhaseBoth('preview');
const totalSec = (events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0) / PPQ) * spb;
previewTimerRef.current = setTimeout(() => { // Cursor: advance previewIdx by audio time (event start ticks).
if (runTokenRef.current === token) { previewEndAtRef.current = t0 + (totalTicks - startTick) * secPerTick;
if (previewTickRef.current) clearInterval(previewTickRef.current);
previewTickRef.current = setInterval(() => {
if (phaseRef.current !== 'preview' || runTokenRef.current !== token) {
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
return;
}
const elapsedTicks = Math.max(0, (audioEngine.now() - t0) / secPerTick) + startTick;
let idx = previewIdxRef.current;
let advanced = false;
while (idx + 1 < events.length && events[idx + 1].startTick <= elapsedTicks) { idx++; advanced = true; }
if (advanced) { previewIdxRef.current = idx; setPreviewIdx(idx); }
if (audioEngine.now() >= previewEndAtRef.current + 0.2) {
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
previewIdxRef.current = -1;
setPreviewIdx(-1);
setPhaseBoth('ready'); setPhaseBoth('ready');
} }
}, 150 + totalSec * 1000 + 200); }, 60);
}); });
}, [events, pause, setPhaseBoth]); }, [events, setPhaseBoth]);
const stopPreview = useCallback(() => {
audioEngine.cancelGroup(PREVIEW_GROUP);
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
previewIdxRef.current = -1;
setPreviewIdx(-1);
setPhaseBoth('ready');
}, [setPhaseBoth]);
/** Pause preview: stop scheduled audio, keep the cursor position. */
const previewPause = useCallback(() => {
if (phaseRef.current !== 'preview') return;
audioEngine.cancelGroup(PREVIEW_GROUP);
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
setPhaseBoth('previewPaused');
}, [setPhaseBoth]);
const previewToggle = useCallback(() => {
const p = phaseRef.current;
if (p === 'preview') {
previewPause();
return;
}
if (p === 'previewPaused') {
// Resume from the paused cursor position.
runTokenRef.current++;
const token = runTokenRef.current;
playPreviewFrom(previewIdxRef.current, token);
return;
}
pause();
runTokenRef.current++;
const token = runTokenRef.current;
previewIdxRef.current = 0;
setPreviewIdx(0);
playPreviewFrom(0, token);
}, [pause, playPreviewFrom, previewPause, stopPreview]);
previewToggleRef.current = previewToggle;
/** Player-style stepping — only while the preview is PAUSED: move the
* cursor one event and play that single note (find-it-on-the-neck). */
const previewStep = useCallback((dir: 1 | -1) => {
if (phaseRef.current !== 'previewPaused') return;
const nextIdx = Math.max(0, Math.min(events.length - 1, previewIdxRef.current + dir));
if (nextIdx === previewIdxRef.current) return;
previewIdxRef.current = nextIdx;
setPreviewIdx(nextIdx);
// Play just this one event as an audible cue.
runTokenRef.current++;
const token = runTokenRef.current;
const e = events[nextIdx];
void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token || !e.pitch) return;
audioEngine.scheduleNote(e.pitch.midi, audioEngine.now() + 0.05, (e.durationTicks / PPQ) * (60 / sanitizeBpm(configRef.current.bpm)), configRef.current.previewVolume ?? 0.4, PREVIEW_GROUP);
});
}, [events, setPhaseBoth]);
const virtualTap = useCallback((midi: number) => { const virtualTap = useCallback((midi: number) => {
const p = phaseRef.current; const p = phaseRef.current;
@@ -548,6 +641,7 @@ export function usePhraseTrainer(
const stop = useCallback(() => { const stop = useCallback(() => {
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; } if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; } if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
audioEngine.cancelGroup(PREVIEW_GROUP); audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP); audioEngine.cancelGroup(CLICK_GROUP);
runTokenRef.current++; runTokenRef.current++;
@@ -576,6 +670,7 @@ export function usePhraseTrainer(
// Unmount: cancel all scheduled audio and timers. // Unmount: cancel all scheduled audio and timers.
if (repeatTimerRef.current) clearTimeout(repeatTimerRef.current); if (repeatTimerRef.current) clearTimeout(repeatTimerRef.current);
if (previewTimerRef.current) clearTimeout(previewTimerRef.current); if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
if (previewTickRef.current) clearInterval(previewTickRef.current);
audioEngine.cancelGroup(PREVIEW_GROUP); audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP); audioEngine.cancelGroup(CLICK_GROUP);
}, []); }, []);
@@ -602,6 +697,7 @@ export function usePhraseTrainer(
statuses, statuses,
currentIdx, currentIdx,
countInLeft, countInLeft,
previewIdx,
scoreStart: scoreStartRef.current, scoreStart: scoreStartRef.current,
spb: spbRef.current, spb: spbRef.current,
error, error,
@@ -611,6 +707,7 @@ export function usePhraseTrainer(
retry, retry,
skip, skip,
previewToggle, previewToggle,
previewStep,
virtualTap, virtualTap,
pause, pause,
stop, stop,