26 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
OpenClaw
6253194632 Metronome sync toggle in widget, more Giuliani figures, chord pool list
- MetronomeWidget: the Phrase Mode switch now lives IN the widget as a
  Free/Sync toggle. Free = independent metronome (own clock, starts when
  toggled, with or without clicks). Sync = silent while idle, starts with
  the exercise (count-in included), pendulum mirrors the run's beats.
- Fixed double phraseBeatBus.emit (leftover from an earlier edit) that
  flipped the pendulum twice per beat — animation appeared frozen.
- Click decision unified: one sound checkbox governs both the widget and
  (in sync mode) the run's click scheduler. 'Click 1 after count-in' is
  musically the last count-in click (suppressed duplicate) — now with a
  working pendulum the offset is visible rather than confusing.
- Giuliani figures extended: p-i-m-a-m-i, p-a-p-i-m-a, a-m-i-p-i-m,
  m-i-a-m-i-m (six-note rolling/bass-skip/tremolo-adjacent figures).
- Arpeggio chord pool: togglable degree list (empty = all 7); functional
  and random progressions honor the pool; first bar draws a random chord
  from the pool.
- Tests 174/174.
2026-09-14 20:13:42 +00:00
OpenClaw
ffb94cc93f Metronome rewrite: single sound checkbox, clean click architecture
- MetronomeWidget rebuilt cleanly: ONE 'Click sound' checkbox (the earlier
  restructure left duplicated option rows behind — neither worked). Widget
  body always visible (dimmed when disarmed); toggle arms sound+pendulum.
- Click decision unified: 'sync to metronome' derives phrase clickSound
  from the widget's sound checkbox (no separate phrase click option);
  'at your pace' clicks when the metronome is armed.
- Chord selection: abwählbare Liste (arpeggioChordSelection) restricts
  progression chords; first bar draws a random chord FROM THE SELECTION
  (fixed first chord repeated the exercise identically).
- Scales: rhythm setting (quarters/eighths) shared with arpeggios;
  drill id includes rhythm to invalidate cached scores.
- Beam fix: register Beam via note.setBeam so VexFlow suppresses flags
  (eighths/16ths previously drew BOTH beam and flag).
2026-09-14 19:46:23 +00:00
OpenClaw
9dc95e3787 Metronome always-visible controls, Giuliani patterns, pool fix
- Metronome widget: BPM/slider row and pendulum always visible (dimmed
  when off); the toggle gates sound + animation only. BPM/Timer mode
  switch restored for single-note auto-advance.
- Giuliani right-hand figures as arpeggio patterns: p-i-m, p-i-m-a,
  p-a-m-i, m-a-m-i, a-m-i — cycle [root, third, fifth, octave] per figure
  with octave register shift per repetition (120-studies feel).
- Pool fix: fretted instruments without active filters now get the full
  0-12 fretboard in ALL modes (legacy difficulty ranges no longer clamp
  the pool since the Note Set dropdown was removed).
- Tests 174/174.
2026-09-14 19:20:15 +00:00
OpenClaw
263b6f6b69 Arpeggio sequences + auto-start on first note
- Sequence mode: bars (2/4/6/8) with one chord per bar, chosen by
  functional rules (I->IV/V/vi...), diatonic cycle, or random (no immediate
  repeats); deterministic via melody seed. Chord symbols per bar in the
  score model (chordSymbols) rendered above the staff via setSection.
- Rhythm: quarters or eighths (cycling up/down through the pattern).
- Single-chord mode unchanged (whole path, overflow bars if needed).
- Auto-start: 'Start on first note (no count-in)' — step pace begins the
  run as soon as the mic detects the first note; no start press needed.
- 'New' button re-rolls the progression (new seed).
- Tests 174/174.
2026-09-14 18:44:14 +00:00
OpenClaw
d76d24ab01 Arpeggio material for Phrase Mode (variant 1)
- music/arpeggioGenerator.ts: diatonic triads (I-vii*) built from the
  selected key/mode via the shared degree spelling; patterns up / down /
  up-down / 1-2-3-5; one or two octaves (cycle ends on the octave; two-
  octave path continues from the turning root). Quarter-note rhythm with
  final note extended to fill the bar; title is labeled 'Arpeggio -
  <tonic> <degree> (pattern, coverage)'.
- Settings: PhraseMaterial 'arpeggio' + arpeggioDegree/Pattern/Coverage
  (defaults I / up / one-octave).
- PhraseTrainer: material option 'Arpeggios (generated)' + Chord/Pattern/
  Coverage/Meter selects; key/mode reuse the shared Target key controls.
- Tests: 9 arpeggio cases (170 total).
2026-09-14 17:10:40 +00:00
OpenClaw
a965dde585 Click behavior per pace: continuous in step, run-synced (incl. preview) in tempo
- Step pace ('at your pace'): metronome ticks continuously when the user
  switches it on — phraseRunBus gate no longer applies (clock stays free).
- Tempo pace ('sync to metronome'): gate now covers preview too; preview
  schedules quarter-note clicks along with the notes (accented each bar)
  so the rhythm is audible while listening.
2026-09-14 16:41:49 +00:00
OpenClaw
fc8f9bfb44 Render dotted durations with visible dots (Dot modifier)
durationCode returned '4d'/'2d' codes but VexFlow only reads the base
duration for glyph/spacing — the dot must be attached explicitly via
Dot.buildAndAttach. Dotted notes rendered without dots, which read like
extra plain quarters ('five quarters in bar 2'). Now dotted notes/rests
show their dot; spacing uses the base code correctly.
2026-09-14 16:37:15 +00:00
OpenClaw
2ed1a88184 Metronome/phrase click unification: run-driven beats, gated widget
- Phrase Mode (sync to metronome): the run's scheduler is the single click
  source (with count-in); the metronome widget is muted and its pendulum
  mirrors the run's beats via the new phraseBeatBus (audio-timestamp
  scheduled). No more double clicks; clicks only while the run is active
  (phraseRunBus gates the widget's own ticking).
- Schedule entries now carry continuous beat index + accent flag (downbeats
  accented, count-in beat 1 accented).
- 'At your pace': optional click via the metronome toggle (kept explicit).
- Metronome widget: gate prop; in gated mode it listens to the beat bus
  instead of running its own scheduler.
2026-09-14 16:33:39 +00:00
OpenClaw
57a6a654a0 Feedback round 3: metronome/phrase integration, 16ths, count-in badge, wider transpose
- Metronome/phrase: 'With tempo' renamed to 'Sync to metronome' (auto-enables
  the metronome when selected); phrase click now yields to the metronome
  click (no more double clicks) with an explicit opt-out checkbox.
- Count-in: subtle numeric badge next to the status line (muted pill, 11px).
- Melody rhythm level 3 'Elaborate': sixteenth/dotted-eighth templates for
  4/4 and 3/4; renderer supports 120/360-tick codes and beams 16th pairs.
- Transpose control for library/import material widened to ±2 octaves.
- Tests 161/161 (bar-total invariants hold over 500 seeds at level 2).
2026-09-14 16:14:20 +00:00
OpenClaw
85fe20bf51 Remove Note Set dropdown + legacy fret-range block; fix pendulum transition
- Note Set dropdown and custom-fret Fret Range inputs removed from Controls:
  the unified target-note filter (strings + fret window + key) fully
  supersedes them; difficulty stays as internal default (clef/transpose
  resolution) but is no longer user-facing.
- Pendulum: drop the beat-parity key that remounted the dot each beat
  (killed the CSS transition); the same element now glides left<->right.
2026-09-14 12:51:43 +00:00
OpenClaw
b214c209f9 Feedback round 2: strings filter, key sync, fixed clef, unified metronome
- Strings filter: per-string toggle buttons (replaces fret-window-only
  semantics as a third 'limits notes' control); combines with fret window
  and key filter in targetPool (fretted instruments).
- Key sync checkbox: display key signature can follow the target key
  (major/minor mapping), default off — both remain independently settable.
- LiveNoteStaff: optional fixed clef — guitar/bass now keep their usual
  clef instead of flipping at middle C; octave markers handle range.
- Unified metronome widget (MetronomeWidget) with animated linear pendulum
  synced to the audible click; shown in ALL modes next to the tuner.
  Phrase BPM now reads from settings.rhythm.bpm (single source); the
  phrase-local BPM control was removed from the setup panel.
- Tests: 160/160; tsc + eslint clean; build ok.
2026-09-14 12:38:22 +00:00
OpenClaw
15b8705907 Unified target-note controls: key filter + fret window across all modes
- New shared PracticeFilter settings (keyTonic/keyMode/fretWindow), persisted
  via AppSettings.practice, defaults off (behavior unchanged).
- music/targetPool.ts: single source for the effective target pool —
  base playable range ∩ fret window ∩ key/mode pitch classes (9 modes incl.
  pentatonic, same scale machinery as Phrase Mode).
- useGameLogic now builds validNotes via computeTargetPool.
- TargetNoteControls component (key on/off + tonic/mode, fret window on/off +
  min/max) rendered in Controls for all game modes; key is independent of the
  displayed key signature.
- Phrase Mode binds its key/fret-window setup to the same shared filter
  (read/write mirror) and drops the duplicate controls in its setup panel.
- Tests: targetPool.test.ts (7 cases); suite 156/156 green, build ok.
2026-09-14 07:16:29 +00:00
31 changed files with 3226 additions and 686 deletions

5
TOOLS_LOCAL.md Normal file
View File

@@ -0,0 +1,5 @@
# Local deployment (nyx server) — beta channel
- Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart)
- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081), image `smt-beta-image`
- Tailnet URL: http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081)
- Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only

View File

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

View File

@@ -1040,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}
@@ -332,6 +377,7 @@ function App() {
midi={displayedPitch ? displayedPitch.midi : null} midi={displayedPitch ? displayedPitch.midi : null}
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose} transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
keySignature={settings.keySignature} keySignature={settings.keySignature}
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
theme={settings.theme} theme={settings.theme}
/> />
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}> <div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
@@ -390,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,11 +1,11 @@
import { Settings, Guitar, Music, Gauge } from 'lucide-react'; import React, { useEffect, useState } from 'react';
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings'; import { Gauge, SlidersHorizontal } from 'lucide-react';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { getTempoMarking } from '../music/TempoMarkings';
import { TuningMeter } from './TuningMeter'; import { TuningMeter } from './TuningMeter';
import { TargetNoteControls } from './TargetNoteControls';
import { MetronomeWidget } from './MetronomeWidget';
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';
@@ -15,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;
const handleDifficultyChange = (e: React.ChangeEvent<HTMLSelectElement>) => { // Phrase run gate: the metronome only ticks while the phrase trainer is
onUpdateSettings({ ...settings, difficulty: e.target.value as Difficulty }); // counting in / playing (armed by the user toggle, fired by the run).
}; const [phraseRunActive, setPhraseRunActive] = useState(false);
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({
@@ -52,146 +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
value={settings.instrument}
onChange={handleInstrumentChange}
className="control-select"
> >
{Object.values(INSTRUMENT_DEFINITIONS).map(def => ( <SlidersHorizontal size={16} />
<option key={def.id} value={def.id}>{def.displayName}</option> Filter
))} {activeFilterCount > 0 && (
</select> <span style={{
</div> display: 'inline-block', minWidth: '18px', padding: '1px 5px',
marginLeft: '4px', borderRadius: '9px', fontSize: '11px',
{/* Row 1, Col 2 (Conditional) */} background: 'var(--color-primary)', color: 'var(--color-surface)',
{currentInstrumentDef.showTuning ? ( fontWeight: 600, textAlign: 'center'
<div className="control-group"> }}>{activeFilterCount}</span>
<label className="control-label"> )}
<Guitar size={18} /> </button>
<span>Tuning</span>
</label>
<select
value={settings.tuningId}
onChange={handleTuningChange}
className="control-select"
>
{availableTunings.map(id => (
<option key={id} value={id}>{TUNINGS[id].name}</option>
))}
</select>
</div>
) : <div />} {/* Spacer to maintain grid flow if needed, or omit to let items flow. User asked for specific rows.
If I omit, Note Set goes here. Let's omit for "Dense" feel, or keep empty div for strict rows?
The request "Top row: instrument and tuning" implies if tuning is missing, maybe just 1 item?
I'll output an empty div if I want to FORCE strict placement, but generally flow is better.
However, "Second row: note set..." suggests structure.
I will use an empty div if tuning is hidden to push Note Set to next row?
Actually, grid-template-columns: 1fr 1fr.
If Tuning is hidden, Note Set becomes Item 2.
I'll forgo complexity and just render what's available. */}
{/* Row 2, Col 1 */}
<div className="control-group">
<label className="control-label">
<Settings size={18} />
<span>Note Set</span>
</label>
<select
value={settings.difficulty}
onChange={handleDifficultyChange}
className="control-select"
>
{currentInstrumentDef.ranges.map(r => (
<option key={r.id} value={r.id}>{r.label}</option>
))}
</select>
</div>
{/* Row 2, Col 2 */}
{!inPhraseMode && (
<div className="control-group">
<label className="control-label">
<span>Key signature</span>
</label>
<select
value={settings.keySignature}
onChange={(e) => onUpdateSettings({ ...settings, keySignature: e.target.value })}
className="control-select"
>
<optgroup label="Major Keys">
<option value="C">C Major</option>
<option value="G">G Major</option>
<option value="D">D Major</option>
<option value="A">A Major</option>
<option value="E">E Major</option>
<option value="F">F Major</option>
<option value="Bb">Bb Major</option>
<option value="Eb">Eb Major</option>
</optgroup>
<optgroup label="Minor Keys">
<option value="Am">A Minor</option>
<option value="Em">E Minor</option>
<option value="Dm">D Minor</option>
</optgroup>
</select>
</div> </div>
)} )}
{shouldShowFilters && <TargetNoteControls />}
{/* Row 3: Fret Range (Full Width) */}
{currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && (
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
<label className="control-label">
<span>Fret Range</span>
</label>
<div style={{ display: 'flex', gap: '8px', width: '100%' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', opacity: 0.7 }}>Min</span>
<input
type="number"
min="0"
max="24"
value={settings.customMinFret ?? 0}
onChange={(e) => onUpdateSettings({ ...settings, customMinFret: parseInt(e.target.value) || 0 })}
className="control-input"
style={{ width: '100%', padding: '4px' }}
/>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', opacity: 0.7 }}>Max</span>
<input
type="number"
min="0"
max="24"
value={settings.customMaxFret ?? 12}
onChange={(e) => onUpdateSettings({ ...settings, customMaxFret: parseInt(e.target.value) || 0 })}
className="control-input"
style={{ width: '100%', padding: '4px' }}
/>
</div>
</div>
</div>
)}
</div>
{/* 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}>
@@ -220,120 +95,16 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
)} )}
</div> </div>
{/* Tool 2: Metronome (single-note modes only) */} {/* Tool 2: Metronome (all modes) */}
{!inPhraseMode && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <MetronomeWidget
<label className="control-label" style={{ marginBottom: 0 }}> rhythm={settings.rhythm}
<span>Metronome</span> onUpdate={updateRhythm}
</label> showAutoAdvance={!inPhraseMode}
<button syncMode={inPhraseMode ? 'option' : null}
className={`switch-button ${settings.rhythm.active ? 'active' : ''}`} gate={inPhraseMode ? phraseRunActive : null}
onClick={() => updateRhythm({ active: !settings.rhythm.active })}
title={settings.rhythm.active ? "Turn Off" : "Turn On"}
>
<div className="switch-thumb" />
</button>
</div>
{settings.rhythm.active && (
<div className="rhythm-details" style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<div style={{ display: 'flex', gap: '4px' }}>
<button
className={`control-button small ${settings.rhythm.mode === 'bpm' ? 'active' : ''}`}
onClick={() => updateRhythm({ mode: 'bpm' })}
style={{ flex: 1, fontSize: '10px' }}
>BPM</button>
<button
className={`control-button small ${settings.rhythm.mode === 'seconds' ? 'active' : ''}`}
onClick={() => updateRhythm({ mode: 'seconds' })}
style={{ flex: 1, fontSize: '10px' }}
>Timer</button>
</div>
{settings.rhythm.mode === 'bpm' ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<button
className="control-button small"
onClick={() => updateRhythm({ bpm: Math.max(30, settings.rhythm.bpm - 1) })}
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
-
</button>
<input
type="number"
min="30"
max="300"
value={settings.rhythm.bpm}
onChange={(e) => updateRhythm({ bpm: Math.max(1, parseInt(e.target.value) || 60) })}
className="control-input"
style={{
width: '48px',
textAlign: 'center',
padding: '2px'
}}
/>
<button
className="control-button small"
onClick={() => updateRhythm({ bpm: Math.min(300, settings.rhythm.bpm + 1) })}
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
+
</button>
<input
type="range"
min="30"
max="240"
step="1"
value={settings.rhythm.bpm}
onChange={(e) => updateRhythm({ bpm: Number(e.target.value) })}
style={{ flex: 1 }}
/> />
</div> </div>
<div style={{ fontSize: '11px', opacity: 0.7, textAlign: 'center' }}>
{getTempoMarking(settings.rhythm.bpm)}
</div>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', minWidth: '32px' }}>{settings.rhythm.seconds}s</span>
<input
type="range"
min="1"
max="60"
step="1"
value={settings.rhythm.seconds}
onChange={(e) => updateRhythm({ seconds: Number(e.target.value) })}
style={{ flex: 1 }}
/>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={settings.rhythm.autoAdvance}
onChange={(e) => updateRhythm({ autoAdvance: e.target.checked })}
/>
Auto
</label>
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={settings.rhythm.sound}
onChange={(e) => updateRhythm({ sound: e.target.checked })}
/>
Sound
</label>
</div>
</div>
)}
</div>
)}
</div> </div>
</div> </div>

View File

@@ -14,10 +14,12 @@ import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, Annotation }
import { getNoteInKey } from '../music/NoteUtils'; import { getNoteInKey } from '../music/NoteUtils';
interface LiveNoteStaffProps { interface LiveNoteStaffProps {
/** Sounding midi of the live detected pitch, or null when silent. */
midi: number | null; midi: number | null;
transpose: number; transpose: number;
keySignature: string; keySignature: string;
/** Force a clef (e.g. the instrument's usual clef). When omitted the
* clef auto-switches treble/bass around middle C (piano-style). */
clef?: 'treble' | 'bass';
width?: number; width?: number;
theme?: 'light' | 'dark' | 'auto'; theme?: 'light' | 'dark' | 'auto';
} }
@@ -32,6 +34,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
midi, midi,
transpose = 0, transpose = 0,
keySignature = 'C', keySignature = 'C',
clef: clefOverride,
width = 130, width = 130,
theme = 'auto', theme = 'auto',
}) => { }) => {
@@ -63,8 +66,9 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
const stave = new Stave(0, STAVE_Y, width - 2); const stave = new Stave(0, STAVE_Y, width - 2);
stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 }); stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 });
const written0 = midi !== null ? midi + transpose : null; const written0 = midi !== null ? midi + transpose : null;
// Clef like the grand-staff split: below middle C reads bass clef. // Instrument-fixed clef when provided (guitar always treble, bass
const clef: 'treble' | 'bass' = written0 !== null && written0 < 60 ? 'bass' : 'treble'; // always bass); otherwise piano-style split at middle C.
const clef: 'treble' | 'bass' = clefOverride ?? (written0 !== null && written0 < 60 ? 'bass' : 'treble');
stave.addClef(clef); stave.addClef(clef);
if (keySignature) stave.addKeySignature(keySignature); if (keySignature) stave.addKeySignature(keySignature);
stave.setContext(context).draw(); stave.setContext(context).draw();
@@ -109,7 +113,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
new Formatter().joinVoices([voice]).format([voice], width - 40); new Formatter().joinVoices([voice]).format([voice], width - 40);
voice.draw(context, stave); voice.draw(context, stave);
} }
}, [midi, transpose, keySignature, width, theme, schemeVersion]); }, [midi, transpose, keySignature, clefOverride, width, theme, schemeVersion]);
return ( return (
<div <div

View File

@@ -0,0 +1,231 @@
/**
* Metronome widget — always-visible tempo controls + pendulum.
*
* Modes:
* - Plain (single-note modes): one on/off toggle arms sound + animation.
* - Sync option (Phrase Mode): the toggle switches between
* * FREE — the metronome runs independently (own clock, starts as soon
* as toggled, with or without clicks per the sound checkbox)
* * SYNC — the metronome starts with the exercise (count-in included),
* is silent while idle, and its pendulum mirrors the run's beats.
*
* Exactly ONE sound checkbox exists (here). In sync mode it governs the
* run's click scheduler too — clicks can never double.
*/
import { useEffect, useRef, useState, useCallback } from 'react';
import { useMetronome } from '../hooks/useMetronome';
import { getTempoMarking } from '../music/TempoMarkings';
import { phraseBeatBus } from '../hooks/phraseBeatBus';
import { audioEngine } from '../audio/AudioEngine';
import type { RhythmSettings } from '../types/SettingsTypes';
interface MetronomeWidgetProps {
rhythm: RhythmSettings;
/** Persist changes. */
onUpdate: (updates: Partial<RhythmSettings>) => void;
/** Single-note mode extra: auto-advance targets on each tick. */
showAutoAdvance?: boolean;
/** Compact layout (phrase transport row). */
compact?: boolean;
/** '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;
/** External gate (sync mode): true while the exercise runs. */
gate?: boolean | null;
}
export const MetronomeWidget: React.FC<MetronomeWidgetProps> = ({
rhythm,
onUpdate,
showAutoAdvance = false,
compact = false,
syncMode = null,
gate = null,
}) => {
const [beatParity, setBeatParity] = useState(0);
const lastTickRef = useRef(0);
const handleTick = useCallback((beat: number) => {
const now = performance.now();
if (now - lastTickRef.current < 60) return; // glitch guard
lastTickRef.current = now;
setBeatParity(beat % 2);
}, []);
const isSync = syncMode === 'option' && !!rhythm.syncToExercise;
const runActive = gate === true;
// Sync: pendulum/click only while the run is active (the run's scheduler
// is the click source; the widget's own scheduler stays off).
// Free/plain: the widget's own scheduler runs whenever armed.
const running = isSync ? runActive : rhythm.active;
const { restart } = useMetronome({
bpm: rhythm.bpm,
volume: rhythm.sound ? rhythm.volume : 0,
playing: running && !isSync,
onTick: handleTick,
});
void restart;
// Sync: pendulum mirrors the phrase run's beats (audio-time scheduled).
useEffect(() => {
if (!isSync || !runActive) return;
return phraseBeatBus.subscribe((_beat, at) => {
const delay = Math.max(0, (at - audioEngine.now()) * 1000);
window.setTimeout(() => setBeatParity(p => (p + 1) % 2), delay);
});
}, [isSync, runActive]);
const beatMs = 60000 / Math.max(1, rhythm.bpm);
const armed = rhythm.active;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* Header: label + mode toggle */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
<span>Metronome</span>
</label>
{syncMode === 'option' ? (
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={!!rhythm.syncToExercise}
onChange={(e) => onUpdate({ syncToExercise: e.target.checked })}
/>
Sync to exercise
</label>
) : (
<button
className={`switch-button ${rhythm.active ? 'active' : ''}`}
onClick={() => onUpdate({ active: !rhythm.active })}
title={rhythm.active ? 'Turn Off' : 'Turn On (sound + pendulum)'}
>
<div className="switch-thumb" />
</button>
)}
</div>
{/* Always visible body; dimmed when disarmed */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', opacity: armed ? 1 : 0.5 }}>
{/* Pendulum */}
<div
style={{
position: 'relative',
height: compact ? 14 : 18,
borderRadius: 9,
background: 'color-mix(in srgb, var(--color-text-main) 8%, transparent)',
overflow: 'hidden',
}}
aria-hidden
>
<div
style={{
position: 'absolute',
top: '50%',
left: beatParity === 0 ? '6%' : '88%',
width: compact ? 10 : 12,
height: compact ? 10 : 12,
borderRadius: '50%',
background: 'var(--color-text-main)',
transform: 'translateY(-50%)',
transition: `left ${beatMs}ms linear`,
display: armed && running ? 'block' : 'none',
}}
/>
</div>
{/* BPM / Timer controls */}
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
{!compact && (
<div style={{ display: 'flex', gap: '4px', flexShrink: 0 }}>
<button
className={`control-button small ${rhythm.mode === 'bpm' ? 'active' : ''}`}
onClick={() => onUpdate({ mode: 'bpm' })}
style={{ fontSize: '10px' }}
>BPM</button>
<button
className={`control-button small ${rhythm.mode === 'seconds' ? 'active' : ''}`}
onClick={() => onUpdate({ mode: 'seconds' })}
style={{ fontSize: '10px' }}
>Timer</button>
</div>
)}
{rhythm.mode === 'bpm' ? (
<>
<button
className="control-button small"
onClick={() => onUpdate({ bpm: Math.max(30, rhythm.bpm - 1) })}
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>−</button>
<input
type="number"
min="30"
max="300"
value={rhythm.bpm}
onChange={(e) => onUpdate({ bpm: Math.max(1, parseInt(e.target.value) || 60) })}
className="control-input"
style={{ width: '48px', textAlign: 'center', padding: '2px' }}
/>
<button
className="control-button small"
onClick={() => onUpdate({ bpm: Math.min(300, rhythm.bpm + 1) })}
style={{ width: '24px', padding: '0', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>+</button>
<input
type="range"
min="30"
max="240"
step="1"
value={rhythm.bpm}
onChange={(e) => onUpdate({ bpm: Number(e.target.value) })}
style={{ flex: 1 }}
/>
</>
) : (
<>
<span style={{ fontSize: '12px', minWidth: '32px' }}>{rhythm.seconds}s</span>
<input
type="range"
min="1"
max="60"
step="1"
value={rhythm.seconds}
onChange={(e) => onUpdate({ seconds: Number(e.target.value) })}
style={{ flex: 1 }}
/>
</>
)}
</div>
{rhythm.mode === 'bpm' && (
<div style={{ fontSize: '11px', opacity: 0.7, textAlign: 'center' }}>
{getTempoMarking(rhythm.bpm)}
</div>
)}
{/* Options row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{showAutoAdvance ? (
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={rhythm.autoAdvance}
onChange={(e) => onUpdate({ autoAdvance: e.target.checked })}
/>
Auto
</label>
) : <span />}
<label style={{ fontSize: '10px', display: 'flex', alignItems: 'center', gap: '4px' }}>
<input
type="checkbox"
checked={rhythm.sound}
onChange={(e) => onUpdate({ sound: e.target.checked })}
/>
Click sound
</label>
</div>
</div>
</div>
);
};

View File

@@ -13,7 +13,7 @@
* microphone frame rate. * microphone frame rate.
*/ */
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { Renderer, Stave, StaveNote, GhostNote, Accidental, Voice, Formatter, StaveConnector, Beam, StaveTie, Annotation } from 'vexflow'; import { Renderer, Stave, StaveNote, GhostNote, Accidental, Voice, Formatter, StaveConnector, Beam, StaveTie, Annotation, Dot } from 'vexflow';
import { scoreEvents, type Score } from '../score/model'; import { scoreEvents, type Score } from '../score/model';
import { STEP_LETTERS, type StepLetter } from '../score/model'; import { STEP_LETTERS, type StepLetter } from '../score/model';
import type { NoteStatus } from '../game/PhraseMatcher'; import type { NoteStatus } from '../game/PhraseMatcher';
@@ -40,6 +40,7 @@ interface Fragment {
ticks: number; ticks: number;
isAttack: boolean; isAttack: boolean;
isEighth: boolean; isEighth: boolean;
isSixteenth: boolean;
staveKey: 'treble' | 'bass'; staveKey: 'treble' | 'bass';
rest: boolean; rest: boolean;
} }
@@ -47,7 +48,9 @@ interface Fragment {
// Duration ticks → VexFlow duration code (subset the model guarantees). // Duration ticks → VexFlow duration code (subset the model guarantees).
function durationCode(ticks: number): string | null { function durationCode(ticks: number): string | null {
switch (ticks) { switch (ticks) {
case 120: return '16';
case 240: return '8'; case 240: return '8';
case 360: return '8d';
case 480: return '4'; case 480: return '4';
case 720: return '4d'; case 720: return '4d';
case 960: return '2'; case 960: return '2';
@@ -102,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;
@@ -162,6 +170,11 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
const ticks = fragEnd - cursor; const ticks = fragEnd - cursor;
const code = durationCode(ticks); const code = durationCode(ticks);
if (!code) { cursor = fragEnd; continue; } if (!code) { cursor = fragEnd; continue; }
// 'd' suffix encodes a dotted duration; the visible dot is
// a modifier that must be added explicitly (VexFlow only
// reads the base duration for spacing otherwise).
const isDotted = code.endsWith('d');
const baseCode = isDotted ? code.slice(0, -1) : code;
const rest = ev.pitch === null; const rest = ev.pitch === null;
const staveKey: 'treble' | 'bass' = rest const staveKey: 'treble' | 'bass' = rest
@@ -171,7 +184,7 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
let note: StaveNote; let note: StaveNote;
if (rest) { if (rest) {
note = new StaveNote({ keys: ['b/4'], duration: `${code}r`, clef: staveKey }); note = new StaveNote({ keys: ['b/4'], duration: `${baseCode}r`, clef: staveKey });
} else { } else {
const p = ev.pitch!; const p = ev.pitch!;
// WRITTEN pitch for the staff: sounding midi + display // WRITTEN pitch for the staff: sounding midi + display
@@ -192,9 +205,10 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
measureState[stateKey] = written.alter; measureState[stateKey] = written.alter;
note = new StaveNote({ note = new StaveNote({
keys: [keyString(written.step, written.alter, written.octave)], keys: [keyString(written.step, written.alter, written.octave)],
duration: code, duration: baseCode,
clef: staveKey, clef: staveKey,
}); });
if (isDotted) Dot.buildAndAttach([note]);
if (glyph) note.addModifier(new Accidental(glyph)); if (glyph) note.addModifier(new Accidental(glyph));
} }
@@ -226,6 +240,7 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
ticks, ticks,
isAttack, isAttack,
isEighth: code === '8' && !rest, isEighth: code === '8' && !rest,
isSixteenth: code === '16' && !rest,
staveKey, staveKey,
rest, rest,
}); });
@@ -262,6 +277,14 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
if (r === 0) treble.addTimeSignature(`${score.meter.numerator}/${score.meter.denominator}`); if (r === 0) treble.addTimeSignature(`${score.meter.numerator}/${score.meter.denominator}`);
treble.setMeasure(measure.number); treble.setMeasure(measure.number);
} }
// Chord symbol above the staff (bar-indexed), drawn via the
// stave's annotation area so it shifts with the clef offset.
const chordSym = score.chordSymbols?.[m];
if (chordSym) {
try {
treble.setSection(chordSym, 0);
} catch { /* courtesy label — never fatal */ }
}
let bass: Stave | null = null; let bass: Stave | null = null;
if (isGrand) { if (isGrand) {
bass = new Stave(x, y + 130, barWidth); bass = new Stave(x, y + 130, barWidth);
@@ -301,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!);
@@ -319,19 +366,6 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
line.setContext(context).draw(); line.setContext(context).draw();
} }
// --- Beams: consecutive eighth pairs within one beat ---
const eighths = frags.filter(f => f.isEighth && f.note instanceof StaveNote);
for (let i = 0; i + 1 < eighths.length; i++) {
const a = eighths[i], b2 = eighths[i + 1];
if (a.staveKey !== b2.staveKey) continue;
if (b2.start - a.start !== 240) 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);
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,29 +13,34 @@ 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, type PhraseSettings } 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, fretWindowNotes } 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 { isMode, MODE_LABELS, TONICS, type ModeId } from '../music/scales'; 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 { 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';
import { getNoteDetails } from '../music/NoteUtils'; 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;
@@ -68,18 +73,50 @@ 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 the shared practice
// filter (single source of truth across all modes).
const pf = getPracticeFilter(settings);
// Unified metronome: phrase tempo reads from the shared rhythm BPM
// (single source of truth across modes; the phrase-local BPM control
// was replaced by the common metronome widget in Controls).
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.
// - Sync (tempo): run clicks when armed && sound (count-in incl.);
// widget silent while idle.
// - Free metronome: widget clicks freely; the run stays silent to
// avoid doubling (sync off in tempo = free widget during runs).
phrase.clickSound = pace === 'tempo'
? settings.rhythm.active && settings.rhythm.sound
: settings.rhythm.active && settings.rhythm.sound && !settings.rhythm.syncToExercise;
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => { const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } })); updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
}, [updateSettings]); }, [updateSettings]);
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument]; const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const currentTuning = TUNINGS[settings.tuningId]; const currentTuning = TUNINGS[settings.tuningId];
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning; const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
// ---- Playable pool ---------------------------------------------------- // ---- Playable pool ----------------------------------------------------
const pool = useMemo(() => { const pool = useMemo(() => {
if (phrase.fretWindowEnabled && fretted && currentTuning) { // Unified filter: strings ∩ fret window, computed identically to
return fretWindowNotes(currentTuning, phrase.fretMin, phrase.fretMax); // the single-note modes (full 0–12 fretboard when no filter is
// active — the legacy difficulty ranges no longer apply).
if (fretted && currentTuning) {
const all = currentTuning.strings;
const selected = pf.strings.length > 0
? all.filter((_, i) => pf.strings.includes(i))
: all;
if (selected.length === 0) return [];
const lo = pf.fretWindowEnabled ? Math.max(0, Math.min(24, pf.fretMin)) : 0;
const hi = pf.fretWindowEnabled ? Math.max(lo, Math.min(24, pf.fretMax)) : 12;
return selected.flatMap(open => {
const notes: number[] = [];
for (let fret = lo; fret <= hi; fret++) notes.push(open + fret);
return notes;
}).sort((a, b) => a - b);
} }
return computePlayableNotes({ return computePlayableNotes({
instrumentId: settings.instrument, instrumentId: settings.instrument,
@@ -88,7 +125,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
customMinFret: settings.customMinFret, customMinFret: settings.customMinFret,
customMaxFret: settings.customMaxFret, customMaxFret: settings.customMaxFret,
}); });
}, [phrase.fretWindowEnabled, phrase.fretMin, phrase.fretMax, fretted, currentTuning, }, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning,
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) --------------------------------------
@@ -98,20 +135,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000)); const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000));
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const pendingAutoStart = useRef(false); const pendingAutoStart = useRef(false);
// BPM text field: editing stays free-form while typing; commits on
// blur/Enter (single-note widget behavior).
const [bpmText, setBpmText] = useState(String(phrase.bpm));
const [lastBpm, setLastBpm] = useState(phrase.bpm);
if (phrase.bpm !== lastBpm) {
// React's "adjust state when a prop changes" pattern (no effect).
setLastBpm(phrase.bpm);
setBpmText(String(phrase.bpm));
}
const commitBpm = useCallback(() => {
const v = parseInt(bpmText, 10);
if (Number.isNaN(v)) { setBpmText(String(phrase.bpm)); return; }
setPhrase({ bpm: Math.max(30, Math.min(180, v)) });
}, [bpmText, phrase.bpm, setPhrase]);
const handleImportFile = useCallback((file: File) => { const handleImportFile = useCallback((file: File) => {
setImportError(null); setImportError(null);
@@ -135,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,
@@ -146,11 +169,41 @@ 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,
}, 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') {
return generateArpeggio({
keyTonic: pf.keyTonic,
keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
degree: (phrase.arpeggioDegree as ArpeggioDegree) || 'I',
pattern: phrase.arpeggioPattern,
coverage: phrase.arpeggioCoverage,
bars: phrase.arpeggioBars,
progression: phrase.arpeggioProgression,
chordSelection: phrase.arpeggioChordSelection as ArpeggioDegree[],
customPattern: phrase.customArpeggioPattern,
seed: melodySeed,
}, pool); }, pool);
} }
if (phrase.material === 'library') { if (phrase.material === 'library') {
@@ -163,8 +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.libraryId, phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.scaleRhythm, phrase.libraryId,
phrase.arpeggioDegree, phrase.arpeggioPattern, phrase.arpeggioCoverage,
phrase.arpeggioBars, phrase.arpeggioProgression,
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(() => {
@@ -192,6 +249,7 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
clickSound: phrase.clickSound, clickSound: phrase.clickSound,
inputMode: phrase.inputMode, inputMode: phrase.inputMode,
previewVolume: settings.autoPlayVolume ?? 0.4, previewVolume: settings.autoPlayVolume ?? 0.4,
autoStartOnNote: phrase.autoStartOnNote,
}, },
listening, listening,
micError, micError,
@@ -250,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') { 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) {
@@ -260,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]);
@@ -267,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(),
@@ -276,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) {
@@ -307,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') {
@@ -326,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]);
@@ -379,102 +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">
<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' || 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' || 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>
@@ -483,30 +459,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
<select value={phrase.material} onChange={e => setPhrase({ material: e.target.value as PhraseSettings['material'] })}> <select value={phrase.material} onChange={e => setPhrase({ material: e.target.value as PhraseSettings['material'] })}>
<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="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') && (
<>
<label>Key
<select value={phrase.keyTonic} onChange={e => setPhrase({ keyTonic: e.target.value })}>
{Object.keys(TONICS).filter(t => !['C#', 'Gb', 'D#', 'G#', 'A#'].includes(t)).map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
</label>
<label>Mode
<select value={phrase.keyMode} onChange={e => setPhrase({ keyMode: e.target.value })}>
{(Object.keys(MODE_LABELS) as ModeId[]).map(m => (
<option key={m} value={m}>{MODE_LABELS[m]}</option>
))}
</select>
</label>
</>
)}
{phrase.material === 'melody' && ( {phrase.material === 'melody' && (
<> <>
<label>Bars <label>Bars
@@ -515,15 +474,17 @@ 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
<select value={phrase.rhythmLevel} onChange={e => setPhrase({ rhythmLevel: Number(e.target.value) === 2 ? 2 : 1 })}> <select value={phrase.rhythmLevel} onChange={e => setPhrase({ rhythmLevel: Math.max(1, Math.min(3, Number(e.target.value))) as 1 | 2 | 3 })}>
<option value={1}>Simple</option> <option value={1}>Simple</option>
<option value={2}>Mixed</option> <option value={2}>Mixed</option>
<option value={3}>Elaborate (16ths)</option>
</select> </select>
</label> </label>
</> </>
@@ -546,14 +507,128 @@ 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>
</> </>
)} )}
{phrase.material === 'arpeggio' && (
<>
<label>Pattern
<select value={phrase.arpeggioPattern} onChange={e => setPhrase({ arpeggioPattern: e.target.value as PhraseSettings['arpeggioPattern'] })}>
<option value="up">Up</option>
<option value="down">Down</option>
<option value="updown">Up &amp; down</option>
<option value="1235">1-2-3-5</option>
<optgroup label="Broken-chord figures">
<option value="1321">1-3-2-1</option>
<option value="1325">1-3-2-5(8)</option>
<option value="1535">1-5-3-5(8)</option>
<option value="12353">1-2-3-5-3</option>
<option value="1353">1-3-5-3</option>
<option value="121321">1-2-1-3-2-1</option>
<option value="15453">1-5-4-5-3-5</option>
<option value="132532">1-3-2-5-3-2</option>
<option value="random">Random</option>
<option value="custom">Custom…</option>
</optgroup>
</select>
</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
<select value={phrase.arpeggioCoverage} onChange={e => setPhrase({ arpeggioCoverage: e.target.value as PhraseSettings['arpeggioCoverage'] })}>
<option value="one-octave">One octave</option>
<option value="two-octave">Two octaves</option>
</select>
</label>
<label>Length
<select value={phrase.arpeggioBars} onChange={e => setPhrase({ arpeggioBars: Math.max(1, Math.min(8, Number(e.target.value))) })}>
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b === 1 ? 'Single chord' : `${b} bars`}</option>)}
</select>
</label>
{phrase.arpeggioBars > 1 && (
<label>Chords
<select value={phrase.arpeggioProgression} onChange={e => setPhrase({ arpeggioProgression: e.target.value as PhraseSettings['arpeggioProgression'] })}>
<option value="functional">Functional (I-IV-V...)</option>
<option value="diatonic-cycle">Diatonic cycle</option>
<option value="random">Random</option>
</select>
</label>
)}
<div style={{ gridColumn: '1 / -1' }}>
<span style={{ fontSize: '11px', opacity: 0.7, display: 'block', marginBottom: '4px' }}>
Chords ({phrase.arpeggioChordSelection.length === 0 ? 'all 7' : `${phrase.arpeggioChordSelection.length} selected`}):
</span>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
{(Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[]).map(d => {
const active = phrase.arpeggioChordSelection.length === 0 || phrase.arpeggioChordSelection.includes(d);
return (
<button
key={d}
className={`control-button small ${active ? 'active' : ''}`}
style={{ minWidth: '42px', padding: '5px 8px', fontSize: '12px' }}
onClick={() => {
const cur = phrase.arpeggioChordSelection.length === 0
? (Object.keys(ARPEGGIO_DEGREE_LABELS) as ArpeggioDegree[])
: [...phrase.arpeggioChordSelection as ArpeggioDegree[]];
const next = cur.includes(d) ? cur.filter(x => x !== d) : [...cur, d];
setPhrase({ arpeggioChordSelection: next });
}}
>
{ARPEGGIO_DEGREE_LABELS[d]}
</button>
);
})}
</div>
</div>
</>
)}
{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>
</label>
<label>Length
<select value={phrase.giulianiBars} onChange={e => setPhrase({ giulianiBars: Math.max(1, Math.min(8, Number(e.target.value))) })}>
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b} bars</option>)}
</select>
</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>
</>
)}
{phrase.material === 'library' && ( {phrase.material === 'library' && (
<label>Exercise <label>Exercise
<select value={phrase.libraryId || EXERCISES[0].id} onChange={e => setPhrase({ libraryId: e.target.value, startBar: 1 })}> <select value={phrase.libraryId || EXERCISES[0].id} onChange={e => setPhrase({ libraryId: e.target.value, startBar: 1 })}>
@@ -578,11 +653,13 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)} {[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)}
</select> </select>
</label> </label>
<label>Octave <label>Transpose
<select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}> <select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}>
<option value={-1}>-1</option> <option value={-2}>-2 oct</option>
<option value={-1}>-1 oct</option>
<option value={0}>0</option> <option value={0}>0</option>
<option value={1}>+1</option> <option value={1}>+1 oct</option>
<option value={2}>+2 oct</option>
</select> </select>
</label> </label>
</> </>
@@ -603,63 +680,9 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
</label> </label>
)} )}
{fretted && ( {/* Pace lives in the metronome widget now:
<> 'Sync to exercise' checked = tempo pace,
<label className="phrase-check"> unchecked = at-your-pace (step). */}
<input
type="checkbox"
checked={phrase.fretWindowEnabled}
onChange={e => setPhrase({ fretWindowEnabled: e.target.checked })}
/>
Fret window
</label>
{phrase.fretWindowEnabled && (
<>
<label>Min fret
<input type="number" min={0} max={24} value={phrase.fretMin}
onChange={e => setPhrase({ fretMin: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
</label>
<label>Max fret
<input type="number" min={0} max={24} value={phrase.fretMax}
onChange={e => setPhrase({ fretMax: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
</label>
</>
)}
</>
)}
<label>Pace
<select value={phrase.pace} onChange={e => setPhrase({ pace: e.target.value as PhraseSettings['pace'] })}>
<option value="step">At your pace</option>
<option value="tempo">With tempo</option>
</select>
</label>
{phrase.pace === 'tempo' && (
<label>BPM
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
<button className="control-button small" onClick={() => setPhrase({ bpm: Math.max(30, phrase.bpm - 1) })}>−</button>
<input
type="text"
inputMode="numeric"
value={bpmText}
onChange={e => { if (/^\d{0,3}$/.test(e.target.value)) setBpmText(e.target.value); }}
onBlur={commitBpm}
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
style={{ width: '46px', textAlign: 'center' }}
/>
<button className="control-button small" onClick={() => setPhrase({ bpm: Math.min(180, phrase.bpm + 1) })}>+</button>
</div>
<input
type="range"
min={30}
max={180}
step={1}
value={phrase.bpm}
onChange={e => setPhrase({ bpm: Number(e.target.value) })}
aria-label="Phrase tempo"
/>
</label>
)}
<label className="phrase-check"> <label className="phrase-check">
<input <input
type="checkbox" type="checkbox"
@@ -668,16 +691,6 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
/> />
Virtual input only Virtual input only
</label> </label>
{phrase.pace === 'tempo' && (
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.clickSound}
onChange={e => setPhrase({ clickSound: e.target.checked })}
/>
Click sound
</label>
)}
<label className="phrase-check"> <label className="phrase-check">
<input <input
type="checkbox" type="checkbox"
@@ -686,17 +699,178 @@ export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
/> />
Auto-continue Auto-continue
</label> </label>
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.autoStartOnNote}
onChange={e => setPhrase({ autoStartOnNote: e.target.checked })}
/>
Start on first note (no count-in)
</label>
</div> </div>
{phrase.pace === 'tempo' && phrase.clickSound && ( {phrase.pace === 'tempo' && (
<p className="phrase-note">Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.</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">
@@ -727,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

@@ -0,0 +1,264 @@
/**
* Unified target-note controls, shared by all game modes (v2 controls).
*
* - Key filter: restrict target notes to a tonic+mode (independent of the
* displayed key signature on the staff — optional sync checkbox).
* - Strings: pick which strings contribute target notes (fretted instruments).
* - Fret window: restrict target notes to a fret range (fretted instruments).
*
* In Phrase Mode the Key section binds to the generator key (tonic+mode are
* 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 { Key, Guitar, Music, SlidersHorizontal } from 'lucide-react';
import { useSettings } from '../context/useSettings';
import { getPracticeFilter, type PracticeFilter } from '../types/SettingsTypes';
import { MODE_LABELS, TONICS, isMode, keyFor, type ModeId } from '../music/scales';
import { isFrettedInstrument } from '../music/playableRange';
import { TUNINGS } from '../music/Tunings';
/** Conventional string names, low→high, for guitar/bass tunings. */
const GUITAR_STRING_NAMES = ['E6', 'A5', 'D4', 'G3', 'B2', 'E1'];
const BASS_STRING_NAMES = ['E4', 'A3', 'D2', 'G1'];
function stringNames(tuningId: string, count: number): string[] {
const names = tuningId.startsWith('bass') ? BASS_STRING_NAMES : GUITAR_STRING_NAMES;
// Align from the low end (index 0 = lowest)
if (count === names.length) return names;
return Array.from({ length: count }, (_, i) => {
const midi = TUNINGS[tuningId]?.strings[i] ?? 0;
const pc = midi % 12;
const LETTERS = ['C', 'C#', 'D', 'Eb', 'E', 'F', 'F#', 'G', 'Ab', 'A', 'Bb', 'B'];
return LETTERS[pc] ?? `S${i + 1}`;
});
}
/** Display key signature names that correspond to a tonic+mode pair. */
const MAJOR_TONIC_TO_SIGNATURE: Record<string, string> = {
'C': 'C', 'G': 'G', 'D': 'D', 'A': 'A', 'E': 'E', 'B': 'C#', 'F#': 'C#',
'Db': 'Db', 'Ab': 'Ab', 'Eb': 'Eb', 'Bb': 'Bb', 'F': 'F',
};
const MINOR_TONIC_TO_SIGNATURE: Record<string, string> = {
'C': 'Eb', 'G': 'Bb', 'D': 'F', 'A': 'C', 'E': 'G', 'B': 'D', 'F#': 'A',
'Db': 'Eb', 'Ab': 'Bb', 'Eb': 'Gb', 'Bb': 'Db', 'F': 'Dm',
};
export const TargetNoteControls: React.FC = () => {
const { settings, updateSettings } = useSettings();
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>) => {
updateSettings(s => {
const next: typeof s = { ...s, practice: { ...getPracticeFilter(s), ...updates } };
// Optional coupling: display key signature follows the target key.
if (updates.keyTonic !== undefined || updates.keyMode !== undefined) {
const tonic = updates.keyTonic ?? pf.keyTonic;
const mode = updates.keyMode ?? pf.keyMode;
if (s.keyFollowsTarget) {
const table = mode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
const sig = table[tonic];
if (sig) next.keySignature = sig;
}
}
return next;
});
};
const fretted = useMemo(
() => isFrettedInstrument(settings.instrument) && !!TUNINGS[settings.tuningId],
[settings.instrument, settings.tuningId],
);
const stringCount = TUNINGS[settings.tuningId]?.strings.length ?? 0;
const names = stringNames(settings.tuningId, stringCount);
const toggleString = (index: number) => {
const cur = pf.strings.length > 0 ? [...pf.strings] : Array.from({ length: stringCount }, (_, i) => i);
const next = cur.includes(index) ? cur.filter(i => i !== index) : [...cur, index].sort((a, b) => a - b);
// Deselecting all = back to "all strings" (empty array)
setFilter({ strings: next.length === stringCount ? [] : next });
};
const allSelected = pf.strings.length === 0;
const anyActive =
(!inPhraseMode && pf.keyEnabled) || pf.fretWindowEnabled || pf.strings.length > 0;
return (
<div className="practice-filter-grid" style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px', gridColumn: '1 / -1' }}>
{/* 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 */}
<div className="control-group">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label">
<Key size={18} />
<span>Key</span>
</label>
{!inPhraseMode && (
<button
className={`switch-button ${pf.keyEnabled ? 'active' : ''}`}
onClick={() => setFilter({ keyEnabled: !pf.keyEnabled })}
title={pf.keyEnabled ? 'Limit to key: on' : 'Limit to key: off'}
>
<div className="switch-thumb" />
</button>
)}
</div>
{(pf.keyEnabled || inPhraseMode) && (
<>
<div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
<select
className="control-select"
value={pf.keyTonic}
onChange={e => setFilter({ keyTonic: e.target.value })}
aria-label="Target key tonic"
>
{Object.keys(TONICS).filter(t => !['C#', 'Gb', 'D#', 'G#', 'A#'].includes(t)).map(t => (
<option key={t} value={t}>{t}</option>
))}
</select>
<select
className="control-select"
value={pf.keyMode}
onChange={e => setFilter({ keyMode: isMode(e.target.value) ? e.target.value : 'major' })}
aria-label="Target key mode"
>
{(Object.keys(MODE_LABELS) as ModeId[]).map(m => (
<option key={m} value={m}>{MODE_LABELS[m]}</option>
))}
</select>
</div>
{!inPhraseMode && (
<label style={{ fontSize: '11px', display: 'flex', alignItems: 'center', gap: '6px', marginTop: '6px', opacity: 0.85 }}>
<input
type="checkbox"
checked={settings.keyFollowsTarget ?? false}
onChange={e => updateSettings(s => {
const next = { ...s, keyFollowsTarget: e.target.checked };
// When enabling sync, snap the display signature now.
if (e.target.checked) {
const table = pf.keyMode === 'minor' ? MINOR_TONIC_TO_SIGNATURE : MAJOR_TONIC_TO_SIGNATURE;
const sig = table[pf.keyTonic];
if (sig) next.keySignature = sig;
}
return next;
})}
/>
Sync display key signature with target key
</label>
)}
</>
)}
</div>
{/* Strings (fretted instruments) */}
{fretted && (
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label">
<Guitar size={18} />
<span>Strings</span>
</label>
<button
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>
{!allSelected && <div style={{ display: 'flex', gap: '6px', marginTop: '8px', flexWrap: 'wrap' }}>
{Array.from({ length: stringCount }, (_, i) => {
const active = allSelected || pf.strings.includes(i);
return (
<button
key={i}
className={`control-button small ${active ? 'active' : ''}`}
onClick={() => toggleString(i)}
title={`String ${i + 1} (${names[i]})`}
style={{ flex: '1 1 0', minWidth: '40px', padding: '6px 4px', fontSize: '11px' }}
>
{names[i]}
</button>
);
})}
</div>}
{pf.strings.length > 0 && (
<button
className="link-button"
style={{ fontSize: '11px', marginTop: '6px', padding: 0 }}
onClick={() => setFilter({ strings: [] })}
>
Reset to all strings
</button>
)}
</div>
)}
{/* Fret window (fretted instruments) */}
{fretted && (
<div className="control-group" style={{ gridColumn: '1 / -1' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label className="control-label">
<Music size={18} />
<span>Fret window</span>
</label>
<button
className={`switch-button ${pf.fretWindowEnabled ? 'active' : ''}`}
onClick={() => setFilter({ fretWindowEnabled: !pf.fretWindowEnabled })}
title={pf.fretWindowEnabled ? 'Fret window: on' : 'Fret window: off'}
>
<div className="switch-thumb" />
</button>
</div>
{pf.fretWindowEnabled && (
<div style={{ display: 'flex', gap: '8px', marginTop: '8px', alignItems: 'center' }}>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', opacity: 0.7 }}>Min</span>
<input
type="number" min={0} max={24}
className="control-input"
style={{ width: '100%', padding: '4px' }}
value={pf.fretMin}
onChange={e => setFilter({ fretMin: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })}
/>
</div>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{ fontSize: '12px', opacity: 0.7 }}>Max</span>
<input
type="number" min={0} max={24}
className="control-input"
style={{ width: '100%', padding: '4px' }}
value={pf.fretMax}
onChange={e => setFilter({ fretMax: Math.max(pf.fretMin, Math.min(24, parseInt(e.target.value) || 0)) })}
/>
</div>
</div>
)}
</div>
)}
</div>
);
};
// Re-export keyFor for potential external use (avoids unused-import lint)
void keyFor;

View File

@@ -16,6 +16,7 @@ export interface ScheduleEntry {
readonly at: number; readonly at: number;
/** Beat number within the count-in (count-in-beat only). */ /** Beat number within the count-in (count-in-beat only). */
readonly beat?: number; readonly beat?: number;
readonly accent?: boolean;
/** Event index (note-end only). */ /** Event index (note-end only). */
readonly index?: number; readonly index?: number;
} }
@@ -41,7 +42,7 @@ export function buildSchedule(score: Score, bpm: number, startAt: number, countI
const entries: ScheduleEntry[] = []; const entries: ScheduleEntry[] = [];
for (let i = 0; i < countInBeats; i++) { for (let i = 0; i < countInBeats; i++) {
entries.push({ kind: 'count-in-beat', at: startAt + i * spb, beat: i }); entries.push({ kind: 'count-in-beat', at: startAt + i * spb, beat: i, accent: i === 0 });
} }
const scoreStart = startAt + countInBeats * spb; const scoreStart = startAt + countInBeats * spb;
@@ -53,7 +54,7 @@ export function buildSchedule(score: Score, bpm: number, startAt: number, countI
const barTicks = score.meter.numerator * PPQ; const barTicks = score.meter.numerator * PPQ;
for (let i = 0; i < beatCount; i++) { for (let i = 0; i < beatCount; i++) {
const tick = i * PPQ; const tick = i * PPQ;
entries.push({ kind: 'beat', at: scoreStart + (tick / PPQ) * spb, beat: (tick % barTicks === 0) ? i : undefined, index: i }); entries.push({ kind: 'beat', at: scoreStart + (tick / PPQ) * spb, beat: i, accent: tick % barTicks === 0, index: i });
} }
scoreEvents(score).forEach((e, i) => { scoreEvents(score).forEach((e, i) => {

View File

@@ -0,0 +1,21 @@
/**
* Beat clock shared between the phrase trainer's scheduler and the global
* metronome widget. The phrase run emits every beat (count-in + score beats)
* here; the metronome widget mirrors them on its pendulum so the visual
* stays in sync with the audible phrase click. Beats carry an absolute
* AudioContext timestamp so the widget can schedule the visual swing at the
* right moment even when callbacks fire ahead of time.
*/
type BeatListener = (beat: number, at: number) => void;
const listeners = new Set<BeatListener>();
export const phraseBeatBus = {
emit(beat: number, at: number): void {
listeners.forEach(l => l(beat, at));
},
subscribe(l: BeatListener): () => void {
listeners.add(l);
return () => { listeners.delete(l); };
},
};

26
src/hooks/phraseRunBus.ts Normal file
View File

@@ -0,0 +1,26 @@
/**
* Tiny pub/sub for the phrase trainer's running state.
*
* Lets the (global) metronome widget gate its pendulum/click on the phrase
* run: in Phrase Mode the metronome should only be active while the trainer
* is counting in or playing, not while idle.
*/
type Listener = (running: boolean) => void;
const listeners = new Set<Listener>();
let running = false;
export const phraseRunBus = {
set(v: boolean): void {
if (v === running) return;
running = v;
listeners.forEach(l => l(v));
},
get(): boolean {
return running;
},
subscribe(l: Listener): () => void {
listeners.add(l);
return () => { listeners.delete(l); };
},
};

View File

@@ -8,12 +8,8 @@ import {
getRandomNote, getRandomNote,
getNoteDetails getNoteDetails
} from '../music/NoteUtils'; } from '../music/NoteUtils';
import { import { computeTargetPool } from '../music/targetPool';
TUNINGS, import { getPracticeFilter } from '../types/SettingsTypes';
getOpenStringNotes,
getFirstPositionNotes,
} from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { import {
NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_THRESHOLD_MS,
NOTE_MATCH_GRACE_MS, NOTE_MATCH_GRACE_MS,
@@ -36,65 +32,24 @@ export const useGameLogic = (
const [tracker] = useState(() => new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS)); const [tracker] = useState(() => new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS));
// --- Valid Notes Calculation --- // --- Valid Notes Calculation ---
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const validNotes = useMemo(() => { const validNotes = useMemo(() => {
const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty); const pf = getPracticeFilter(settings);
return computeTargetPool({
const getNotesFromConfig = (config: typeof rangeConfig) => { instrumentId: settings.instrument,
if (!config) return []; difficulty: settings.difficulty,
tuningId: settings.tuningId,
if (config.type === 'open_strings') { customMinFret: settings.customMinFret,
if (currentTuning) return getOpenStringNotes(currentTuning); customMaxFret: settings.customMaxFret,
return config.notes || []; keyEnabled: pf.keyEnabled,
} keyTonic: pf.keyTonic,
keyMode: pf.keyMode,
if (config.type === 'first_position') { strings: pf.strings,
if (currentTuning) return getFirstPositionNotes(currentTuning); fretWindowEnabled: pf.fretWindowEnabled,
return config.notes || []; fretMin: pf.fretMin,
} fretMax: pf.fretMax,
if (config.type === 'custom_fret') {
if (currentTuning) {
const minFret = settings.customMinFret ?? config.defaultMinFret ?? 0;
const maxFret = settings.customMaxFret ?? config.defaultMaxFret ?? 12;
const notes = new Set<number>();
currentTuning.strings.forEach(stringMidi => {
for (let fret = minFret; fret <= maxFret; fret++) {
notes.add(stringMidi + fret);
}
}); });
return Array.from(notes).sort((a, b) => a - b); }, [settings]);
}
return [];
}
if (config.type === 'specific_string') {
if (currentTuning && config.stringIndex !== undefined) {
const openNote = currentTuning.strings[config.stringIndex];
if (openNote === undefined) return [];
const notes = [];
for (let i = 0; i <= 12; i++) {
notes.push(openNote + i);
}
return notes;
}
return [];
}
if (config.notes) return config.notes;
if (config.min !== undefined && config.max !== undefined) {
return Array.from({ length: config.max - config.min + 1 }, (_, i) => config.min! + i);
}
return [];
};
if (rangeConfig) return getNotesFromConfig(rangeConfig);
const fallbackRange = currentInstrumentDef.ranges[0];
return getNotesFromConfig(fallbackRange);
}, [settings.difficulty, currentInstrumentDef, currentTuning, settings.customMinFret, settings.customMaxFret]);
// --- Note Generation --- // --- Note Generation ---
const generateNewNote = useCallback((keepFeedback = false) => { const generateNewNote = useCallback((keepFeedback = false) => {

View File

@@ -18,8 +18,10 @@ import { buildSchedule, sanitizeBpm, type ScheduleEntry } from '../game/PhraseTr
import { PPQ } from '../score/model'; import { PPQ } from '../score/model';
import { audioEngine } from '../audio/AudioEngine'; import { audioEngine } from '../audio/AudioEngine';
import { subscribeRawFrames } from './rawFrameBus'; import { subscribeRawFrames } from './rawFrameBus';
import { phraseRunBus } from './phraseRunBus';
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;
@@ -32,6 +34,9 @@ export interface PhraseTrainerConfig {
readonly clickSound: boolean; readonly clickSound: boolean;
readonly inputMode: 'mic' | 'virtual'; readonly inputMode: 'mic' | 'virtual';
readonly previewVolume?: number; readonly previewVolume?: number;
/** Auto-start: begin without count-in as soon as the mic detects the
* first note (step pace only — tempo needs the scheduled clock). */
readonly autoStartOnNote?: boolean;
} }
export interface PhraseSummary { export interface PhraseSummary {
@@ -51,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[];
@@ -71,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;
@@ -90,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) ---------------------------
@@ -97,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);
@@ -112,12 +125,22 @@ 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]);
const setPhaseBoth = useCallback((p: PhrasePhase) => { const setPhaseBoth = useCallback((p: PhrasePhase) => {
phaseRef.current = p; phaseRef.current = p;
setPhase(p); setPhase(p);
// Gate the shared metronome only in 'sync to metronome' (tempo) pace:
// there the run owns the clock (clicks during count-in/playing only).
// In 'at your pace' (step) the metronome ticks continuously when the
// user has it switched on — like the single-note modes.
const ownsClock = paceRef.current === 'tempo';
phraseRunBus.set(ownsClock && (p === 'countIn' || p === 'playing' || p === 'preview'));
}, []); }, []);
useEffect(() => { paceRef.current = config.pace; }, [config.pace]); useEffect(() => { paceRef.current = config.pace; }, [config.pace]);
@@ -146,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);
@@ -197,7 +223,12 @@ export function usePhraseTrainer(
countInBeatsRef.current > 0 && countInBeatsRef.current > 0 &&
e.at <= scoreStartRef.current + 0.02; e.at <= scoreStartRef.current + 0.02;
if ((e.kind === 'count-in-beat' || e.kind === 'beat') && !isRedundantDownbeat && configRef.current.clickSound) { if ((e.kind === 'count-in-beat' || e.kind === 'beat') && !isRedundantDownbeat && configRef.current.clickSound) {
audioEngine.playClickAt(e.at, CLICK_VOLUME, e.kind === 'count-in-beat' ? e.beat === 0 : e.beat !== undefined); audioEngine.playClickAt(e.at, CLICK_VOLUME, e.kind === 'count-in-beat' ? e.beat === 0 : e.accent === true);
}
// Publish every beat to the shared bus so the metronome widget's
// pendulum stays in sync with the run (click or no click).
if (e.kind === 'count-in-beat' || e.kind === 'beat') {
phraseBeatBus.emit(e.beat ?? 0, e.at);
} }
clickIdxRef.current++; clickIdxRef.current++;
} }
@@ -264,7 +295,18 @@ export function usePhraseTrainer(
const unsubscribe = subscribeRawFrames(frame => { const unsubscribe = subscribeRawFrames(frame => {
if (configRef.current.inputMode !== 'mic') return; if (configRef.current.inputMode !== 'mic') return;
const p = phaseRef.current; const p = phaseRef.current;
if (p !== 'countIn' && p !== 'playing') return; // Auto-start: first detected note begins the run immediately
// (no count-in) — only in step pace, only from 'ready', mic only.
if (
p === 'ready' &&
configRef.current.autoStartOnNote &&
paceRef.current === 'step'
) {
startRef.current?.();
// Fall through: this frame feeds the fresh run below.
}
const p2 = phaseRef.current;
if (p2 !== 'countIn' && p2 !== 'playing') return;
// Pitched speaker output (preview, virtual instruments) must not // Pitched speaker output (preview, virtual instruments) must not
// be scored; while audible the matcher sees no frames at all. // be scored; while audible the matcher sees no frames at all.
if (audioEngine.isAudible()) return; if (audioEngine.isAudible()) return;
@@ -361,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);
@@ -435,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(() => {
@@ -458,35 +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
// 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) {
const beats = Math.floor((totalTicks - startTick) / PPQ);
for (let i = 0; i < beats; i++) {
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;
@@ -510,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++;
@@ -538,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);
}, []); }, []);
@@ -564,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,
@@ -573,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

@@ -0,0 +1,241 @@
import { describe, it, expect } from 'vitest';
import { generateArpeggio } from './arpeggioGenerator';
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,
meter: { numerator: 4 as const, denominator: 4 as const },
};
describe('generateArpeggio', () => {
it('builds a C major arpeggio (up, one octave) from the pool', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value);
// C-E-G-C as eighths (always 240 ticks now) plus a rest fill in the
// auto-derived bar.
const midis = ev.filter(e => e.pitch).map(e => e.pitch!.midi);
expect(midis).toEqual([48, 52, 55, 60]);
expect(validateScore(r.value)).toEqual([]);
expect(r.value.title).toContain('Arpeggio');
expect(r.value.title).toContain('C I');
});
it('down pattern reverses the ascending cycle', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'down', 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([60, 55, 52, 48]);
});
it('updown returns to the starting root without duplicating the top', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'updown', 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);
// 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.filter(m => m === 60)).toHaveLength(1);
});
it('1235 pattern ends on the octave of the root', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: '1235', 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, 52, 55, 60]);
});
it('two-octave coverage spans 12 semitones beyond the first cycle', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'two-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, 52, 55, 60, 64, 67, 72]);
});
it('spells the dominant triad G-B-D in C major', () => {
const r = generateArpeggio({ ...base, degree: 'V', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
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
});
it('spells the leading-tone dim triad B-D-F in C major', () => {
const r = generateArpeggio({ ...base, degree: 'vii0', pattern: 'up', coverage: 'one-octave' }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
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
});
it('fails cleanly when the pool cannot fit a full triad cycle', () => {
// Only frets 0-1 on all strings: no complete C-E-G cycle with octave
const tiny: number[] = [];
for (const open of strings) for (let f = 0; f <= 1; f++) tiny.push(open + f);
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave' }, tiny);
expect(r.ok).toBe(false);
});
it('is deterministic for identical configs', () => {
const a = generateArpeggio({ ...base, degree: 'IV', pattern: 'updown', coverage: 'one-octave' }, pool);
const b = generateArpeggio({ ...base, degree: 'IV', pattern: 'updown', coverage: 'one-octave' }, pool);
expect(a).toEqual(b);
});
});
describe('generateArpeggio — sequence mode', () => {
it('generates one chord per bar with functional progression', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 4, progression: 'functional', seed: 7 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.value.measures.length).toBe(4);
expect(r.value.chordSymbols?.length).toBe(4);
expect(r.value.chordSymbols?.[0]).toBe('C'); // starts on I
// bar boundaries: each bar starts on a chord root? (not enforced) — but ticks align
const ev = scoreEvents(r.value);
expect(validateScore(r.value)).toEqual([]);
// Auto meter from a 4-note path (960 ticks): 3/4 wins (remainder 480
// vs 960 in 4/4) → 6 eighths per bar × 4 bars = 24
expect(ev.length).toBe(24);
// deterministic
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);
});
it('diatonic cycle walks I ii iii IV V vi vii', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 7, progression: 'diatonic-cycle', seed: 1 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const syms = r.value.chordSymbols!;
expect(syms).toEqual(['C', 'Dm', 'Em', 'F', 'G', 'Am', 'Bdim']);
});
it('eighths fill each bar (always-eighths rhythm)', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 2, progression: 'functional', seed: 3 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const ev = scoreEvents(r.value);
// 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(validateScore(r.value)).toEqual([]);
});
it('random avoids repeating the same chord back-to-back', () => {
const r = generateArpeggio({ ...base, degree: 'I', pattern: 'up', coverage: 'one-octave', bars: 6, progression: 'random', seed: 11 }, pool);
expect(r.ok).toBe(true);
if (!r.ok) return;
const syms = r.value.chordSymbols!;
for (let i = 1; i < syms.length; i++) {
expect(syms[i]).not.toBe(syms[i - 1]);
}
});
});
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

@@ -0,0 +1,535 @@
/**
* Arpeggio generator (Phrase Mode material).
*
* Diatonic triads (I, ii, iii, IV, V, vi, vii°) built from the selected
* key/mode; each practice run arpeggiates ONE chord — the user picks the
* degree, the pattern (up / down / up-down / 1-2-3-5) and the coverage
* (one or two octaves). Chord tones are spelled via the key's degree
* spelling (same machinery as melody/scale generators), so spelling and
* 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 { keyFor, type KeyContext, type ModeId } from './scales';
import { STEP_LETTERS, LETTER_PC, type StepLetter } from '../score/model';
import { makeRng } from './melodyGenerator';
export type ArpeggioDegree = 'I' | 'ii' | 'iii' | 'IV' | 'V' | 'vi' | 'vii0';
export type ArpeggioPattern =
| 'up' | 'down' | 'updown' | '1235'
// Giuliani-style right-hand figures over one chord (indices into the
// chord cycle per note; 1=root 2=third 3=fifth 4=octave):
| '1321' // root-fifth-third-root
| '1325' // root-fifth-third-octave
| '1535' // root-octave-fifth-octave
| '12353' // root-third-fifth-octave-fifth
| '121321' // rolling thirds (root-third-root-fifth-third-root)
| '1353' // root-third-fifth-third
| '15453' // root-octave-fifth-octave-fifth-octave
| '132532' // long broken-chord wave
| 'custom' // user-defined pattern from customPattern digits
| 'random'; // one broken-chord figure, chosen randomly per exercise (seed)
export type ArpeggioCoverage = 'one-octave' | 'two-octave';
/** How the chord per bar is chosen in sequence mode. */
export type ArpeggioProgression = 'random' | 'functional' | 'diatonic-cycle';
export interface ArpeggioConfig {
readonly keyTonic: string;
readonly keyMode: ModeId;
readonly degree: ArpeggioDegree;
readonly pattern: ArpeggioPattern;
readonly coverage: ArpeggioCoverage;
/** Sequence mode: bars > 1 with chord progression selection. */
readonly bars?: number;
readonly progression?: ArpeggioProgression;
/** Restrict progression chords to these degrees (empty = all 7).
* The first bar always uses `degree` if it is in the selection. */
readonly chordSelection?: readonly ArpeggioDegree[];
/** Seed for progression selection (deterministic). */
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. */
const DEGREE_INDEX: Record<ArpeggioDegree, number> = {
I: 0, ii: 1, iii: 2, IV: 3, V: 4, vi: 5, 'vii0': 6,
};
export const ARPEGGIO_DEGREE_LABELS: Record<ArpeggioDegree, string> = {
I: 'I', ii: 'ii', iii: 'iii', IV: 'IV', V: 'V', vi: 'vi', 'vii0': 'vii°',
};
export const PATTERN_LABELS: Record<ArpeggioPattern, string> = {
up: 'Up', down: 'Down', updown: 'Up & down', '1235': '1-2-3-5',
'1321': '1-3-2-1',
'1325': '1-3-2-5(8)',
'1535': '1-5-3-5(8)',
'12353': '1-2-3-5-3',
'121321': '1-2-1-3-2-1',
'1353': '1-3-5-3',
'15453': '1-5-4-5-3-5',
'132532': '1-3-2-5-3-2',
'custom': 'Custom…',
'random': 'Random',
};
/**
* Broken-chord figures — NOTE sequences (not fingerings): the bass takes the
* root, the remaining notes ride the upper chord tones.
* Indices into the octave cycle [0=root, 1=third, 2=fifth, 3=octave].
*/
const BROKEN_FIGURES: Record<string, number[]> = {
'1321': [0, 2, 1, 0],
'1325': [0, 2, 1, 3],
'1535': [0, 3, 2, 3],
'12353': [0, 1, 2, 3, 2],
'121321': [0, 1, 0, 2, 1, 0],
'1353': [0, 1, 2, 1],
'15453': [0, 3, 2, 3, 2, 3],
'132532': [0, 2, 1, 3, 2, 1],
};
/**
* Renderable note durations — subset of VexFlow duration codes the
* PhraseSheetMusic renderer accepts. ALL generated durations MUST be in this
* set, otherwise the renderer produces ghost slots and crashes or misaligns.
*/
const RENDERABLE_NOTE_TICKS = new Set([120, 240, 360, 480, 720, 960, 1440, 1920]);
/** Descending renderable durations for rest fills (largest first). */
const REST_FILL_TICKS = [1920, 1440, 960, 720, 480, 240];
/** pc of a spelled degree (step letter + alter). */
const degreePc = (d: { step: StepLetter; alter: number }): number =>
(LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 144) % 12;
/** Chord symbol name for a diatonic degree, e.g. "C", "Am", "Bdim". */
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;
}
/**
* Chord-tone midi notes of the diatonic triad on `degreeIndex` (0-based),
* intersected with the playable pool, ascending.
*/
function chordTonesInPool(key: KeyContext, degreeIndex: number, pool: number[]): number[] {
const steps = [0, 2, 4].map(offset => (degreeIndex + offset) % 7);
const pcs = new Set(steps.map(i => degreePc(key.degrees[i])));
return pool.filter(midi => pcs.has(((midi % 12) + 12) % 12));
}
/** Spelling (step, alter) for a midi note in the key — via scale degrees. */
function spellInKey(key: KeyContext, midi: number): { step: StepLetter; alter: number; octave: number } | null {
const pc = ((midi % 12) + 12) % 12;
const d = key.degrees.find(deg => degreePc(deg) === pc);
if (!d) return null;
const octave = (midi - pc) / 12 - 1;
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
* root (degree tone) that allows a full one/two-octave cycle in the pool.
* Pattern up/down/updown/1235 is applied to one octave cycle.
*/
function buildPath(key: KeyContext, degreeIndex: number, pool: number[], coverage: ArpeggioCoverage, pattern: ArpeggioPattern): number[] | null {
const tones = chordTonesInPool(key, degreeIndex, pool);
if (tones.length < 3) return null; // need at least one full triad
const rootPc = degreePc(key.degrees[degreeIndex]);
const octaveCycle = (startMidi: number): number[] => {
// chord tones >= startMidi within one octave, INCLUDING the octave
// of the root (an arpeggio cycle conventionally ends on the octave)
const cycle: number[] = [];
for (const midi of tones) {
if (midi >= startMidi && midi <= startMidi + 12) cycle.push(midi);
}
if (!cycle.includes(startMidi + 12) && pool.includes(startMidi + 12)) {
cycle.push(startMidi + 12);
}
return cycle.sort((a, b) => a - b);
};
const cycles: number[][] = [];
// candidate roots: chord-tone midis with pc == rootPc, ascending
const roots = tones.filter(m => ((m % 12) + 12) % 12 === rootPc);
const octavesWanted = coverage === 'one-octave' ? 1 : 2;
for (const root of roots) {
const first = octaveCycle(root);
if (first.length < 3 || first[0] !== root) continue;
// second octave: tones one octave above `root`
const second = first.map(m => m + 12).filter(m => pool.includes(m));
if (octavesWanted === 2 && second.length < 4) continue;
cycles.push(first);
break; // lowest fitting root
}
if (cycles.length === 0) return null;
const base = cycles[0];
const up = octavesWanted === 2
// base already ends on the octave of the root — the second octave
// continues from there (drop the duplicated turning root)
? [...base, ...base.map(m => m + 12).slice(1)]
: base;
switch (pattern) {
case 'up': return up;
case 'down': return [...up].reverse();
case 'updown': {
// ascend then descend, dropping the duplicated turning note
const desc = [...up].reverse().slice(1);
return [...up, ...desc];
}
case '1235': {
// root, third, fifth, octave (of the root) — repeated per octave
const path: number[] = [];
for (let oct = 0; oct < octavesWanted; oct++) {
path.push(base[0] + 12 * oct);
path.push(base[1] + 12 * oct);
path.push(base[2] + 12 * oct);
path.push(base[0] + 12 * (oct + 1));
}
return path;
}
default: {
// Broken-chord figure: cycle [root, third, fifth, octave] with the
// figure selecting cycle positions per note; the octave wraps so
// e.g. 1321 over C-E-G becomes C G E C' ... (ascending octave
// register shift every figure repetition).
const figure = BROKEN_FIGURES[pattern];
// 4-note cycle per octave: root, third, fifth, octave
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);
for (const idx of figure) {
const note = c[idx];
if (note !== undefined && (oct === 0 || pool.includes(note))) path.push(note);
}
}
return path;
}
}
}
/**
* 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(
config: ArpeggioConfig,
eligiblePitches: number[],
): Result<Score> {
const key = keyFor(config.keyTonic, config.keyMode);
const pool = Array.from(new Set(eligiblePitches.filter(m => Number.isInteger(m)))).sort((a, b) => a - b);
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 pattern = resolveRandomPattern(config.pattern, config.seed ?? 0);
const coverage = config.coverage;
// ---- Chord sequence ----------------------------------------------------
const degreeIndices: number[] = [];
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];
if (bars === 1) {
degreeIndices.push(DEGREE_INDEX[config.degree]);
} else {
const rng = makeRng(config.seed ?? 1);
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
for (let b = 0; b < bars; b++) {
if (b === 0) {
degreeIndices.push(pool7.includes(DEGREE_INDEX[config.degree]) && rng() < 0.34
? DEGREE_INDEX[config.degree]
: pick(pool7));
continue;
}
const prev = degreeIndices[b - 1];
if (config.progression === 'diatonic-cycle') {
let next = (prev + 1) % 7;
for (let tries = 0; tries < 7 && !pool7.includes(next); tries++) next = (next + 1) % 7;
degreeIndices.push(next);
} else if (config.progression === 'functional') {
const NEXT: Record<number, number[]> = {
0: [3, 4, 5],
1: [4],
2: [3, 5],
3: [0, 4],
4: [0, 5],
5: [1, 3],
6: [0],
};
const targets = (NEXT[prev] ?? [0]).filter(d => pool7.includes(d));
degreeIndices.push(pick(targets.length > 0 ? targets : pool7.filter(d => d !== prev)));
} else {
const candidates = pool7.filter(d => d !== prev);
degreeIndices.push(pick(candidates.length > 0 ? candidates : pool7));
}
}
}
// ---- Custom pattern digits (only for pattern === 'custom') --------------
const customDigits = pattern === 'custom' ? parseCustomPattern(config.customPattern ?? '') : null;
if (pattern === 'custom' && !customDigits) {
return { ok: false, error: 'Custom pattern is empty or invalid — enter digits like "1321" (1=root, 2=third, 3=fifth, 4=octave).' };
}
// ---- Build the path for the first chord ----------------------------------
// 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 chordSymbols: (string | null)[] = [];
if (bars === 1) {
// Single-chord mode: the path plays once; any remaining ticks in the
// final bar(s) become rests (renderable durations only). The path is
// NEVER stretched — durations stay at 240 ticks.
const barCount = Math.max(1, Math.ceil((firstPath.length * noteDur) / barLength));
chordSymbols.push(chordNameFor(key, degreeIndices[0]));
let cursor = 0;
for (let slot = 0; slot < firstPath.length; slot++) {
pushNote(events, key, firstPath[slot], cursor, `a0-${slot}`);
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 patternLabel = PATTERN_LABELS[pattern];
const totalMeasures = Math.max(1, Math.ceil(events.reduce((s, e) => Math.max(s, e.startTick + e.durationTicks), 0) / barLength));
const title = bars === 1
? `Arpeggio — ${chordName} (${patternLabel}, ${coverage})`
: `Arpeggio — ${key.tonic}: ${bars} bars (${config.progression ?? 'functional'})`;
const score: Score = {
version: 1,
id: `arpeggio-${config.keyTonic}-${config.keyMode}-${config.degree}-${config.pattern}-${config.coverage}-${bars}b-${config.progression ?? 'single'}-${config.seed ?? 0}`,
title,
meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: totalMeasures }, (_, i) => ({
number: i + 1,
startTick: i * barLength,
durationTicks: barLength,
})),
chordSymbols: bars === 1
// repeat the single chord symbol across the overflow bars
? Array.from({ length: totalMeasures }, () => chordSymbols[0])
: chordSymbols,
voices: [{ id: 'melody', events }],
};
const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal arpeggio error: ${problems[0]}` };
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

@@ -0,0 +1,29 @@
import { describe, it, expect } from 'vitest';
import { generateMelody } from './melodyGenerator';
import { scoreEvents, validateScore } from '../score/model';
// Reproduce user report: "bei 4/4 manchmal 5 Viertel in bar 2"
describe('melody generator bar totals (4/4, level 2)', () => {
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);
it('bar 2 always totals exactly one bar', () => {
let checked = 0;
for (let seed = 1; seed <= 500; seed++) {
const r = generateMelody(
{ keyTonic: 'C', keyMode: 'major', bars: 2, meter: { numerator: 4, denominator: 4 }, rhythmLevel: 2, seed },
pool,
);
if (!r.ok) continue;
checked++;
const ev = scoreEvents(r.value.score);
const problems = validateScore(r.value.score);
expect(problems, `seed ${seed}`).toEqual([]);
const bar2 = ev.filter(e => e.startTick >= 1920 && e.startTick < 3840);
const bar2Len = bar2.reduce((a, e) => a + e.durationTicks, 0);
expect(bar2Len, `seed ${seed} bar2 ticks`).toBe(1920);
}
expect(checked).toBeGreaterThan(400);
});
});

View File

@@ -30,9 +30,10 @@ 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),
readonly rhythmLevel: 1 | 2; * 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */
readonly rhythmLevel: 1 | 2 | 3;
readonly seed: number; readonly seed: number;
} }
@@ -53,6 +54,32 @@ const TEMPLATES_4_L2: number[][] = [
[720, 720, 480], [720, 720, 480],
[720, 480, 240, 240, 240], [720, 480, 240, 240, 240],
]; ];
// Level 3: sixteenth-note material (120 = 16th, 360 = dotted 8th).
const TEMPLATES_4_L3: number[][] = [
[480, 480, 120, 120, 120, 120, 480],
[120, 120, 120, 120, 480, 480, 480],
[480, 120, 120, 120, 120, 480, 480],
[360, 120, 480, 480, 480],
[480, 360, 120, 480, 480],
[120, 120, 120, 120, 120, 120, 120, 120, 480, 480],
[480, 480, 360, 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],
@@ -65,12 +92,33 @@ const TEMPLATES_3_L2: number[][] = [
[720, 720], [720, 720],
[720, 240, 240, 240], [720, 240, 240, 240],
]; ];
// Level 3 for 3/4: sixteenth material filling 1440 ticks.
const TEMPLATES_3_L3: number[][] = [
[480, 120, 120, 120, 120, 480, 480],
[120, 120, 120, 120, 480, 480, 480],
[360, 120, 480, 480, 480],
[480, 360, 120, 480, 480],
[120, 120, 120, 120, 120, 120, 120, 120, 480],
];
function templatesFor(meter: { numerator: 3 | 4 }, level: 1 | 2): 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 : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2]) if (meter.numerator === 4) {
: (level === 1 ? TEMPLATES_3_L1 : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2]); 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

@@ -12,13 +12,16 @@ import { keyFor, scalePitches, type KeyContext, type ModeId } from './scales';
export type DrillDirection = 'up' | 'down' | 'updown'; export type DrillDirection = 'up' | 'down' | 'updown';
export type DrillCoverage = 'one-octave' | 'two-octave' | 'position'; export type DrillCoverage = 'one-octave' | 'two-octave' | 'position';
export type DrillRhythm = 'quarters' | 'eighths';
export interface DrillConfig { export interface DrillConfig {
readonly keyTonic: string; readonly keyTonic: string;
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. */
readonly rhythm?: DrillRhythm;
} }
/** Complete ascending scale path from a starting tonic, if it fits the pool. */ /** Complete ascending scale path from a starting tonic, if it fits the pool. */
@@ -87,14 +90,15 @@ export function generateScaleDrill(
else path = [...up, ...[...up].reverse().slice(1)]; // no duplicate turning tonic else path = [...up, ...[...up].reverse().slice(1)]; // no duplicate turning tonic
} }
// ---- Quarter-note rhythm, final note extended to fill its bar --------- // ---- Rhythm: quarters (default) or eighths; final note extended ---------
const noteDur = config.rhythm === 'eighths' ? PPQ / 2 : PPQ;
const full = config.meter.numerator * PPQ; const full = config.meter.numerator * PPQ;
if (path.length * PPQ > 8 * full) { if (path.length * noteDur > 8 * full) {
return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage or direction.' }; return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage, direction or note duration.' };
} }
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full)); const barCount = Math.max(1, Math.ceil((path.length * noteDur) / full));
const totalTicks = barCount * full; const totalTicks = barCount * full;
const lastDuration = PPQ + (totalTicks - path.length * PPQ); const lastDuration = noteDur + (totalTicks - path.length * noteDur);
const events: ScoreEvent[] = []; const events: ScoreEvent[] = [];
let cursor = 0; let cursor = 0;
@@ -104,7 +108,7 @@ export function generateScaleDrill(
return found ? found.pitch : sp[0]?.pitch ?? null; return found ? found.pitch : sp[0]?.pitch ?? null;
}; };
for (let i = 0; i < path.length; i++) { for (let i = 0; i < path.length; i++) {
const dur = i === path.length - 1 ? lastDuration : PPQ; const dur = i === path.length - 1 ? lastDuration : noteDur;
const pitch = spellFor(path[i]); const pitch = spellFor(path[i]);
if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' }; if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' };
events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch }); events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch });
@@ -113,7 +117,7 @@ export function generateScaleDrill(
const score: Score = { const score: Score = {
version: 1, version: 1,
id: `scale-${config.keyTonic}-${config.keyMode}-${config.coverage}-${config.direction}-${config.meter.numerator}4`, id: `scale-${config.keyTonic}-${config.keyMode}-${config.coverage}-${config.direction}-${config.rhythm ?? 'quarters'}-${config.meter.numerator}4`,
title: `${key.tonic} scale — ${config.coverage === 'position' ? 'within position' : config.coverage} ${config.direction}`, title: `${key.tonic} scale — ${config.coverage === 'position' ? 'within position' : config.coverage} ${config.direction}`,
meter: config.meter, meter: config.meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature }, key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { computeTargetPool } from './targetPool';
const base = {
instrumentId: 'guitar',
difficulty: 'custom',
tuningId: 'standard',
customMinFret: 0,
customMaxFret: 12,
keyEnabled: false,
keyTonic: 'C',
keyMode: 'major',
strings: [],
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
};
describe('computeTargetPool', () => {
it('returns the base playable range when no filters are active', () => {
const pool = computeTargetPool(base);
expect(pool).toContain(40); // low E
expect(pool).toContain(64); // high e (12th fret)
expect(pool.length).toBeGreaterThan(30);
});
it('applies the fret window when enabled', () => {
const pool = computeTargetPool({ ...base, fretWindowEnabled: true, fretMin: 0, fretMax: 4 });
// guitar standard: open strings 40,45,50,55,59,64; window adds up to +4
const lo = Math.min(...pool);
const hi = Math.max(...pool);
expect(lo).toBe(40);
expect(hi).toBe(68); // 64 + 4
expect(pool).toContain(59); // open g
expect(pool).not.toContain(69);
});
it('restricts to pitch classes of the key when key filter is on', () => {
const pool = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'C', keyMode: 'major' });
const pcs = new Set([0, 2, 4, 5, 7, 9, 11]); // C major
for (const midi of pool) {
expect(pcs.has(((midi % 12) + 12) % 12)).toBe(true);
}
// chromatic notes excluded
expect(pool).not.toContain(61); // C# within range
});
it('combines fret window and key filter', () => {
const pool = computeTargetPool({ ...base, fretWindowEnabled: true, fretMin: 0, fretMax: 5, keyEnabled: true, keyTonic: 'E', keyMode: 'minor' });
const pcs = new Set([4, 6, 7, 9, 11, 0, 2]); // E minor natural: E F# G A B C D
expect(pool.length).toBeGreaterThan(0);
for (const midi of pool) {
expect(pcs.has(((midi % 12) + 12) % 12)).toBe(true);
}
// F (pc 5) and F# (pc 6 is in E minor; F natural is not) — F natural excluded
expect(pool.some(m => ((m % 12) + 12) % 12 === 5)).toBe(false);
});
it('supports modes beyond the single-note key signatures (dorian)', () => {
const pool = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'D', keyMode: 'dorian' });
const pcs = new Set([2, 4, 5, 7, 9, 11, 0]); // D dorian: D E F G A B C
expect(pool.length).toBeGreaterThan(0);
for (const midi of pool) {
expect(pcs.has(((midi % 12) + 12) % 12)).toBe(true);
}
});
it('falls back to major for an invalid mode id', () => {
const a = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'C', keyMode: 'major' });
const b = computeTargetPool({ ...base, keyEnabled: true, keyTonic: 'C', keyMode: 'bogus' as string });
expect(b).toEqual(a);
});
it('returns an empty pool rather than crashing for a non-fretted window request', () => {
const pool = computeTargetPool({ ...base, instrumentId: 'piano', fretWindowEnabled: true });
// piano has no fret window: window is ignored, base pool returned
expect(pool.length).toBeGreaterThan(0);
});
});
describe('computeTargetPool — string selection', () => {
it('restricts to a single string when selected', () => {
// String index 5 = high E (open midi 64)
const pool = computeTargetPool({ ...base, strings: [5] });
expect(pool).toContain(64); // open high E
expect(pool).toContain(65); // fret 1
expect(pool).not.toContain(45); // open A (string 1) — different string
// all notes come from one string
expect(pool.every(m => m >= 64)).toBe(true);
});
it('combines strings + fret window', () => {
// String index 1 (open A = 45), frets 0..2
const pool = computeTargetPool({ ...base, strings: [1], fretWindowEnabled: true, fretMin: 0, fretMax: 2 });
expect(pool).toEqual([45, 46, 47]);
});
it('combines strings + fret window + key filter', () => {
// Low E string (index 0), frets 0..4, C major → only natural notes in that range
const pool = computeTargetPool({ ...base, strings: [0], fretWindowEnabled: true, fretMin: 0, fretMax: 4, keyEnabled: true, keyTonic: 'C', keyMode: 'major' });
// Low E string frets 0-4: E(40) F(41) F#(42) G(43) G#(44); C major pcs: C D E F G A B
expect(pool).toEqual([40, 41, 43]); // E, F, G (F#, G# excluded)
});
it('treats empty strings array as all strings', () => {
const a = computeTargetPool({ ...base, strings: [] });
const b = computeTargetPool({ ...base, strings: [0, 1, 2, 3, 4, 5] });
expect(a).toEqual(b);
});
});

79
src/music/targetPool.ts Normal file
View File

@@ -0,0 +1,79 @@
/**
* Unified target-note pool: base playable range (instrument + difficulty /
* fret window) intersected with the optional key-filter (pitch classes of a
* tonic+mode) and the optional fret window. Shared by all game modes so the
* controls behave identically everywhere.
*/
import { keyFor, type ModeId } from './scales';
import { computePlayableNotes, isFrettedInstrument } from './playableRange';
import { TUNINGS } from './Tunings';
export interface TargetPoolInput {
readonly instrumentId: string;
readonly difficulty: string;
readonly tuningId: string;
readonly customMinFret?: number;
readonly customMaxFret?: number;
/** Key filter (target-note key, independent of the displayed key signature). */
readonly keyEnabled: boolean;
readonly keyTonic: string;
readonly keyMode: string;
/** Selected string indices (0 = lowest). Empty = all strings. */
readonly strings: number[];
/** Fret window (fretted instruments only). */
readonly fretWindowEnabled: boolean;
readonly fretMin: number;
readonly fretMax: number;
}
/**
* Compute the effective target-note pool.
*
* Order of application:
* 1. base playable range (instrument difficulty / custom frets)
* 2. string selection ∩ fret window (fretted instruments only — replaces
* the range, same semantics as Phrase Mode's fret window)
* 3. key filter (pitch-class intersection, keeps spelling/degree data intact
* via the same scale machinery Phrase Mode uses)
*/
export function computeTargetPool(input: TargetPoolInput): number[] {
const tuning = TUNINGS[input.tuningId];
const fretted = isFrettedInstrument(input.instrumentId) && !!tuning;
// 1+2: base pool with string selection and optional fret window.
// For fretted instruments with NO filters the pool is the full fretboard
// (frets 0–12) — the legacy difficulty ranges no longer apply since the
// Note Set dropdown was removed.
let base: number[];
if (fretted) {
const all = tuning!.strings;
const selected = input.strings.length > 0
? all.filter((_, i) => input.strings.includes(i))
: all;
if (selected.length === 0) return [];
const lo = input.fretWindowEnabled ? Math.max(0, Math.min(24, input.fretMin)) : 0;
const hi = input.fretWindowEnabled ? Math.max(lo, Math.min(24, input.fretMax)) : 12;
base = selected.flatMap(open => {
const notes: number[] = [];
for (let fret = lo; fret <= hi; fret++) notes.push(open + fret);
return notes;
}).sort((a, b) => a - b);
} else {
base = computePlayableNotes({
instrumentId: input.instrumentId,
difficulty: input.difficulty,
tuningId: input.tuningId,
customMinFret: input.customMinFret,
customMaxFret: input.customMaxFret,
});
}
if (!input.keyEnabled) return base;
// 3: key/mode pitch-class filter
const mode: ModeId = (['major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian', 'locrian', 'major-pentatonic', 'minor-pentatonic'] as const).includes(input.keyMode as ModeId)
? input.keyMode as ModeId
: 'major';
const key = keyFor(input.keyTonic, mode);
const pcs = new Set(key.pitchClasses);
return base.filter(midi => pcs.has(((midi % 12) + 12) % 12));
}

View File

@@ -86,11 +86,14 @@ 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. */
readonly voices: ReadonlyArray<{ readonly id: 'melody'; readonly events: ScoreEvent[] }>; readonly voices: ReadonlyArray<{ readonly id: 'melody'; readonly events: ScoreEvent[] }>;
/** Optional chord symbol per bar index (0-based), e.g. "C", "Am", "G7".
* Rendered above the staff; absent = no symbol for that bar. */
readonly chordSymbols?: ReadonlyArray<string | null>;
readonly source?: ScoreSource; readonly source?: ScoreSource;
} }

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

@@ -1,33 +1,72 @@
export type Difficulty = string; export type Difficulty = string;
/** Unified target-note filter, shared by all game modes (v2 controls). */
export interface PracticeFilter {
/** Restrict target notes to pitch classes of this key/mode (null = off). */
keyEnabled: boolean;
keyTonic: string;
keyMode: string; // ModeId
/** Restrict target notes to selected strings (fretted instruments only).
* Empty array = no restriction (all strings). Index 0 = lowest string. */
strings: number[];
/** Restrict target notes to a fret range (fretted instruments only). */
fretWindowEnabled: boolean;
fretMin: number;
fretMax: number;
}
export const DEFAULT_PRACTICE_FILTER: PracticeFilter = {
keyEnabled: false,
keyTonic: 'C',
keyMode: 'major',
strings: [],
fretWindowEnabled: false,
fretMin: 0,
fretMax: 4,
};
export function getPracticeFilter(s: AppSettings): PracticeFilter {
return { ...DEFAULT_PRACTICE_FILTER, ...(s.practice ?? {}) };
}
export interface RhythmSettings { export interface RhythmSettings {
mode: 'bpm' | 'seconds'; mode: 'bpm' | 'seconds';
bpm: number; bpm: number;
seconds: number; seconds: number;
active: boolean; active: boolean;
/** Phrase Mode: sync the metronome to the exercise (starts with it,
* silent while idle) instead of running freely. */
syncToExercise: boolean;
autoAdvance: boolean; autoAdvance: boolean;
sound: boolean; sound: boolean;
volume: number; volume: number;
} }
export type PhraseMaterial = 'melody' | 'scale' | '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; rhythmLevel: 1 | 2 | 3;
scaleCoverage: 'one-octave' | 'two-octave' | 'position'; scaleCoverage: 'one-octave' | 'two-octave' | 'position';
scaleDirection: 'up' | 'down' | 'updown'; scaleDirection: 'up' | 'down' | 'updown';
/** Phrase-local fret window (guitar/bass) — overrides the note set. */ scaleRhythm: 'quarters' | 'eighths';
fretWindowEnabled: boolean; /** Arpeggio settings (material = 'arpeggio'). */
fretMin: number; arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start)
fretMax: number; /** Sequence mode: which degrees may appear (empty = all 7). */
arpeggioChordSelection: string[];
arpeggioPattern: 'up' | 'down' | 'updown' | '1235' | 'custom' | 'random';
arpeggioCoverage: 'one-octave' | 'two-octave';
/** Sequence mode: bars per exercise (1 = single chord). */
arpeggioBars: number;
arpeggioProgression: 'random' | 'functional' | 'diatonic-cycle';
/** Giuliani study material (own category — voicing-enforced). */
giulianiPattern: 'pim' | 'pima' | 'pami' | 'aim' | 'pimamim' | 'pmamim' | 'piai' | 'pmami' | 'pimami' | 'pimaia' | 'pimai' | 'pmia' | 'pmim';
giulianiBars: 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;
@@ -38,21 +77,32 @@ export interface PhraseSettings {
* new melody) after a short pause — keeps practice flowing. */ * new melody) after a short pause — keeps practice flowing. */
autoContinue: boolean; autoContinue: boolean;
inputMode: 'mic' | 'virtual'; inputMode: 'mic' | 'virtual';
/** Begin the run as soon as the first note is played (no count-in,
* step pace only). */
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,
scaleCoverage: 'one-octave', scaleCoverage: 'one-octave',
scaleDirection: 'updown', scaleDirection: 'updown',
fretWindowEnabled: false, scaleRhythm: 'quarters',
fretMin: 0, arpeggioDegree: 'I',
fretMax: 4, arpeggioChordSelection: [],
arpeggioPattern: 'up',
arpeggioCoverage: 'one-octave',
arpeggioBars: 1,
arpeggioProgression: 'functional',
giulianiPattern: 'pima',
giulianiBars: 4,
startBar: 1, startBar: 1,
barCount: 2, barCount: 2,
pace: 'step', pace: 'step',
@@ -60,6 +110,9 @@ export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
clickSound: false, clickSound: false,
autoContinue: true, autoContinue: true,
inputMode: 'mic', inputMode: 'mic',
autoStartOnNote: false,
customArpeggioPattern: '',
giulianiAlternateBass: false,
}; };
export function getPhraseSettings(s: AppSettings): PhraseSettings { export function getPhraseSettings(s: AppSettings): PhraseSettings {
@@ -68,16 +121,20 @@ export function getPhraseSettings(s: AppSettings): PhraseSettings {
export interface AppSettings { export interface AppSettings {
phrase?: PhraseSettings; phrase?: PhraseSettings;
/** Unified target-note filter (key + fret window), all modes. */
practice?: PracticeFilter;
difficulty: Difficulty; difficulty: Difficulty;
showHint: boolean; showHint: boolean;
showFretboard: boolean; showFretboard: boolean;
showTuningMeter: boolean; showTuningMeter: boolean;
tuningId: string; tuningId: string;
keySignature: string; keySignature: string;
/** Keep display key signature in sync with the target-key control. */
keyFollowsTarget?: boolean;
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;
@@ -101,12 +158,14 @@ export const DEFAULT_SETTINGS: AppSettings = {
showTuningMeter: false, showTuningMeter: false,
tuningId: 'standard', tuningId: 'standard',
keySignature: 'C', keySignature: 'C',
keyFollowsTarget: false,
instrument: 'guitar', instrument: 'guitar',
rhythm: { rhythm: {
mode: 'bpm', mode: 'bpm',
bpm: 60, bpm: 60,
seconds: 5, seconds: 5,
active: false, active: false,
syncToExercise: true,
autoAdvance: false, autoAdvance: false,
sound: true, sound: true,
volume: 0.5 volume: 0.5
@@ -114,6 +173,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
zenMode: false, zenMode: false,
gameMode: 'sight_reading', gameMode: 'sight_reading',
phrase: DEFAULT_PHRASE_SETTINGS, phrase: DEFAULT_PHRASE_SETTINGS,
practice: DEFAULT_PRACTICE_FILTER,
customMinFret: 0, customMinFret: 0,
customMaxFret: 12, customMaxFret: 12,
autoPlaySightReading: false, autoPlaySightReading: false,