mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Compare commits
15 Commits
6b52a049e8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dfd8682500 | |||
| 751c3eec2f | |||
| cc75141430 | |||
| 206d4d63cc | |||
| 71271387ab | |||
| 56ace9efac | |||
| 05d5548d0c | |||
| 7a030f2728 | |||
| e43a17efb3 | |||
| c1f8f46cfe | |||
| ef96258e53 | |||
| baa66c0dc7 | |||
|
|
fe153a777a | ||
|
|
672cb341a7 | ||
|
|
75ad4613bb |
@@ -1,5 +1,7 @@
|
|||||||
# Local deployment (nyx server) — beta channel
|
# Local deployment (nyx server) — beta channel (tracks main = production state by default)
|
||||||
- Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart)
|
- Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart; **default branch: main**)
|
||||||
- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081), image `smt-beta-image`
|
- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081 **and** tailnet IP 100.64.0.4:8081), image `smt-beta-image`
|
||||||
- Tailnet URL: http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081)
|
- Tailnet URLs:
|
||||||
|
- http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081; needs MagicDNS; Chrome history may autocomplete a broken https:// variant — delete it from history)
|
||||||
|
- **http://100.64.0.4:8081/ (preferred: Chrome never HTTPS-upgrades IP:port URLs, no MagicDNS needed)**
|
||||||
- Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only
|
- Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only
|
||||||
|
|||||||
71
src/App.css
71
src/App.css
@@ -1041,29 +1041,53 @@ button {
|
|||||||
|
|
||||||
.phrase-setup {
|
.phrase-setup {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
border: 1px solid color-mix(in srgb, var(--color-text-main) 18%, transparent);
|
||||||
padding-bottom: 12px;
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--color-text-main) 4%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phrase-setup summary {
|
.phrase-setup summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
color: var(--color-text-muted);
|
font-weight: 600;
|
||||||
|
color: var(--color-text-main);
|
||||||
|
padding: 12px 14px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the default disclosure triangle — the chevron icon does the job */
|
||||||
|
.phrase-setup summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phrase-setup summary:hover {
|
.phrase-setup summary:hover {
|
||||||
color: var(--color-text-main);
|
background: color-mix(in srgb, var(--color-text-main) 7%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup[open] summary {
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
||||||
|
border-radius: 10px 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup summary svg {
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.phrase-setup[open] summary svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.phrase-setup-grid {
|
.phrase-setup-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
gap: 10px 14px;
|
gap: 10px 14px;
|
||||||
margin-top: 12px;
|
padding: 12px 14px 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.phrase-setup-grid label {
|
.phrase-setup-grid label {
|
||||||
@@ -1166,9 +1190,36 @@ button {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.assist-hint {
|
/* ---- 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;
|
font-size: 12px;
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/App.tsx
42
src/App.tsx
@@ -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;
|
||||||
@@ -271,11 +277,19 @@ function App() {
|
|||||||
|
|
||||||
<div className="header-controls">
|
<div className="header-controls">
|
||||||
<div className="game-mode-toggle">
|
<div className="game-mode-toggle">
|
||||||
|
<button
|
||||||
|
className={`toggle-option ${settings.gameMode === 'assist' ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, gameMode: 'assist' }))}
|
||||||
|
title="Practice companion: shows played notes while you work from paper"
|
||||||
|
>
|
||||||
|
Assist
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
|
className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
|
||||||
onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))}
|
onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))}
|
||||||
|
title="Learn to read notes from the staff"
|
||||||
>
|
>
|
||||||
Sight Reading
|
Learn Notes
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
|
className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
|
||||||
@@ -290,13 +304,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
Phrases
|
Phrases
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
className={`toggle-option ${settings.gameMode === 'assist' ? 'active' : ''}`}
|
|
||||||
onClick={() => setSettings(s => ({ ...s, gameMode: 'assist' }))}
|
|
||||||
title="Practice companion: shows played notes while you work from paper"
|
|
||||||
>
|
|
||||||
Assist
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -367,13 +374,17 @@ function App() {
|
|||||||
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<LiveNoteStaff
|
{/* Live staff omitted in Assist mode: the Assist card already
|
||||||
midi={displayedPitch ? displayedPitch.midi : null}
|
shows the played note large above the fretboard. */}
|
||||||
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
|
{!isAssist && (
|
||||||
keySignature={settings.keySignature}
|
<LiveNoteStaff
|
||||||
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
|
midi={displayedPitch ? displayedPitch.midi : null}
|
||||||
theme={settings.theme}
|
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
|
||||||
/>
|
keySignature={settings.keySignature}
|
||||||
|
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
|
||||||
|
theme={settings.theme}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
|
||||||
{displayedPitch ? (
|
{displayedPitch ? (
|
||||||
<>
|
<>
|
||||||
@@ -430,6 +441,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>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
|
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
|
||||||
const pf = getPracticeFilter(settings);
|
const pf = getPracticeFilter(settings);
|
||||||
|
|
||||||
|
const { clef, transpose } = useMemo(() =>
|
||||||
|
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
|
||||||
|
[currentInstrumentDef, settings.difficulty]);
|
||||||
|
|
||||||
// Alternative positions for the detected note (respect fret window filter)
|
// Alternative positions for the detected note (respect fret window filter)
|
||||||
const positions = useMemo(() => {
|
const positions = useMemo(() => {
|
||||||
if (!fretted || !currentTuning || !pitchData) return [];
|
if (!fretted || !currentTuning || !pitchData) return [];
|
||||||
@@ -38,9 +42,23 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
return all.filter(p => p.fret >= Math.max(0, pf.fretMin) && p.fret <= Math.min(24, pf.fretMax));
|
return all.filter(p => p.fret >= Math.max(0, pf.fretMin) && p.fret <= Math.min(24, pf.fretMax));
|
||||||
}, [fretted, currentTuning, pitchData, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]);
|
}, [fretted, currentTuning, pitchData, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]);
|
||||||
|
|
||||||
const { clef, transpose } = useMemo(() =>
|
// Full written range of the currently configured pool (strings × frets,
|
||||||
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
|
// respecting the same filter as the position markers). The staff box is
|
||||||
[currentInstrumentDef, settings.difficulty]);
|
// sized to fit it, so notes render at their TRUE written position with
|
||||||
|
// real ledger lines — no 8va/8vb octave shifting.
|
||||||
|
const range = useMemo(() => {
|
||||||
|
if (!fretted || !currentTuning) return undefined;
|
||||||
|
const strings = pf.strings.length > 0
|
||||||
|
? currentTuning.strings.filter((_, i) => pf.strings.includes(i))
|
||||||
|
: currentTuning.strings;
|
||||||
|
if (strings.length === 0) return undefined;
|
||||||
|
const lo = pf.fretWindowEnabled ? Math.max(0, pf.fretMin) : 0;
|
||||||
|
const hi = pf.fretWindowEnabled ? Math.min(24, pf.fretMax) : 24;
|
||||||
|
return {
|
||||||
|
min: Math.min(...strings) + lo + transpose,
|
||||||
|
max: Math.max(...strings) + hi + transpose,
|
||||||
|
};
|
||||||
|
}, [fretted, currentTuning, pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, transpose]);
|
||||||
|
|
||||||
const staffWidth = Math.min(windowWidth - 40, 860);
|
const staffWidth = Math.min(windowWidth - 40, 860);
|
||||||
|
|
||||||
@@ -61,6 +79,8 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
keySignature={settings.keySignature}
|
keySignature={settings.keySignature}
|
||||||
clef={clef === 'grand' ? undefined : clef}
|
clef={clef === 'grand' ? undefined : clef}
|
||||||
width={Math.min(240, staffWidth)}
|
width={Math.min(240, staffWidth)}
|
||||||
|
height={88}
|
||||||
|
range={range}
|
||||||
theme={settings.theme}
|
theme={settings.theme}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,19 +91,12 @@ export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }
|
|||||||
tuning={currentTuning}
|
tuning={currentTuning}
|
||||||
positions={positions}
|
positions={positions}
|
||||||
maxFrets={15}
|
maxFrets={15}
|
||||||
showHints={false}
|
showHints={true}
|
||||||
interactive={false}
|
interactive={false}
|
||||||
displayTranspose={transpose}
|
displayTranspose={transpose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!pitchData && (
|
|
||||||
<p className="assist-hint">
|
|
||||||
Play a note — it shows here. Mic is{' '}
|
|
||||||
{settings.showTuningMeter ? 'on' : 'controlled by the mic button below'}.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,8 +68,8 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
)}
|
)}
|
||||||
{shouldShowFilters && <TargetNoteControls />}
|
{shouldShowFilters && <TargetNoteControls />}
|
||||||
|
|
||||||
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
{/* Bottom Section: Tools Grid (stacked in all modes) */}
|
||||||
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
|
<div className="tools-grid" style={{ gridTemplateColumns: '1fr' }}>
|
||||||
{/* Tool 1: Tuner */}
|
{/* Tool 1: Tuner */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
@@ -86,7 +86,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{settings.showTuningMeter && (
|
{settings.showTuningMeter && (
|
||||||
<div style={{ paddingTop: '8px', borderTop: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)' }}>
|
<div style={{ paddingTop: '8px', borderTop: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)', display: 'flex', justifyContent: 'center' }}>
|
||||||
<TuningMeter
|
<TuningMeter
|
||||||
cents={currentPitch ? currentPitch.cents : null}
|
cents={currentPitch ? currentPitch.cents : null}
|
||||||
noteName={currentPitch ? currentPitch.note : null}
|
noteName={currentPitch ? currentPitch.note : null}
|
||||||
|
|||||||
@@ -21,14 +21,45 @@ interface LiveNoteStaffProps {
|
|||||||
* clef auto-switches treble/bass around middle C (piano-style). */
|
* clef auto-switches treble/bass around middle C (piano-style). */
|
||||||
clef?: 'treble' | 'bass';
|
clef?: 'treble' | 'bass';
|
||||||
width?: number;
|
width?: number;
|
||||||
|
/** Box height. 64px matches the mic bar; larger boxes (e.g. 88px in
|
||||||
|
* Assist mode) give ledger lines room inside instead of being clipped
|
||||||
|
* by the parent's overflow: hidden. Ignored when `range` is set. */
|
||||||
|
height?: number;
|
||||||
|
/** Written-pitch range the box must fit WITHOUT octave shifting.
|
||||||
|
* When set, notes render at their true staff position with real ledger
|
||||||
|
* lines (no 8va/8vb marker) and the box height is computed from the
|
||||||
|
* range so every note in it stays inside the box. */
|
||||||
|
range?: { min: number; max: number };
|
||||||
theme?: 'light' | 'dark' | 'auto';
|
theme?: 'light' | 'dark' | 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
const BOX_H = 64; // mic button height — vertical centering via the bar
|
const BOX_H = 64; // mic button height — vertical centering via the bar
|
||||||
// VexFlow draws the first stave line ~40.5px BELOW the constructor y
|
// VexFlow draws the first stave line ~40.5px BELOW the constructor y
|
||||||
// (default space_above_staff_ln = 4 line-spaces). To center a 38px staff in
|
// (default space_above_staff_ln = 4 line-spaces). To center the ~38px staff
|
||||||
// the 64px box the constructor y must be negative.
|
// in a box of height h the constructor y must be negative. For h = 64 this
|
||||||
const STAVE_Y = -28;
|
// yields -27.5 ≈ -28 (the original hardcoded value).
|
||||||
|
const staveYFor = (h: number) => (h - 38) / 2 - 40.5;
|
||||||
|
|
||||||
|
// Diatonic index (C0 = 0, one step per letter name) for ledger-line math.
|
||||||
|
const DI_STEP = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
|
||||||
|
const diFromMidi = (m: number) => Math.floor(m / 12 - 1) * 7 + DI_STEP[m % 12];
|
||||||
|
// Top/bottom staff-line diatonic index per clef (treble F5/E4, bass A3/G2).
|
||||||
|
const CLEF_LINES: Record<'treble' | 'bass', { top: number; bottom: number }> = {
|
||||||
|
treble: { top: 38, bottom: 30 },
|
||||||
|
bass: { top: 26, bottom: 18 },
|
||||||
|
};
|
||||||
|
// One diatonic step = half a staff space = 5px (standard 10px spaces).
|
||||||
|
// Box that fits a whole written range: staff anchored below the top
|
||||||
|
// headroom so the highest note has room for its ledger lines, lowest note
|
||||||
|
// the same at the bottom.
|
||||||
|
const rangeLayout = (range: { min: number; max: number }, clef: 'treble' | 'bass') => {
|
||||||
|
const lines = CLEF_LINES[clef];
|
||||||
|
const aboveLines = Math.max(0, Math.ceil((diFromMidi(range.max) - lines.top) / 2));
|
||||||
|
const belowLines = Math.max(0, Math.ceil((lines.bottom - diFromMidi(range.min)) / 2));
|
||||||
|
const abovePx = aboveLines * 10 + 10; // + notehead margin
|
||||||
|
const belowPx = belowLines * 10 + 10;
|
||||||
|
return { height: abovePx + 38 + belowPx, y: abovePx - 40.5 };
|
||||||
|
};
|
||||||
|
|
||||||
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||||
midi,
|
midi,
|
||||||
@@ -36,9 +67,18 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
keySignature = 'C',
|
keySignature = 'C',
|
||||||
clef: clefOverride,
|
clef: clefOverride,
|
||||||
width = 130,
|
width = 130,
|
||||||
|
height = BOX_H,
|
||||||
|
range,
|
||||||
theme = 'auto',
|
theme = 'auto',
|
||||||
}) => {
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Box geometry: range-fit wins over the fixed height prop.
|
||||||
|
const fit = range
|
||||||
|
? rangeLayout(range, clefOverride ?? 'treble')
|
||||||
|
: { height, y: staveYFor(height) };
|
||||||
|
const boxH = fit.height;
|
||||||
|
const staveY = fit.y;
|
||||||
const [schemeVersion, setSchemeVersion] = useState(0);
|
const [schemeVersion, setSchemeVersion] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
@@ -53,7 +93,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
const renderer = new Renderer(container, Renderer.Backends.SVG);
|
const renderer = new Renderer(container, Renderer.Backends.SVG);
|
||||||
renderer.resize(width, BOX_H);
|
renderer.resize(width, boxH);
|
||||||
const context = renderer.getContext();
|
const context = renderer.getContext();
|
||||||
|
|
||||||
const resolvedColor =
|
const resolvedColor =
|
||||||
@@ -63,7 +103,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
context.setFillStyle(resolvedColor);
|
context.setFillStyle(resolvedColor);
|
||||||
context.setStrokeStyle(resolvedColor);
|
context.setStrokeStyle(resolvedColor);
|
||||||
|
|
||||||
const stave = new Stave(0, STAVE_Y, width - 2);
|
const stave = new Stave(0, staveY, width - 2);
|
||||||
stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 });
|
stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 });
|
||||||
const written0 = midi !== null ? midi + transpose : null;
|
const written0 = midi !== null ? midi + transpose : null;
|
||||||
// Instrument-fixed clef when provided (guitar always treble, bass
|
// Instrument-fixed clef when provided (guitar always treble, bass
|
||||||
@@ -79,13 +119,17 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
// treble: staff E4..F5 (+ up to two ledger lines each way)
|
// treble: staff E4..F5 (+ up to two ledger lines each way)
|
||||||
// bass: staff G2..A3 (+ up to two ledger lines each way)
|
// bass: staff G2..A3 (+ up to two ledger lines each way)
|
||||||
let written = written0!;
|
let written = written0!;
|
||||||
|
// Range-fit mode renders the TRUE written position with real
|
||||||
|
// ledger lines — no 8va/8vb shifting.
|
||||||
let octaveMark: '8va' | '8vb' | null = null;
|
let octaveMark: '8va' | '8vb' | null = null;
|
||||||
if (clef === 'treble') {
|
if (!range) {
|
||||||
while (written < 58) { written += 12; octaveMark = '8vb'; }
|
if (clef === 'treble') {
|
||||||
while (written > 81) { written -= 12; octaveMark = '8va'; }
|
while (written < 58) { written += 12; octaveMark = '8vb'; }
|
||||||
} else {
|
while (written > 81) { written -= 12; octaveMark = '8va'; }
|
||||||
while (written < 40) { written += 12; octaveMark = '8vb'; }
|
} else {
|
||||||
while (written > 61) { written -= 12; octaveMark = '8va'; }
|
while (written < 40) { written += 12; octaveMark = '8vb'; }
|
||||||
|
while (written > 61) { written -= 12; octaveMark = '8va'; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const spec = getNoteInKey(written, keySignature);
|
const spec = getNoteInKey(written, keySignature);
|
||||||
@@ -113,13 +157,13 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
new Formatter().joinVoices([voice]).format([voice], width - 40);
|
new Formatter().joinVoices([voice]).format([voice], width - 40);
|
||||||
voice.draw(context, stave);
|
voice.draw(context, stave);
|
||||||
}
|
}
|
||||||
}, [midi, transpose, keySignature, clefOverride, width, theme, schemeVersion]);
|
}, [midi, transpose, keySignature, clefOverride, width, boxH, staveY, range, theme, schemeVersion]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="live-note-staff"
|
className="live-note-staff"
|
||||||
style={{ width, height: BOX_H }}
|
style={{ width, height: boxH }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
|
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -55,13 +55,22 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
|
|
||||||
const isSync = syncMode === 'option' && !!rhythm.syncToExercise;
|
const isSync = syncMode === 'option' && !!rhythm.syncToExercise;
|
||||||
const runActive = gate === true;
|
const runActive = gate === true;
|
||||||
|
// Phrase mode (syncMode 'option') has no on/off switch — the sync
|
||||||
|
// checkbox replaces it (unchecked = free metronome, checked = synced to
|
||||||
|
// the exercise), so the metronome is always armed there. Gating on
|
||||||
|
// rhythm.active would leave phrase users with no way to enable clicks.
|
||||||
|
const armed = syncMode === 'option' ? true : rhythm.active;
|
||||||
// Sync: pendulum/click only while the run is active (the run's scheduler
|
// Sync: pendulum/click only while the run is active (the run's scheduler
|
||||||
// is the click source; the widget's own scheduler stays off).
|
// is the click source; the widget's own scheduler stays off).
|
||||||
// Free/plain: the widget's own scheduler runs whenever armed.
|
// Free/plain: the widget's own scheduler runs whenever armed.
|
||||||
const running = isSync ? runActive : rhythm.active;
|
const running = isSync ? runActive : armed;
|
||||||
|
|
||||||
|
// Timer mode = one beat per N seconds: effective BPM 60/seconds so
|
||||||
|
// clicks and pendulum match the trainer's auto-advance cadence.
|
||||||
|
const effectiveBpm = rhythm.mode === 'bpm' ? rhythm.bpm : 60 / Math.max(1, rhythm.seconds);
|
||||||
|
|
||||||
const { restart } = useMetronome({
|
const { restart } = useMetronome({
|
||||||
bpm: rhythm.bpm,
|
bpm: effectiveBpm,
|
||||||
volume: rhythm.sound ? rhythm.volume : 0,
|
volume: rhythm.sound ? rhythm.volume : 0,
|
||||||
playing: running && !isSync,
|
playing: running && !isSync,
|
||||||
onTick: handleTick,
|
onTick: handleTick,
|
||||||
@@ -77,8 +86,7 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
|
|||||||
});
|
});
|
||||||
}, [isSync, runActive]);
|
}, [isSync, runActive]);
|
||||||
|
|
||||||
const beatMs = 60000 / Math.max(1, rhythm.bpm);
|
const beatMs = 60000 / Math.max(1, effectiveBpm);
|
||||||
const armed = rhythm.active;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -84,13 +85,15 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
|
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
|
||||||
phrase.pace = pace;
|
phrase.pace = pace;
|
||||||
// Click decision — ONE source: the metronome widget's sound checkbox.
|
// Click decision — ONE source: the metronome widget's sound checkbox.
|
||||||
// - Sync (tempo): run clicks when armed && sound (count-in incl.);
|
// Phrase mode has no metronome on/off switch (the sync checkbox
|
||||||
// widget silent while idle.
|
// replaces it, see MetronomeWidget), so rhythm.active must NOT gate
|
||||||
// - Free metronome: widget clicks freely; the run stays silent to
|
// clicks here — with the default active:false a synced run would
|
||||||
// avoid doubling (sync off in tempo = free widget during runs).
|
// stay silent with no way to fix it from this mode.
|
||||||
phrase.clickSound = pace === 'tempo'
|
// - Sync (tempo): run clicks when sound is on (count-in included);
|
||||||
? settings.rhythm.active && settings.rhythm.sound
|
// the widget stays silent while idle.
|
||||||
: settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
|
// - Free metronome (step): the widget clicks freely; the run stays
|
||||||
|
// silent to avoid doubling.
|
||||||
|
phrase.clickSound = settings.rhythm.sound;
|
||||||
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
|
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
|
||||||
updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
|
updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
|
||||||
}, [updateSettings]);
|
}, [updateSettings]);
|
||||||
@@ -328,6 +331,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 +341,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 +377,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 +397,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 +725,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 +769,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
|
||||||
|
className="hint-button step-button"
|
||||||
|
onClick={() => trainer.previewStep(-1)}
|
||||||
|
title="One event back (while previewing)"
|
||||||
|
aria-label="Step back"
|
||||||
|
>
|
||||||
|
<SkipBack size={16} />
|
||||||
|
</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
|
<button
|
||||||
className="hint-button"
|
className={`hint-button ${trainer.phase === 'preview' || trainer.phase === 'previewPaused' ? 'active' : ''}`}
|
||||||
onClick={trainer.previewToggle}
|
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'
|
||||||
{trainer.phase === 'preview' ? 'Stop' : '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,28 +833,45 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!settings.zenMode && currentInstrumentDef.showTuning && (
|
|
||||||
<button
|
|
||||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
|
||||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
|
||||||
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
|
||||||
>
|
|
||||||
<Guitar size={18} />
|
|
||||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!settings.zenMode && (
|
|
||||||
<button
|
|
||||||
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
|
||||||
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
|
||||||
title="Keyboard Shortcut: H"
|
|
||||||
>
|
|
||||||
<HelpCircle size={18} />
|
|
||||||
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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
|
||||||
|
className={`switch-button ${settings.showFretboard ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||||
|
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
||||||
|
aria-label={`Show virtual ${currentInstrumentDef.displayName}`}
|
||||||
|
>
|
||||||
|
<div className="switch-thumb" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="display-toggle">
|
||||||
|
<span className="display-toggle-label">
|
||||||
|
<HelpCircle size={15} />
|
||||||
|
Hint
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={`switch-button ${settings.showHint ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
||||||
|
title="Keyboard Shortcut: H"
|
||||||
|
aria-label="Show hint"
|
||||||
|
>
|
||||||
|
<div className="switch-thumb" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* Hint instruments */}
|
{/* Hint instruments */}
|
||||||
{(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
|
{(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
|
||||||
|
|||||||
@@ -149,38 +149,57 @@ export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerP
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!settings.zenMode && (
|
{!settings.zenMode && (
|
||||||
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
|
<>
|
||||||
{currentInstrumentDef.showTuning && (
|
{/* Display toggles — switch style (like phrase mode):
|
||||||
|
pure show/hide, no trainer actions. */}
|
||||||
|
<div className="display-toggles" style={{ marginTop: '24px' }}>
|
||||||
|
{currentInstrumentDef.showTuning && (
|
||||||
|
<div className="display-toggle">
|
||||||
|
<span className="display-toggle-label">
|
||||||
|
<Guitar size={15} />
|
||||||
|
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={`switch-button ${settings.showFretboard ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||||
|
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
||||||
|
aria-label={`Show virtual ${currentInstrumentDef.displayName}`}
|
||||||
|
>
|
||||||
|
<div className="switch-thumb" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="display-toggle">
|
||||||
|
<span className="display-toggle-label">
|
||||||
|
<HelpCircle size={15} />
|
||||||
|
Hint
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className={`switch-button ${settings.showHint ? 'active' : ''}`}
|
||||||
|
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
||||||
|
title="Keyboard Shortcut: H"
|
||||||
|
aria-label="Show hint"
|
||||||
|
>
|
||||||
|
<div className="switch-thumb" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="action-row" style={{ marginTop: '16px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
|
||||||
<button
|
<button
|
||||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
className="hint-button"
|
||||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
|
||||||
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
title="Keyboard Shortcut: P or R"
|
||||||
>
|
>
|
||||||
<Guitar size={18} />
|
<Volume2 size={18} />
|
||||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
Play Note
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
|
||||||
<button
|
<SkipForward size={18} />
|
||||||
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
Skip Note
|
||||||
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
</button>
|
||||||
title="Keyboard Shortcut: H"
|
</div>
|
||||||
>
|
</>
|
||||||
<HelpCircle size={18} />
|
|
||||||
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="hint-button"
|
|
||||||
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
|
|
||||||
title="Keyboard Shortcut: P or R"
|
|
||||||
>
|
|
||||||
<Volume2 size={18} />
|
|
||||||
Play Note
|
|
||||||
</button>
|
|
||||||
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
|
|
||||||
<SkipForward size={18} />
|
|
||||||
Skip Note
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,7 +85,11 @@ export const useGameLogic = (
|
|||||||
|
|
||||||
const { restart: restartMetronome } = useMetronome({
|
const { restart: restartMetronome } = useMetronome({
|
||||||
bpm: effectiveBpm,
|
bpm: effectiveBpm,
|
||||||
volume: settings.rhythm.sound ? settings.rhythm.volume : 0,
|
// Audible clicks come from the shared footer widget (Controls) in
|
||||||
|
// non-zen single-note modes — this scheduler is only the tick source
|
||||||
|
// for auto-advance. Sounding it here too doubles every beat. In zen
|
||||||
|
// mode the widget isn't rendered, so this scheduler IS the click.
|
||||||
|
volume: settings.zenMode && settings.rhythm.sound ? settings.rhythm.volume : 0,
|
||||||
playing: settings.rhythm.active,
|
playing: settings.rhythm.active,
|
||||||
onTick: handleTick
|
onTick: handleTick
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
|
|||||||
|
|
||||||
const playClick = useCallback((time: number) => {
|
const playClick = useCallback((time: number) => {
|
||||||
if (!audioContext.current) return;
|
if (!audioContext.current) return;
|
||||||
|
onTickRef.current(currentBeat.current);
|
||||||
|
if (volume <= 0) return; // silent tick source — no audio nodes needed
|
||||||
|
|
||||||
const osc = audioContext.current.createOscillator();
|
const osc = audioContext.current.createOscillator();
|
||||||
const gainNode = audioContext.current.createGain();
|
const gainNode = audioContext.current.createGain();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -134,6 +134,11 @@ export interface AppSettings {
|
|||||||
instrument: string;
|
instrument: string;
|
||||||
rhythm: RhythmSettings;
|
rhythm: RhythmSettings;
|
||||||
zenMode: boolean;
|
zenMode: boolean;
|
||||||
|
/**
|
||||||
|
* TODO: Rename 'sight_reading' to 'learn_notes' internally (UI label is
|
||||||
|
* already "Learn Notes"). Deferred because the value is persisted in
|
||||||
|
* localStorage — needs a settings migration for existing users.
|
||||||
|
*/
|
||||||
gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist';
|
gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist';
|
||||||
customMinFret?: number;
|
customMinFret?: number;
|
||||||
customMaxFret?: number;
|
customMaxFret?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user