mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 13:53:55 +01:00
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.
This commit is contained in:
@@ -35,7 +35,7 @@ import { TUNINGS, getFretboardPositions } from '../music/Tunings';
|
||||
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
|
||||
const AUTO_CONTINUE_DELAY_MS = 1500;
|
||||
|
||||
import { Play, Pause, RotateCcw, SkipForward, SkipBack, 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 {
|
||||
pauseToggle: () => void;
|
||||
@@ -341,7 +341,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
const events = scoreEvents(activeScore);
|
||||
// While previewing, the hint instruments follow the preview cursor so
|
||||
// the user can find notes on the fretboard while listening.
|
||||
const displayIdx = trainer.phase === 'preview' ? trainer.previewIdx : trainer.currentIdx;
|
||||
const displayIdx = trainer.phase === 'preview' || trainer.phase === 'previewPaused'
|
||||
? trainer.previewIdx
|
||||
: trainer.currentIdx;
|
||||
const currentMidi = displayIdx >= 0 && events[displayIdx]?.pitch
|
||||
? events[displayIdx]!.pitch!.midi
|
||||
: null;
|
||||
@@ -373,7 +375,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
const statusText = useMemo(() => {
|
||||
if (!scoreOk) return materialError ?? 'No material.';
|
||||
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 === 'paused') return 'Paused';
|
||||
if (p === 'done') {
|
||||
@@ -392,12 +395,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
const p = trainer.phase;
|
||||
if (p === 'playing' || p === 'countIn') return 'Pause';
|
||||
if (p === 'paused') return 'Resume';
|
||||
if (p === 'preview') return 'Stop preview';
|
||||
if (p === 'preview') return 'Pause';
|
||||
if (p === 'previewPaused') return 'Resume';
|
||||
return 'Start';
|
||||
}, [trainer.phase]);
|
||||
|
||||
const startAction = useMemo(() => {
|
||||
if (trainer.phase === 'preview') return trainer.previewToggle;
|
||||
if (trainer.phase === 'preview' || trainer.phase === 'previewPaused') return trainer.previewToggle;
|
||||
return trainer.pauseToggle;
|
||||
}, [trainer]);
|
||||
|
||||
@@ -719,7 +723,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
{scoreOk ? (
|
||||
<PhraseSheetMusic
|
||||
score={trainer.activeScore}
|
||||
currentIdx={trainer.phase === 'preview' ? trainer.previewIdx : trainer.currentIdx}
|
||||
currentIdx={trainer.phase === 'preview' || trainer.phase === 'previewPaused' ? trainer.previewIdx : trainer.currentIdx}
|
||||
statuses={trainer.statuses}
|
||||
clef={activeClef}
|
||||
transpose={activeTranspose}
|
||||
@@ -763,7 +767,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</button>
|
||||
{!settings.zenMode && (
|
||||
<>
|
||||
{trainer.phase === 'preview' && (
|
||||
{trainer.phase === 'previewPaused' && (
|
||||
<div className="transport-step-group" role="group" aria-label="Preview stepping">
|
||||
<button
|
||||
className="hint-button step-button"
|
||||
@@ -784,12 +788,15 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className={`hint-button ${trainer.phase === 'preview' ? 'active' : ''}`}
|
||||
className={`hint-button ${trainer.phase === 'preview' || trainer.phase === 'previewPaused' ? 'active' : ''}`}
|
||||
onClick={trainer.previewToggle}
|
||||
title="Preview the phrase (P)"
|
||||
title="Preview: play / pause / resume (P)"
|
||||
>
|
||||
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
|
||||
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
|
||||
{trainer.phase === 'preview'
|
||||
? <><Pause size={18} /> Pause</>
|
||||
: trainer.phase === 'previewPaused'
|
||||
? <><Play size={18} /> Resume</>
|
||||
: <><Volume2 size={18} /> Preview</>}
|
||||
</button>
|
||||
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
|
||||
<RotateCcw size={18} />
|
||||
|
||||
@@ -21,7 +21,7 @@ import { subscribeRawFrames } from './rawFrameBus';
|
||||
import { phraseRunBus } from './phraseRunBus';
|
||||
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 {
|
||||
readonly startBar: number;
|
||||
@@ -109,6 +109,7 @@ export function usePhraseTrainer(
|
||||
const matcherRef = useRef<PhraseMatcher | null>(null);
|
||||
const repeatBlockedRef = useRef(false);
|
||||
const previewIdxRef = useRef(-1);
|
||||
const previewToggleRef = useRef<(() => void) | null>(null);
|
||||
const phaseRef = useRef<PhrasePhase>('ready');
|
||||
const paceRef = useRef(config.pace);
|
||||
const configRef = useRef(config);
|
||||
@@ -124,6 +125,10 @@ export function usePhraseTrainer(
|
||||
const runTokenRef = useRef(0);
|
||||
const repeatTimerRef = 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]);
|
||||
|
||||
@@ -164,6 +169,7 @@ export function usePhraseTrainer(
|
||||
setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 });
|
||||
audioEngine.cancelGroup(PREVIEW_GROUP);
|
||||
audioEngine.cancelGroup(CLICK_GROUP);
|
||||
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
|
||||
previewIdxRef.current = -1;
|
||||
setPreviewIdx(-1);
|
||||
setPhaseBoth('ready');
|
||||
@@ -397,6 +403,15 @@ export function usePhraseTrainer(
|
||||
const pauseInternalRef = useRef<(() => void) | null>(null);
|
||||
|
||||
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 (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
|
||||
audioEngine.cancelGroup(CLICK_GROUP);
|
||||
@@ -471,7 +486,7 @@ export function usePhraseTrainer(
|
||||
if (paceRef.current === 'tempo') resumeTempo();
|
||||
else setPhaseBoth('playing');
|
||||
} else if (p === 'ready' || p === 'done') start();
|
||||
// preview: handled by previewToggle
|
||||
else if (p === 'preview' || p === 'previewPaused') previewToggleRef.current?.();
|
||||
}, [pause, resumeTempo, setPhaseBoth, start]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
@@ -494,14 +509,8 @@ export function usePhraseTrainer(
|
||||
advanceStep(m);
|
||||
}, [advanceStep]);
|
||||
|
||||
const stopPreview = useCallback(() => {
|
||||
audioEngine.cancelGroup(PREVIEW_GROUP);
|
||||
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
|
||||
previewIdxRef.current = -1;
|
||||
setPreviewIdx(-1);
|
||||
setPhaseBoth('ready');
|
||||
}, [setPhaseBoth]);
|
||||
|
||||
/** Preview from event `fromIdx` to the end. A cursor interval mirrors
|
||||
* playback time so the notation indicator follows the sounding note. */
|
||||
const playPreviewFrom = useCallback((fromIdx: number, token: number) => {
|
||||
void audioEngine.ensureRunning().then(() => {
|
||||
if (runTokenRef.current !== token) return;
|
||||
@@ -510,6 +519,8 @@ export function usePhraseTrainer(
|
||||
const t0 = audioEngine.now() + 0.15;
|
||||
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) {
|
||||
if (!e.pitch) continue;
|
||||
if (e.startTick < startTick) continue;
|
||||
@@ -520,48 +531,93 @@ export function usePhraseTrainer(
|
||||
// Registered in PREVIEW_GROUP so stopping the preview also stops
|
||||
// its clicks (no ghost metronomes after an aborted preview).
|
||||
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);
|
||||
for (let i = 0; i < beats; i++) {
|
||||
audioEngine.playClickAt(t0 + i * spb, CLICK_VOLUME, (fromIdx + i) % 4 === 0, PREVIEW_GROUP);
|
||||
}
|
||||
}
|
||||
setPhaseBoth('preview');
|
||||
const totalSec = ((events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0) - startTick) / PPQ) * spb;
|
||||
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||
previewTimerRef.current = setTimeout(() => {
|
||||
if (runTokenRef.current === token) {
|
||||
setPhaseBoth('ready');
|
||||
setPreviewIdx(-1);
|
||||
|
||||
// Cursor: advance previewIdx by audio time (event start ticks).
|
||||
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;
|
||||
}
|
||||
}, 150 + totalSec * 1000 + 200);
|
||||
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');
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
}, [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(() => {
|
||||
if (phaseRef.current === 'preview') {
|
||||
stopPreview();
|
||||
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;
|
||||
setPreviewIdx(0);
|
||||
previewIdxRef.current = 0;
|
||||
setPreviewIdx(0);
|
||||
playPreviewFrom(0, token);
|
||||
}, [pause, playPreviewFrom, stopPreview]);
|
||||
}, [pause, playPreviewFrom, previewPause, stopPreview]);
|
||||
previewToggleRef.current = previewToggle;
|
||||
|
||||
/** Player-style stepping while previewing: move the cursor, play that
|
||||
* single note so the user can find it on the instrument. */
|
||||
/** 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 !== 'preview') return;
|
||||
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);
|
||||
const token = ++runTokenRef.current;
|
||||
playPreviewFrom(nextIdx, token);
|
||||
}, [events.length, playPreviewFrom]);
|
||||
// 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 p = phaseRef.current;
|
||||
@@ -585,6 +641,7 @@ export function usePhraseTrainer(
|
||||
const stop = useCallback(() => {
|
||||
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.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(CLICK_GROUP);
|
||||
runTokenRef.current++;
|
||||
@@ -613,6 +670,7 @@ export function usePhraseTrainer(
|
||||
// Unmount: cancel all scheduled audio and timers.
|
||||
if (repeatTimerRef.current) clearTimeout(repeatTimerRef.current);
|
||||
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
|
||||
if (previewTickRef.current) clearInterval(previewTickRef.current);
|
||||
audioEngine.cancelGroup(PREVIEW_GROUP);
|
||||
audioEngine.cancelGroup(CLICK_GROUP);
|
||||
}, []);
|
||||
|
||||
Reference in New Issue
Block a user