20 Commits

Author SHA1 Message Date
dfd8682500 Assist: drop flickering hint paragraph; center tuner in its frame
The 'Play a note' paragraph toggled with every detection gap and shifted
the layout — removed (incl. unused .assist-hint rule). TuningMeter is a
fixed-width 260px block; the tuner tool's body row now centers it.
2026-09-20 00:56:15 +02:00
751c3eec2f Assist: true staff positions with ledger lines + fretboard note markers
- LiveNoteStaff: new range prop. With a written range set, notes render
  at their TRUE staff position with real ledger lines (no 8va/8vb
  shifting) and the box height is computed to fit the whole range
  (treble F5/E4, bass A3/G2 diatonic math; staff anchored asymmetrically
  below the top headroom). Mic bar keeps compact octave-marker behavior.
- AssistMode: passes the written range of the current pool (strings x
  frets, honoring the note filter) — guitar high notes now show with
  ledger lines instead of being octave-shifted.
- Assist fretboard bug: positions (played-note markers) were gated on
  showHints which Assist passed as false — they never rendered. Now
  always on (non-interactive, no tooltips).
2026-09-20 00:51:35 +02:00
cc75141430 Docs: smt-beta deploys main (production state) by default 2026-09-20 00:32:09 +02:00
206d4d63cc Merge feature/mode-labels-reorder: mode reorder/rename, UI tidy-ups, metronome fixes 2026-09-20 00:31:31 +02:00
71271387ab Fix double metronome click in single-note modes
useGameLogic's own scheduler and the footer widget's scheduler both
played audible clicks while rhythm.active — assist->learn-notes switch
made the second one mount. Now the widget is the single click source in
non-zen modes; useGameLogic's scheduler is a silent tick source for
auto-advance (full sound only in zen mode, where the widget isn't
rendered). Also: widget honors Timer mode (60/seconds) like the trainer,
and useMetronome skips audio nodes at volume 0.
2026-09-20 00:27:59 +02:00
56ace9efac Assist mode: taller live-note staff box (88px)
The Assist card wrapped the LiveNoteStaff in .sheet-music-container,
which clips (overflow: hidden) — extreme ledger lines poked out of the
64px box by a few px. LiveNoteStaff now takes a height prop; stave y is
computed as (h-38)/2 - 40.5 (equals the old -28 for h=64). Assist uses
88px; mic bar keeps 64px.
2026-09-20 00:25:36 +02:00
05d5548d0c Phrase setup panel: prominent collapsed header
The collapsed Setup row was a small muted 13px text link (plus the
default disclosure triangle next to our chevron). Now a proper bar:
bordered/rounded, 15px semibold, hover feedback, chevron rotates on
open, default marker hidden.
2026-09-20 00:20:58 +02:00
7a030f2728 Single-note modes: switch-style display toggles (like phrase mode)
Guitar/Piano and Hint show/hide controls in Learn Notes / Ear Training
now use the small switch buttons in a .display-toggles row instead of
the big hint-buttons. Play Note / Skip Note stay as action buttons.
2026-09-20 00:15:55 +02:00
e43a17efb3 Footer tools stacked in all modes; fix silent phrase-mode metronome
Layout: tuner + metronome now stack vertically everywhere (was phrase-only).

Phrase metronome bug: clicks were gated on rhythm.active, but phrase mode
has no on/off switch (the sync checkbox replaces it) and the default is
active:false — so a synced run stayed silent with no in-mode remedy.
Now: phrase mode is always armed (sync checkbox = free vs synced,
sound checkbox = clicks), and phrase clickSound reads rhythm.sound only.
Single-note modes keep the rhythm.active on/off semantics.
2026-09-20 00:10:37 +02:00
c1f8f46cfe Assist mode: drop duplicate live-note staff from mic bar
The Assist card already shows the played note large (staff + fretboard);
the pitch-monitor-bar staff duplicated it. Text readout (note + Hz) stays.
2026-09-20 00:04:16 +02:00
ef96258e53 Docs: beta now also on tailnet IP:8081 (avoids Chrome HTTPS-upgrade) 2026-09-20 00:02:40 +02:00
baa66c0dc7 UI: reorder game modes (Assist first), rename Sight Reading -> Learn Notes
Internal key 'sight_reading' kept for localStorage compatibility;
TODO added at gameMode type def for future rename + migration.
2026-09-19 23:45:53 +02:00
OpenClaw
fe153a777a Merge feature/ui-reorg: UI reorg, setup panel, transport controls, preview pause/stepping 2026-09-18 14:49:53 +00:00
OpenClaw
672cb341a7 Preview: pause/resume with cursor, stepping only while paused, indicator follows playback
- New phase 'previewPaused': pause stops scheduled audio but keeps the
  preview cursor; resume continues from the cursor position.
- Step buttons (and arrow keys) are active ONLY while the preview is
  paused; each step moves the cursor one event and plays that single
  note as a find-it-on-the-neck cue.
- Preview indicator now follows playback in real time: a cursor
  interval advances previewIdx by audio time (event start ticks), so
  the notation caret moves with the sounding note — also while
  paused at the stepped position.
- Main Start button shares the semantics: while previewing it shows
  Pause/Resume and routes to the preview controls (exercise and
  preview transport behave consistently).
- External pause (settings dialog, tab hide) during preview now keeps
  the cursor (previewPaused) instead of dropping to ready.
2026-09-15 22:37:29 +00:00
OpenClaw
75ad4613bb Phrase mode: player-style transport, separate display toggles, preview cursor + stepping
- Transport restructured like a music player: Start/Pause, step
  back/forward buttons (while previewing), Preview, Retry, Next.
- Virtual instrument and Hint moved out of the transport row into a
  separate display-toggles row using switch-style toggles (same visual
  language as tuner/metronome switches) — pure show/hide, no run state.
- Preview now tracks a cursor: the current preview event is marked in
  the notation (caret + color via existing currentIdx pipeline).
- Preview stepping: pause-free stepping forward/backward one event at
  a time; each step replays from the cursor position so notes can be
  located on the fretboard. Arrow keys ←/→ work while previewing.
- With Hint active, the fretboard/piano marks the preview cursor note
  (displayIdx follows the preview cursor).
- previewIdx resets on structural changes and when the preview ends.
2026-09-15 22:27:04 +00:00
OpenClaw
6b52a049e8 Restore text-entry gate for keyboard shortcuts (cleanup) 2026-09-15 21:33:33 +00:00
OpenClaw
97a9a83f68 Fix Next/New no-op for seeded material + keyboard shortcuts after button clicks
- nextAction: giuliani and single-chord arpeggios now roll a new seed
  too (they consume melodySeed); previously the handler silently did
  nothing for them. Scale drills keep no Next (deterministic).
- New/Next button visibility matches the same condition, and the label
  is 'Next' for fixed material only.
- Keyboard shortcuts: HTMLButtonElement removed from the interactive
  gate — after any mouse click the button keeps focus and previously
  swallowed N/R/P/H/L/V/M. Space/Enter on a focused button still
  activate it natively and no longer double-fire the global
  pauseToggle/skip handlers.
2026-09-15 21:32:27 +00:00
OpenClaw
b4ba788d8e Move Setup panel above notation; host note filter inside it; fix key signature semantics
- Phrase mode: Setup panel now sits between the title row and the
  sheet music (was below the transport). It hosts the material options
  plus the shared TargetNoteControls (key/strings/fret window).
- TargetNoteControls: in Phrase Mode the Key section binds to the
  generator key (tonic+mode always applied, no enable toggle) and the
  display-signature sync checkbox is hidden — phrase notation takes
  its signature from the score's key, so a display-signature select
  there would be a no-op.
- Footer filter button hidden in Phrase Mode (lives in Setup now);
  still present in single-note modes.
- Remove leftover '&& null' placeholder blocks.
2026-09-15 21:24:09 +00:00
OpenClaw
1ec1353c0b Add Assist mode: primitive note display for practicing from paper
New game mode 'assist' — no trainer, no scoring:
- Large display of the currently mic-detected note
- Live notation staff (same pipeline as the pitch monitor)
- Live fretboard showing alternative positions for the played note
  (respects the fret-window practice filter)
- Tuner and Metronome remain the shared footer tools, identical in
  all modes; user can practice own-pace from paper with a free
  metronome

Header mode toggle gains 'Assist'; 'm' shortcut cycles back to sight
reading from assist/phrase. Export PitchData type.
2026-09-15 21:11:21 +00:00
OpenClaw
b7c8187aef UI reorg: settings grouped by usage frequency
- Filter (key/strings/fret window) collapsed behind Filter button with
  active-count badge; opens the unified TargetNoteControls panel
- Display key signature moved into the filter panel (all modes)
- Instrument + Tuning selects moved into Settings modal (Instrument
  section); removed from footer
- Settings modal restructured into collapsible sections: Instrument,
  Appearance, Audio, Microphone
- Phrase setup panel: removed cross-reference hint texts
- PracticeFilter is now the single source of truth: removed mirrored
  keyTonic/keyMode/fretWindow fields from PhraseSettings
- Dark mode: neutral gray with slight blue tint (was warm brown)
2026-09-15 21:05:44 +00:00
17 changed files with 906 additions and 459 deletions

View File

@@ -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

View File

@@ -1040,30 +1040,54 @@ button {
} }
.phrase-setup { .phrase-setup {
margin-top: 16px; margin-bottom: 16px;
border-top: 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-top: 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 {
@@ -1126,3 +1150,76 @@ button {
justify-content: safe center; justify-content: safe center;
justify-content: center; justify-content: center;
} }
/* ---- Assist Mode ------------------------------------------------------- */
/* Primitive practice companion: big detected note + live staff + optional
fretboard with alternative positions. No trainer actions. */
.assist-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-lg);
}
.assist-note-area {
display: flex;
align-items: center;
justify-content: center;
min-height: 110px;
}
.assist-note-name {
font-size: 5rem;
font-weight: 800;
line-height: 1;
color: var(--color-text-main);
font-variant-emoji: text;
}
.assist-note-name.placeholder {
color: var(--color-text-muted);
opacity: 0.5;
}
.assist-fretboard {
width: 100%;
display: flex;
justify-content: center;
overflow-x: auto;
}
/* ---- Transport step group (preview stepping) ---------------------------- */
.transport-step-group {
display: flex;
gap: 6px;
}
.step-button {
padding: 0.4rem 0.55rem;
}
/* ---- Display toggles (virtual instrument / hint show-hide) -------------- */
/* Separate row of pure show/hide switches, styled like the tuner and
metronome switches — visually distinct from transport actions. */
.display-toggles {
display: flex;
justify-content: center;
gap: var(--spacing-lg);
margin-top: 10px;
}
.display-toggle {
display: flex;
align-items: center;
gap: 8px;
}
.display-toggle-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--color-text-muted);
}

View File

@@ -8,6 +8,7 @@ import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNot
import { LiveNoteStaff } from './components/LiveNoteStaff'; import { LiveNoteStaff } from './components/LiveNoteStaff';
import { resolveClefTranspose } from './music/InstrumentConfigs'; import { resolveClefTranspose } from './music/InstrumentConfigs';
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer'; import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
import { AssistMode } from './components/AssistMode';
import { usePitchDetector } from './hooks/usePitchDetector'; import { usePitchDetector } from './hooks/usePitchDetector';
import { useSettings } from './context/useSettings'; import { useSettings } from './context/useSettings';
import { audioEngine } from './audio/AudioEngine'; import { audioEngine } from './audio/AudioEngine';
@@ -26,6 +27,7 @@ function App() {
const setSettings = updateSettings; const setSettings = updateSettings;
const isPhrase = settings.gameMode === 'phrase'; const isPhrase = settings.gameMode === 'phrase';
const isAssist = settings.gameMode === 'assist';
const currentTuning = TUNINGS[settings.tuningId]; const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
@@ -121,24 +123,28 @@ function App() {
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
const onInteractive = // Text inputs / selects keep their native keys — shortcuts must not
// fire while typing. FOCUSED BUTTONS however stay eligible: after a
// click the button keeps focus, and gating there made N/R/P/L
// silently dead until the user clicked elsewhere. Space/Enter on a
// focused button activate it natively (guarded below), so no
// double-fire.
const onTextEntry =
target instanceof HTMLInputElement || target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement || target instanceof HTMLTextAreaElement ||
target instanceof HTMLButtonElement ||
target instanceof HTMLSelectElement || target instanceof HTMLSelectElement ||
!!target?.isContentEditable; !!target?.isContentEditable;
if (onTextEntry) return;
if (e.code === 'Escape') { if (e.code === 'Escape') {
if (showHelp) setShowHelp(false); if (showHelp) setShowHelp(false);
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false })); else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
return; return;
} }
// Focused buttons handle their own keys (native activation); // Prevent default for space to stop scrolling (Space on a focused
// global shortcuts must not double-fire or block them. // button activates it natively — preventDefault only applies when the
if (onInteractive) return; // focus is on the body, where the shortcut below takes over).
if (e.code === 'Space' && !(target instanceof HTMLButtonElement)) {
// Prevent default for space to stop scrolling
if (e.code === 'Space') {
e.preventDefault(); e.preventDefault();
} }
@@ -159,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;
@@ -169,12 +181,33 @@ function App() {
setListening(l => !l); setListening(l => !l);
break; break;
} }
if (e.code === 'Space' || e.code === 'Enter') { // Space/Enter on a focused button natively activate that button
// (e.g. Start); only treat them as pause-toggle when NOT on a button.
if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) {
phraseRef.current?.pauseToggle(); phraseRef.current?.pauseToggle();
} }
return; return;
} }
if (isAssist) {
// Assist mode: only display shortcuts — no trainer actions.
switch (e.key.toLowerCase()) {
case 'h':
setSettings(s => ({ ...s, showHint: !s.showHint }));
break;
case 'z':
setSettings(s => ({ ...s, zenMode: !s.zenMode }));
break;
case 'v':
setSettings(s => ({ ...s, showFretboard: !s.showFretboard }));
break;
case 'l':
setListening(l => !l);
break;
}
return;
}
switch (e.key.toLowerCase()) { switch (e.key.toLowerCase()) {
case 'h': case 'h':
setSettings(s => ({ ...s, showHint: !s.showHint })); setSettings(s => ({ ...s, showHint: !s.showHint }));
@@ -200,23 +233,23 @@ function App() {
break; break;
} }
if (e.code === 'Space' || e.code === 'Enter') { if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) {
singleNoteRef.current?.skipNote(); singleNoteRef.current?.skipNote();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [settings, showHelp, isPhrase, setSettings]); }, [settings, showHelp, isPhrase, isAssist, setSettings]);
// 'm' cycles all three modes from phrase mode too. // 'm' cycles back to sight reading from phrase/assist modes.
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
if (e.key.toLowerCase() !== 'm') return; if (e.key.toLowerCase() !== 'm') return;
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ||
target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return; target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return;
if (settings.gameMode === 'phrase') { if (settings.gameMode === 'phrase' || settings.gameMode === 'assist') {
setSettings(s => ({ ...s, gameMode: 'sight_reading' })); setSettings(s => ({ ...s, gameMode: 'sight_reading' }));
} }
}; };
@@ -244,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' : ''}`}
@@ -305,6 +346,11 @@ function App() {
micError={error} micError={error}
windowWidth={windowWidth} windowWidth={windowWidth}
/> />
) : isAssist ? (
<AssistMode
pitchData={displayedPitch}
windowWidth={windowWidth}
/>
) : ( ) : (
<SingleNoteTrainer <SingleNoteTrainer
ref={singleNoteRef} ref={singleNoteRef}
@@ -328,6 +374,9 @@ function App() {
{listening ? <Mic size={28} /> : <MicOff size={28} />} {listening ? <Mic size={28} /> : <MicOff size={28} />}
</button> </button>
{/* Live staff omitted in Assist mode: the Assist card already
shows the played note large above the fretboard. */}
{!isAssist && (
<LiveNoteStaff <LiveNoteStaff
midi={displayedPitch ? displayedPitch.midi : null} midi={displayedPitch ? displayedPitch.midi : null}
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose} transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
@@ -335,6 +384,7 @@ function App() {
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined} clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
theme={settings.theme} theme={settings.theme}
/> />
)}
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}> <div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
{displayedPitch ? ( {displayedPitch ? (
<> <>
@@ -391,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>

View File

@@ -0,0 +1,102 @@
/**
* Assist Mode — primitive practice companion for working from paper.
*
* No trainer, no scoring: shows the currently played (mic-detected) note
* large, optionally with a live fretboard of alternative positions for that
* note. Tuner and Metronome come from the shared footer tools (Controls),
* so they stay identical across all modes.
*/
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
import { useMemo } from 'react';
import { useSettings } from '../context/useSettings';
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
import { isFrettedInstrument } from '../music/playableRange';
import { getPracticeFilter } from '../types/SettingsTypes';
import { Fretboard } from './Fretboard';
import { LiveNoteStaff } from './LiveNoteStaff';
import type { PitchData } from '../hooks/usePitchDetector';
interface AssistModeProps {
pitchData: PitchData | null;
/** True while the app's own audio is audible (mic gate) — caller decides. */
windowWidth: number;
}
export const AssistMode: React.FC<AssistModeProps> = ({ pitchData, windowWidth }) => {
const { settings } = useSettings();
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
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)
const positions = useMemo(() => {
if (!fretted || !currentTuning || !pitchData) return [];
const all = getFretboardPositions(pitchData.midi, currentTuning, 20);
if (!pf.fretWindowEnabled) return all;
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]);
// Full written range of the currently configured pool (strings × frets,
// respecting the same filter as the position markers). The staff box is
// 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);
return (
<div className="card sheet-music-card assist-card">
<div className="assist-note-area">
{pitchData ? (
<span className="assist-note-name">{pitchData.note}</span>
) : (
<span className="assist-note-name placeholder">—</span>
)}
</div>
<div className="sheet-music-container">
<LiveNoteStaff
midi={pitchData ? pitchData.midi : null}
transpose={transpose}
keySignature={settings.keySignature}
clef={clef === 'grand' ? undefined : clef}
width={Math.min(240, staffWidth)}
height={88}
range={range}
theme={settings.theme}
/>
</div>
{fretted && currentTuning && (
<div className="assist-fretboard">
<Fretboard
tuning={currentTuning}
positions={positions}
maxFrets={15}
showHints={true}
interactive={false}
displayTranspose={transpose}
/>
</div>
)}
</div>
);
};

View File

@@ -1,14 +1,11 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Guitar, Music, Gauge } from 'lucide-react'; import { Gauge, SlidersHorizontal } from 'lucide-react';
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { TuningMeter } from './TuningMeter'; import { TuningMeter } from './TuningMeter';
import { TargetNoteControls } from './TargetNoteControls'; import { TargetNoteControls } from './TargetNoteControls';
import { MetronomeWidget } from './MetronomeWidget'; import { MetronomeWidget } from './MetronomeWidget';
import { phraseRunBus } from '../hooks/phraseRunBus'; import { phraseRunBus } from '../hooks/phraseRunBus';
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes'; import { getPracticeFilter, type RhythmSettings } from '../types/SettingsTypes';
@@ -18,35 +15,13 @@ interface ControlsProps {
export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => { export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
// Alias to minimize refactor, or just use updateSettings.
const onUpdateSettings = updateSettings; const onUpdateSettings = updateSettings;
// Phrase run gate: the metronome only ticks while the phrase trainer is // Phrase run gate: the metronome only ticks while the phrase trainer is
// counting in / playing (armed by the user toggle, fired by the run). // counting in / playing (armed by the user toggle, fired by the run).
const [phraseRunActive, setPhraseRunActive] = useState(false); const [phraseRunActive, setPhraseRunActive] = useState(false);
useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []); useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []);
const handleTuningChange = (e: React.ChangeEvent<HTMLSelectElement>) => { // Note-filter panel: hidden by default (minimal UI), toggled here.
onUpdateSettings({ ...settings, tuningId: e.target.value }); const [filtersOpen, setFiltersOpen] = useState(false);
};
const handleInstrumentChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const newInstrumentId = e.target.value;
const instDef = INSTRUMENT_DEFINITIONS[newInstrumentId];
const defaultRange = instDef.ranges[0].id;
// Reset tuning if applicable, or just keep as is (it won't be shown/used)
let newTuningId = settings.tuningId;
if (instDef.showTuning && INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass']) {
newTuningId = INSTRUMENT_TUNINGS[newInstrumentId as 'guitar' | 'bass'][0];
}
onUpdateSettings({
...settings,
instrument: newInstrumentId,
difficulty: defaultRange as Difficulty,
tuningId: newTuningId
});
};
const updateRhythm = (updates: Partial<RhythmSettings>) => { const updateRhythm = (updates: Partial<RhythmSettings>) => {
onUpdateSettings({ onUpdateSettings({
@@ -55,101 +30,46 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
}); });
}; };
// Cast instrument to specific key if needed, or use generic record access
const availableTunings = INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] || [];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
// Phrase Mode brings its own key/tempo machinery; the single-note
// key-signature select and metronome tool would be competing controls.
const inPhraseMode = settings.gameMode === 'phrase'; const inPhraseMode = settings.gameMode === 'phrase';
const pf = getPracticeFilter(settings);
const activeFilterCount =
(pf.keyEnabled ? 1 : 0) +
(pf.strings.length > 0 ? 1 : 0) +
(pf.fretWindowEnabled ? 1 : 0);
// Phrase Mode hosts the filter inside its Setup panel (above the
// notation) — no footer filter button there.
const showFilterButton = !inPhraseMode;
const shouldShowFilters = showFilterButton && filtersOpen;
return ( return (
<div className="controls-container"> <div className="controls-container">
{/* Top Section: Settings Grid (2 Columns) */} {/* Note filter: collapsed behind a button — visible only when needed */}
<div className="settings-grid"> {showFilterButton && (
{/* Row 1, Col 1 */} <div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}>
<div className="control-group"> <button
<label className="control-label"> className={`hint-button ${filtersOpen ? 'active' : ''}`}
<Music size={18} /> onClick={() => setFiltersOpen(o => !o)}
<span>Instrument</span> title="Limit the notes used for exercises (key, strings, fret window)"
</label>
<select
value={settings.instrument}
onChange={handleInstrumentChange}
className="control-select"
> >
{Object.values(INSTRUMENT_DEFINITIONS).map(def => ( <SlidersHorizontal size={16} />
<option key={def.id} value={def.id}>{def.displayName}</option> Filter
))} {activeFilterCount > 0 && (
</select> <span style={{
</div> display: 'inline-block', minWidth: '18px', padding: '1px 5px',
marginLeft: '4px', borderRadius: '9px', fontSize: '11px',
{/* Row 1, Col 2 (Conditional) */} background: 'var(--color-primary)', color: 'var(--color-surface)',
{currentInstrumentDef.showTuning ? ( fontWeight: 600, textAlign: 'center'
<div className="control-group"> }}>{activeFilterCount}</span>
<label className="control-label"> )}
<Guitar size={18} /> </button>
<span>Tuning</span>
</label>
<select
value={settings.tuningId}
onChange={handleTuningChange}
className="control-select"
>
{availableTunings.map(id => (
<option key={id} value={id}>{TUNINGS[id].name}</option>
))}
</select>
</div>
) : <div />} {/* Spacer to maintain grid flow if needed, or omit to let items flow. User asked for specific rows.
If I omit, Note Set goes here. Let's omit for "Dense" feel, or keep empty div for strict rows?
The request "Top row: instrument and tuning" implies if tuning is missing, maybe just 1 item?
I'll output an empty div if I want to FORCE strict placement, but generally flow is better.
However, "Second row: note set..." suggests structure.
I will use an empty div if tuning is hidden to push Note Set to next row?
Actually, grid-template-columns: 1fr 1fr.
If Tuning is hidden, Note Set becomes Item 2.
I'll forgo complexity and just render what's available. */}
{/* Row 2, Col 2 */}
{!inPhraseMode && (
<div className="control-group">
<label className="control-label">
<span>Key signature</span>
</label>
<select
value={settings.keySignature}
onChange={(e) => onUpdateSettings({ ...settings, keySignature: e.target.value })}
className="control-select"
>
<optgroup label="Major Keys">
<option value="C">C Major</option>
<option value="G">G Major</option>
<option value="D">D Major</option>
<option value="A">A Major</option>
<option value="E">E Major</option>
<option value="F">F Major</option>
<option value="Bb">Bb Major</option>
<option value="Eb">Eb Major</option>
</optgroup>
<optgroup label="Minor Keys">
<option value="Am">A Minor</option>
<option value="Em">E Minor</option>
<option value="Dm">D Minor</option>
</optgroup>
</select>
</div> </div>
)} )}
{shouldShowFilters && <TargetNoteControls />}
</div> {/* Bottom Section: Tools Grid (stacked in all modes) */}
<div className="tools-grid" style={{ gridTemplateColumns: '1fr' }}>
{/* Target-note filter: unified controls, all modes */}
<TargetNoteControls />
{/* Bottom Section: Tools Grid (2 Columns now) */}
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
{/* 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' }}>
@@ -166,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}

View File

@@ -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,7 +119,10 @@ 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 (!range) {
if (clef === 'treble') { if (clef === 'treble') {
while (written < 58) { written += 12; octaveMark = '8vb'; } while (written < 58) { written += 12; octaveMark = '8vb'; }
while (written > 81) { written -= 12; octaveMark = '8va'; } while (written > 81) { written -= 12; octaveMark = '8va'; }
@@ -87,6 +130,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
while (written < 40) { written += 12; octaveMark = '8vb'; } while (written < 40) { written += 12; octaveMark = '8vb'; }
while (written > 61) { written -= 12; octaveMark = '8va'; } while (written > 61) { written -= 12; octaveMark = '8va'; }
} }
}
const spec = getNoteInKey(written, keySignature); const spec = getNoteInKey(written, keySignature);
const note = new StaveNote({ const note = new StaveNote({
@@ -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'}
/> />

View File

@@ -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' }}>

View File

@@ -13,7 +13,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { useAudioPlayer } from '../hooks/useAudioPlayer'; import { useAudioPlayer } from '../hooks/useAudioPlayer';
import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer'; import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer';
import { getPhraseSettings, getPracticeFilter, type PhraseSettings, type PracticeFilter } from '../types/SettingsTypes'; import { getPhraseSettings, getPracticeFilter, type PhraseSettings } from '../types/SettingsTypes';
import { rawFrameCount } from '../hooks/rawFrameBus'; import { rawFrameCount } from '../hooks/rawFrameBus';
import { audioEngine } from '../audio/AudioEngine'; import { audioEngine } from '../audio/AudioEngine';
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange'; import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange';
@@ -28,17 +28,19 @@ import { getNoteDetails } from '../music/NoteUtils';
import { EXERCISES, loadExercise } from '../exercises/library'; import { EXERCISES, loadExercise } from '../exercises/library';
import { parseAbc } from '../exercises/abcParser'; import { parseAbc } from '../exercises/abcParser';
import { PhraseSheetMusic } from './PhraseSheetMusic'; import { PhraseSheetMusic } from './PhraseSheetMusic';
import { TargetNoteControls } from './TargetNoteControls';
import { Fretboard } from './Fretboard'; import { Fretboard } from './Fretboard';
import { PianoKeys } from './PianoKeys'; import { PianoKeys } from './PianoKeys';
import { TUNINGS, getFretboardPositions } from '../music/Tunings'; 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;
@@ -71,14 +73,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const setSettings = updateSettings; const setSettings = updateSettings;
const { playNote } = useAudioPlayer(); const { playNote } = useAudioPlayer();
const phrase = getPhraseSettings(settings); const phrase = getPhraseSettings(settings);
// Unified target-note controls: Phrase Mode reads/writes the shared // Unified target-note controls: Phrase Mode reads the shared practice
// practice filter so all modes expose identical key/fret-window state. // filter (single source of truth across all modes).
const pf = getPracticeFilter(settings); const pf = getPracticeFilter(settings);
phrase.keyTonic = pf.keyTonic;
phrase.keyMode = pf.keyMode;
phrase.fretWindowEnabled = pf.fretWindowEnabled;
phrase.fretMin = pf.fretMin;
phrase.fretMax = pf.fretMax;
// Unified metronome: phrase tempo reads from the shared rhythm BPM // Unified metronome: phrase tempo reads from the shared rhythm BPM
// (single source of truth across modes; the phrase-local BPM control // (single source of truth across modes; the phrase-local BPM control
// was replaced by the common metronome widget in Controls). // was replaced by the common metronome widget in Controls).
@@ -88,26 +85,17 @@ 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 => { updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
const next = { ...s, phrase: { ...getPhraseSettings(s), ...updates } };
// Mirror shared filter fields into the unified practice filter.
const shared: Partial<PracticeFilter> = {};
if ('keyTonic' in updates) shared.keyTonic = updates.keyTonic;
if ('keyMode' in updates) shared.keyMode = updates.keyMode;
if ('fretWindowEnabled' in updates) shared.fretWindowEnabled = updates.fretWindowEnabled;
if ('fretMin' in updates) shared.fretMin = updates.fretMin;
if ('fretMax' in updates) shared.fretMax = updates.fretMax;
if (Object.keys(shared).length > 0) next.practice = { ...getPracticeFilter(s), ...shared };
return next;
});
}, [updateSettings]); }, [updateSettings]);
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const currentTuning = TUNINGS[settings.tuningId]; const currentTuning = TUNINGS[settings.tuningId];
@@ -140,7 +128,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
customMaxFret: settings.customMaxFret, customMaxFret: settings.customMaxFret,
}); });
}, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning, }, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning,
phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax,
settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]); settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]);
// ---- Imported score (session-only) -------------------------------------- // ---- Imported score (session-only) --------------------------------------
@@ -173,8 +160,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const baseScore: Result<Score> | null = useMemo(() => { const baseScore: Result<Score> | null = useMemo(() => {
if (phrase.material === 'melody') { if (phrase.material === 'melody') {
const result = generateMelody({ const result = generateMelody({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
bars: phrase.bars, bars: phrase.bars,
meter: { numerator: phrase.meterNumerator, denominator: 4 }, meter: { numerator: phrase.meterNumerator, denominator: 4 },
rhythmLevel: phrase.rhythmLevel, rhythmLevel: phrase.rhythmLevel,
@@ -184,8 +171,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
} }
if (phrase.material === 'scale') { if (phrase.material === 'scale') {
return generateScaleDrill({ return generateScaleDrill({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
direction: phrase.scaleDirection, direction: phrase.scaleDirection,
coverage: phrase.scaleCoverage, coverage: phrase.scaleCoverage,
meter: { numerator: phrase.meterNumerator, denominator: 4 }, meter: { numerator: phrase.meterNumerator, denominator: 4 },
@@ -196,8 +183,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
// Meter is auto-derived inside the generator from the figure // Meter is auto-derived inside the generator from the figure
// length (always eighths) — the user no longer picks a meter. // length (always eighths) — the user no longer picks a meter.
return generateGiulianiStudy({ return generateGiulianiStudy({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
pattern: phrase.giulianiPattern, pattern: phrase.giulianiPattern,
tuningId: settings.tuningId, tuningId: settings.tuningId,
bars: phrase.giulianiBars, bars: phrase.giulianiBars,
@@ -209,8 +196,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
} }
if (phrase.material === 'arpeggio') { if (phrase.material === 'arpeggio') {
return generateArpeggio({ return generateArpeggio({
keyTonic: phrase.keyTonic, keyTonic: pf.keyTonic,
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId, keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I', degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I',
pattern: phrase.arpeggioPattern, pattern: phrase.arpeggioPattern,
coverage: phrase.arpeggioCoverage, coverage: phrase.arpeggioCoverage,
@@ -231,7 +218,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
return { ok: true, value: importedScore }; return { ok: true, value: importedScore };
} }
return null; return null;
}, [phrase.material, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator, }, [phrase.material, pf.keyTonic, pf.keyMode, phrase.bars, phrase.meterNumerator,
phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId, phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId,
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage, phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
phrase.arpeggioBars, phrase.arpeggioProgression, phrase.arpeggioBars, phrase.arpeggioProgression,
@@ -323,9 +310,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
}, [scoreOk, fixedMaterial, pool, fullScore]); }, [scoreOk, fixedMaterial, pool, fullScore]);
const nextAction = useCallback(() => { const nextAction = useCallback(() => {
if (phrase.material === 'melody' || (phrase.material === 'arpeggio' && phrase.arpeggioBars > 1)) { if (fixedMaterial && scoreOk) {
setMelodySeed(s => (s + 1) % 999983);
} else if (fixedMaterial && scoreOk) {
const total = fullScore!.value.measures.length; const total = fullScore!.value.measures.length;
const nextStart = phrase.startBar + phrase.barCount; const nextStart = phrase.startBar + phrase.barCount;
if (nextStart > total) { if (nextStart > total) {
@@ -333,6 +318,12 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
} else { } else {
setPhrase({ startBar: nextStart }); setPhrase({ startBar: nextStart });
} }
return;
}
// Seeded material: roll a new variation (giuliani and single-chord
// arpeggios included — they take melodySeed too).
if (phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani') {
setMelodySeed(s => (s + 1) % 999983);
} }
}, [phrase.material, phrase.startBar, phrase.barCount, fixedMaterial, scoreOk, fullScore, setPhrase]); }, [phrase.material, phrase.startBar, phrase.barCount, fixedMaterial, scoreOk, fullScore, setPhrase]);
@@ -340,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(),
@@ -349,17 +341,22 @@ 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(() => {
if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return []; if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return [];
if (phrase.fretWindowEnabled) { if (pf.fretWindowEnabled) {
return positionsWithinWindow(currentMidi, currentTuning, phrase.fretMin, phrase.fretMax); return positionsWithinWindow(currentMidi, currentTuning, pf.fretMin, pf.fretMax);
} }
return getFretboardPositions(currentMidi, currentTuning); return getFretboardPositions(currentMidi, currentTuning);
}, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax]); }, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]);
const handleVirtualPlay = useCallback((midi: number) => { const handleVirtualPlay = useCallback((midi: number) => {
if (!settings.virtualGuitarMute) { if (!settings.virtualGuitarMute) {
@@ -380,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') {
@@ -399,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]);
@@ -452,118 +451,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</span> </span>
</div> </div>
<div className="sheet-music-container"> {/* Setup panel — above the notation, holds material options
{scoreOk ? ( plus the shared note filter (key/strings/fret window) */}
<PhraseSheetMusic
score={trainer.activeScore}
currentIdx={trainer.currentIdx}
statuses={trainer.statuses}
clef={activeClef}
transpose={activeTranspose}
width={staffWidth}
theme={settings.theme}
/>
) : (
<div className="phrase-error">{materialError}</div>
)}
</div>
{trainer.error && <div className="error-message">{trainer.error}</div>}
{importError && <div className="error-message">{importError}</div>}
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div>
</div>
{/* Transport — reuses the single-note button styles/behavior */}
<div className="action-row phrase-transport">
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
{startLabel}
</button>
{!settings.zenMode && (
<>
<button
className="hint-button"
onClick={trainer.previewToggle}
title="Preview the phrase (P)"
>
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
</button>
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
{phrase.pace === 'step' && (
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
<SkipForward size={18} />
Skip
</button>
)}
</>
)}
{/* Same virtual-instrument / hint toggles as single-note mode */}
{trainer.phase === 'done' && settings.zenMode && (
<>
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
<SkipForward size={18} />
{phrase.material === 'melody' ? 'New' : 'Next'}
</button>
)}
</>
)}
{!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>
{/* Setup panel */}
{!settings.zenMode && ( {!settings.zenMode && (
<details className="phrase-setup"> <details className="phrase-setup">
<summary><ChevronDown size={16} /> Setup</summary> <summary><ChevronDown size={16} /> Setup</summary>
@@ -579,12 +468,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
{(phrase.material === 'melody' || phrase.material === 'scale' || phrase.material === 'arpeggio') && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Key/mode: use the "Target key" controls below the sheet — shared across modes.
</span>
)}
{phrase.material === 'melody' && ( {phrase.material === 'melody' && (
<> <>
<label>Bars <label>Bars
@@ -799,12 +682,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
)} )}
{fretted && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Fret window: use the "Fret window" controls below the sheet — shared across modes.
</span>
)}
{/* Pace lives in the metronome widget now: {/* Pace lives in the metronome widget now:
'Sync to exercise' checked = tempo pace, 'Sync to exercise' checked = tempo pace,
unchecked = at-your-pace (step). */} unchecked = at-your-pace (step). */}
@@ -816,11 +693,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
/> />
Virtual input only Virtual input only
</label> </label>
{phrase.pace === 'tempo' && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
BPM + click: on the metronome widget below.
</span>
)}
<label className="phrase-check"> <label className="phrase-check">
<input <input
type="checkbox" type="checkbox"
@@ -839,15 +711,168 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
</div> </div>
{phrase.pace === 'tempo' && ( {phrase.pace === 'tempo' && (
<p className="phrase-note">The run clicks a full count-in bar, then one click per beat. The metronome pendulum mirrors these beats — set the tempo on the metronome tool.</p> <p className="phrase-note">Tempo and click sound live on the metronome tool (Sync checked = the run sets the pace; unchecked = play at your own pace with a free metronome).</p>
)} )}
<p className="phrase-note"> <p className="phrase-note">
Matching checks pitch, not fingering — several fretboard positions produce the same pitch. Matching checks pitch, not fingering — several fretboard positions produce the same pitch.
For repeated identical notes, briefly release between notes so each attack is detected. For repeated identical notes, briefly release between notes so each attack is detected.
</p> </p>
<TargetNoteControls />
</details> </details>
)} )}
<div className="sheet-music-container">
{scoreOk ? (
<PhraseSheetMusic
score={trainer.activeScore}
currentIdx={trainer.phase === 'preview' || trainer.phase === 'previewPaused' ? trainer.previewIdx : trainer.currentIdx}
statuses={trainer.statuses}
clef={activeClef}
transpose={activeTranspose}
width={staffWidth}
theme={settings.theme}
/>
) : (
<div className="phrase-error">{materialError}</div>
)}
</div>
{trainer.error && <div className="error-message">{trainer.error}</div>}
{importError && <div className="error-message">{importError}</div>}
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
<div className="feedback-area">
{trainer.phase === 'countIn' && (
<span
className="phrase-countin"
style={{
display: 'inline-block',
minWidth: '20px',
padding: '1px 7px',
marginRight: '8px',
borderRadius: '10px',
fontSize: '11px',
opacity: 0.75,
background: 'color-mix(in srgb, var(--color-text-main) 10%, transparent)',
}}
aria-label={`Count-in: ${trainer.countInLeft} beats left`}
>{trainer.countInLeft}</span>
)}
<div className="instruction-text phrase-status">{statusText}</div>
</div>
{/* Transport — reuses the single-note button styles/behavior */}
<div className="action-row phrase-transport">
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
{startLabel}
</button>
{!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
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 className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
<SkipForward size={18} />
{(fixedMaterial || phrase.material === 'scale') ? 'Next' : 'New'}
</button>
)}
{phrase.pace === 'step' && (
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
<SkipForward size={18} />
Skip
</button>
)}
</>
)}
{/* Same virtual-instrument / hint toggles as single-note mode */}
{trainer.phase === 'done' && settings.zenMode && (
<>
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
<RotateCcw size={18} />
Retry
</button>
{(phrase.material === 'melody' || phrase.material === 'arpeggio' || phrase.material === 'giuliani' || fixedMaterial) && (
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
<SkipForward size={18} />
{(fixedMaterial || phrase.material === 'scale') ? 'Next' : 'New'}
</button>
)}
</>
)}
</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 && (
<div className="hint-card"> <div className="hint-card">
@@ -878,7 +903,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
onPlayNote={handleVirtualPlay} onPlayNote={handleVirtualPlay}
onHover={setHoveredMidi} onHover={setHoveredMidi}
showHints={settings.showHint} showHints={settings.showHint}
maxFrets={Math.max(15, phrase.fretWindowEnabled ? phrase.fretMax + 1 : 0)} maxFrets={Math.max(15, pf.fretWindowEnabled ? pf.fretMax + 1 : 0)}
/> />
) )
)} )}

View File

@@ -1,8 +1,32 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock } from 'lucide-react'; import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor, Clock, Guitar } from 'lucide-react';
import type { AppSettings } from '../types/SettingsTypes'; import type { AppSettings } from '../types/SettingsTypes';
import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig'; import { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector'; import type { MicrophoneDebugInfo } from '../hooks/usePitchDetector';
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
/** Collapsible settings section. First section open by default. */
const Section: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = false, children }) => {
const [open, setOpen] = useState(defaultOpen);
return (
<div style={{ borderBottom: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
width: '100%', background: 'transparent', border: 'none', color: 'inherit',
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 0', cursor: 'pointer', font: 'inherit'
}}
aria-expanded={open}
>
<span style={{ fontSize: '15px', fontWeight: 600 }}>{title}</span>
{open ? <ChevronUp size={16} style={{ opacity: 0.6 }} /> : <ChevronDown size={16} style={{ opacity: 0.6 }} />}
</button>
{open && <div style={{ paddingBottom: '20px' }}>{children}</div>}
</div>
);
};
interface SettingsModalProps { interface SettingsModalProps {
isOpen: boolean; isOpen: boolean;
@@ -78,7 +102,52 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
<div className="help-popup-overlay" onClick={onClose}> <div className="help-popup-overlay" onClick={onClose}>
<div className="help-popup" onClick={e => e.stopPropagation()}> <div className="help-popup" onClick={e => e.stopPropagation()}>
<button className="help-close" onClick={onClose}><X size={20} /></button> <button className="help-close" onClick={onClose}><X size={20} /></button>
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Settings</h2> <h2 style={{ marginTop: 0, marginBottom: '12px' }}>Settings</h2>
{/* Instrument & Tuning (rarely changed — lives here now) */}
<Section title="Instrument" defaultOpen>
<div className="control-group" style={{ marginBottom: '16px' }}>
<label className="control-label" style={{ marginBottom: '8px', fontSize: '16px' }}>
<Guitar size={18} />
<span>Instrument</span>
</label>
<select
value={settings.instrument}
onChange={(e) => {
const id = e.target.value;
const def = INSTRUMENT_DEFINITIONS[id];
let tuningId = settings.tuningId;
if (def.showTuning && INSTRUMENT_TUNINGS[id as 'guitar' | 'bass']) {
tuningId = INSTRUMENT_TUNINGS[id as 'guitar' | 'bass'][0];
}
onUpdateSettings({ ...settings, instrument: id, difficulty: def.ranges[0].id, tuningId });
}}
className="control-select"
>
{Object.values(INSTRUMENT_DEFINITIONS).map(def => (
<option key={def.id} value={def.id}>{def.displayName}</option>
))}
</select>
</div>
{INSTRUMENT_DEFINITIONS[settings.instrument]?.showTuning && INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] && (
<div className="control-group">
<label className="control-label" style={{ marginBottom: '8px', fontSize: '16px' }}>
<span>Tuning</span>
</label>
<select
value={settings.tuningId}
onChange={(e) => onUpdateSettings({ ...settings, tuningId: e.target.value })}
className="control-select"
>
{INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'].map(id => (
<option key={id} value={id}>{TUNINGS[id]?.name ?? id}</option>
))}
</select>
</div>
)}
</Section>
<Section title="Appearance">
{/* Theme Settings */} {/* Theme Settings */}
<div className="control-group" style={{ marginBottom: '24px' }}> <div className="control-group" style={{ marginBottom: '24px' }}>
@@ -119,8 +188,6 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</div> </div>
</div> </div>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} />
{/* Animation Settings */} {/* Animation Settings */}
<div className="control-group" style={{ marginBottom: '24px' }}> <div className="control-group" style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
@@ -140,7 +207,9 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</p> </p>
</div> </div>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} /> </Section>
<Section title="Audio" defaultOpen>
<div className="control-group" style={{ marginBottom: '24px' }}> <div className="control-group" style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
@@ -261,7 +330,9 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</p> </p>
</div> </div>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} /> </Section>
<Section title="Microphone" defaultOpen>
{/* Microphone Toggle */} {/* Microphone Toggle */}
<div className="control-group" style={{ marginBottom: '16px' }}> <div className="control-group" style={{ marginBottom: '16px' }}>
@@ -434,6 +505,8 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
)} )}
</div> </div>
)} )}
</Section>
</div> </div>
</div> </div>

View File

@@ -149,25 +149,43 @@ export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerP
)} )}
{!settings.zenMode && ( {!settings.zenMode && (
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}> <>
{/* Display toggles — switch style (like phrase mode):
pure show/hide, no trainer actions. */}
<div className="display-toggles" style={{ marginTop: '24px' }}>
{currentInstrumentDef.showTuning && ( {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>
)} )}
<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 className="action-row" style={{ marginTop: '16px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
<button <button
className="hint-button" className="hint-button"
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)} onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
@@ -181,6 +199,7 @@ export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerP
Skip Note Skip Note
</button> </button>
</div> </div>
</>
)} )}
</div> </div>
); );

View File

@@ -6,8 +6,10 @@
* - Strings: pick which strings contribute target notes (fretted instruments). * - Strings: pick which strings contribute target notes (fretted instruments).
* - Fret window: restrict target notes to a fret range (fretted instruments). * - Fret window: restrict target notes to a fret range (fretted instruments).
* *
* In Phrase Mode the same controls bind to the shared practice filter * In Phrase Mode the Key section binds to the generator key (tonic+mode are
* (mirrored into phrase settings), so state stays consistent across modes. * always applied; no toggle), and the display-signature sync checkbox is
* hidden (phrase notation takes its signature from the score's key).
* Rendered inside the per-mode Setup panel above the notation.
*/ */
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react'; import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
@@ -46,6 +48,8 @@ const MINOR_TONIC_TO_SIGNATURE: Record<string, string> = {
export const TargetNoteControls: React.FC = () => { export const TargetNoteControls: React.FC = () => {
const { settings, updateSettings } = useSettings(); const { settings, updateSettings } = useSettings();
const pf = getPracticeFilter(settings); const pf = getPracticeFilter(settings);
// Phrase Mode: key tonic/mode are the generator key — always in effect.
const inPhraseMode = settings.gameMode === 'phrase';
const setFilter = (updates: Partial<PracticeFilter>) => { const setFilter = (updates: Partial<PracticeFilter>) => {
updateSettings(s => { updateSettings(s => {
@@ -81,7 +85,8 @@ export const TargetNoteControls: React.FC = () => {
const allSelected = pf.strings.length === 0; const allSelected = pf.strings.length === 0;
const anyActive = pf.keyEnabled || pf.fretWindowEnabled || pf.strings.length > 0; const anyActive =
(!inPhraseMode && pf.keyEnabled) || pf.fretWindowEnabled || pf.strings.length > 0;
return ( return (
<div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}> <div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}>
{/* Section header: unified with Tuner/Metronome style */} {/* Section header: unified with Tuner/Metronome style */}
@@ -108,6 +113,7 @@ export const TargetNoteControls: React.FC = () => {
<Key size={18} /> <Key size={18} />
<span>Key</span> <span>Key</span>
</label> </label>
{!inPhraseMode && (
<button <button
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`} className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })} onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
@@ -115,8 +121,9 @@ export const TargetNoteControls: React.FC = () => {
> >
<div className="switch-thumb" /> <div className="switch-thumb" />
</button> </button>
)}
</div> </div>
{pf.keyEnabled && ( {(pf.keyEnabled || inPhraseMode) && (
<> <>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}> <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<select <select
@@ -140,6 +147,7 @@ export const TargetNoteControls: React.FC = () => {
))} ))}
</select> </select>
</div> </div>
{!inPhraseMode && (
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}> <label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
<input <input
type="checkbox" type="checkbox"
@@ -157,6 +165,7 @@ export const TargetNoteControls: React.FC = () => {
/> />
Sync display key signature with target key Sync display key signature with target key
</label> </label>
)}
</> </>
)} )}
</div> </div>

View File

@@ -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
}); });

View File

@@ -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();

View File

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

View File

@@ -9,7 +9,7 @@ import {
NOTE_DISPLAY_STABLE_FRAMES, NOTE_DISPLAY_STABLE_FRAMES,
} from '../AppConfig'; } from '../AppConfig';
interface PitchData { export interface PitchData {
frequency: number; frequency: number;
midi: number; midi: number;
note: string; note: string;

View File

@@ -59,24 +59,24 @@
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
:root { :root {
color-scheme: dark; color-scheme: dark;
--color-bg: hsl(30, 20%, 10%); --color-bg: hsl(220, 6%, 10%);
--color-surface: hsl(30, 20%, 15%); --color-surface: hsl(220, 6%, 15%);
--color-text-main: hsl(30, 10%, 90%); --color-text-main: hsl(220, 4%, 90%);
--color-text-muted: hsl(30, 10%, 60%); --color-text-muted: hsl(220, 4%, 60%);
--color-primary: hsl(0, 0%, 90%); --color-primary: hsl(220, 4%, 90%);
--color-primary-dark: hsl(0, 0%, 100%); --color-primary-dark: hsl(220, 4%, 100%);
} }
} }
/* Manual Dark Mode Override */ /* Manual Dark Mode Override */
html[data-theme="dark"] { html[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
--color-bg: hsl(30, 20%, 10%); --color-bg: hsl(220, 6%, 10%);
--color-surface: hsl(30, 20%, 15%); --color-surface: hsl(220, 6%, 15%);
--color-text-main: hsl(30, 10%, 90%); --color-text-main: hsl(220, 4%, 90%);
--color-text-muted: hsl(30, 10%, 60%); --color-text-muted: hsl(220, 4%, 60%);
--color-primary: hsl(0, 0%, 90%); --color-primary: hsl(220, 4%, 90%);
--color-primary-dark: hsl(0, 0%, 100%); --color-primary-dark: hsl(220, 4%, 100%);
} }
/* Manual Light Mode Override */ /* Manual Light Mode Override */

View File

@@ -49,8 +49,6 @@ export interface PhraseSettings {
material: PhraseMaterial; material: PhraseMaterial;
libraryId: string; libraryId: string;
/** Imported score id (the parsed score lives in component state, session-only). */ /** Imported score id (the parsed score lives in component state, session-only). */
keyTonic: string;
keyMode: string; // ModeId
bars: number; // 1–8 (melodies) bars: number; // 1–8 (melodies)
meterNumerator: 3 | 4 | 6; meterNumerator: 3 | 4 | 6;
rhythmLevel: 1 | 2 | 3; rhythmLevel: 1 | 2 | 3;
@@ -69,10 +67,6 @@ export interface PhraseSettings {
/** Giuliani study material (own category — voicing-enforced). */ /** Giuliani study material (own category — voicing-enforced). */
giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim'; giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim';
giulianiBars: number; giulianiBars: number;
/** Phrase-local fret window (guitar/bass) — overrides the note set. */
fretWindowEnabled: boolean;
fretMin: number;
fretMax: number;
/** Practice range for library/import material (1-based, inclusive). */ /** Practice range for library/import material (1-based, inclusive). */
startBar: number; startBar: number;
barCount: number; barCount: number;
@@ -95,8 +89,6 @@ export interface PhraseSettings {
export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = { export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
material: 'melody', material: 'melody',
libraryId: '', libraryId: '',
keyTonic: 'C',
keyMode: 'major',
bars: 2, bars: 2,
meterNumerator: 4, meterNumerator: 4,
rhythmLevel: 1, rhythmLevel: 1,
@@ -111,9 +103,6 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
arpeggioProgression: 'functional', arpeggioProgression: 'functional',
giulianiPattern: 'pima', giulianiPattern: 'pima',
giulianiBars: 4, giulianiBars: 4,
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
startBar: 1, startBar: 1,
barCount: 2, barCount: 2,
pace: 'step', pace: 'step',
@@ -145,7 +134,12 @@ export interface AppSettings {
instrument: string; instrument: string;
rhythm: RhythmSettings; rhythm: RhythmSettings;
zenMode: boolean; zenMode: boolean;
gameMode: 'sight_reading' | 'ear_training' | 'phrase'; /**
* 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';
customMinFret?: number; customMinFret?: number;
customMaxFret?: number; customMaxFret?: number;
autoPlaySightReading?: boolean; autoPlaySightReading?: boolean;