mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Compare commits
2 Commits
6b52a049e8
...
672cb341a7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
672cb341a7 | ||
|
|
75ad4613bb |
34
src/App.css
34
src/App.css
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user