19 Commits

Author SHA1 Message Date
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
OpenClaw
aebed68127 Arpeggio random pattern option + docs update 2026-09-15 07:17:36 +00:00
OpenClaw
d7549e0111 Fix strict tsc -b errors: GiulianiPattern type import, placement null guard 2026-09-15 06:30:26 +00:00
OpenClaw
8ecb7f2de2 Phrase mode: always-eighths arpeggio/Giuliani, auto meter, custom patterns, alternating bass
- arpeggioGenerator: always 240-tick eighths; auto-derive 3/4 vs 4/4
  from pattern length (remainder heuristic); never stretch the last
  note (fixes the two-octave 2880-tick VexFlow crash in 6/8); rest
  fills built from renderable durations only; new 'custom' pattern
  from digit input (1=root..5=fifth-above-octave); removed meter/rhythm
  config — generator computes its own meter.
- giulianiGenerator: always eighths; meter auto-derived from figure
  length (3v->3/4, 4v->4/4, 5v->4/4+rest, 6v->3/4); fixed pimami
  figure [0,1,2,3,2,1]; added pimai/pmia/pmim figures; alternating
  bass option (even bars voice the fifth as bass); removed the
  quarter-note fallback.
- PhraseTrainer: removed meter/rhythm selects for arpeggio+giuliani;
  custom pattern text input; alternating bass checkbox.
- SettingsTypes: 'custom' arpeggio pattern, customArpeggioPattern,
  giulianiAlternateBass; removed arpeggioRhythm.
- PhraseSheetMusic: minWidthPerBar 420 -> 500 for 16th notes.
- Tests updated for the new invariants + coverage for custom patterns,
  alternating bass, new figures, renderable-tick sweeps (1031 -> 191
  per-file, all green); removed temporary crash_test.test.ts.
2026-09-15 06:29:11 +00:00
23 changed files with 1488 additions and 642 deletions

View File

@@ -1,5 +1,7 @@
# Local deployment (nyx server) — beta channel # Local deployment (nyx server) — beta channel
- 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)
- 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

@@ -99,3 +99,35 @@ Implementation of [PHRASE_MODE_PLAN.md](../PHRASE_MODE_PLAN.md) and
- MIDI adapter, motif-based generation, persisted phrase setup, extra - MIDI adapter, motif-based generation, persisted phrase setup, extra
repertoire, per-note timing scoring — all explicitly deferred in the plan. repertoire, per-note timing scoring — all explicitly deferred in the plan.
## Feedback round 4 (2026-09-15): always-eighths arpeggio/Giuliani + custom/random patterns
**Generator redesign (Phrase Mode materials `arpeggio` + `giuliani`):**
- **Always eighth notes** (240 ticks/note). The quarter-note fallback and
stretch-the-last-note logic were removed — the latter produced
non-renderable 2880-tick durations (two-octave arpeggio in 6/8) that
crashed the VexFlow renderer (white screen).
- **Auto-derived meter** (`autoArpeggioMeter`): 3/4 vs 4/4 chosen by the
smallest rest remainder for the path length (fewer bars as tiebreaker).
Giuliani meter derives from figure length (3 voices → 3/4, 4 → 4/4,
5 → 4/4 + rest fill, 6 → 3/4). The Meter and Rhythm selects were removed
from both setups; leftover bar space is filled with rests built from
renderable durations only.
- **Custom arpeggio pattern**: new `custom` pattern + text field; digits map
1=root, 2=third, 3=fifth, 4=octave, 5=fifth above the octave; invalid
input fails with an actionable error.
- **Random arpeggio pattern**: new `random` pattern — one broken-chord
figure is chosen deterministically per seed, so every auto-continued run
gets a fresh figure while Retry stays reproducible.
- **Two-octave coverage** doubles the path; the auto-derived meter adjusts
(e.g. updown two-octave = 13 notes → 4/4 with a rest fill).
- **Giuliani**: `pimami` figure corrected to 6 voices; new figures
`pimai`, `pmia`, `pmim`; new **alternating bass** option (even bars voice
the fifth as the bass — the original studies' thumb-jump feel).
- **Renderer**: `minWidthPerBar` for sixteenths raised 420 → 500 px
(mobile clipping).
**Verification:** 193 unit tests green (`npx vitest run`), `tsc --noEmit`
clean, production build + deploy to `smt-beta`. A pattern×degree×coverage
sweep test guarantees every generated duration stays in the renderable set.

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

View File

@@ -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,13 +374,17 @@ function App() {
{listening ? <Mic size={28} /> : <MicOff size={28} />} {listening ? <Mic size={28} /> : <MicOff size={28} />}
</button> </button>
<LiveNoteStaff {/* Live staff omitted in Assist mode: the Assist card already
midi={displayedPitch ? displayedPitch.midi : null} shows the played note large above the fretboard. */}
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose} {!isAssist && (
keySignature={settings.keySignature} <LiveNoteStaff
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined} midi={displayedPitch ? displayedPitch.midi : null}
theme={settings.theme} transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
/> keySignature={settings.keySignature}
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
theme={settings.theme}
/>
)}
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}> <div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
{displayedPitch ? ( {displayedPitch ? (
<> <>
@@ -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,90 @@
/**
* 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);
// 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]);
const { clef, transpose } = useMemo(() =>
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
[currentInstrumentDef, settings.difficulty]);
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}
theme={settings.theme}
/>
</div>
{fretted && currentTuning && (
<div className="assist-fretboard">
<Fretboard
tuning={currentTuning}
positions={positions}
maxFrets={15}
showHints={false}
interactive={false}
displayTranspose={transpose}
/>
</div>
)}
{!pitchData && (
<p className="assist-hint">
Play a note — it shows here. Mic is{' '}
{settings.showTuningMeter ? 'on' : 'controlled by the mic button below'}.
</p>
)}
</div>
);
};

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 <SlidersHorizontal size={16} />
value={settings.instrument} Filter
onChange={handleInstrumentChange} {activeFilterCount > 0 && (
className="control-select" <span style={{
> display: 'inline-block', minWidth: '18px', padding: '1px 5px',
{Object.values(INSTRUMENT_DEFINITIONS).map(def => ( marginLeft: '4px', borderRadius: '9px', fontSize: '11px',
<option key={def.id} value={def.id}>{def.displayName}</option> background: 'var(--color-primary)', color: 'var(--color-surface)',
))} fontWeight: 600, textAlign: 'center'
</select> }}>{activeFilterCount}</span>
</div> )}
</button>
{/* Row 1, Col 2 (Conditional) */}
{currentInstrumentDef.showTuning ? (
<div className="control-group">
<label className="control-label">
<Guitar size={18} />
<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> </div>
)}
{shouldShowFilters && <TargetNoteControls />}
{/* Target-note filter: unified controls, all modes */} {/* Bottom Section: Tools Grid (stacked in all modes) */}
<TargetNoteControls /> <div className="tools-grid" style={{ gridTemplateColumns: '1fr' }}>
{/* 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' }}>

View File

@@ -21,14 +21,19 @@ 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. */
height?: 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;
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
midi, midi,
@@ -36,6 +41,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
keySignature = 'C', keySignature = 'C',
clef: clefOverride, clef: clefOverride,
width = 130, width = 130,
height: boxH = BOX_H,
theme = 'auto', theme = 'auto',
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -53,7 +59,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 +69,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, staveYFor(boxH), 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
@@ -119,7 +125,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
<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

@@ -109,7 +109,7 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
// horizontal room than quarters) so narrow screens wrap into more rows // horizontal room than quarters) so narrow screens wrap into more rows
// instead of clipping notes. // instead of clipping notes.
const densestTick = Math.min(...events.filter(e => e.pitch).map(e => e.durationTicks), 1920); const densestTick = Math.min(...events.filter(e => e.pitch).map(e => e.durationTicks), 1920);
const minWidthPerBar = densestTick <= 120 ? 420 : densestTick <= 240 ? 330 : 240; const minWidthPerBar = densestTick <= 120 ? 500 : densestTick <= 240 ? 330 : 240;
const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / minWidthPerBar))); const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / minWidthPerBar)));
const rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow)); const rowCount = Math.max(1, Math.ceil(totalBars / barsPerRow));
const isGrand = clef === 'grand'; const isGrand = clef === 'grand';

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 },
@@ -193,30 +180,31 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
}, pool); }, pool);
} }
if (phrase.material === 'giuliani') { if (phrase.material === 'giuliani') {
// Meter is auto-derived inside the generator from the figure
// 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,
meter: { numerator: phrase.meterNumerator, denominator: 4 },
tuningId: settings.tuningId, tuningId: settings.tuningId,
bars: phrase.giulianiBars, bars: phrase.giulianiBars,
chordSelection: phrase.arpeggioChordSelection as string[], chordSelection: phrase.arpeggioChordSelection as string[],
seed: melodySeed, seed: melodySeed,
allowedStrings: pf.strings, allowedStrings: pf.strings,
alternateBass: phrase.giulianiAlternateBass ?? false,
}, pool); }, pool);
} }
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,
meter: { numerator: phrase.meterNumerator, denominator: 4 },
bars: phrase.arpeggioBars, bars: phrase.arpeggioBars,
progression: phrase.arpeggioProgression, progression: phrase.arpeggioProgression,
chordSelection: phrase.arpeggioChordSelection as ArpeggioDegree[], chordSelection: phrase.arpeggioChordSelection as ArpeggioDegree[],
rhythm: phrase.arpeggioRhythm, customPattern: phrase.customArpeggioPattern,
seed: melodySeed, seed: melodySeed,
}, pool); }, pool);
} }
@@ -230,12 +218,12 @@ 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.arpeggioRhythm, phrase.arpeggioBars, phrase.arpeggioProgression,
phrase.arpeggioChordSelection, melodySeed, phrase.arpeggioChordSelection, melodySeed,
phrase.giulianiPattern, phrase.giulianiBars, phrase.giulianiPattern, phrase.giulianiBars, phrase.giulianiAlternateBass,
melodySeed, pool, importedScore]); melodySeed, pool, importedScore]);
const fullScore: Result<Score> | null = useMemo(() => { const fullScore: Result<Score> | null = useMemo(() => {
@@ -322,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) {
@@ -332,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]);
@@ -339,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(),
@@ -348,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) {
@@ -379,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') {
@@ -398,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]);
@@ -451,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>
@@ -578,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
@@ -651,9 +535,25 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<option value="121321">1-2-1-3-2-1</option> <option value="121321">1-2-1-3-2-1</option>
<option value="15453">1-5-4-5-3-5</option> <option value="15453">1-5-4-5-3-5</option>
<option value="132532">1-3-2-5-3-2</option> <option value="132532">1-3-2-5-3-2</option>
<option value="random">Random</option>
<option value="custom">Custom…</option>
</optgroup> </optgroup>
</select> </select>
</label> </label>
{phrase.arpeggioPattern === 'custom' && (
<label>Custom pattern
<input
type="text"
value={phrase.customArpeggioPattern || ''}
onChange={e => setPhrase({ customArpeggioPattern: e.target.value.replace(/[^1-5]/g, '') })}
placeholder="e.g. 1321"
style={{ width: '90px' }}
/>
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block' }}>
Digits: 1=root, 2=third, 3=fifth, 4=octave, 5=fifth-above-octave
</span>
</label>
)}
<label>Coverage <label>Coverage
<select value={phrase.arpeggioCoverage} onChange={e => setPhrase({ arpeggioCoverage: e.target.value as PhraseSettings['arpeggioCoverage'] })}> <select value={phrase.arpeggioCoverage} onChange={e => setPhrase({ arpeggioCoverage: e.target.value as PhraseSettings['arpeggioCoverage'] })}>
<option value="one-octave">One octave</option> <option value="one-octave">One octave</option>
@@ -700,19 +600,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
})} })}
</div> </div>
</div> </div>
<label>Rhythm
<select value={phrase.arpeggioRhythm} onChange={e => setPhrase({ arpeggioRhythm: e.target.value as PhraseSettings['arpeggioRhythm'] })}>
<option value="quarters">Quarter notes</option>
<option value="eighths">Eighth notes</option>
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
<option value={6}>6/8</option>
</select>
</label>
</> </>
)} )}
@@ -730,15 +617,16 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b} bars</option>)} {[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b} bars</option>)}
</select> </select>
</label> </label>
<label>Meter <label style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '6px' }}>
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}> <input
<option value={4}>4/4</option> type="checkbox"
<option value={3}>3/4</option> checked={phrase.giulianiAlternateBass ?? false}
<option value={6}>6/8</option> onChange={e => setPhrase({ giulianiAlternateBass: e.target.checked })}
</select> />
Alternating bass (fifth on even bars)
</label> </label>
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}> <span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Chords: from the arpeggio chord pool; voicing enforced (bass + upper strings). Meter is derived from the pattern; chords come from the arpeggio chord pool; voicing enforced (bass + upper strings).
</span> </span>
</> </>
)} )}
@@ -794,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). */}
@@ -811,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"
@@ -834,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">
@@ -873,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,38 +149,57 @@ export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerP
)} )}
{!settings.zenMode && ( {!settings.zenMode && (
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}> <>
{currentInstrumentDef.showTuning && ( {/* Display toggles — switch style (like phrase mode):
pure show/hide, no trainer actions. */}
<div className="display-toggles" style={{ marginTop: '24px' }}>
{currentInstrumentDef.showTuning && (
<div className="display-toggle">
<span className="display-toggle-label">
<Guitar size={15} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</span>
<button
className={`switch-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
aria-label={`Show virtual ${currentInstrumentDef.displayName}`}
>
<div className="switch-thumb" />
</button>
</div>
)}
<div className="display-toggle">
<span className="display-toggle-label">
<HelpCircle size={15} />
Hint
</span>
<button
className={`switch-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H"
aria-label="Show hint"
>
<div className="switch-thumb" />
</button>
</div>
</div>
<div className="action-row" style={{ marginTop: '16px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
<button <button
className={`hint-button ${settings.showFretboard ? 'active' : ''}`} className="hint-button"
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))} onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`} title="Keyboard Shortcut: P or R"
> >
<Guitar size={18} /> <Volume2 size={18} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'} Play Note
</button> </button>
)} <button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
<button <SkipForward size={18} />
className={`hint-button ${settings.showHint ? 'active' : ''}`} Skip Note
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))} </button>
title="Keyboard Shortcut: H" </div>
> </>
<HelpCircle size={18} />
{settings.showHint ? "Hide Hint" : "Show Hint"}
</button>
<button
className="hint-button"
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
title="Keyboard Shortcut: P or R"
>
<Volume2 size={18} />
Play Note
</button>
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
<SkipForward size={18} />
Skip Note
</button>
</div>
)} )}
</div> </div>
); );

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,15 +113,17 @@ export const TargetNoteControls: React.FC = () => {
<Key size={18} /> <Key size={18} />
<span>Key</span> <span>Key</span>
</label> </label>
<button {!inPhraseMode && (
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`} <button
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })} className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'} onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
> title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
<div className="switch-thumb" /> >
</button> <div className="switch-thumb" />
</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,23 +147,25 @@ export const TargetNoteControls: React.FC = () => {
))} ))}
</select> </select>
</div> </div>
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}> {!inPhraseMode && (
<input <label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
type="checkbox" <input
checked={settings.keyFollowsTarget ?? false} type="checkbox"
onChange={e => updateSettings(s => { checked={settings.keyFollowsTarget ?? false}
const next = { ...s, keyFollowsTarget: e.target.checked }; onChange={e => updateSettings(s => {
// When enabling sync, snap the display signature now. const next = { ...s, keyFollowsTarget: e.target.checked };
if (e.target.checked) { // When enabling sync, snap the display signature now.
const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE; if (e.target.checked) {
const sig = table[pf.keyTonic]; const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
if (sig) next.keySignature = sig; const sig = table[pf.keyTonic];
} if (sig) next.keySignature = sig;
return next; }
})} return next;
/> })}
Sync display key signature with target key />
</label> Sync display key signature with target key
</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

@@ -18,8 +18,9 @@ describe('generateArpeggio', () => {
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
// C-E-G-C: 4 notes, quarter each (plus final extension) // C-E-G-C as eighths (always 240 ticks now) plus a rest fill in the
const midis = ev.map(e => e.pitch!.midi); // auto-derived bar.
const midis = ev.filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60]); expect(midis).toEqual([48, 52, 55, 60]);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
expect(r.value.title).toContain('Arpeggio'); expect(r.value.title).toContain('Arpeggio');
@@ -30,7 +31,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'down', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'down', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([60, 55, 52, 48]); expect(midis).toEqual([60, 55, 52, 48]);
}); });
@@ -38,7 +39,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'updown', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'updown', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
// ascend C-E-G-C then descend back to C; only one C5 (turning point) // ascend C-E-G-C then descend back to C; only one C5 (turning point)
expect(midis).toEqual([48, 52, 55, 60, 55, 52, 48]); expect(midis).toEqual([48, 52, 55, 60, 55, 52, 48]);
expect(midis.filter(m => m === 60)).toHaveLength(1); expect(midis.filter(m => m === 60)).toHaveLength(1);
@@ -48,7 +49,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: '1235', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: '1235', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60]); expect(midis).toEqual([48, 52, 55, 60]);
}); });
@@ -56,14 +57,14 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'two-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'two-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const midis = scoreEvents(r.value).map(e => e.pitch!.midi); const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60, 64, 67, 72]); expect(midis).toEqual([48, 52, 55, 60, 64, 67, 72]);
}); });
it('spells the dominant triad G-B-D in C major', () => { it('spells the dominant triad G-B-D in C major', () => {
const r = generateArpeggio({ ...base, degree: 'V', pattern: 'up', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'V', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const pcs = scoreEvents(r.value).map(e => e.pitch!.midi % 12); const pcs = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi % 12);
expect(pcs).toEqual([7, 11, 2, 7]); // G B D G expect(pcs).toEqual([7, 11, 2, 7]); // G B D G
}); });
@@ -71,7 +72,7 @@ describe('generateArpeggio', () => {
const r = generateArpeggio({ ...base, degree: 'vii0', pattern: 'up', coverage: 'one-octave' }, pool); const r = generateArpeggio({ ...base, degree: 'vii0', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const pcs = scoreEvents(r.value).map(e => e.pitch!.midi % 12); const pcs = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi % 12);
expect(pcs).toEqual([11, 2, 5, 11]); // B D F B expect(pcs).toEqual([11, 2, 5, 11]); // B D F B
}); });
@@ -101,8 +102,9 @@ describe('generateArpeggio — sequence mode', () => {
// bar boundaries: each bar starts on a chord root? (not enforced) — but ticks align // bar boundaries: each bar starts on a chord root? (not enforced) — but ticks align
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
// 4 beats per bar × 4 bars = 16 notes in quarters mode // Auto meter from a 4-note path (960 ticks): 3/4 wins (remainder 480
expect(ev.length).toBe(16); // vs 960 in 4/4) → 6 eighths per bar × 4 bars = 24
expect(ev.length).toBe(24);
// deterministic // deterministic
const r2 = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 4, progression: 'functional', seed: 7 }, pool); const r2 = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 4, progression: 'functional', seed: 7 }, pool);
expect(r2.ok && JSON.stringify(scoreEvents(r2.value)) === JSON.stringify(ev)).toBe(true); expect(r2.ok && JSON.stringify(scoreEvents(r2.value)) === JSON.stringify(ev)).toBe(true);
@@ -116,12 +118,13 @@ describe('generateArpeggio — sequence mode', () => {
expect(syms).toEqual(['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']); expect(syms).toEqual(['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']);
}); });
it('eighths rhythm doubles notes per bar', () => { it('eighths fill each bar (always-eighths rhythm)', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 2, progression: 'functional', rhythm: 'eighths', seed: 3 }, pool); const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 2, progression: 'functional', seed: 3 }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
expect(ev.length).toBe(16); // 2 bars × 4 beats × 2 // Auto meter from a 4-note path: 3/4 → 6 eighths per bar × 2 bars
expect(ev.length).toBe(12);
expect(ev[0].durationTicks).toBe(240); expect(ev[0].durationTicks).toBe(240);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
}); });
@@ -136,3 +139,103 @@ describe('generateArpeggio — sequence mode', () => {
} }
}); });
}); });
describe('generateArpeggio — always-eighths + custom pattern', () => {
it('auto-derives 3/4 for a 4-note path (remainder 480 < 960)', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.meter).toEqual({ numerator: 3, denominator: 4 });
const ev = scoreEvents(r.value);
for (const e of ev.filter(x => x.pitch)) expect(e.durationTicks).toBe(240);
// 4 notes fill 960 of the 1440-tick bar; remainder is a 480 rest
const rest = ev.find(e => e.pitch === null);
expect(rest).toBeDefined();
expect(rest!.durationTicks).toBe(480);
expect(rest!.startTick).toBe(960);
});
it('auto-derives 4/4 for a 6-note path (remainder 480 < 480? no — 6×240=1440 fits 3/4)', () => {
// 1321 figure → 4 notes; use 121321 → 6 notes = 1440 ticks = 3/4 exact
const r = generateArpeggio({ ...base, degree: 'I', pattern: '121321', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.meter).toEqual({ numerator: 3, denominator: 4 });
const ev = scoreEvents(r.value).filter(e => e.pitch);
expect(ev.length).toBe(6);
expect(ev.every(e => e.durationTicks === 240)).toBe(true);
});
it('never stretches the last note (the old 2880-tick crash)', () => {
const ev = scoreEvents(
(generateArpeggio({ ...base, degree: 'I', pattern: 'updown', coverage: 'two-octave' }, pool) as { ok: true; value: import('../score/model').Score }).value
);
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
for (const e of ev) expect(RENDERABLE).toContain(e.durationTicks);
});
it('builds a custom pattern from digits (1321 = root-fifth-third-root)', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', customPattern: '1321', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 55, 52, 48]); // C G E C
});
it('digit 5 reaches the fifth above the octave', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', customPattern: '15', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const midis = scoreEvents(r.value).filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 67]); // C + G above the octave C
});
it('rejects invalid custom patterns cleanly', () => {
const empty = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', coverage: 'one-octave' }, pool);
expect(empty.ok).toBe(false);
const bad = generateArpeggio({ ...base, degree: 'I', pattern: 'custom', customPattern: '179', coverage: 'one-octave' }, pool);
expect(bad.ok).toBe(false);
});
it('random pattern resolves to a valid broken-chord figure per seed', () => {
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
const seen = new Set<string>();
for (let seed = 0; seed < 30; seed++) {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'one-octave', seed }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
for (const e of scoreEvents(r.value)) expect(RENDERABLE).toContain(e.durationTicks);
expect(validateScore(r.value)).toEqual([]);
// The title reveals the resolved figure label
const match = r.value.title.match(/\(([^)]+)\,/);
if (match) seen.add(match[1]);
}
// Across seeds more than one distinct figure should appear
expect(seen.size).toBeGreaterThan(1);
});
it('random pattern is deterministic per seed', () => {
const a = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'two-octave', seed: 42 }, pool);
const b = generateArpeggio({ ...base, degree: 'I', pattern: 'random', coverage: 'two-octave', seed: 42 }, pool);
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
});
it('sweep: every pattern × degree × coverage produces only renderable ticks', () => {
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
const patterns = ['up', 'down', 'updown', '1235', '1321', '1325', '1535', '12353', '121321', '1353', '15453', '132532'] as const;
const degrees = ['I', 'ii', 'iii', 'IV', 'V', 'vi', 'vii0'] as const;
for (const pattern of patterns) {
for (const degree of degrees) {
for (const coverage of ['one-octave', 'two-octave'] as const) {
const r = generateArpeggio({ ...base, degree, pattern, coverage }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
const ev = scoreEvents(r.value);
expect(ev.length).toBeGreaterThan(0);
for (const e of ev) expect(RENDERABLE).toContain(e.durationTicks);
expect(validateScore(r.value)).toEqual([]);
}
}
}
});
});

View File

@@ -7,6 +7,9 @@
* (one or two octaves). Chord tones are spelled via the key's degree * (one or two octaves). Chord tones are spelled via the key's degree
* spelling (same machinery as melody/scale generators), so spelling and * spelling (same machinery as melody/scale generators), so spelling and
* validation match the Phrase Mode invariants exactly. * validation match the Phrase Mode invariants exactly.
*
* Arpeggio mode is ALWAYS eighths (240 ticks per note). Meter is auto-derived
* from the pattern length — the user no longer picks rhythm or meter.
*/ */
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model'; import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
import { keyFor, type KeyContext, type ModeId } from './scales'; import { keyFor, type KeyContext, type ModeId } from './scales';
@@ -25,12 +28,12 @@ export type ArpeggioPattern =
| '121321' // rolling thirds (root-third-root-fifth-third-root) | '121321' // rolling thirds (root-third-root-fifth-third-root)
| '1353' // root-third-fifth-third | '1353' // root-third-fifth-third
| '15453' // root-octave-fifth-octave-fifth-octave | '15453' // root-octave-fifth-octave-fifth-octave
| '132532';// long broken-chord wave | '132532' // long broken-chord wave
| 'custom' // user-defined pattern from customPattern digits
| 'random'; // one broken-chord figure, chosen randomly per exercise (seed)
export type ArpeggioCoverage = 'one-octave' | 'two-octave'; export type ArpeggioCoverage = 'one-octave' | 'two-octave';
/** How the chord per bar is chosen in sequence mode. */ /** How the chord per bar is chosen in sequence mode. */
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle'; export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle';
/** Rhythm density for arpeggio sequences. */
export type ArpeggioRhythm = 'quarters' | 'eighths';
export interface ArpeggioConfig { export interface ArpeggioConfig {
readonly keyTonic: string; readonly keyTonic: string;
@@ -38,16 +41,17 @@ export interface ArpeggioConfig {
readonly degree: ArpeggioDegree; readonly degree: ArpeggioDegree;
readonly pattern: ArpeggioPattern; readonly pattern: ArpeggioPattern;
readonly coverage: ArpeggioCoverage; readonly coverage: ArpeggioCoverage;
readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
/** Sequence mode: bars > 1 with chord progression selection. */ /** Sequence mode: bars > 1 with chord progression selection. */
readonly bars?: number; readonly bars?: number;
readonly progression?: ArpeggioProgression; readonly progression?: ArpeggioProgression;
readonly rhythm?: ArpeggioRhythm;
/** Restrict progression chords to these degrees (empty = all 7). /** Restrict progression chords to these degrees (empty = all 7).
* The first bar always uses `degree` if it is in the selection. */ * The first bar always uses `degree` if it is in the selection. */
readonly chordSelection?: readonly ArpeggioDegree[]; readonly chordSelection?: readonly ArpeggioDegree[];
/** Seed for progression selection (deterministic). */ /** Seed for progression selection (deterministic). */
readonly seed?: number; readonly seed?: number;
/** User-defined custom pattern digits (1-indexed: 1=root, 2=third, 3=fifth,
* 4=octave, 5=fifth-above-octave). Only used when pattern === 'custom'. */
readonly customPattern?: string;
} }
/** Degree index (0-based) into the scale for each arpeggio degree label. */ /** Degree index (0-based) into the scale for each arpeggio degree label. */
@@ -69,17 +73,15 @@ export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
'1353': '1-3-5-3', '1353': '1-3-5-3',
'15453': '1-5-4-5-3-5', '15453': '1-5-4-5-3-5',
'132532': '1-3-2-5-3-2', '132532': '1-3-2-5-3-2',
'custom': 'Custom…',
'random': 'Random',
}; };
/** /**
* Giuliani figures — NOTE sequences (not fingerings): the thumb (p) always * Broken-chord figures — NOTE sequences (not fingerings): the bass takes the
* takes the bass/root, the remaining notes ride the upper chord tones. * root, the remaining notes ride the upper chord tones.
* Indices into the octave cycle [0=root, 1=third, 2=fifth, 3=octave]; the * Indices into the octave cycle [0=root, 1=third, 2=fifth, 3=octave].
* figure repeats with an ascending octave shift per repetition (the bass
* stays in place — like the Maestoso studies).
*/ */
// Broken-chord figures — NOTE sequences on the cycle [0=root, 1=third,
// 2=fifth, 3=octave]; register ascends per repetition.
const BROKEN_FIGURES: Record<string, number[]> = { const BROKEN_FIGURES: Record<string, number[]> = {
'1321': [0, 2, 1, 0], '1321': [0, 2, 1, 0],
'1325': [0, 2, 1, 3], '1325': [0, 2, 1, 3],
@@ -91,6 +93,16 @@ const BROKEN_FIGURES: Record<string, number[]> = {
'132532': [0, 2, 1, 3, 2, 1], '132532': [0, 2, 1, 3, 2, 1],
}; };
/**
* Renderable note durations — subset of VexFlow duration codes the
* PhraseSheetMusic renderer accepts. ALL generated durations MUST be in this
* set, otherwise the renderer produces ghost slots and crashes or misaligns.
*/
const RENDERABLE_NOTE_TICKS = new Set([120, 240, 360, 480, 720, 960, 1440, 1920]);
/** Descending renderable durations for rest fills (largest first). */
const REST_FILL_TICKS = [1920, 1440, 960, 720, 480, 240];
/** pc of a spelled degree (step letter + alter). */ /** pc of a spelled degree (step letter + alter). */
const degreePc = (d: { step: StepLetter; alter: number }): number => const degreePc = (d: { step: StepLetter; alter: number }): number =>
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12; (LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12;
@@ -128,6 +140,29 @@ function spellInKey(key: KeyContext, midi: number): { step: StepLetter; alter: n
return { step: d.step, alter: d.alter, octave }; return { step: d.step, alter: d.alter, octave };
} }
/**
* Auto-derive a meter (3/4 or 4/4) from the number of eighths in the phrase.
* Always returns 3/4 or 4/4 — 6/8 is never used for arpeggios since it can
* produce non-renderable tick sizes with some pattern lengths.
*
* For N notes at 240 ticks each:
* Try 4/4: bars4 = ceil(N*240 / 1920), remainder4 = bars4*1920 - N*240
* Try 3/4: bars3 = ceil(N*240 / 1440), remainder3 = bars3*1440 - N*240
* Pick the one with smaller remainder (fewer bars as tiebreaker).
*/
export function autoArpeggioMeter(noteCount: number): { numerator: 4 | 3; denominator: 4 } {
const totalTicks = noteCount * 240;
const bars4 = Math.ceil(totalTicks / 1920);
const remainder4 = bars4 * 1920 - totalTicks;
const bars3 = Math.ceil(totalTicks / 1440);
const remainder3 = bars3 * 1440 - totalTicks;
if (remainder3 < remainder4) return { numerator: 3, denominator: 4 };
if (remainder4 < remainder3) return { numerator: 4, denominator: 4 };
// equal remainders → prefer fewer bars
return bars3 <= bars4 ? { numerator: 3, denominator: 4 } : { numerator: 4, denominator: 4 };
}
/** /**
* Build the arpeggio path: chord tones ascending from the lowest available * Build the arpeggio path: chord tones ascending from the lowest available
* root (degree tone) that allows a full one/two-octave cycle in the pool. * root (degree tone) that allows a full one/two-octave cycle in the pool.
@@ -193,10 +228,10 @@ function buildPath(key: KeyContext, degreeIndex: number, pool: number[], coverag
return path; return path;
} }
default: { default: {
// Giuliani figure: cycle [root, third, fifth, octave] with the // Broken-chord figure: cycle [root, third, fifth, octave] with the
// figure selecting cycle positions per note; the octave wraps so // figure selecting cycle positions per note; the octave wraps so
// e.g. p-i-m over C-E-G becomes C E G | C' E' G' ... (ascending // e.g. 1321 over C-E-G becomes C G E C' ... (ascending octave
// octave register shift every figure repetition). // register shift every figure repetition).
const figure = BROKEN_FIGURES[pattern]; const figure = BROKEN_FIGURES[pattern];
// 4-note cycle per octave: root, third, fifth, octave // 4-note cycle per octave: root, third, fifth, octave
const cycleOct = (oct: number): number[] => [ const cycleOct = (oct: number): number[] => [
@@ -215,6 +250,143 @@ function buildPath(key: KeyContext, degreeIndex: number, pool: number[], coverag
} }
} }
/**
* Parse custom pattern digits into cycle indices (0-based).
* Digits are 1-indexed: 1=root, 2=third, 3=fifth, 4=octave, 5=fifth-above-octave.
* Returns null for empty/invalid input.
*/
function parseCustomPattern(digits: string): number[] | null {
if (!digits || digits.trim().length === 0) return null;
const result: number[] = [];
for (const ch of digits.trim()) {
const n = parseInt(ch, 10);
if (isNaN(n) || n < 1 || n > 5) return null;
// Map 1→0 (root), 2→1 (third), 3→2 (fifth), 4→3 (octave), 5→4 (fifth above octave)
result.push(n - 1);
}
return result.length > 0 ? result : null;
}
/**
* Build a custom path from 0-based digit indices using the one-octave cycle
* [root, third, fifth, octave] (index 4 = fifth above the octave).
*/
function buildCustomPath(
base: number[],
pattern: number[],
octavesWanted: number,
pool: number[],
): number[] | null {
const cycleOct = (oct: number): number[] => [
base[0] + 12 * oct, base[1] + 12 * oct, base[2] + 12 * oct, base[0] + 12 * (oct + 1),
];
const path: number[] = [];
for (let oct = 0; oct < octavesWanted; oct++) {
const c = cycleOct(oct);
// Extend the cycle for digit 5: fifth above the octave (+7 semitones).
const extended = [...c, c[3] + 7];
for (const idx of pattern) {
const note = extended[idx];
if (note === undefined) continue;
if (oct > 0 && !pool.includes(note)) continue;
path.push(note);
}
}
return path.length > 0 ? path : null;
}
/**
* Build the path for one chord (degree), honouring built-in patterns and the
* user-defined custom pattern. Returns null when the chord cannot be voiced
* in the playable pool.
*/
function pathForChord(
key: KeyContext,
degreeIndex: number,
pool: number[],
coverage: ArpeggioCoverage,
pattern: ArpeggioPattern,
customDigits: number[] | null,
): number[] | null {
if (pattern === 'custom') {
if (!customDigits) return null;
const tones = chordTonesInPool(key, degreeIndex, pool);
if (tones.length < 3) return null;
const rootPc = degreePc(key.degrees[degreeIndex]);
const roots = tones.filter(m => ((m % 12) + 12) % 12 === rootPc);
const octavesWanted = coverage === 'one-octave' ? 1 : 2;
for (const root of roots) {
// chord tones >= root within one octave, including the octave
const cycle: number[] = [];
for (const midi of tones) {
if (midi >= root && midi <= root + 12) cycle.push(midi);
}
if (!cycle.includes(root + 12) && pool.includes(root + 12)) cycle.push(root + 12);
cycle.sort((a, b) => a - b);
if (cycle.length < 3 || cycle[0] !== root) continue;
const p = buildCustomPath(cycle, customDigits, octavesWanted, pool);
if (p) return p;
}
return null;
}
return buildPath(key, degreeIndex, pool, coverage, pattern);
}
/**
* Emit one eighth-note event with key-aware spelling.
* Returns false when the midi note cannot be spelled in this key.
*/
function pushNote(
events: ScoreEvent[],
key: KeyContext,
midi: number,
startTick: number,
id: string,
): boolean {
const spelled = spellInKey(key, midi);
if (!spelled) return false;
events.push({
id,
startTick,
durationTicks: 240,
pitch: { midi, ...spelled },
});
return true;
}
/**
* Fill the tail of the material (after the last note) with rests built from
* RENDERABLE durations only. Splits the gap greedily, largest duration first.
*/
function pushRestFill(events: ScoreEvent[], fromTick: number, untilTick: number): void {
let cursor = fromTick;
while (cursor < untilTick) {
const gap = untilTick - cursor;
const dur = REST_FILL_TICKS.find(d => d <= gap);
if (!dur) break; // gap smaller than an eighth — should not happen
events.push({
id: `r${events.length}`,
startTick: cursor,
durationTicks: dur,
pitch: null, // rest
});
cursor += dur;
}
}
/**
* Resolve the effective broken-chord figure for this exercise. 'random'
* picks one deterministic figure per seed so every auto-continued run gets
* a fresh broken-chord pattern while remaining reproducible.
*/
function resolveRandomPattern(pattern: ArpeggioPattern, seed: number): ArpeggioPattern {
if (pattern !== 'random') return pattern;
const figures = Object.keys(BROKEN_FIGURES);
const rng = makeRng(seed * 7919 + 13);
return figures[Math.floor(rng() * figures.length)] as ArpeggioPattern;
}
export function generateArpeggio( export function generateArpeggio(
config: ArpeggioConfig, config: ArpeggioConfig,
eligiblePitches: number[], eligiblePitches: number[],
@@ -224,14 +396,11 @@ export function generateArpeggio(
if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' }; if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' };
const bars = config.bars ?? 1; const bars = config.bars ?? 1;
const pattern = config.pattern; const pattern = resolveRandomPattern(config.pattern, config.seed ?? 0);
const coverage = config.coverage; const coverage = config.coverage;
// ---- Chord sequence ---------------------------------------------------- // ---- Chord sequence ----------------------------------------------------
// Single-bar mode: the chosen degree for all bars (bars is 1).
// Sequence mode: one chord per bar, chosen by progression rule.
const degreeIndices: number[] = []; const degreeIndices: number[] = [];
// Sequence chord pool: user selection (restricted to it) or all 7.
const sel = (config.chordSelection ?? []).filter(d => d in DEGREE_INDEX).map(d => DEGREE_INDEX[d as ArpeggioDegree]); const sel = (config.chordSelection ?? []).filter(d => d in DEGREE_INDEX).map(d => DEGREE_INDEX[d as ArpeggioDegree]);
const pool7 = sel.length > 0 ? Array.from(new Set(sel)).sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6]; const pool7 = sel.length > 0 ? Array.from(new Set(sel)).sort((a, b) => a - b) : [0, 1, 2, 3, 4, 5, 6];
if (bars === 1) { if (bars === 1) {
@@ -241,8 +410,6 @@ export function generateArpeggio(
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)]; const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
for (let b = 0; b < bars; b++) { for (let b = 0; b < bars; b++) {
if (b === 0) { if (b === 0) {
// First bar: a random chord FROM THE SELECTION — fixed starts
// repeated the exercise identically every time.
degreeIndices.push(pool7.includes(DEGREE_INDEX[config.degree]) && rng() < 0.34 degreeIndices.push(pool7.includes(DEGREE_INDEX[config.degree]) && rng() < 0.34
? DEGREE_INDEX[config.degree] ? DEGREE_INDEX[config.degree]
: pick(pool7)); : pick(pool7));
@@ -250,20 +417,10 @@ export function generateArpeggio(
} }
const prev = degreeIndices[b - 1]; const prev = degreeIndices[b - 1];
if (config.progression === 'diatonic-cycle') { if (config.progression === 'diatonic-cycle') {
// Next diatonic degree that is also in the selection
let next = (prev + 1) % 7; let next = (prev + 1) % 7;
for (let tries = 0; tries < 7 && !pool7.includes(next); tries++) next = (next + 1) % 7; for (let tries = 0; tries < 7 && !pool7.includes(next); tries++) next = (next + 1) % 7;
degreeIndices.push(next); degreeIndices.push(next);
} else if (config.progression === 'functional') { } else if (config.progression === 'functional') {
// Simple tonal function rules (major-oriented; mirrored for
// minor by degree relationships below):
// I -> {IV, V, vi}
// ii -> {V}
// iii -> {IV, vi}
// IV -> {I, V}
// V -> {I, vi}
// vi -> {ii, IV}
// vii -> {I}
const NEXT: Record<number, number[]> = { const NEXT: Record<number, number[]> = {
0: [3, 4, 5], 0: [3, 4, 5],
1: [4], 1: [4],
@@ -273,86 +430,85 @@ export function generateArpeggio(
5: [1, 3], 5: [1, 3],
6: [0], 6: [0],
}; };
// functional: honor the selection — filter rule targets by it
const targets = (NEXT[prev] ?? [0]).filter(d => pool7.includes(d)); const targets = (NEXT[prev] ?? [0]).filter(d => pool7.includes(d));
degreeIndices.push(pick(targets.length > 0 ? targets : pool7.filter(d => d !== prev))); degreeIndices.push(pick(targets.length > 0 ? targets : pool7.filter(d => d !== prev)));
} else { } else {
// random: any selected degree, avoid immediate repetition
const candidates = pool7.filter(d => d !== prev); const candidates = pool7.filter(d => d !== prev);
degreeIndices.push(pick(candidates.length > 0 ? candidates : pool7)); degreeIndices.push(pick(candidates.length > 0 ? candidates : pool7));
} }
} }
} }
// ---- Path per bar: one arpeggio cycle per chord ------------------------- // ---- Custom pattern digits (only for pattern === 'custom') --------------
const full = config.meter.numerator * PPQ; const customDigits = pattern === 'custom' ? parseCustomPattern(config.customPattern ?? '') : null;
const perBarSegments: { path: number[]; degreeIndex: number }[] = []; if (pattern === 'custom' && !customDigits) {
for (const di of degreeIndices) { return { ok: false, error: 'Custom pattern is empty or invalid — enter digits like "1321" (1=root, 2=third, 3=fifth, 4=octave).' };
const path = buildPath(key, di, pool, coverage, pattern);
if (!path || path.length === 0) {
return {
ok: false,
error: 'The arpeggio does not fit the playable range — widen the range (strings/fret window) or pick another degree.',
};
}
perBarSegments.push({ path, degreeIndex: di });
} }
// ---- Events: fit each segment into one bar ------------------------------ // ---- Build the path for the first chord ----------------------------------
const rhythm = config.rhythm ?? 'quarters'; // All bars share the same pattern, so the path length (and thus meter) is
// derived from the first chord's voicing. Sequence mode repeats the path
// within each bar, so the meter also derives from the path length itself.
const firstPath = pathForChord(key, degreeIndices[0], pool, coverage, pattern, customDigits);
if (!firstPath || firstPath.length === 0) {
return {
ok: false,
error: 'The arpeggio does not fit the playable range — widen the range (strings/fret window) or pick another degree.',
};
}
const meter = autoArpeggioMeter(firstPath.length);
const notesPerBar = meter.numerator * 2; // 2 eighths per beat
const barLength = meter.numerator * PPQ;
const noteDur = 240; // always eighths
// ---- Events: single-chord vs sequence mode --------------------------------
const events: ScoreEvent[] = []; const events: ScoreEvent[] = [];
const chordSymbols: (string | null)[] = []; const chordSymbols: (string | null)[] = [];
let cursor = 0;
for (let b = 0; b < bars; b++) { if (bars === 1) {
const { path, degreeIndex } = perBarSegments[b]; // Single-chord mode: the path plays once; any remaining ticks in the
chordSymbols.push(chordNameFor(key, degreeIndex)); // final bar(s) become rests (renderable durations only). The path is
if (bars === 1) { // NEVER stretched — durations stay at 240 ticks.
// Single-bar mode (v1 behavior): whole path, final note extended const barCount = Math.max(1, Math.ceil((firstPath.length * noteDur) / barLength));
// to fill the bar — or, when the path overflows one bar, spread chordSymbols.push(chordNameFor(key, degreeIndices[0]));
// over the needed bars with the overflow in the last note.
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full)); let cursor = 0;
const totalTicks = barCount * full; for (let slot = 0; slot < firstPath.length; slot++) {
const lastDuration = PPQ + (totalTicks - path.length * PPQ); pushNote(events, key, firstPath[slot], cursor, `a0-${slot}`);
path.forEach((midi, n) => {
const spelled = spellInKey(key, midi);
if (!spelled) return;
const dur = n === path.length - 1 ? lastDuration : PPQ;
events.push({ id: `a${b}-${n}`, startTick: cursor, durationTicks: dur, pitch: { midi, ...spelled } });
cursor += dur;
});
continue;
}
// Sequence mode: fit the bar exactly. 'quarters': one note per beat,
// cycling up/down through the path. 'eighths': two notes per beat.
const slots = rhythm === 'quarters' ? 1 : 2;
const notesPerBar = config.meter.numerator * slots;
const line: number[] = [];
let dir = 1;
let lastIdx = 0;
while (line.length < notesPerBar) {
line.push(path[lastIdx]);
if (lastIdx + dir < 0 || lastIdx + dir >= path.length) dir = -dir;
else lastIdx += dir;
}
const noteDur = full / notesPerBar;
line.forEach((midi, n) => {
const spelled = spellInKey(key, midi);
if (!spelled) return;
events.push({
id: `a${b}-${n}`,
startTick: cursor,
durationTicks: noteDur,
pitch: { midi, ...spelled },
});
cursor += noteDur; cursor += noteDur;
}); }
const totalTicks = barCount * barLength;
if (cursor < totalTicks) pushRestFill(events, cursor, totalTicks);
} else {
// Sequence mode: one chord per bar, each bar holds exactly
// `notesPerBar` eighths (the path cycles if it is shorter, and is
// truncated if longer — with two-octave coverage the auto-derived
// meter always gives enough room).
let cursor = 0;
for (let b = 0; b < bars; b++) {
const di = degreeIndices[b];
chordSymbols.push(chordNameFor(key, di));
const barPath = pathForChord(key, di, pool, coverage, pattern, customDigits);
if (!barPath || barPath.length === 0) {
return { ok: false, error: `Cannot voice arpeggio for chord ${chordNameFor(key, di)}.` };
}
for (let n = 0; n < notesPerBar; n++) {
pushNote(events, key, barPath[n % barPath.length], cursor, `a${b}-${n}`);
cursor += noteDur;
}
}
}
// ---- Validate all durations are renderable -----------------------------
for (const e of events) {
if (!RENDERABLE_NOTE_TICKS.has(e.durationTicks)) {
return { ok: false, error: `Internal arpeggio error: non-renderable duration ${e.durationTicks} at ${e.id}.` };
}
} }
const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`; const chordName = `${key.tonic} ${ARPEGGIO_DEGREE_LABELS[config.degree]}`;
const patternLabel = PATTERN_LABELS[config.pattern]; const patternLabel = PATTERN_LABELS[pattern];
// Single-bar paths can overflow into extra bars (updown/two-octave); const totalMeasures = Math.max(1, Math.ceil(events.reduce((s, e) => Math.max(s, e.startTick + e.durationTicks), 0) / barLength));
// sequence mode always has exactly `bars` measures.
const totalMeasures = bars === 1 ? Math.max(1, Math.ceil((cursor) / full)) : bars;
const title = bars === 1 const title = bars === 1
? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})` ? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})`
: `Arpeggio — ${key.tonic}: ${bars} bars (${config.progression ?? 'functional'})`; : `Arpeggio — ${key.tonic}: ${bars} bars (${config.progression ?? 'functional'})`;
@@ -360,12 +516,12 @@ export function generateArpeggio(
version: 1, version: 1,
id: `arpeggio-${config.keyTonic}-${config.keyMode}-${config.degree}-${config.pattern}-${config.coverage}-${bars}b-${config.progression ?? 'single'}-${config.seed ?? 0}`, id: `arpeggio-${config.keyTonic}-${config.keyMode}-${config.degree}-${config.pattern}-${config.coverage}-${bars}b-${config.progression ?? 'single'}-${config.seed ?? 0}`,
title, title,
meter: config.meter, meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature }, key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: totalMeasures }, (_, i) => ({ measures: Array.from({ length: totalMeasures }, (_, i) => ({
number: i + 1, number: i + 1,
startTick: i * full, startTick: i * barLength,
durationTicks: full, durationTicks: barLength,
})), })),
chordSymbols: bars === 1 chordSymbols: bars === 1
// repeat the single chord symbol across the overflow bars // repeat the single chord symbol across the overflow bars
@@ -376,4 +532,4 @@ export function generateArpeggio(
const problems = validateScore(score); const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal arpeggio error: ${problems[0]}` }; if (problems.length > 0) return { ok: false, error: `Internal arpeggio error: ${problems[0]}` };
return { ok: true, value: score }; return { ok: true, value: score };
} }

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { generateGiulianiStudy } from './giulianiGenerator'; import { generateGiulianiStudy, type GiulianiPattern } from './giulianiGenerator';
import { scoreEvents, validateScore } from '../score/model'; import { scoreEvents, validateScore } from '../score/model';
const strings = [40, 45, 50, 55, 59, 64]; const strings = [40, 45, 50, 55, 59, 64];
@@ -9,7 +9,6 @@ for (const open of strings) for (let f = 0; f <= 12; f++) pool.push(open + f);
const base = { const base = {
keyTonic: 'C', keyTonic: 'C',
keyMode: 'major' as const, keyMode: 'major' as const,
meter: { numerator: 4 as const, denominator: 4 as const },
tuningId: 'standard', tuningId: 'standard',
bars: 4, bars: 4,
chordSelection: [] as string[], chordSelection: [] as string[],
@@ -23,7 +22,8 @@ describe('generateGiulianiStudy', () => {
if (!r.ok) return; if (!r.ok) return;
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value);
expect(r.value.measures.length).toBe(4); expect(r.value.measures.length).toBe(4);
expect(ev.length).toBe(4 * 4 * 4); // bars × beats × figure length // 4 voices → 4/4 auto-derived; eighths: 8 notes per bar
expect(ev.length).toBe(4 * 8);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
expect(r.value.title).toContain('Giuliani'); expect(r.value.title).toContain('Giuliani');
}); });
@@ -34,7 +34,7 @@ describe('generateGiulianiStudy', () => {
if (!r.ok) return; if (!r.ok) return;
// For each repetition of 4 consecutive notes, all midis must differ // For each repetition of 4 consecutive notes, all midis must differ
// (distinct cycle positions → distinct pitches within a beat figure) // (distinct cycle positions → distinct pitches within a beat figure)
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value).filter(e => e.pitch);
for (let i = 0; i + 3 < ev.length; i += 4) { for (let i = 0; i + 3 < ev.length; i += 4) {
const four = ev.slice(i, i + 4).map(e => e.pitch!.midi); const four = ev.slice(i, i + 4).map(e => e.pitch!.midi);
expect(new Set(four).size).toBe(4); expect(new Set(four).size).toBe(4);
@@ -45,10 +45,10 @@ describe('generateGiulianiStudy', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pimamim', seed: 1 }, pool); const r = generateGiulianiStudy({ ...base, pattern: 'pimamim', seed: 1 }, pool);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
if (!r.ok) return; if (!r.ok) return;
const ev = scoreEvents(r.value); const ev = scoreEvents(r.value).filter(e => e.pitch);
// 4/4: figure falls back to quarter-note walk (6-note figure over 4 // 6 voices → 3/4 auto-derived; eighths: 6 notes per bar (one cycle)
// beats is not renderable in even subdivisions) — 4 bars × 4 quarters expect(ev.length).toBe(4 * 6);
expect(ev.length).toBe(16); expect(ev.every(e => e.durationTicks === 240)).toBe(true);
expect(validateScore(r.value)).toEqual([]); expect(validateScore(r.value)).toEqual([]);
}); });
@@ -63,3 +63,94 @@ describe('generateGiulianiStudy', () => {
expect(r.ok).toBe(false); // 4 voices cannot live on one string expect(r.ok).toBe(false); // 4 voices cannot live on one string
}); });
}); });
describe('generateGiulianiStudy — always-eighths + new patterns', () => {
it('all note durations are exactly 240 across every pattern', () => {
const patterns = ['pim', 'pima', 'pami', 'aim', 'pimamim', 'pmamim', 'piai', 'pmami', 'pimami', 'pimaia', 'pimai', 'pmia', 'pmim'] as const;
for (const p of patterns) {
const r = generateGiulianiStudy({ ...base, pattern: p, seed: 1 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
const ev = scoreEvents(r.value);
const RENDERABLE = [120, 240, 360, 480, 720, 960, 1440, 1920];
for (const e of ev.filter(x => x.pitch)) expect(e.durationTicks).toBe(240);
for (const e of ev.filter(x => x.pitch === null)) expect(RENDERABLE).toContain(e.durationTicks);
expect(validateScore(r.value)).toEqual([]);
}
});
it('alternating bass puts the fifth degree in the bass on even bars', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4, alternateBass: true }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value).filter(e => e.pitch);
const full = 4 * 480; // 4/4 bar
// Even bars (0-indexed): first note of each figure cycle is the bass.
// Its pc must be the FIFTH of the bar's chord (C major: cycle starts
// on I or another diatonic chord; verify bass ≠ root and bass = fifth).
for (let bar = 0; bar < 4; bar += 2) {
const barEv = ev.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full);
expect(barEv.length).toBeGreaterThan(0);
const bassMidi = barEv[0].pitch!.midi;
const upperPcs = new Set(barEv.slice(1).map(e => e.pitch!.midi % 12));
// the bass pc must NOT equal any upper-voice root pc check below;
// direct check: bass pc differs from the plain-root voicing
const plain = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4 }, pool);
expect(plain.ok).toBe(true);
if (!plain.ok) return;
const plainEv = scoreEvents(plain.value).filter(e => e.pitch);
const plainBass = plainEv.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full)[0];
expect(bassMidi % 12).not.toBe(plainBass.pitch!.midi % 12);
// and it must be a fifth above the root (7 semitones) — i.e. a
// chord tone that is neither root nor third of the upper cycle
const bassPc = bassMidi % 12;
const rootPc = plainBass.pitch!.midi % 12;
// fifth of a diatonic triad: +7 semitones (+6 for the dim chord)
expect([6, 7]).toContain((bassPc - rootPc + 12) % 12);
void upperPcs;
}
});
it('odd bars keep the root bass when alternating is on', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4, alternateBass: true }, pool);
const plain = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 4, bars: 4 }, pool);
expect(r.ok && plain.ok).toBe(true);
if (!r.ok || !plain.ok) return;
const ev = scoreEvents(r.value).filter(e => e.pitch);
const plainEv = scoreEvents(plain.value).filter(e => e.pitch);
const full = 4 * 480;
for (let bar = 1; bar < 4; bar += 2) {
const bass = ev.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full)[0];
const plainBass = plainEv.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full)[0];
expect(bass.pitch!.midi % 12).toBe(plainBass.pitch!.midi % 12);
}
});
it('new figure lengths auto-derive the specified meter', () => {
const cases: [GiulianiPattern, 3 | 4][] = [
['pim', 3], ['pima', 4], ['pimamim', 3], ['pimai', 4], ['pmia', 4], ['pmim', 4],
];
for (const [p, num] of cases) {
const r = generateGiulianiStudy({ ...base, pattern: p, seed: 2 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) continue;
expect(r.value.meter.numerator).toBe(num);
expect(r.value.meter.denominator).toBe(4);
}
});
it('5-voice figures (pimai) fill 4/4 with a trailing 720-tick rest', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pimai', seed: 2, bars: 2 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value);
const full = 1920;
for (let bar = 0; bar < 2; bar++) {
const barEv = ev.filter(e => e.startTick >= bar * full && e.startTick < (bar + 1) * full);
const rest = barEv.find(e => e.pitch === null);
expect(rest).toBeDefined();
expect(rest!.startTick).toBe(bar * full + 1200);
expect(rest!.durationTicks).toBe(720);
}
});
});

View File

@@ -10,6 +10,10 @@
* the root) and V-1 higher strings; each note of the cycle is placed on its * the root) and V-1 higher strings; each note of the cycle is placed on its
* designated string at the fret matching the chord tone. Figure repeats * designated string at the fret matching the chord tone. Figure repeats
* over the chord; every bar re-voices (deterministically) so sequences work. * over the chord; every bar re-voices (deterministically) so sequences work.
*
* Rhythm is ALWAYS eighths (240 ticks per note); meter is auto-derived from
* the figure length, so odd figure lengths never produce non-renderable
* durations.
*/ */
import { PPQ, validateScore, type Result, type Score, type ScoreEvent, STEP_LETTERS, LETTER_PC, type StepLetter } from '../score/model'; import { PPQ, validateScore, type Result, type Score, type ScoreEvent, STEP_LETTERS, LETTER_PC, type StepLetter } from '../score/model';
import { keyFor, type KeyContext, type ModeId } from './scales'; import { keyFor, type KeyContext, type ModeId } from './scales';
@@ -25,8 +29,11 @@ export type GiulianiPattern =
| 'pmamim' // asymmetric | 'pmamim' // asymmetric
| 'piai' // bass + octave-third (wide spacing) | 'piai' // bass + octave-third (wide spacing)
| 'pmami' // bass + fifth-octave-third | 'pmami' // bass + fifth-octave-third
| 'pimami' // bass-third-fifth-third (waltz-like) | 'pimami' // bass-third-fifth-octave-third-fifth (waltz-like)
| 'pimaia'; // bass with two upper-voice pairs | 'pimaia' // bass with two upper-voice pairs
| 'pimai' // p-i-m-a-i
| 'pmia' // p-m-i-a
| 'pmim'; // p-m-i-m
export const GIULIANI_PATTERN_LABELS: Record<GiulianiPattern, string> = { export const GIULIANI_PATTERN_LABELS: Record<GiulianiPattern, string> = {
pim: 'p-i-m (3 voices)', pim: 'p-i-m (3 voices)',
@@ -39,6 +46,9 @@ export const GIULIANI_PATTERN_LABELS: Record<GiulianiPattern, string> = {
pmami: 'p-m-a-m-i', pmami: 'p-m-a-m-i',
pimami: 'p-i-m-a-m-i (waltz)', pimami: 'p-i-m-a-m-i (waltz)',
pimaia: 'p-i-m-a-i-a', pimaia: 'p-i-m-a-i-a',
pimai: 'p-i-m-a-i',
pmia: 'p-m-i-a',
pmim: 'p-m-i-m',
}; };
/** Figure voice slots per repetition (indices into the cycle [root, third, fifth, octave]). */ /** Figure voice slots per repetition (indices into the cycle [root, third, fifth, octave]). */
@@ -51,15 +61,17 @@ const FIGURES: Record<GiulianiPattern, number[]> = {
pmamim: [0, 2, 3, 2, 1, 2], pmamim: [0, 2, 3, 2, 1, 2],
piai: [0, 1, 3, 1], piai: [0, 1, 3, 1],
pmami: [0, 2, 3, 2, 1], pmami: [0, 2, 3, 2, 1],
pimami: [0, 1, 2, 3, 2], pimami: [0, 1, 2, 3, 2, 1],
pimaia: [0, 1, 2, 3, 1, 3], pimaia: [0, 1, 2, 3, 1, 3],
pimai: [0, 1, 2, 3, 1],
pmia: [0, 2, 1, 3],
pmim: [0, 2, 1, 2],
}; };
export interface GiulianiConfig { export interface GiulianiConfig {
readonly keyTonic: string; readonly keyTonic: string;
readonly keyMode: ModeId; readonly keyMode: ModeId;
readonly pattern: GiulianiPattern; readonly pattern: GiulianiPattern;
readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
readonly tuningId: string; readonly tuningId: string;
/** Bars (one chord per bar). */ /** Bars (one chord per bar). */
readonly bars: number; readonly bars: number;
@@ -69,6 +81,9 @@ export interface GiulianiConfig {
readonly seed: number; readonly seed: number;
/** Deterministic pattern from the practice filter (strings restriction). */ /** Deterministic pattern from the practice filter (strings restriction). */
readonly allowedStrings: readonly number[]; readonly allowedStrings: readonly number[];
/** Alternating bass: even bars (0-indexed) take the fifth degree as the
* bass note instead of the root. Upper voices stay on chord tones. */
readonly alternateBass?: boolean;
} }
const DEGREE_INDEX: Record<string, number> = { I: 0, ii: 1, iii: 2, IV: 3, V: 4, vi: 5, 'vii0': 6 }; const DEGREE_INDEX: Record<string, number> = { I: 0, ii: 1, iii: 2, IV: 3, V: 4, vi: 5, 'vii0': 6 };
@@ -98,32 +113,63 @@ function triadPcs(key: KeyContext, degreeIndex: number): ChordTone[] {
})); }));
} }
/**
* Auto-derive meter from the figure length (always eighths):
* 3 voices (240×3=720): 3/4 (2 cycles = 1440, perfect fit)
* 4 voices (240×4=960): 4/4 (2 cycles = 1920, perfect fit)
* 5 voices (240×5=1200): 4/4 (1 cycle + 720 rest)
* 6 voices (240×6=1440): 3/4 (1 cycle = 1440, perfect fit)
*/
export function autoGiulianiMeter(figureLength: number): { numerator: 3 | 4; denominator: 4 } {
switch (figureLength) {
case 3: return { numerator: 3, denominator: 4 };
case 4: return { numerator: 4, denominator: 4 };
case 5: return { numerator: 4, denominator: 4 };
case 6: return { numerator: 3, denominator: 4 };
default: {
// Fall back to the same remainder heuristic as arpeggios.
const totalTicks = figureLength * 240;
const bars4 = Math.ceil(totalTicks / 1920);
const remainder4 = bars4 * 1920 - totalTicks;
const bars3 = Math.ceil(totalTicks / 1440);
const remainder3 = bars3 * 1440 - totalTicks;
if (remainder3 < remainder4) return { numerator: 3, denominator: 4 };
if (remainder4 < remainder3) return { numerator: 4, denominator: 4 };
return bars3 <= bars4 ? { numerator: 3, denominator: 4 } : { numerator: 4, denominator: 4 };
}
}
}
/** /**
* Voice a figure bar: assign each cycle position (root/third/fifth/octave) * Voice a figure bar: assign each cycle position (root/third/fifth/octave)
* to a string with a valid fret, bass string lowest. Returns per-slot * to a string with a valid fret, bass string lowest. Returns per-slot
* {stringIndex, fret, midi} or null when impossible with the allowed strings. * {stringIndex, fret, midi} or null when impossible with the allowed strings.
* When `bassOverridePc` is given, the bass (cycle position 0) uses that pitch
* class instead of the root (alternating bass).
*/ */
function voiceBar( function voiceBar(
tuning: Tuning, tuning: Tuning,
chord: ChordTone[], chord: ChordTone[],
pattern: GiulianiPattern, pattern: GiulianiPattern,
allowedStrings: number[], allowedStrings: number[],
bassOverridePc?: number,
): { stringIndex: number; fret: number; midi: number }[] | null { ): { stringIndex: number; fret: number; midi: number }[] | null {
const figure = FIGURES[pattern]; const figure = FIGURES[pattern];
const maxVoice = Math.max(...figure) + 1; // e.g. 3 for p-i-m, 4 for p-i-m-a const maxVoice = Math.max(...figure) + 1; // e.g. 3 for p-i-m, 4 for p-i-m-a
const bassPc = chord[0].pc; const bassPc = bassOverridePc ?? chord[0].pc;
// Candidate strings sorted low→high for the bass; upper voices fill above. // Candidate strings sorted low→high for the bass; upper voices fill above.
const strings = tuning.strings.map((open, idx) => ({ open, idx })); const strings = tuning.strings.map((open, idx) => ({ open, idx }));
const sorted = [...strings].sort((a, b) => a.open - b.open); const sorted = [...strings].sort((a, b) => a.open - b.open);
const usable = sorted.filter(s => allowedStrings.length === 0 || allowedStrings.includes(s.idx)); const usable = sorted.filter(s => allowedStrings.length === 0 || allowedStrings.includes(s.idx));
const octavePcs = [...chord.map(c => c.pc), (chord[0].pc + 12) % 12]; // cyclePcs[i] = pitch class of cycle position i (0 = bass, 1..3 = upper
// cyclePcs[i] = pitch class of cycle position i // chord tones: third, fifth, octave-of-root).
const cyclePcs = octavePcs; const cyclePcs = [...chord.map(c => c.pc), (chord[0].pc + 12) % 12];
if (bassOverridePc !== undefined) cyclePcs[0] = bassOverridePc;
const bassCandidates = usable.filter(s => { const bassCandidates = usable.filter(s => {
// bass must be able to reach the root within 12 frets // bass must be able to reach the bass pc within 12 frets
for (let fret = 0; fret <= 12; fret++) { for (let fret = 0; fret <= 12; fret++) {
if (((s.open + fret) % 12 + 12) % 12 === bassPc) return true; if (((s.open + fret) % 12 + 12) % 12 === bassPc) return true;
} }
@@ -188,13 +234,23 @@ export function generateGiulianiStudy(
} }
// Voice each bar; drop bars that cannot be voiced (rare with full pool). // Voice each bar; drop bars that cannot be voiced (rare with full pool).
const full = config.meter.numerator * PPQ; const figure = FIGURES[config.pattern];
// Meter is ALWAYS auto-derived from the figure length; the user no longer
// picks a meter for Giuliani studies.
const meter = autoGiulianiMeter(figure.length);
const full = meter.numerator * PPQ;
const events: ScoreEvent[] = []; const events: ScoreEvent[] = [];
const chordSymbols: (string | null)[] = []; const chordSymbols: (string | null)[] = [];
const usableBars: { degreeIndex: number; placement: ReturnType<typeof voiceBar> }[] = []; const usableBars: { degreeIndex: number; placement: ReturnType<typeof voiceBar> }[] = [];
for (const di of degreeIndices) { for (let b = 0; b < degreeIndices.length; b++) {
const di = degreeIndices[b];
const chord = triadPcs(key, di); const chord = triadPcs(key, di);
const placement = voiceBar(tuning, chord, config.pattern, config.allowedStrings as number[]); // Alternating bass: even bars (0-indexed) take the fifth degree as
// the bass note instead of the root.
const bassOverridePc = config.alternateBass && b % 2 === 0
? chord[2].pc
: undefined;
const placement = voiceBar(tuning, chord, config.pattern, config.allowedStrings as number[], bassOverridePc);
if (!placement) continue; if (!placement) continue;
usableBars.push({ degreeIndex: di, placement }); usableBars.push({ degreeIndex: di, placement });
} }
@@ -202,39 +258,20 @@ export function generateGiulianiStudy(
return { ok: false, error: 'No bar could be voiced with the current string selection — allow more strings or a lower fret window.' }; return { ok: false, error: 'No bar could be voiced with the current string selection — allow more strings or a lower fret window.' };
} }
// Rhythm: one note per slot (figure repeats); one figure per beat. // Rhythm: ALWAYS eighths (240 ticks). Each bar holds
const figure = FIGURES[config.pattern]; // `notesPerBar = (bar length / 240)` notes; the figure repeats to fill
// Renderable note durations only (durationCode subset). Figures with a // the bar. For 3- and 4-voice figures in their derived meter this is a
// length that divides neither 4 nor 3 evenly would produce exotic ticks // whole number of repetitions; 5-voice figures in 4/4 leave a 720-tick
// (e.g. 96 = 32nd triplets) that the renderer can't spell — snap those // rest at the bar end.
// combinations to a safe layout instead of crashing the renderer. const notesPerBar = Math.floor((meter.numerator * PPQ) / 240);
const RENDERABLE_NOTE_TICKS = [120, 240, 360, 480, 720, 960]; const fillSlots = Math.floor(notesPerBar / figure.length) * figure.length;
let notesPerBar = config.meter.numerator * figure.length;
let noteDur = full / notesPerBar;
if (!RENDERABLE_NOTE_TICKS.includes(noteDur)) {
// Fall back: one figure per TWO beats (halves the notes per bar),
// then per bar, then quarters as last resort.
for (const div of [2, 3, 4]) {
const cand = full / div;
if (cand % figure.length === 0 && RENDERABLE_NOTE_TICKS.includes(cand / figure.length)) {
notesPerBar = div * figure.length;
noteDur = cand / figure.length;
break;
}
}
if (!RENDERABLE_NOTE_TICKS.includes(noteDur)) {
// Final fallback: quarter-note walk through the figure (cycling).
notesPerBar = config.meter.numerator;
noteDur = PPQ;
}
}
let cursor = 0; let cursor = 0;
for (let b = 0; b < usableBars.length; b++) { for (let b = 0; b < usableBars.length; b++) {
const { degreeIndex, placement } = usableBars[b]; const { degreeIndex, placement } = usableBars[b];
if (!placement) continue; if (!placement) continue; // filtered above, but satisfies strict TS
chordSymbols.push(chordNameFor(key, degreeIndex)); chordSymbols.push(chordNameFor(key, degreeIndex));
for (let n = 0; n < notesPerBar; n++) { for (let n = 0; n < fillSlots; n++) {
const f = n % figure.length; const f = n % figure.length;
const slot = figure[f]; const slot = figure[f];
const note = placement[slot]; const note = placement[slot];
@@ -247,10 +284,24 @@ export function generateGiulianiStudy(
events.push({ events.push({
id: `g${b}-${n}`, id: `g${b}-${n}`,
startTick: cursor, startTick: cursor,
durationTicks: noteDur, durationTicks: 240,
pitch: { midi: note.midi, step: d.step, alter: d.alter, octave }, pitch: { midi: note.midi, step: d.step, alter: d.alter, octave },
}); });
cursor += noteDur; cursor += 240;
}
// Rest fill for figure lengths that leave a gap (e.g. 5 voices in 4/4:
// one cycle = 1200 ticks, remaining 720 = dotted quarter rest).
const barStart = b * full;
const barEnd = barStart + full;
if (cursor < barEnd) {
const gap = barEnd - cursor;
events.push({
id: `gr${b}`,
startTick: cursor,
durationTicks: gap,
pitch: null,
});
cursor = barEnd;
} }
} }
@@ -259,7 +310,7 @@ export function generateGiulianiStudy(
version: 1, version: 1,
id: `giuliani-${config.keyTonic}-${config.keyMode}-${config.pattern}-${config.bars}b-${config.seed}`, id: `giuliani-${config.keyTonic}-${config.keyMode}-${config.pattern}-${config.bars}b-${config.seed}`,
title: `Giuliani study — ${key.tonic} ${config.pattern} (${totalBars} bars)`, title: `Giuliani study — ${key.tonic} ${config.pattern} (${totalBars} bars)`,
meter: config.meter, meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature }, key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: totalBars }, (_, i) => ({ measures: Array.from({ length: totalBars }, (_, i) => ({
number: i + 1, number: i + 1,
@@ -272,4 +323,4 @@ export function generateGiulianiStudy(
const problems = validateScore(score); const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal giuliani error: ${problems[0]}` }; if (problems.length > 0) return { ok: false, error: `Internal giuliani error: ${problems[0]}` };
return { ok: true, value: score }; return { ok: true, value: score };
} }

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;
@@ -61,19 +59,14 @@ export interface PhraseSettings {
arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start) arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start)
/** Sequence mode: which degrees may appear (empty = all 7). */ /** Sequence mode: which degrees may appear (empty = all 7). */
arpeggioChordSelection: string[]; arpeggioChordSelection: string[];
arpeggioPattern: 'up' | 'down' | 'updown' | '1235'; arpeggioPattern: 'up' | 'down' | 'updown' | '1235' | 'custom' | 'random';
arpeggioCoverage: 'one-octave' | 'two-octave'; arpeggioCoverage: 'one-octave' | 'two-octave';
/** Sequence mode: bars per exercise (1 = single chord). */ /** Sequence mode: bars per exercise (1 = single chord). */
arpeggioBars: number; arpeggioBars: number;
arpeggioProgression: 'random' | 'functional' | 'diatonic-cycle'; arpeggioProgression: 'random' | 'functional' | 'diatonic-cycle';
arpeggioRhythm: 'quarters' | 'eighths';
/** Giuliani study material (own category — voicing-enforced). */ /** Giuliani study material (own category — voicing-enforced). */
giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim'; 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;
@@ -87,13 +80,15 @@ export interface PhraseSettings {
/** Begin the run as soon as the first note is played (no count-in, /** Begin the run as soon as the first note is played (no count-in,
* step pace only). */ * step pace only). */
autoStartOnNote: boolean; autoStartOnNote: boolean;
/** Custom arpeggio pattern as raw digits (1=root, 2=third, etc.). */
customArpeggioPattern?: string;
/** Alternating bass thumb for Giuliani studies. */
giulianiAlternateBass?: boolean;
} }
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,
@@ -106,12 +101,8 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
arpeggioCoverage: 'one-octave', arpeggioCoverage: 'one-octave',
arpeggioBars: 1, arpeggioBars: 1,
arpeggioProgression: 'functional', arpeggioProgression: 'functional',
arpeggioRhythm: 'quarters',
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',
@@ -120,6 +111,8 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
autoContinue: true, autoContinue: true,
inputMode: 'mic', inputMode: 'mic',
autoStartOnNote: false, autoStartOnNote: false,
customArpeggioPattern: '',
giulianiAlternateBass: false,
}; };
export function getPhraseSettings(s: AppSettings): PhraseSettings { export function getPhraseSettings(s: AppSettings): PhraseSettings {
@@ -141,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;