14 Commits

Author SHA1 Message Date
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
OpenClaw
0b85f24797 Mobile clipping fix + 6/8 render crash fix (root cause) + more variants
- Clipping: min width per bar raised (16ths 420px, 8ths 330px, quarters
  240px) and format width reserves more room (clef/key 100px, barline
  margin 24px) — narrow screens now wrap into more rows with real margins
  instead of clipping the last bar.
- 6/8 crash ROOT CAUSE: Giuliani figures whose length didn't divide the
  bar into renderable note values produced 96-tick durations (32nd-ish)
  that durationCode can't spell -> renderer threw -> white screen.
  Giuliani generator now snaps figure subdivision to renderable durations
  (per 2/3/4 divisions, quarter-note walk as last resort); pimamim in
  6/8 renders as a walking quarter figure.
- 6/8 melody templates: fixed sums (were 2640/3120 in 4 templates — those
  filtered out, silently degrading to the fallback).
- More variants: arpeggio figures 1-3-5-3, 1-2-1-3-2-1, 1-5-4-5-3-5,
  1-3-2-5-3-2; Giuliani patterns p-i-a-i, p-m-a-m-i, p-i-m-a-m-i (waltz),
  p-i-m-a-i-a.
- Tests 179/179.
2026-09-15 03:35:00 +00:00
OpenClaw
a7a1d34c11 Mobile width fix, 6/8 crash fix, more arpeggio+Giuliani variants
- PhraseSheetMusic: min width per bar scales with notation density
  (16ths 320px, 8ths 260px, quarters 210px) — narrow screens wrap into
  more rows instead of clipping notes off-screen.
- 6/8 crash fixed at the root: melody templatesFor had no 6/8 templates
  (empty pool -> pick on empty array). Added 6/8 template sets for levels
  1/2 plus an empty-pool fallback guard so generators can never pick from
  an empty array again (any meter/level combination stays playable).
- More arpeggio broken-chord figures: 1-3-5-3, 1-2-1-3-2-1, 1-5-4-5-3-5,
  1-3-2-5-3-2.
- More Giuliani patterns: p-i-a-i (wide), p-m-a-m-i, p-i-m-a-m-i (waltz
  feel), p-i-m-a-i-a.
- Tests 179/179.
2026-09-15 03:04:29 +00:00
OpenClaw
ffb2776c8c Giuliani studies as own material category; beam-before-draw fix
- NEW material 'giuliani': figures require a real voicing — the generator
  assigns cycle positions (bass root + upper chord tones) to DISTINCT
  strings with valid frets per bar; bars that cannot be voiced are skipped
  (deterministic), clear error when nothing fits. Patterns: p-i-m, p-i-m-a,
  p-a-m-i, a-i-m, p-i-m-a-m-i, p-m-a-m-i-m. Honors the chord pool, string
  restrictions and the tuning; one figure per beat.
- 6/8 meter added (numerator 6) across score model, settings, generators;
  meter selects now offer 4/4, 3/4, 6/8.
- Arpeggio category: Giuliani entries replaced with note-sequence
  broken-chord figures (1-3-2-1, 1-3-2-5(8), 1-5-3-5(8), 1-2-3-5-3) —
  pure pitch sequences, no voicing claims.
- Beam fix (root cause): setBeam now happens BEFORE voice.draw — VexFlow
  decides flag vs beam at draw time; registering after drawing left both
  flag and beam. Beams drawn after notes, flags suppressed correctly.
2026-09-15 02:44:52 +00:00
OpenClaw
9acf32a32f UI consolidation round: pace checkbox, unified Limit notes section, fixes
- Pace dropdown REMOVED from Phrase setup — replaced by a 'Sync to
  exercise' checkbox in the Metronome widget (analog to 'Auto' in
  single-note modes). pace is derived from the checkbox (checked = tempo,
  unchecked = step).
- Preview clicks now registered in PREVIEW_GROUP: stopping/abandoning a
  preview cancels its scheduled clicks — no more parallel ghost
  metronomes after preview->stop->next exercise.
- Chord pool replaces the chord dropdown entirely (single chord AND
  sequences): togglable degree list, empty = all 7.
- Giuliani figures corrected to NOTE sequences (p always takes the bass/
  root): p-i-m, p-i-m-a, p-a-m-i, a-i-m, p-i-m-a-m-i, p-m-a-m-i-m,
  p-e-a-m-a-m (replaced the fingering-driven patterns that made no
  musical sense).
- 'Limit notes' is now one bordered section like Tuner/Metronome, with a
  header, Clear-all link, and Strings behind a toggle (row hidden until
  restricted).
2026-09-14 21:08:15 +00:00
22 changed files with 1865 additions and 667 deletions

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,9 +1040,9 @@ 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-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
padding-top: 12px; padding-bottom: 12px;
} }
.phrase-setup summary { .phrase-setup summary {
@@ -1126,3 +1126,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' }));
} }
}; };
@@ -263,6 +296,13 @@ function App() {
> >
Phrases Phrases
</button> </button>
<button
className={`toggle-option ${settings.gameMode === 'assist' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'assist' }))}
title="Practice companion: shows played notes while you work from paper"
>
Assist
</button>
</div> </div>
<button <button
@@ -305,6 +345,11 @@ function App() {
micError={error} micError={error}
windowWidth={windowWidth} windowWidth={windowWidth}
/> />
) : isAssist ? (
<AssistMode
pitchData={displayedPitch}
windowWidth={windowWidth}
/>
) : ( ) : (
<SingleNoteTrainer <SingleNoteTrainer
ref={singleNoteRef} ref={singleNoteRef}
@@ -391,6 +436,7 @@ function App() {
<> <>
<div className="help-item"><span>Start / Pause / Resume</span><span className="shortcut-key">Space</span></div> <div className="help-item"><span>Start / Pause / Resume</span><span className="shortcut-key">Space</span></div>
<div className="help-item"><span>Preview phrase (Play / Stop)</span><span className="shortcut-key">P</span></div> <div className="help-item"><span>Preview phrase (Play / Stop)</span><span className="shortcut-key">P</span></div>
<div className="help-item"><span>While previewing: step note forward / back</span><span className="shortcut-key">→ / ←</span></div>
<div className="help-item"><span>Retry phrase</span><span className="shortcut-key">R</span></div> <div className="help-item"><span>Retry phrase</span><span className="shortcut-key">R</span></div>
<div className="help-item"><span>New melody / next bars</span><span className="shortcut-key">N</span></div> <div className="help-item"><span>New melody / next bars</span><span className="shortcut-key">N</span></div>
<div className="help-item"><span>Skip note (at your pace)</span><span className="shortcut-key">S</span></div> <div className="help-item"><span>Skip note (at your pace)</span><span className="shortcut-key">S</span></div>

View File

@@ -140,8 +140,10 @@ class AudioEngine {
* Short NON-TONAL metronome click (~10 ms noise burst). Not registered in * Short NON-TONAL metronome click (~10 ms noise burst). Not registered in
* the pitched-audio gate; instead the mic is blanked for 30 ms so the * the pitched-audio gate; instead the mic is blanked for 30 ms so the
* click itself can't be scored as a pitch (contract E2). * click itself can't be scored as a pitch (contract E2).
* Optional groupId: the click becomes cancellable via cancelGroup (used
* by preview clicks so stopping a preview also stops its clicks).
*/ */
playClickAt(time: number, volume = 0.5, accent = false): void { playClickAt(time: number, volume = 0.5, accent = false, groupId?: string): void {
const ctx = this.ensureContext(); const ctx = this.ensureContext();
if (!this.noiseBuffer) { if (!this.noiseBuffer) {
const len = Math.floor(ctx.sampleRate * 0.01); const len = Math.floor(ctx.sampleRate * 0.01);
@@ -160,6 +162,11 @@ class AudioEngine {
src.start(time); src.start(time);
src.stop(time + 0.02); src.stop(time + 0.02);
this.clickBlankUntil = Math.max(this.clickBlankUntil, time + 0.03); this.clickBlankUntil = Math.max(this.clickBlankUntil, time + 0.03);
if (groupId) {
// Register as a cancellable node (cast: clicks are buffer sources,
// cancelGroup only calls stop() on them, which BufferSource has).
this.active.push({ osc: src as unknown as OscillatorNode, gain: gainNode, endsAt: time + 0.02, groupId });
}
} }
/** True for ~30 ms after a scheduled click (mic frames should be skipped). */ /** True for ~30 ms after a scheduled click (mic frames should be skipped). */

View File

@@ -0,0 +1,89 @@
/**
* 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)}
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,98 +30,43 @@ 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>
)}
{/* Target-note filter: unified controls, all modes */} {shouldShowFilters && <TargetNoteControls />}
<TargetNoteControls />
{/* Bottom Section: Tools Grid (2 Columns now) */} {/* Bottom Section: Tools Grid (2 Columns now) */}
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}> <div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>

View File

@@ -27,7 +27,10 @@ interface MetronomeWidgetProps {
showAutoAdvance?: boolean; showAutoAdvance?: boolean;
/** Compact layout (phrase transport row). */ /** Compact layout (phrase transport row). */
compact?: boolean; compact?: boolean;
/** 'option': show the sync/free toggle (Phrase Mode). null: plain on/off. */ /** 'option': show the sync checkbox (Phrase Mode) that replaces the pace
* dropdown: checked = clicks/beats only with the exercise (preview &
* run, count-in included); unchecked = free-running metronome.
* null: plain on/off (single-note modes). */
syncMode?: 'option' | null; syncMode?: 'option' | null;
/** External gate (sync mode): true while the exercise runs. */ /** External gate (sync mode): true while the exercise runs. */
gate?: boolean | null; gate?: boolean | null;
@@ -85,18 +88,14 @@ export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
<span>Metronome</span> <span>Metronome</span>
</label> </label>
{syncMode === 'option' ? ( {syncMode === 'option' ? (
<button <label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
className={`switch-button ${rhythm.syncToExercise ? 'active' : ''}`} <input
onClick={() => onUpdate({ type="checkbox"
syncToExercise: !rhythm.syncToExercise, checked={!!rhythm.syncToExercise}
active: true, // armed in either mode; the mode decides behavior onChange={(e) => onUpdate({ syncToExercise: e.target.checked })}
})} />
title={rhythm.syncToExercise Sync to exercise
? 'Sync: starts with the exercise (count-in). Click while idle: off.' </label>
: 'Free: runs independently — start/stop it yourself.'}
>
<div className="switch-thumb" />
</button>
) : ( ) : (
<button <button
className={`switch-button ${rhythm.active ? 'active' : ''}`} className={`switch-button ${rhythm.active ? 'active' : ''}`}

View File

@@ -105,7 +105,12 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
const events = scoreEvents(score); const events = scoreEvents(score);
const totalBars = score.measures.length; const totalBars = score.measures.length;
const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / 210))); // Min width per bar grows with notation density (eighths/16ths need more
// horizontal room than quarters) so narrow screens wrap into more rows
// instead of clipping notes.
const densestTick = Math.min(...events.filter(e => e.pitch).map(e => e.durationTicks), 1920);
const minWidthPerBar = densestTick <= 120 ? 500 : densestTick <= 240 ? 330 : 240;
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';
const rowHeight = isGrand ? 250 : 140; const rowHeight = isGrand ? 250 : 140;
@@ -319,14 +324,38 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
voices.push(buildVoice(treble, () => true)); voices.push(buildVoice(treble, () => true));
} }
const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 92 : 16)); const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 100 : 24));
const formatter = new Formatter(); const formatter = new Formatter();
formatter.joinVoices(voices.map(v => v.voice)); formatter.joinVoices(voices.map(v => v.voice));
formatter.format(voices.map(v => v.voice), formatWidth); formatter.format(voices.map(v => v.voice), formatWidth);
// --- Beams: BEFORE drawing notes. setBeam must happen prior to
// voice.draw — VexFlow decides flag vs beam on this.beam at
// draw time; registering late leaves BOTH flag and beam.
const beamPairs: { a: Fragment; b2: Fragment }[] = [];
const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote);
for (let i = 0; i + 1 < beamed.length; i++) {
const a = beamed[i], b2 = beamed[i + 1];
if (a.staveKey !== b2.staveKey) continue;
const step = a.isSixteenth ? 120 : 240;
if (b2.start - a.start !== step) continue;
if ((a.ticks !== step) || (b2.ticks !== step)) continue;
const beatA = Math.floor((a.start - measureStart) / 480);
const beatB = Math.floor((b2.start - measureStart) / 480);
if (beatA === beatB) beamPairs.push({ a, b2 });
}
// Apply beams to notes FIRST (suppresses flags), then draw.
const beams = beamPairs.map(({ a, b2 }) => {
const beam = new Beam([a.note, b2.note], false);
a.note.setBeam(beam);
b2.note.setBeam(beam);
return beam;
});
treble.setContext(context).draw(); treble.setContext(context).draw();
if (bass) bass.setContext(context).draw(); if (bass) bass.setContext(context).draw();
for (const { voice, stave } of voices) voice.draw(context, stave); for (const { voice, stave } of voices) voice.draw(context, stave);
for (const beam of beams) beam.setContext(context).draw();
if (isGrand && isFirstOfRow) { if (isGrand && isFirstOfRow) {
const brace = new StaveConnector(treble, bass!); const brace = new StaveConnector(treble, bass!);
@@ -337,27 +366,6 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
line.setContext(context).draw(); line.setContext(context).draw();
} }
// --- Beams: consecutive eighth/16th pairs within one beat ---
// Note: the Beam must be registered on each note via setBeam,
// otherwise VexFlow still draws flags (beam === undefined in
// shouldDrawFlag) and you get BOTH flag and beam.
const beamed = frags.filter(f => (f.isEighth || f.isSixteenth) && f.note instanceof StaveNote);
for (let i = 0; i + 1 < beamed.length; i++) {
const a = beamed[i], b2 = beamed[i + 1];
if (a.staveKey !== b2.staveKey) continue;
// consecutive within the same beat, same subdivision size
const step = a.isSixteenth ? 120 : 240;
if (b2.start - a.start !== step) continue;
if ((a.ticks !== step) || (b2.ticks !== step)) continue;
const beatA = Math.floor((a.start - measureStart) / 480);
const beatB = Math.floor((b2.start - measureStart) / 480);
if (beatA === beatB) {
const beam = new Beam([a.note, b2.note], false);
a.note.setBeam(beam);
b2.note.setBeam(beam);
beam.setContext(context).draw();
}
}
} }
// --- Ties between consecutive fragments of one event (this row) --- // --- Ties between consecutive fragments of one event (this row) ---

View File

@@ -13,13 +13,14 @@ 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';
import { generateMelody } from '../music/melodyGenerator'; import { generateMelody } from '../music/melodyGenerator';
import { generateScaleDrill } from '../music/scaleDrills'; import { generateScaleDrill } from '../music/scaleDrills';
import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator'; import { generateArpeggio, type ArpeggioDegree } from '../music/arpeggioGenerator';
import { generateGiulianiStudy, GIULIANI_PATTERN_LABELS, type GiulianiPattern } from '../music/giulianiGenerator';
import { isMode, type ModeId } from '../music/scales'; import { isMode, type ModeId } from '../music/scales';
import { ARPEGGIO_DEGREE_LABELS } from '../music/arpeggioGenerator'; import { ARPEGGIO_DEGREE_LABELS } from '../music/arpeggioGenerator';
import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model'; import { scoreEvents, type Result, type Score, type ScoreEvent } from '../score/model';
@@ -27,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;
@@ -70,41 +73,27 @@ 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).
phrase.bpm = settings.rhythm.bpm; phrase.bpm = settings.rhythm.bpm;
// Pace is now DERIVED from the metronome widget's sync checkbox:
// checked = tempo (sync), unchecked = step (at your pace).
const pace: PhraseSettings['pace'] = settings.rhythm.syncToExercise ? 'tempo' : 'step';
phrase.pace = pace;
// Click decision — ONE source: the metronome widget's sound checkbox. // Click decision — ONE source: the metronome widget's sound checkbox.
// - Tempo + syncToExercise: the run clicks when armed && sound // - Sync (tempo): run clicks when armed && sound (count-in incl.);
// (silent while idle; starts with the exercise incl. count-in). // widget silent while idle.
// - Tempo + free metronome: the widget clicks freely; the run stays // - Free metronome: widget clicks freely; the run stays silent to
// silent (its beats are visual only) to avoid doubling. // avoid doubling (sync off in tempo = free widget during runs).
// - Step: widget clicks freely; phrase clickSound follows the switch. phrase.clickSound = pace === 'tempo'
const sync = settings.rhythm.syncToExercise; ? settings.rhythm.active && settings.rhythm.sound
phrase.clickSound = phrase.pace === 'tempo' : settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
? (sync ? settings.rhythm.active && settings.rhythm.sound : false)
: settings.rhythm.active && 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];
@@ -137,7 +126,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) --------------------------------------
@@ -170,8 +158,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,
@@ -181,26 +169,40 @@ 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 },
rhythm: phrase.scaleRhythm, rhythm: phrase.scaleRhythm,
}, pool); }, pool);
} }
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({
keyTonic: pf.keyTonic,
keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
pattern: phrase.giulianiPattern,
tuningId: settings.tuningId,
bars: phrase.giulianiBars,
chordSelection: phrase.arpeggioChordSelection as string[],
seed: melodySeed,
allowedStrings: pf.strings,
alternateBass: phrase.giulianiAlternateBass ?? false,
}, 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);
} }
@@ -214,11 +216,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.giulianiAlternateBass,
melodySeed, pool, importedScore]); melodySeed, pool, importedScore]);
const fullScore: Result<Score> | null = useMemo(() => { const fullScore: Result<Score> | null = useMemo(() => {
@@ -305,9 +308,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) {
@@ -315,6 +316,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]);
@@ -322,6 +329,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
pauseToggle: () => trainer?.pauseToggle(), pauseToggle: () => trainer?.pauseToggle(),
previewToggle: () => trainer?.previewToggle(), previewToggle: () => trainer?.previewToggle(),
previewStep: (dir) => trainer?.previewStep(dir),
retry: () => trainer?.retry(), retry: () => trainer?.retry(),
skip: () => trainer?.skip(), skip: () => trainer?.skip(),
pause: () => trainer?.pause(), pause: () => trainer?.pause(),
@@ -331,17 +339,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) {
@@ -362,7 +375,8 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const statusText = useMemo(() => { const statusText = useMemo(() => {
if (!scoreOk) return materialError ?? 'No material.'; if (!scoreOk) return materialError ?? 'No material.';
const p = trainer.phase; const p = trainer.phase;
if (p === 'preview') return 'Listen …'; if (p === 'preview') return 'Listen … (pause to step note by note)';
if (p === 'previewPaused') return 'Paused — step with ⏮ ⏭ or ← →, resume with P';
if (p === 'countIn') return `Count-in: ${trainer.countInLeft}`; if (p === 'countIn') return `Count-in: ${trainer.countInLeft}`;
if (p === 'paused') return 'Paused'; if (p === 'paused') return 'Paused';
if (p === 'done') { if (p === 'done') {
@@ -381,12 +395,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const p = trainer.phase; const p = trainer.phase;
if (p === 'playing' || p === 'countIn') return 'Pause'; if (p === 'playing' || p === 'countIn') return 'Pause';
if (p === 'paused') return 'Resume'; if (p === 'paused') return 'Resume';
if (p === 'preview') return 'Stop preview'; if (p === 'preview') return 'Pause';
if (p === 'previewPaused') return 'Resume';
return 'Start'; return 'Start';
}, [trainer.phase]); }, [trainer.phase]);
const startAction = useMemo(() => { const startAction = useMemo(() => {
if (trainer.phase === 'preview') return trainer.previewToggle; if (trainer.phase === 'preview' || trainer.phase === 'previewPaused') return trainer.previewToggle;
return trainer.pauseToggle; return trainer.pauseToggle;
}, [trainer]); }, [trainer]);
@@ -434,118 +449,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>
@@ -555,17 +460,12 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<option value="melody">Melodies (generated)</option> <option value="melody">Melodies (generated)</option>
<option value="scale">Scale drills</option> <option value="scale">Scale drills</option>
<option value="arpeggio">Arpeggios (generated)</option> <option value="arpeggio">Arpeggios (generated)</option>
<option value="giuliani">Giuliani studies</option>
<option value="library">Exercises</option> <option value="library">Exercises</option>
<option value="import">Imported file</option> <option value="import">Imported file</option>
</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
@@ -574,9 +474,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
<label>Meter <label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}> <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={4}>4/4</option>
<option value={3}>3/4</option> <option value={3}>3/4</option>
<option value={6}>6/8</option>
</select> </select>
</label> </label>
<label>Rhythm <label>Rhythm
@@ -606,9 +507,10 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
<label>Meter <label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}> <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={4}>4/4</option>
<option value={3}>3/4</option> <option value={3}>3/4</option>
<option value={6}>6/8</option>
</select> </select>
</label> </label>
</> </>
@@ -616,32 +518,40 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
{phrase.material === 'arpeggio' && ( {phrase.material === 'arpeggio' && (
<> <>
<label>Chord
<select value={phrase.arpeggioDegree} onChange={e => setPhrase({ arpeggioDegree: e.target.value, arpeggioBars: 1 })}>
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => (
<option key={d} value={d}>{ARPEGGIO_DEGREE_LABELS[d]}</option>
))}
</select>
</label>
<label>Pattern <label>Pattern
<select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}> <select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}>
<option value="up">Up</option> <option value="up">Up</option>
<option value="down">Down</option> <option value="down">Down</option>
<option value="updown">Up &amp; down</option> <option value="updown">Up &amp; down</option>
<option value="1235">1-2-3-5</option> <option value="1235">1-2-3-5</option>
<optgroup label="Giuliani studies"> <optgroup label="Broken-chord figures">
<option value="giuliani-pim">p-i-m</option> <option value="1321">1-3-2-1</option>
<option value="giuliani-pima">p-i-m-a</option> <option value="1325">1-3-2-5(8)</option>
<option value="giuliani-pami">p-a-m-i</option> <option value="1535">1-5-3-5(8)</option>
<option value="giuliani-mami">m-a-m-i</option> <option value="12353">1-2-3-5-3</option>
<option value="giuliani-ami">a-m-i</option> <option value="1353">1-3-5-3</option>
<option value="giuliani-pimamim">p-i-m-a-m-i</option> <option value="121321">1-2-1-3-2-1</option>
<option value="giuliani-papima">p-a-p-i-m-a</option> <option value="15453">1-5-4-5-3-5</option>
<option value="giuliani-amipim">a-m-i-p-i-m</option> <option value="132532">1-3-2-5-3-2</option>
<option value="giuliani-miamim">m-i-a-m-i-m</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>
@@ -662,47 +572,60 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</select> </select>
</label> </label>
)} )}
{phrase.arpeggioBars > 1 && ( <div style={{ gridColumn: '1 / -1' }}>
<div style={{ gridColumn: '1 / -1' }}> <span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}>
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}> Chords ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}):
Chord pool ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}): </span>
</span> <div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}> {(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => {
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => { const active = phrase.arpeggioChordSelection.length === 0 || phrase.arpeggioChordSelection.includes(d);
const active = phrase.arpeggioChordSelection.length === 0 || phrase.arpeggioChordSelection.includes(d); return (
return ( <button
<button key={d}
key={d} className={`control-button small ${active ? 'active' : ''}`}
className={`control-button small ${active ? 'active' : ''}`} style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }}
style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }} onClick={() => {
onClick={() => { const cur = phrase.arpeggioChordSelection.length === 0
// Toggle; empty selection = all back on. ? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[])
const cur = phrase.arpeggioChordSelection.length === 0 : [...phrase.arpeggioChordSelection as ArpeggioDegree[]];
? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]) const next = cur.includes(d) ? cur.filter(x => x !== d) : [...cur, d];
: [...phrase.arpeggioChordSelection as ArpeggioDegree[]]; setPhrase({ arpeggioChordSelection: next });
const next = cur.includes(d) ? cur.filter(x => x !== d) : [...cur, d]; }}
setPhrase({ arpeggioChordSelection: next }); >
}} {ARPEGGIO_DEGREE_LABELS[d]}
> </button>
{ARPEGGIO_DEGREE_LABELS[d]} );
</button> })}
);
})}
</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> {phrase.material === 'giuliani' && (
<>
<label>Pattern
<select value={phrase.giulianiPattern} onChange={e => setPhrase({ giulianiPattern: e.target.value as PhraseSettings['giulianiPattern'] })}>
{(Object.keys(GIULIANI_PATTERN_LABELS) as GiulianiPattern[]).map(p => (
<option key={p} value={p}>{GIULIANI_PATTERN_LABELS[p]}</option>
))}
</select> </select>
</label> </label>
<label>Meter <label>Length
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}> <select value={phrase.giulianiBars} onChange={e => setPhrase({ giulianiBars: Math.max(1, Math.min(8, Number(e.target.value))) })}>
<option value={4}>4/4</option> {[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b} bars</option>)}
<option value={3}>3/4</option>
</select> </select>
</label> </label>
<label style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '6px' }}>
<input
type="checkbox"
checked={phrase.giulianiAlternateBass ?? false}
onChange={e => setPhrase({ giulianiAlternateBass: e.target.checked })}
/>
Alternating bass (fifth on even bars)
</label>
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Meter is derived from the pattern; chords come from the arpeggio chord pool; voicing enforced (bass + upper strings).
</span>
</> </>
)} )}
@@ -757,34 +680,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
)} )}
{fretted && ( {/* Pace lives in the metronome widget now:
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}> 'Sync to exercise' checked = tempo pace,
Fret window: use the "Fret window" controls below the sheet — shared across modes. unchecked = at-your-pace (step). */}
</span>
)}
<label>Pace
<select
value={phrase.pace}
onChange={e => {
const pace = e.target.value as PhraseSettings['pace'];
setPhrase({ pace });
// "Sync to metronome" only makes sense with the
// metronome running — switch it on automatically.
if (pace === 'tempo' && !settings.rhythm.active) {
setSettings(s => ({ ...s, rhythm: { ...s.rhythm, active: true } }));
}
}}
>
<option value="step">At your pace</option>
<option value="tempo">Sync to metronome</option>
</select>
</label>
{phrase.pace === 'tempo' && (
<span style={{ fontSize: '11px', opacity: 0.7, alignSelf: 'center' }}>
Tempo (BPM): set it on the metronome in the tools below — shared across modes.
</span>
)}
<label className="phrase-check"> <label className="phrase-check">
<input <input
type="checkbox" type="checkbox"
@@ -793,11 +691,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' }}>
Clicks: use the metronome's "Click sound" checkbox (with count-in, synced).
</span>
)}
<label className="phrase-check"> <label className="phrase-check">
<input <input
type="checkbox" type="checkbox"
@@ -816,15 +709,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">
@@ -855,7 +901,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

@@ -6,11 +6,13 @@
* - 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 } from 'lucide-react'; import { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
import { useSettings } from '../context/useSettings'; import { useSettings } from '../context/useSettings';
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes'; import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales'; import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales';
@@ -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,24 +85,45 @@ export const TargetNoteControls: React.FC = () => {
const allSelected = pf.strings.length === 0; const allSelected = pf.strings.length === 0;
const anyActive =
(!inPhraseMode && pf.keyEnabled) || pf.fretWindowEnabled || pf.strings.length > 0;
return ( return (
<div className="practice-filter-grid"> <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 */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
<SlidersHorizontal size={18} />
<span>Limit notes</span>
</label>
{anyActive && (
<button
className="link-button"
style={{ fontSize: '11px', padding: 0 }}
onClick={() => setFilter({ keyEnabled: false, fretWindowEnabled: false, strings: [] })}
>
Clear all
</button>
)}
</div>
{/* Key filter */} {/* Key filter */}
<div className="control-group" style={{ gridColumn: '1 / -1' }}> <div className="control-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label"> <label className="control-label">
<Key size={18} /> <Key size={18} />
<span>Target key (limits notes)</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
@@ -122,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>
@@ -149,13 +176,17 @@ export const TargetNoteControls: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label"> <label className="control-label">
<Guitar size={18} /> <Guitar size={18} />
<span>Strings (limits notes)</span> <span>Strings</span>
</label> </label>
{allSelected && ( <button
<span style={{ fontSize: '11px', opacity: 0.7 }}>all</span> className={`switch-button ${!allSelected ? 'active' : ''}`}
)} onClick={() => setFilter({ strings: allSelected ? [0] : [] })}
title={allSelected ? 'All strings — toggle on to restrict' : 'Restrict to selected strings'}
>
<div className="switch-thumb" />
</button>
</div> </div>
<div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}> {!allSelected && <div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}>
{Array.from({ length: stringCount }, (_, i) => { {Array.from({ length: stringCount }, (_, i) => {
const active = allSelected || pf.strings.includes(i); const active = allSelected || pf.strings.includes(i);
return ( return (
@@ -170,7 +201,7 @@ export const TargetNoteControls: React.FC = () => {
</button> </button>
); );
})} })}
</div> </div>}
{pf.strings.length > 0 && ( {pf.strings.length > 0 && (
<button <button
className="link-button" className="link-button"
@@ -189,7 +220,7 @@ export const TargetNoteControls: React.FC = () => {
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label"> <label className="control-label">
<Music size={18} /> <Music size={18} />
<span>Fret window (limits notes)</span> <span>Fret window</span>
</label> </label>
<button <button
className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`} className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`}

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,44 +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
// 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); 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;
@@ -546,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++;
@@ -574,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);
}, []); }, []);
@@ -600,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,
@@ -609,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';
@@ -18,20 +21,19 @@ export type ArpeggioPattern =
| 'up' | 'down' | 'updown' | '1235' | 'up' | 'down' | 'updown' | '1235'
// Giuliani-style right-hand figures over one chord (indices into the // Giuliani-style right-hand figures over one chord (indices into the
// chord cycle per note; 1=root 2=third 3=fifth 4=octave): // chord cycle per note; 1=root 2=third 3=fifth 4=octave):
| 'giuliani-pim' // root-third-fifth (p-i-m) | '1321' // root-fifth-third-root
| 'giuliani-pima' // root-third-fifth-octave (p-i-m-a) | '1325' // root-fifth-third-octave
| 'giuliani-pami' // root-octave-fifth-third (p-a-m-i) | '1535' // root-octave-fifth-octave
| 'giuliani-mami' // third-fifth-octave-third (inner-voice feel) | '12353' // root-third-fifth-octave-fifth
| 'giuliani-ami' // octave-fifth-third (descending from the top) | '121321' // rolling thirds (root-third-root-fifth-third-root)
| 'giuliani-pimamim' // p-i-m-a-m-i (six-note rolling figure) | '1353' // root-third-fifth-third
| 'giuliani-papima' // p-a-p-i-m-a (bass-skip pattern) | '15453' // root-octave-fifth-octave-fifth-octave
| 'giuliani-amipim' // a-m-i-p-i-m (tremolo-adjacent) | '132532' // long broken-chord wave
| 'giuliani-miamim'; // m-i-a-m-i-m (triple-string figure) | '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;
@@ -39,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; 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. */
@@ -62,34 +65,44 @@ export const ARPEGGIO_DEGREE_LABELS: Record<ArpeggioDegree, string> = {
export const PATTERN_LABELS: Record<ArpeggioPattern, string> = { export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
up: 'Up', down: 'Down', updown: 'Up & down', '1235': '1-2-3-5', up: 'Up', down: 'Down', updown: 'Up & down', '1235': '1-2-3-5',
'giuliani-pim': 'Giuliani p-i-m', '1321': '1-3-2-1',
'giuliani-pima': 'Giuliani p-i-m-a', '1325': '1-3-2-5(8)',
'giuliani-pami': 'Giuliani p-a-m-i', '1535': '1-5-3-5(8)',
'giuliani-mami': 'Giuliani m-a-m-i', '12353': '1-2-3-5-3',
'giuliani-ami': 'Giuliani a-m-i', '121321': '1-2-1-3-2-1',
'giuliani-pimamim': 'Giuliani p-i-m-a-m-i', '1353': '1-3-5-3',
'giuliani-papima': 'Giuliani p-a-p-i-m-a', '15453': '1-5-4-5-3-5',
'giuliani-amipim': 'Giuliani a-m-i-p-i-m', '132532': '1-3-2-5-3-2',
'giuliani-miamim': 'Giuliani m-i-a-m-i-m', 'custom': 'Custom…',
'random': 'Random',
}; };
/** /**
* Giuliani figures: chord-cycle index per note (0=root 1=third 2=fifth * Broken-chord figures — NOTE sequences (not fingerings): the bass takes the
* 3=octave). Each figure repeats with an octave register shift per * root, the remaining notes ride the upper chord tones.
* repetition, mirroring the p-i-m-a feel of the 120 right-hand studies. * Indices into the octave cycle [0=root, 1=third, 2=fifth, 3=octave].
*/ */
const GIULIANI_FIGURES: Record<string, number[]> = { const BROKEN_FIGURES: Record<string, number[]> = {
'giuliani-pim': [0, 1, 2], '1321': [0, 2, 1, 0],
'giuliani-pima': [0, 1, 2, 3], '1325': [0, 2, 1, 3],
'giuliani-pami': [0, 3, 2, 1], '1535': [0, 3, 2, 3],
'giuliani-mami': [1, 2, 3, 1], '12353': [0, 1, 2, 3, 2],
'giuliani-ami': [3, 2, 1], '121321': [0, 1, 0, 2, 1, 0],
'giuliani-pimamim': [0, 1, 2, 3, 2, 1], '1353': [0, 1, 2, 1],
'giuliani-papima': [0, 3, 0, 1, 2, 3], '15453': [0, 3, 2, 3, 2, 3],
'giuliani-amipim': [3, 2, 1, 0, 1, 2], '132532': [0, 2, 1, 3, 2, 1],
'giuliani-miamim': [2, 1, 3, 2, 1, 2],
}; };
/**
* 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;
@@ -127,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.
@@ -192,11 +228,11 @@ 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 = GIULIANI_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[] => [
base[0] + 12 * oct, base[1] + 12 * oct, base[2] + 12 * oct, base[0] + 12 * (oct + 1), base[0] + 12 * oct, base[1] + 12 * oct, base[2] + 12 * oct, base[0] + 12 * (oct + 1),
@@ -214,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[],
@@ -223,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) {
@@ -240,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));
@@ -249,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],
@@ -272,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'})`;
@@ -359,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
@@ -375,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

@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import { generateGiulianiStudy, type GiulianiPattern } from './giulianiGenerator';
import { scoreEvents, validateScore } from '../score/model';
const strings = [40, 45, 50, 55, 59, 64];
const pool: number[] = [];
for (const open of strings) for (let f = 0; f <= 12; f++) pool.push(open + f);
const base = {
keyTonic: 'C',
keyMode: 'major' as const,
tuningId: 'standard',
bars: 4,
chordSelection: [] as string[],
allowedStrings: [] as number[],
};
describe('generateGiulianiStudy', () => {
it('generates a 4-bar p-i-m-a study in C major', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 5 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value);
expect(r.value.measures.length).toBe(4);
// 4 voices → 4/4 auto-derived; eighths: 8 notes per bar
expect(ev.length).toBe(4 * 8);
expect(validateScore(r.value)).toEqual([]);
expect(r.value.title).toContain('Giuliani');
});
it('keeps figure notes on distinct strings per repetition (voicing rule)', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 3 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
// For each repetition of 4 consecutive notes, all midis must differ
// (distinct cycle positions → distinct pitches within a beat figure)
const ev = scoreEvents(r.value).filter(e => e.pitch);
for (let i = 0; i + 3 < ev.length; i += 4) {
const four = ev.slice(i, i + 4).map(e => e.pitch!.midi);
expect(new Set(four).size).toBe(4);
}
});
it('six-note figures work (pimamim)', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pimamim', seed: 1 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value).filter(e => e.pitch);
// 6 voices → 3/4 auto-derived; eighths: 6 notes per bar (one cycle)
expect(ev.length).toBe(4 * 6);
expect(ev.every(e => e.durationTicks === 240)).toBe(true);
expect(validateScore(r.value)).toEqual([]);
});
it('is deterministic per seed', () => {
const a = generateGiulianiStudy({ ...base, pattern: 'pim', seed: 9 }, pool);
const b = generateGiulianiStudy({ ...base, pattern: 'pim', seed: 9 }, pool);
expect(JSON.stringify(a) === JSON.stringify(b)).toBe(true);
});
it('honors string restrictions (fails cleanly when impossible)', () => {
const r = generateGiulianiStudy({ ...base, pattern: 'pima', seed: 2, allowedStrings: [0] }, pool);
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

@@ -0,0 +1,326 @@
/**
* Giuliani study generator (Phrase Mode material) — its own category
* because the figures require a REAL guitar voicing: the thumb takes the
* bass (root on a lower string) and i/m/a ride higher strings. A figure is
* only playable when each consecutive voice slot maps to a distinct string
* with a valid fret in the playable pool — arbitrary chord arpeggios cannot
* guarantee that.
*
* Voicing search: for a figure of voice-count V, pick a bass string (for
* 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
* 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 { keyFor, type KeyContext, type ModeId } from './scales';
import { TUNINGS, type Tuning } from '../music/Tunings';
import { makeRng } from './melodyGenerator';
export type GiulianiPattern =
| 'pim' // Studies 1–20 feel: bass + two upper voices
| 'pima' // Studies 21–40: bass + three upper voices
| 'pami' // bass + descending upper voices
| 'aim' // upper voices first
| 'pimamim' // six-note rolling
| 'pmamim' // asymmetric
| 'piai' // bass + octave-third (wide spacing)
| 'pmami' // bass + fifth-octave-third
| 'pimami' // bass-third-fifth-octave-third-fifth (waltz-like)
| '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> = {
pim: 'p-i-m (3 voices)',
pima: 'p-i-m-a (4 voices)',
pami: 'p-a-m-i',
aim: 'a-i-m',
pimamim: 'p-i-m-a-m-i',
pmamim: 'p-m-a-m-i-m',
piai: 'p-i-a-i (wide)',
pmami: 'p-m-a-m-i',
pimami: 'p-i-m-a-m-i (waltz)',
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]). */
const FIGURES: Record<GiulianiPattern, number[]> = {
pim: [0, 1, 2],
pima: [0, 1, 2, 3],
pami: [0, 3, 2, 1],
aim: [0, 3, 1],
pimamim: [0, 1, 2, 3, 2, 1],
pmamim: [0, 2, 3, 2, 1, 2],
piai: [0, 1, 3, 1],
pmami: [0, 2, 3, 2, 1],
pimami: [0, 1, 2, 3, 2, 1],
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 {
readonly keyTonic: string;
readonly keyMode: ModeId;
readonly pattern: GiulianiPattern;
readonly tuningId: string;
/** Bars (one chord per bar). */
readonly bars: number;
/** Chord degrees allowed (empty = all 7). */
readonly chordSelection: readonly string[];
/** Seed for chord sequence + voicing choices. */
readonly seed: number;
/** Deterministic pattern from the practice filter (strings restriction). */
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 degreePc = (d: { step: StepLetter; alter: number }): number =>
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12;
function chordNameFor(key: KeyContext, degreeIndex: number): string {
const d = key.degrees[degreeIndex];
const third = key.degrees[(degreeIndex + 2) % 7];
const fifth = key.degrees[(degreeIndex + 4) % 7];
const root = d.step + (d.alter < 0 ? 'b'.repeat(-d.alter) : '#'.repeat(d.alter));
const thirdInterval = (degreePc(third) - degreePc(d) + 12) % 12;
const fifthInterval = (degreePc(fifth) - degreePc(d) + 12) % 12;
let suffix = '';
if (thirdInterval === 3 && fifthInterval === 7) suffix = 'm';
else if (thirdInterval === 3 && fifthInterval === 6) suffix = 'dim';
return root + suffix;
}
interface ChordTone { readonly pc: number; readonly step: StepLetter; readonly alter: number }
/** Diatonic triad pcs for a degree, spelled. */
function triadPcs(key: KeyContext, degreeIndex: number): ChordTone[] {
return [0, 2, 4].map(o => key.degrees[(degreeIndex + o) % 7]).map(d => ({
pc: degreePc(d), step: d.step, alter: d.alter,
}));
}
/**
* 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)
* to a string with a valid fret, bass string lowest. Returns per-slot
* {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(
tuning: Tuning,
chord: ChordTone[],
pattern: GiulianiPattern,
allowedStrings: number[],
bassOverridePc?: number,
): { stringIndex: number; fret: number; midi: number }[] | null {
const figure = FIGURES[pattern];
const maxVoice = Math.max(...figure) + 1; // e.g. 3 for p-i-m, 4 for p-i-m-a
const bassPc = bassOverridePc ?? chord[0].pc;
// Candidate strings sorted low→high for the bass; upper voices fill above.
const strings = tuning.strings.map((open, idx) => ({ open, idx }));
const sorted = [...strings].sort((a, b) => a.open - b.open);
const usable = sorted.filter(s => allowedStrings.length === 0 || allowedStrings.includes(s.idx));
// cyclePcs[i] = pitch class of cycle position i (0 = bass, 1..3 = upper
// chord tones: third, fifth, octave-of-root).
const cyclePcs = [...chord.map(c => c.pc), (chord[0].pc + 12) % 12];
if (bassOverridePc !== undefined) cyclePcs[0] = bassOverridePc;
const bassCandidates = usable.filter(s => {
// bass must be able to reach the bass pc within 12 frets
for (let fret = 0; fret <= 12; fret++) {
if (((s.open + fret) % 12 + 12) % 12 === bassPc) return true;
}
return false;
});
for (const bass of bassCandidates) {
// assign remaining voices to strings above the bass
const uppers = usable.filter(s => s.open > bass.open).slice(0, maxVoice - 1);
if (uppers.length < maxVoice - 1) continue;
// Voice slots: cycle positions 1..maxVoice-1 map to uppers in order
const slotStrings: ({ open: number; idx: number } | null)[] = [bass];
for (let v = 1; v < maxVoice; v++) slotStrings.push(uppers[v - 1]);
let ok = true;
const placement: { stringIndex: number; fret: number; midi: number }[] = [];
const usedFrets = new Set<string>();
for (let slot = 0; slot < maxVoice; slot++) {
const pc = cyclePcs[slot];
const s = slotStrings[slot];
if (!s) { ok = false; break; }
let found: number | null = null;
for (let fret = 0; fret <= 12; fret++) {
if ((((s.open + fret) % 12) + 12) % 12 === pc) { found = fret; break; }
}
if (found === null) { ok = false; break; }
const midi = s.open + found;
const key = `${slot}:${found}`;
if (usedFrets.has(key)) { ok = false; break; } // same string double-stop — not for these studies
usedFrets.add(key);
placement.push({ stringIndex: s.idx, fret: found, midi });
}
if (ok) return placement;
}
return null;
}
export function generateGiulianiStudy(
config: GiulianiConfig,
eligiblePitches: number[],
): Result<Score> {
const tuning = TUNINGS[config.tuningId];
if (!tuning) return { ok: false, error: 'No tuning available for Giuliani studies — pick a fretted instrument.' };
const key = keyFor(config.keyTonic, config.keyMode);
const poolSet = new Set(eligiblePitches.filter(m => Number.isInteger(m)));
if (poolSet.size === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set.' };
const rng = makeRng(config.seed);
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
// Chord sequence: random from selection (no immediate repeats)
const sel = (config.chordSelection ?? []).filter(d => d in DEGREE_INDEX);
const poolDegrees = (sel.length > 0 ? sel : Object.keys(DEGREE_INDEX)) as string[];
const degreeIndices: number[] = [];
for (let b = 0; b < config.bars; b++) {
const prev = degreeIndices[b - 1];
const candidates = poolDegrees
.map(d => DEGREE_INDEX[d])
.filter(di => di !== prev);
degreeIndices.push(pick(candidates.length > 0 ? candidates : poolDegrees.map(d => DEGREE_INDEX[d])));
}
// Voice each bar; drop bars that cannot be voiced (rare with full pool).
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 chordSymbols: (string | null)[] = [];
const usableBars: { degreeIndex: number; placement: ReturnType<typeof voiceBar> }[] = [];
for (let b = 0; b < degreeIndices.length; b++) {
const di = degreeIndices[b];
const chord = triadPcs(key, di);
// 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;
usableBars.push({ degreeIndex: di, placement });
}
if (usableBars.length === 0) {
return { ok: false, error: 'No bar could be voiced with the current string selection — allow more strings or a lower fret window.' };
}
// Rhythm: ALWAYS eighths (240 ticks). Each bar holds
// `notesPerBar = (bar length / 240)` notes; the figure repeats to fill
// the bar. For 3- and 4-voice figures in their derived meter this is a
// whole number of repetitions; 5-voice figures in 4/4 leave a 720-tick
// rest at the bar end.
const notesPerBar = Math.floor((meter.numerator * PPQ) / 240);
const fillSlots = Math.floor(notesPerBar / figure.length) * figure.length;
let cursor = 0;
for (let b = 0; b < usableBars.length; b++) {
const { degreeIndex, placement } = usableBars[b];
if (!placement) continue; // filtered above, but satisfies strict TS
chordSymbols.push(chordNameFor(key, degreeIndex));
for (let n = 0; n < fillSlots; n++) {
const f = n % figure.length;
const slot = figure[f];
const note = placement[slot];
if (!note) continue;
// spelling via key degree of this pc
const pc = ((note.midi % 12) + 12) % 12;
const d = key.degrees.find(deg => degreePc(deg) === pc);
if (!d) continue;
const octave = (note.midi - pc) / 12 - 1;
events.push({
id: `g${b}-${n}`,
startTick: cursor,
durationTicks: 240,
pitch: { midi: note.midi, step: d.step, alter: d.alter, octave },
});
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;
}
}
const totalBars = usableBars.length;
const score: Score = {
version: 1,
id: `giuliani-${config.keyTonic}-${config.keyMode}-${config.pattern}-${config.bars}b-${config.seed}`,
title: `Giuliani study — ${key.tonic} ${config.pattern} (${totalBars} bars)`,
meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: totalBars }, (_, i) => ({
number: i + 1,
startTick: i * full,
durationTicks: full,
})),
chordSymbols,
voices: [{ id: 'melody', events }],
};
const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal giuliani error: ${problems[0]}` };
return { ok: true, value: score };
}

View File

@@ -30,7 +30,7 @@ export interface MelodyConfig {
readonly keyMode: ModeId; readonly keyMode: ModeId;
/** 1..8 */ /** 1..8 */
readonly bars: number; readonly bars: number;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
/** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters), /** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters),
* 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */ * 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */
readonly rhythmLevel: 1 | 2 | 3; readonly rhythmLevel: 1 | 2 | 3;
@@ -65,6 +65,21 @@ const TEMPLATES_4_L3: number[][] = [
[480, 480, 360, 120, 480], [480, 480, 360, 120, 480],
[720, 120, 120, 120, 120, 480], [720, 120, 120, 120, 120, 480],
]; ];
// 6/8 templates (6 quarter-ticks = 2880 per bar; 8th-note oriented).
const TEMPLATES_6_L1: number[][] = [
[240, 240, 240, 240, 240, 240, 240, 240, 480, 480],
[480, 480, 240, 240, 240, 240, 480, 480],
[240, 240, 480, 480, 480, 240, 240, 240, 240],
[960, 480, 480, 480, 480],
[480, 480, 480, 480, 960],
[1440, 480, 480, 480],
];
const TEMPLATES_6_L2: number[][] = [
[480, 480, 120, 120, 120, 120, 240, 240, 480, 480],
[120, 120, 120, 120, 480, 480, 240, 240, 240, 240, 240, 240],
[480, 240, 240, 480, 240, 240, 240, 240, 240, 240],
[480, 240, 240, 480, 240, 240, 240, 240, 240, 240],
[480, 240, 240, 480, 240, 240, 240, 240, 240, 240],];
const TEMPLATES_3_L1: number[][] = [ const TEMPLATES_3_L1: number[][] = [
[480, 480, 480], [480, 480, 480],
[960, 480], [960, 480],
@@ -86,11 +101,24 @@ const TEMPLATES_3_L3: number[][] = [
[120, 120, 120, 120, 120, 120, 120, 120, 480], [120, 120, 120, 120, 120, 120, 120, 120, 480],
]; ];
function templatesFor(meter: { numerator: 3 | 4 }, level: 1 | 2 | 3): number[][] { function templatesFor(meter: { numerator: 3 | 4 | 6 }, level: 1 | 2 | 3): number[][] {
const base = meter.numerator === 4 let base: number[][];
? (level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3]) if (meter.numerator === 4) {
: (level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3]); base = level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3];
return base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ); } else if (meter.numerator === 6) {
base = level === 1 ? TEMPLATES_6_L1 : [...TEMPLATES_6_L1, ...TEMPLATES_6_L2];
} else {
base = level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3];
}
const out = base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
// Never return an empty pool — fall back to the closest available level
// so generator.pick on an empty array can never crash the app.
if (out.length === 0) {
const all = [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_6_L1];
const anyFit = all.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
return anyFit.length > 0 ? anyFit : [[meter.numerator * PPQ]];
}
return out;
} }
/** Motion weights by semitone distance (soft preference, contract C3). */ /** Motion weights by semitone distance (soft preference, contract C3). */

View File

@@ -19,7 +19,7 @@ export interface DrillConfig {
readonly keyMode: ModeId; readonly keyMode: ModeId;
readonly direction: DrillDirection; readonly direction: DrillDirection;
readonly coverage: DrillCoverage; readonly coverage: DrillCoverage;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
/** Note duration: quarters (default) or eighths. */ /** Note duration: quarters (default) or eighths. */
readonly rhythm?: DrillRhythm; readonly rhythm?: DrillRhythm;
} }

View File

@@ -86,7 +86,7 @@ export interface Score {
readonly version: 1; readonly version: 1;
readonly id: string; readonly id: string;
readonly title: string; readonly title: string;
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 }; readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
readonly key: ScoreKey; readonly key: ScoreKey;
readonly measures: ScoreMeasure[]; readonly measures: ScoreMeasure[];
/** Exactly one voice in v1. */ /** Exactly one voice in v1. */

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

@@ -42,17 +42,15 @@ export interface RhythmSettings {
volume: number; volume: number;
} }
export type PhraseMaterial = 'melody' | 'scale' | 'arpeggio' | 'library' | 'import'; export type PhraseMaterial = 'melody' | 'scale' | 'arpeggio' | 'giuliani' | 'library' | 'import';
export type PhrasePace = 'step' | 'tempo'; export type PhrasePace = 'step' | 'tempo';
export interface PhraseSettings { 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; meterNumerator: 3 | 4 | 6;
rhythmLevel: 1 | 2 | 3; rhythmLevel: 1 | 2 | 3;
scaleCoverage: 'one-octave' | 'two-octave' | 'position'; scaleCoverage: 'one-octave' | 'two-octave' | 'position';
scaleDirection: 'up' | 'down' | 'updown'; scaleDirection: 'up' | 'down' | 'updown';
@@ -61,16 +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). */
/** Phrase-local fret window (guitar/bass) — overrides the note set. */ giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim';
fretWindowEnabled: boolean; giulianiBars: number;
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;
@@ -84,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,
@@ -103,10 +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',
fretWindowEnabled: false, giulianiBars: 4,
fretMin: 0,
fretMax: 4,
startBar: 1, startBar: 1,
barCount: 2, barCount: 2,
pace: 'step', pace: 'step',
@@ -115,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 {
@@ -136,7 +134,7 @@ export interface AppSettings {
instrument: string; instrument: string;
rhythm: RhythmSettings; rhythm: RhythmSettings;
zenMode: boolean; zenMode: boolean;
gameMode: 'sight_reading' | 'ear_training' | 'phrase'; gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist';
customMinFret?: number; customMinFret?: number;
customMaxFret?: number; customMaxFret?: number;
autoPlaySightReading?: boolean; autoPlaySightReading?: boolean;