61 Commits

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

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

Header mode toggle gains 'Assist'; 'm' shortcut cycles back to sight
reading from assist/phrase. Export PitchData type.
2026-09-15 21:11:21 +00:00
OpenClaw
b7c8187aef UI reorg: settings grouped by usage frequency
- Filter (key/strings/fret window) collapsed behind Filter button with
  active-count badge; opens the unified TargetNoteControls panel
- Display key signature moved into the filter panel (all modes)
- Instrument + Tuning selects moved into Settings modal (Instrument
  section); removed from footer
- Settings modal restructured into collapsible sections: Instrument,
  Appearance, Audio, Microphone
- Phrase setup panel: removed cross-reference hint texts
- PracticeFilter is now the single source of truth: removed mirrored
  keyTonic/keyMode/fretWindow fields from PhraseSettings
- Dark mode: neutral gray with slight blue tint (was warm brown)
2026-09-15 21:05:44 +00:00
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
da060e5bb9 Delivery record: late-feedback round (octave fix, count-in, auto-continue, live staff, library) 2026-09-14 02:30:43 +02:00
173c4c56bc Fix stave vertical placement: compensate VexFlow space_above_staff_ln offset in live staff and phrase rows (verified via screenshots) 2026-09-14 02:21:33 +02:00
7d4c71539a Live-note staff: 64px box aligned with mic button, extreme ledger lines overflow instead of clipping 2026-09-14 02:13:08 +02:00
9fb3f91b92 Fix live-note staff clipping (written-range headroom, block SVG) and center Zen-mode content vertically 2026-09-14 02:11:14 +02:00
fff512b4dd Phrase mode: QA updates (tempo timing verification, auto-continue, expanded library documented) 2026-09-14 01:49:29 +02:00
4f2ee8798a Phrase mode: count-in click fix, auto-continue sections, BPM widget, live-note staff, 13 new exercises 2026-09-14 01:45:39 +02:00
82c105bcbf Docs: Phrase Mode in README/TECHNICAL_REALIZATION, delivery record with verified evidence 2026-09-14 01:23:20 +02:00
909c30192f Phrase mode: count-in no-scoring fix, re-arm timeout for repeated notes, browser QA scripts 2026-09-14 01:22:55 +02:00
486f3c5dcd Phrase mode: browser debug hook (localStorage.phraseDebug) 2026-09-14 01:16:21 +02:00
9cb78b39d5 Phrase mode UI fixes: visible current-note accent, staff headroom, reused virtual-instrument/hint toggles, zen decluttering 2026-09-14 01:16:00 +02:00
0520cb08b4 Phrase mode UI fixes: visible current-note accent, staff headroom, reused virtual-instrument/hint toggles, zen decluttering 2026-09-14 01:15:52 +02:00
4d6e205b07 Phrase mode: App integration (mode toggle, keyboard, zen), Controls tweaks, styles 2026-09-14 00:58:43 +02:00
821f88d43a Phrase mode: ABC parser (subagent), library content, renderer, PhraseTrainer component 2026-09-14 00:57:32 +02:00
c7f1fa170b Phrase mode: audio engine (groups, clicks, blanking), raw frame bus, trainer hook 2026-09-14 00:50:47 +02:00
7c5632fab6 Phrase mode: PhraseMatcher (episode/latching logic) + transport schedule (tested) 2026-09-14 00:47:29 +02:00
8e20098102 Phrase mode P0: score model, scales/keys, playable range, melody + scale generators (tested) 2026-09-14 00:44:09 +02:00
6e24b94a91 Review phrase mode requirements and specify implementation contracts 2026-09-14 00:21:43 +02:00
0d11570b58 Add Phrase Mode requirements & implementation plan (draft) 2026-09-14 00:05:20 +02:00
141487e3df Update docs: detection/matching pipeline, new settings, testing 2026-09-13 19:16:54 +02:00
3cc9a0c43d Add reference note duration and wait-for-silence options
- Settings: reference note duration slider (0.5-4s, default 1.5s) applies to
  ear-training auto-play, sight-reading auto-play and the manual Play Note
  button/keys alike.
- Settings: 'Wait for Silence' toggle holds the next reference note until the
  mic detects the instrument has gone quiet (a still-ringing string masking
  the next note was easy to miss). Safety cap of 8s so continuously
  detectable room noise can't stall the app; without a mic it plays
  immediately. The quiet state is a boolean dep (not the per-frame pitch
  object) so the play timer isn't reset 60x/sec.
- Gate all live feedback (staff preview, readout, tuner) during speaker
  playback: the mic inevitably hears the reference note and would briefly
  leak the target in ear training. Virtual fretboard taps (explicit user
  input) still display instantly.
2026-09-13 18:52:33 +02:00
6e60ced477 Dark mode contrast, tuner display and misc UI fixes
- color-scheme per theme so native controls (selects, checkboxes, number
  inputs, sliders) render dark-native instead of black-on-black.
- Themed .control-select text color; new .control-input class replaces
  hardcoded white-on-black inline input styles.
- Tuner widget stays mounted when enabled: idle state (dimmed, needle parked,
  '—') instead of appearing/disappearing. Fully theme-var based (was
  hardcoded white — invisible in light mode).
- Remove CSS transitions on per-frame values (tuner needle, level meter):
  transitions interrupted 60x/sec are left stuck by WebKit/Safari; the
  display EMA from the detection loop replaces them.
- VexFlow notation: resolve CSS var() to concrete colors (SVG attributes
  can't resolve var(), silently fell back to black) and override VexFlow's
  hardcoded #444 ledger-line default; re-render on theme changes
  (useLayoutEffect so children read the updated attribute in time).
- Themed switch toggles (were hardcoded #ddd / white — glowed in dark mode).
- Fixed-height pitch readout so the layout stops jumping when a note starts
  or stops sounding.
- Split SettingsContext into context/Provider/hook files (fast-refresh);
  memoized provider functions; typed webkitAudioContext; prefer-const;
  removed 'as any' casts. ESLint now passes clean.
2026-09-13 18:52:27 +02:00
079d574b66 Overhaul pitch detection and note matching
- Pick detector per instrument range: ACF2PLUS (robust down to ~40 Hz) for
  normal instruments, YIN only for the whistle register. pitchfinder's YIN
  latches onto tau=2 (~20 kHz garbage) below ~100 Hz and for quiet input;
  the old fixed 4096 window masked this, the smaller window exposed it.
- RMS-normalize analysis input for the YIN path only; ACF2PLUS keeps the raw
  signal so its internal unpitched gate rejects quiet decayed audio instead
  of amplifying noise into confident wrong pitches (tuner stuck on 'a note').
- Adaptive analysis window sized to the instrument's lowest note (~2 periods):
  half the latency, a quarter of the CPU for guitar/piano/voice; bass keeps
  the full window for E1.
- Reject detections implausibly below the instrument floor (mains hum).
- Clear stale pitch readings after 200 ms instead of keeping them forever.
- Shared AudioEngine singleton for all playback (was: two AudioContexts);
  tracks audibility so the app stops hearing its own speaker (ear-training
  self-scoring) and gates matching plus display during playback.
- MatchTracker: grace-period based note hold instead of reset-on-every-flicker
  (YIN flickers during attacks/decays; the old hard reset made the 50 ms
  threshold fire unreliably).
- Display damping in the producer (EMA for cents/level, 3-frame note-name
  hysteresis); display components render directly without CSS transitions.
- Regression tests: synthetic signals through the exact pipeline helpers
  (MatchTracker + pitchDetection test suites).
2026-09-13 18:52:21 +02:00
91 changed files with 11267 additions and 920 deletions

147
PHRASE_MODE_PLAN.md Normal file
View File

@@ -0,0 +1,147 @@
# Phrase Mode — Reviewed Requirements & Plan
**Status: revised draft; implementation still requires the user's “Go”.**
This replaces the initial GLM draft. The product direction is good; the review
corrects several music, audio, scope, and deployment assumptions. Implementation
must follow this document **and** [the implementation contract](docs/PHRASE_MODE_IMPLEMENTATION.md).
The contract makes behavior and acceptance tests explicit for the next model.
If they appear inconsistent, resolve the inconsistency before implementing;
do not treat the original draft in Git history as an alternative specification.
## 1. Recommendation
Add **Phrases**, alongside **Sight Reading** and **Ear Training**. Keep the
existing modes and their defaults. In Phrases, display a few bars of one melodic
line and follow the player through them. No chords or simultaneous voices yet.
Three material choices, not three additional top-level modes:
- **Melodies:** reproducible, rule-based generation in a selected key/mode and range.
- **Scales:** ascending, descending, or up-and-down scales in any key, optionally
constrained to a fretboard region. This is a core requirement, not spare-time polish.
- **Exercises:** a small, verified file-backed collection and basic local ABC import.
Two practice paces:
- **At your pace** (default): wait for each correct pitch; rhythm is displayed,
not assessed. Wrong notes don't advance; an explicit Skip is available.
- **With tempo:** one-bar count-in, then advance according to written durations;
mark pitches matched during their slots and continue past misses. This is
deliberately forgiving **pitch-following with a metronome**, not a precise
assessment of note attacks, release times, or rhythmic accuracy.
All generation, imports, and practice remain browser-local. No backend, runtime
LLM calls, accounts, analytics, or new network dependency for practicing.
## 2. Important corrections to the first draft
| Initial assumption | Reviewed requirement |
|---|---|
| Reusing `MatchTracker` unchanged solves sequence matching | It can repeatedly accept a held pitch. Add per-note result latching and repeated-note re-arming. A tied continuation is not a new attack. |
| `pitchData` is a fresh detection stream | Its pitch can remain displayed for 200 ms after detection stops. Add a separate timestamped raw frame stream, including null frames, for phrase matching. Keep existing display smoothing. |
| The metronome is an already-correct phrase clock | `useMetronome` calls `onTick` when scheduling audio, up to about 100 ms before it sounds. Schedule sounds ahead, but advance the cursor only when the clock reaches the event. |
| Speaker gating can simply cover every sound | Gating 400 ms after every metronome click would disable much of practice. Preview/virtual-note gating and metronome behavior need separate policies. |
| Fretboard positions come “for free” | Pitch ranges help generation, but hints must respect string/fret constraints. A microphone cannot identify which string/fret produced an equivalent pitch. Do not claim fingering verification. |
| Existing key-signature helpers suffice for phrases | They neither restrict current random-note generation nor track accidentals through a measure. Preserve pitch spelling and implement measure-aware accidentals. |
| Giuliani arpeggio studies are automatically single-voice and easy to transcribe | Many contain simultaneous bass/upper notes or sustained voices. Verify a source and explicitly label any single-line adaptation. Never invent an exercise and attribute it to Giuliani. |
| ABC is a roughly 300-line, notation-perfect shortcut | A documented subset is reasonable; full ABC is not. Validate supported syntax and reject unsupported music instead of silently changing it. MIDI is also viable, especially quantized MIDI; it is deferred for scope, not because it is inherently unsuitable. |
| Hiding the single-note UI prevents regressions | `useGameLogic` currently runs unconditionally in `App`. Its matching/metronome effects must actually be inactive in Phrases. |
| Pushing `origin/main` deploys GitHub Pages | `origin` is git.0jm.de; `github` is the GitHub remote. The checked-in workflow deploys on GitHub `main`. Do not conflate backup and production deployment. |
## 3. Stakeholder requirements — required for v1
| ID | Requirement / observable outcome |
|---|---|
| R1 | **Preserve simplicity.** Third mode only; a compact material selector and transport. Advanced generation/import settings live in a collapsible panel. No per-note popups or dashboard. |
| R2 | **Readable notation.** A moving highlight in a stable, responsive view of 1–4 bars at a time; pieces may be longer. Show real durations, rests, ties, bar numbers, clef, and key/time signatures. Do not shrink eight bars onto a phone. |
| R3 | **Correct sequential matching.** Wrong notes wait in at-your-pace mode. Repeated equal pitches need separate playing; holding one note cannot finish a phrase. Rest and tie behavior is explicitly defined in the contract. |
| R4 | **Forgiving tempo practice.** Count-in, Start/Pause/Resume, misses that never stop the run, and a compact matched/skipped summary. No claim of millisecond-level rhythmic assessment. |
| R5 | **Musical generation.** 1–8 bars, default two bars in 4/4; 3/4 also supported. Seeded generation respects the selected scale and playable note set. Favor steps, small contours, and a stable ending rather than unconstrained randomness. |
| R6 | **Any key and scale drills.** All 12 tonic pitch classes; major, natural minor, major/minor pentatonic, and the diatonic modes. One-/two-octave scales or a scale-through-position drill; up/down/both. Only offer a full octave scale when the range can contain it. |
| R7 | **Position-aware guidance.** Reuse note-set/tuning settings; additionally offer a phrase-local fret window for fretted instruments, including bass. Restrict hints to allowed positions. Explain that mic matching checks pitch, not fingering. |
| R8 | **Fixed material.** File-backed library with provenance, work/exercise identification, and adaptation notes. Aim for 4–8 good items, not a quota of unverified transcriptions. Include verified Giuliani material if a suitable lawful source is available. |
| R9 | **Add material without rebuilding.** Minimal local `.abc` file import, validation feedback, and a documented example. In-session use is sufficient; no library manager or import persistence required. |
| R10 | **Focused practice.** Select a bar range in a longer exercise. Retry preserves exactly the same notes; Next moves to the next range (or generates a new melody). Optional Repeat loops the selected range with a count-in in tempo mode. |
| R11 | **Listen before playing.** Cancellable phrase preview at the selected BPM, isolated from scoring. It must stop on mode/material changes and not leave queued notes sounding later. |
| R12 | **Zen, mobile, and accessibility.** Zen retains discreet touch-accessible transport/mic controls and Retry/Next at completion. Keyboard use, visible focus, non-color-only current-note indication, both themes, and reduced motion work. |
| R13 | **Safe lifecycle.** No background scoring/playback after leaving the mode. Pause on a hidden tab, capture interruption, or audio suspension. Existing microphone permission/error handling and virtual instruments remain usable. |
| R14 | **Tested delivery.** Unit/integration tests plus real-browser checks of notation, virtual playing, and synthetic microphone input. Existing tests, lint, and build pass. Documentation states actual limitations. |
### Default experience
Open Phrases → a two-bar C-major melody in the current instrument range → press
Start → play the highlighted notes at your pace → Retry the identical melody
or choose New melody. Nothing sounds until requested; microphone capture still
requires permission/user intent. If the microphone is unavailable, choose the
virtual instrument rather than presenting an unplayable screen.
Settings are progressive: the compact selector can read
“Melody · C major · At your pace”; editing it opens the setup panel. The old
single-note metronome and key-signature controls must not also appear as
competing phrase controls.
## 4. Content and format decisions
**ABC is the primary v1 interchange format**, with a deliberately limited,
documented single-voice subset. The normalized score is independent of ABC,
so a future MIDI or MusicXML adapter can use the same trainer and renderer.
Support common simple notation, including rests, dotted durations, and ties.
Simple repeats and pickups have explicit bounded behavior in the contract.
Unsupported tuplets, polyphony, meter changes, etc. produce actionable errors.
Do not advertise support for arbitrary ABC files.
A bundled exercise must identify its source and the transcription's rights,
not just cite the composer's death date. Generic arpeggio patterns may be
original exercises; they must not be presented as numbered historical studies.
If a Giuliani transcription cannot be verified, ship other verified/original
material and record that limitation. Do not let content research block the app.
## 5. Scope boundaries and optional improvements
**Not v1:** chords, polyphonic recognition, a full notation editor, complete
ABC/MIDI/MusicXML compatibility, phrase ear-training, persistent score history,
strict onset/duration scoring, exhaustive fingering optimization, or automatic
production deployment.
**Only after all required behavior is working:** more repertoire, scale-in-thirds
and arpeggio drill patterns, motif-based call/response generation, saving the
last phrase setup locally, and a MIDI adapter. These must not displace audio
correctness, scales, Zen/mobile behavior, import validation, or tests.
## 6. Implementation sequence and checkpoints
| Phase | Deliverable | Gate before continuing |
|---|---|---|
| P0 | Baseline checks; normalized score, range/scale helpers, matcher/transport contracts; feature branch | Existing tests still green; regression fixtures added |
| P1 | Generated melodies + scale drills + phrase renderer + at-your-pace mode | Playable through mic and virtual input; repeated notes/rests/ties covered; responsive/Zen path usable |
| P2 | Tempo transport, count-in, pause/resume, preview, cancellation | Fake-clock and synthetic-audio tests prove no early cursor, self-scoring, or stale events |
| P3 | ABC adapter, verified library, local import, range selection/repeat | Every bundled file validated; invalid/unsupported imports handled; source notes recorded |
| P4 | Accessibility/lifecycle regression pass, browser QA, docs, delivery | All R1–R14 checked; limitations and exact verification results written down |
Quality work in P4 is **not optional**. Optional features are cut first if the
implementation budget is tight. Finish required phases or explicitly report
what is incomplete; never declare the task done just because it compiles.
## 7. Handoff and publishing
- Implementation begins only after approval. Read
[the implementation contract](docs/PHRASE_MODE_IMPLEMENTATION.md) before coding.
- Work on `feature/phrase-mode`, branched from the current local state. Preserve
the existing local commits. Commit coherent, tested checkpoints.
- A normal backup push of the **feature branch to `origin`** is the proposed
delivery default after “Go”. Do not update either remote's `main`, merge,
force-push, or trigger production deployment without explicit approval.
- Verify actual model/tool availability before delegation; do not assume the
suggested GLM model ID or a subagent tool exists. Use cheaper workers only
for bounded independent work with fixed interfaces and tests. The main
implementer reviews all output. No unbounded retry/research loops.
- Maintain a short implementation checklist with R1–R14 status, test commands,
decisions/deviations, known limitations, commit, and push status. This is the
completion/handoff artifact, not a claim that physical-instrument testing was
performed when only synthetic input was available.
**Approval defaults:** Phrases label; ABC subset with local import; scales and
all-key support required; at-your-pace/two-bar defaults; tempo mode is forgiving;
feature-branch backup only. Reply “Go”, optionally changing these defaults.

View File

@@ -7,11 +7,14 @@ A web-based application designed to help musicians practice sight-reading and ea
## Features
- **Sight Reading Mode**: Read notes from the interactive staff and play them on your instrument. The app listens and confirms when you hit the correct note.
- **Ear Training Mode**: Listen to a reference note and try to reproduce it.
- **Real-time Pitch Detection**: Uses your device's microphone to detect notes instantly.
- **Instrument Support**: Optimized for Guitar and Piano, with configurable tunings for guitar.
- **Ear Training Mode**: Listen to a reference note and try to reproduce it. The reference note duration is configurable, and it can optionally wait for your instrument to go quiet before the next one plays.
- **Phrase Mode**: Practice short phrases — several bars of a single-voice line. Material: generated melodies (any key/mode, seeded), scale drills (one/two octaves or within a fretboard position, up/down/up-down), bundled exercises (verified public-domain melodies + original studies), or your own `.abc` files. Two paces: *at your pace* (beginner, waits for each note) and *with tempo* (metronome count-in, forgiving hit/miss tracking). Note that matching checks pitch, not fingering, and repeated identical notes need a brief release between them.
- **Real-time Pitch Detection**: Uses your device's microphone to detect notes instantly, with an analysis window and detector adapted to your instrument.
- **Chromatic Tuner**: Tunable needle with cents display, always-on when enabled.
- **Instrument Support**: Optimized for Guitar, Bass, Piano, Voice and Whistle, with configurable tunings for guitar and bass.
- **Virtual Instruments**: On-screen interactive guitar fretboard and piano keys for visual reference or touch input.
- **Customizable**: Adjust difficulty (range, accidentals), key signatures, rhythm/metronome settings, and more.
- **Customizable**: Difficulty (range, accidentals), key signatures, rhythm/metronome settings, reference note duration, microphone sensitivity, and more.
- **Dark & Light Themes**: Full theme support including native form controls.
- **Zen Mode**: Distraction-free practice interface.
## Getting Started

View File

@@ -11,8 +11,10 @@ The **Sheet Music Trainer** is a client-side Single Page Application (SPA) built
- State management is primarily handled via React's `useState` and `useReducer` at the `App` component level, with props drilling for simpler hierarchies.
2. **Audio Processing**:
- **Pitch Detection**: Utilizes the `pitchfinder` library (YIN algorithm) to detect pitch from the user's microphone in real-time. This processing happens in a dedicated hook/worker to keep the main thread responsive.
- **Audio Synthesis**: Uses standard Web Audio API for generating simple tones (sine/triangle waves) for playback and feedback.
- **Pitch Detection** (`src/audio/PitchAnalyzer.ts`): Uses the `pitchfinder` library with a detector chosen per instrument range — ACF2PLUS for normal instruments (robust down to ~40 Hz; pitchfinder's YIN returns ~20 kHz garbage below ~100 Hz and on quiet input), YIN only for high registers (whistle). The analysis window adapts to the instrument's lowest note (~2 periods, power of two): roughly half the latency and a quarter of the CPU of the previous fixed 4096-sample window for guitar/piano/voice, while bass keeps the full window for E1. Readings implausibly below the instrument's floor (e.g. mains hum) are rejected. Detection runs on the requestAnimationFrame loop inside `usePitchDetector`, with display-only damping (EMA for cents/level, short note-name hysteresis) applied in the producer.
- **Note Matching** (`src/game/MatchTracker.ts`): A note counts as matched when held for `NOTE_MATCH_THRESHOLD_MS` (50 ms), tolerated across brief detection dropouts up to `NOTE_MATCH_GRACE_MS` (150 ms). Pitch detectors flicker during note attacks and decays; a naive consecutive-frame timer resets on every flicker.
- **Audio Synthesis** (`src/audio/AudioEngine.ts`): All speaker output goes through a single shared AudioContext (triangle waves). The engine tracks when output may still be audible so the app can ignore its own speaker: while a reference note is sounding (plus a 0.4 s decay tail), microphone-based matching and live feedback are gated — otherwise ear training would score the reference note it just played through the speaker. A suspended AudioContext (autoplay policy) is treated as inaudible so the gate can never deadlock.
- **Ear training auto-play**: The next reference note can optionally wait for the microphone to detect silence ("Wait for Silence" setting, 8 s safety cap), and its duration is configurable ("Reference Note Duration", 0.5–4 s).
3. **Music Rendering**:
- **VexFlow**: The standard library for rendering music notation on the web. It is used in the `SheetMusic` component to draw the staff, notes, clefs, and key signatures dynamically based on the current state.
@@ -34,15 +36,30 @@ The **Sheet Music Trainer** is a client-side Single Page Application (SPA) built
### UI & Icons
* **[Lucide React](https://lucide.dev/)**: A clean and consistent icon library for the interface.
### Phrase Mode (sequential practice)
**VexFlow 5 stave placement quirk**: `new Stave(x, y, w)` does not put the first stave line at `y` — the lines draw ~40.5px lower (default `space_above_staff_ln` = 4 line-spaces). The single-note `SheetMusic` absorbed this inside generous heights; the compact live-note staff and phrase staff had to compensate explicitly (negative `Stave` y / shifted row offset) or low ledger notes clipped.
The third game mode renders and scores multi-bar single-voice phrases from a versioned normalized score (`src/score/model.ts`, PPQ ticks, ties pre-merged into "logical notes"). Generators (`src/music/melodyGenerator.ts`, `scaleDrills.ts`) and the ABC subset adapter (`src/exercises/abcParser.ts`) both produce it; `PhraseSheetMusic` renders it with measure-aware accidental display and a current-note caret. Matching (`src/game/PhraseMatcher.ts`) latches per-note results and blocks a sustained pitch from re-crediting a later note on the same pitch (release evidence, stable-pitch frames, at-your-pace timeout re-arm). Tempo transport uses audio-clock boundaries (schedule in `src/game/PhraseTransport.ts`) — cursor state follows the clock, never the click scheduler. Raw detection frames flow through `src/hooks/rawFrameBus.ts` (one per analysis tick, silence included); display smoothing is untouched. Metronome clicks are short noise bursts that blank the mic for 30 ms instead of entering the 400 ms pitched-audio gate. See `docs/PHRASE_MODE_IMPLEMENTATION.md` (contracts) and `docs/PHRASE_DELIVERY.md` (verified behavior + limitations).
## Testing
* **Unit/integration** ([Vitest](https://vitest.dev/)): `src/game/MatchTracker.test.ts` covers the flicker-tolerance matching logic; `src/audio/pitchDetection.test.ts` feeds synthetic instrument-like signals (guitar/bass/piano/whistle, quiet plucks, mains hum) through the exact pipeline helpers used by `PitchAnalyzer`; `src/music/NoteUtils.test.ts` covers note math and key signatures.
* **End-to-end**: the microphone can be stubbed in a real browser by replacing `navigator.mediaDevices.getUserMedia` with a `MediaStreamDestination` fed by a scripted oscillator — frequency ramps simulate tuning, envelopes simulate plucks. This setup (puppeteer-core + system Chrome, headless) reproduced and verified several real bugs: YIN's low-frequency garbage mode, noise-floor amplification freezing the tuner on a "note nobody played", and WebKit/Safari leaving constantly-interrupted CSS transitions visually stuck.
## Directory Structure
```
src/
├── components/ # Reusable UI components (SheetMusic, Fretboard, etc.)
├── hooks/ # Custom React hooks (usePitchDetector, useAudioPlayer, etc.)
├── music/ # Music logic, tuning definitions, and note utilities
├── styles/ # Global styles and component-specific CSS
├── assets/ # Static assets
├── audio/ # PitchAnalyzer (detection pipeline), AudioEngine (shared playback + mic gating)
├── components/ # Reusable UI components (SheetMusic, Fretboard, TuningMeter, etc.)
├── context/ # Settings context (split into context/Provider/hook files for fast-refresh)
├── game/ # MatchTracker: flicker-tolerant note-hold matching (unit-tested)
├── hooks/ # Custom React hooks (usePitchDetector, useGameLogic, useAudioPlayer, etc.)
├── music/ # Music logic, tuning definitions, and note utilities
├── styles/ # Global styles and component-specific CSS
├── types/ # Shared TypeScript types + pitchfinder module declarations
├── assets/ # Static assets
├── App.tsx # Main application logic and layout
└── main.tsx # Entry point
```

7
TOOLS_LOCAL.md Normal file
View File

@@ -0,0 +1,7 @@
# Local deployment (nyx server) — beta channel (tracks main = production state by default)
- Managed by Nyx; deploy script: `~/deploy/smt-beta.sh [branch]` (fetch, build, docker rebuild, restart; **default branch: main**)
- Container: `smt-beta` (nginx:alpine, 127.0.0.1:8081 **and** tailnet IP 100.64.0.4:8081), image `smt-beta-image`
- Tailnet URLs:
- http://nyx-rg3jpx6w.internal.0jm.de/ (tailscale serve → 8081; needs MagicDNS; Chrome history may autocomplete a broken https:// variant — delete it from history)
- **http://100.64.0.4:8081/ (preferred: Chrome never HTTPS-upgrades IP:port URLs, no MagicDNS needed)**
- Note: `tailscale serve --https` not available on this tailnet (feature not enabled); plain HTTP inside tailnet only

133
docs/PHRASE_DELIVERY.md Normal file
View File

@@ -0,0 +1,133 @@
# Phrase Mode — Delivery Record
Implementation of [PHRASE_MODE_PLAN.md](../PHRASE_MODE_PLAN.md) and
[the implementation contract](PHRASE_MODE_IMPLEMENTATION.md), branch
`feature/phrase-mode`.
## Requirement status
| Req | Status | Where |
|-----|--------|-------|
| R1 Preserve simplicity | ✅ | Third mode toggle (`Phrases`); transport reuses single-note button styles; setup is one collapsible panel |
| R2 Readable notation | ✅ | `PhraseSheetMusic` (multi-system, 1–4 bars/row adaptive, ties, rests, dotted values, bar numbers, key/time sigs, grand staff, themes); row height increased after user feedback on clipped ledger notes |
| R3 Correct sequential matching | ✅ | `PhraseMatcher`: latched results, sustained-episode blocking for repeated pitches (release ≥80 ms, ≥2-frame stable different pitch, or 2.5 s timeout in at-your-pace mode) |
| R4 Forgiving tempo practice | ✅ | 1-bar count-in (no scoring during it — fixed after browser test caught a false credit), windows advance regardless, misses marked, summary "Matched X of Y" |
| R5 Musical generation | ✅ | `generateMelody`: seeded, scale∩playable pool, soft step/contour/tonic-end preferences, exact bar totals, 1–8 bars, 4/4 + 3/4, Simple/Mixed rhythm tiers |
| R6 Any key + scale drills | ✅ | 12 tonics × 9 modes (incl. pentatonics), one/two-octave (complete path only — refuses with actionable error) + within-position, up/down/up-down |
| R7 Position-aware guidance | ✅ | Phrase-local fret window (guitar/bass) overrides note set; hints restricted to allowed positions; "pitch not fingering" note shown |
| R8 Fixed material | ✅ (adjusted) | 19 bundled items: 5 verified public-domain melodies (Ode to Joy, Ah! vous dirai-je, Jingle Bells opening, Mary Had a Little Lamb, London Bridge phrase) + 14 clearly-labeled original exercises. **No Giuliani**: no verifiable source available offline; per contract the quota was not faked — limitation recorded |
| R9 Add material without rebuilding | ✅ | Local `.abc` import in the setup panel with validation errors shown; failures keep the previous score |
| R10 Focused practice | ✅ | Start bar + bars-at-a-time selection (fixed material), Retry identical (same seed), Next advances/wraps, **auto-continue** (default on, tempo mode) rolls to the next section/new melody without interruption |
| R11 Listen before playing | ✅ | Cancellable preview at practice BPM via `AudioEngine` groups; returns to ready, cancels future sounds and gate reservations |
| R12 Zen/mobile/accessibility | ✅ mostly | Zen: staff + Start/Pause + mic; Retry/Next appear at completion. Keyboard shortcuts per mode (help popup reflects active mode); focused buttons never double-fire global shortcuts; current note = color + caret glyph; aria-label on the staff |
| R13 Safe lifecycle | ✅ | Pause on tab-hide, mic error, mic-off, dialogs; audio-group cancellation; run tokens; StrictMode-safe; unmount cleanup |
| R14 Tested delivery | ✅ | 149 unit/integration tests + real-browser synthetic-mic suite (see below) |
## Late feedback round (post first delivery)
- **Guitar octave bug**: the phrase renderer drew sounding pitch; fixed by
deriving the written pitch through the shared `spelledPitch` helper, and
clef/transpose resolution extracted to `resolveClefTranspose` (used by both
modes — requested reuse of proven single-note logic).
- **Count-in**: an extra 5th click at the score's downbeat made players start
a beat early (perceived as "play the note before"); removed (the count-in's
last click IS the downbeat).
- **Tempo timing proven**: browser test plays every Ode note exactly in its
audio window → 8/8 matched (also caught + fixed false scoring during the
count-in itself).
- **Repeated notes**: release-based re-arm + 2.5 s at-your-pace timeout + a
clear status hint ("release, then strike again").
- **Auto-continue** (tempo, default on): exercises roll to the next section
(wrapping), melodies roll a new seed, scale drills restart.
- **BPM widget**: −/+ buttons + slider + free-typing text field (commit on
blur/Enter) — the single-note widget pattern.
- **Live-note staff** next to the mic readout: written-pitch mini staff with
8va/8vb normalization; box matches the mic button; VexFlow's
`space_above_staff_ln` (~40.5px) offset compensated (see TECHNICAL_REALIZATION).
- **Library**: 19 items (5 verified PD melodies + 14 labeled originals).
- **Zen centering** + live staff sizing/overlap per user feedback.
## Verification actually performed
- `npm run lint`, `npm run build`, `npm test -- --run`: all green (149 tests,
9 files) at commit `909c301`.
- Real-browser (headless Chrome via `scripts/phrase_e2e*.cjs`, synthetic mic):
- **Tempo timing: every note of the Ode opening played exactly in its audio
window matches (8/8)** — proves count-in/window/display alignment after
the redundant-downbeat click fix.
- Detection pipeline sanity (single-note readout shows synthetic 440 Hz).
- Phrase mode: first note matches with a sustained synthetic pitch; cursor
advances; no console errors.
- Repeated E–E (Ode to Joy): sustained pitch blocked; release (400 ms mute)
+ re-attack matches; 2.5 s sustained hold re-arms in at-your-pace mode.
- Tempo mode: count-in phase, no scoring during count-in (found + fixed a
false credit during count-in), 8 windows expired as `missed` with silence,
run reaches `done` without deadlock.
## Known limitations (honest)
- **Repeated same-pitch notes**: a genuine re-attack with no detectable gap
(same string, ringing decay) is indistinguishable from holding the note.
Mitigations shipped: release/timeout re-arm + explicit status hint. A
raw-RMS reattack detector is future work.
- **Mic matching is pitch-only**: no fingering/string verification (a mic
cannot know which string was played); no timing/onset precision in tempo
mode — it is pitch-following with a metronome, as specified.
- **Giuliani content**: not included (no verifiable source at hand). Library
holds 2 verified PD melodies + 4 clearly-labeled original exercises.
- **Live played-note staff** next to the mic readout shows the detected pitch as written notation (same display pipeline as the single-note "played note" measure).
- **ABC subset only**: documented subset (no tuplets, polyphony, alternate
endings, mid-tune meter/key changes); rejects with line/column errors.
- **Count-in/preview drift**: click scheduling uses the shared AudioContext
lookahead (~100 ms) — cosmetic audio jitter possible under load; cursor
timing derives from boundaries, not the scheduler.
- **Deviation recorded**: fixed-material range conflicts warn + offer octave
shift instead of blocking the run (contract said "require playable before
start"); judged more user-hostile than helpful at this scale.
- **Settings persistence**: in-session only (pre-existing app behavior).
## Reuse of single-note logic (user request)
- Clef/transpose resolution extracted to `resolveClefTranspose` — used by
both renderers; detection floor, tunings, note sets, `MatchTracker`,
`AudioEngine` mic-gating, Fretboard/PianoKeys all shared unchanged.
- The renderer octave bug (staff showed sounding instead of written pitch)
was fixed via the shared `spelledPitch` helper — same sounding+transpose
semantics as `SheetMusic`.
## Not done / deferred
- MIDI adapter, motif-based generation, persisted phrase setup, extra
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

@@ -0,0 +1,550 @@
# Phrase Mode — Implementation Contract
Read [the reviewed requirements](../PHRASE_MODE_PLAN.md) first. This document is
normative for the implementation handoff. It specifies behavior, not a mandate
to use exactly these filenames. Equivalent architecture is fine if the same
invariants and tests hold. Product scope changes must be recorded, not hidden.
## A. Verified baseline and hazards
Review baseline: `0d11570` (initial plan, before this review commit).
`npm run lint`, `npm run build`, and `npm test -- --run` all passed during review:
39 tests in three files. Vite already warns about a large bundle. Use the
non-watch test command for unattended execution.
Relevant current behavior:
- `App.tsx` calls `useGameLogic(pitchData)` unconditionally, owns single-note
shortcuts, and always renders the old `Controls` outside Zen.
- `useGameLogic.ts` owns random-note selection, matching, autoplay, and the old
metronome. Merely hiding its staff does not disable these effects.
- `MatchTracker.ts` returns true repeatedly under sustained matching, resetting
itself after each success. Its threshold is an elapsed matching span tolerant
of short dropouts, **not** 50 ms of strictly accumulated matching samples.
- `usePitchDetector.ts` preserves the last display pitch up to 200 ms during
null detection. `audioLevel` is smoothed. Neither is a reliable release stream.
- `useMetronome.ts` calls `onTick` while scheduling future sounds; it also creates
a separate AudioContext and emits an 880 Hz, 50 ms tone. Do not copy that callback
behavior into the phrase clock or assume those clicks cannot be recognized as A5.
- `AudioEngine.ts` has immediate `playNote` and an audible-until gate. It currently
has no cancellable group, phrase scheduling API, or public transport clock.
- `getNoteInKey` is stateless and uses a limited signature table. It cannot alone
handle measure accidental carry, correct E#/B# spelling, or arbitrary modes.
- `SettingsProvider.tsx` stores settings in memory only. There is no existing
localStorage migration/persistence mechanism to rely on.
- Guitar/bass notation normally adds 12 semitones to sounding MIDI. Voice range
overrides and whistle's -12 display offset must continue to work.
## B. Score contract: separate music, presentation, and performance
Use a versioned normalized score. Both generators and import adapters must
produce it; VexFlow and the matcher must not consume raw ABC directly.
A suitable minimal shape (names may change):
```ts
const PPQ = 480; // ticks per quarter note; never floating beat accumulation
type SpelledPitch = {
midi: number; // SOUNDING pitch, integer 0..127
step: 'C'|'D'|'E'|'F'|'G'|'A'|'B';
alter: -2|-1|0|1|2;
octave: number; // spelling octave, not floor(midi/12)
};
type ScoreEvent = {
id: string; // stable through rendering/retries
startTick: number;
durationTicks: number;
pitch: SpelledPitch | null; // null = rest
};
type Measure = { number: number; startTick: number; durationTicks: number };
type Score = {
version: 1;
id: string;
title: string;
meter: { numerator: 3|4; denominator: 4 };
key: { tonic: string; mode: string; signature: string }; // signature = parent major name, e.g. 'Bb'
measures: Measure[];
voices: [{ id: 'melody'; events: ScoreEvent[] }]; // exactly one in v1
source?: {
composer?: string;
work?: string;
locator?: string; // exercise/page/measure identification
url?: string;
rights: string; // source AND transcription status
adaptation?: string;
writtenToSoundingSemitones: number; // explicit import normalization
};
};
```
Invariants:
1. Ticks are finite nonnegative integers; durations are positive. A full measure
is `numerator * PPQ` for supported meters. Explicit pickup/closing measures
may be short; other measures must be full.
2. Events are ordered, nonoverlapping, and contiguous (silence is an explicit
rest). The final event ends at the score's final tick. Reject empty/all-rest
scores for practice. No chords or second voice are silently discarded.
3. A **logical note** is one required attack. Explicit ties between equal
sounding pitches merge into one event. The renderer splits that event into
tied glyphs at measure boundaries; matcher/summary count it once. Adjacent
equal pitches without a tie stay distinct events.
4. Keep runtime state separate: selected bar range, source-event mapping,
seed, cursor, status, matched/missed/skipped results, and scheduled audio are
not mutable fields on the score.
5. A selected range clips events to the range and rebases ticks. A tie entering
the selection becomes a new attack at its beginning; internal ties remain
one logical note. This makes starting at any bar well-defined.
6. Match and synthesize SOUNDING MIDI. Preserve enharmonic spelling. Apply the
active instrument/range display transpose **once**, only when rendering.
Example: guitar-source ABC `E,` means written E3 (MIDI 52); an explicitly
configured -12 source conversion normalizes it to sounding E2 (40). Guitar
rendering then shows E3 (52), while the mic must match 40, not 52 or 64.
7. Imported ABC defaults to concert pitch. An import option explicitly selects
“written guitar/bass (sounds octave lower)” if needed. Do not infer this
merely because the user's current instrument is guitar. Bundled metadata
supplies the appropriate convention. An optional practice octave shift is
separate and visible; never silently shift individual notes to fit a range.
## C. Theory, playable range, and generation
### C1. Shared range resolution
Extract the existing playable-note calculation into a pure helper with tests.
Preserve its old single-note behavior. Phrase mode uses that set by default.
When an explicit phrase-local fret window is enabled, derive pitches directly
from the tuning and that window instead of intersecting with the old note-set
bounds (which would incorrectly exclude high positions, especially on bass).
Make this override visible and disable the competing note-set control; retain
a selected single-string restriction if applicable. Generated material then
intersects the resolved playable set with the scale; fixed scores do not.
- Deduplicate and numerically sort pitches. Validate instrument, tuning, range,
and `0 <= minFret <= maxFret <= 24`; prevent NaN/empty values from reaching
generation. A stored guitar tuning must not constrain piano/voice/whistle.
The microphone's instrument-floor calculation must likewise ignore irrelevant
tunings; test whistle/voice/piano after switching from guitar.
- Fret constraints apply to guitar **and bass**. Do not depend on bass having a
`custom_fret` range in `InstrumentConfigs` (currently it does not).
- Each allowed guitar/bass pitch must have at least one actual allowed
`{stringIndex, fret}`. Restrict hints to those positions, including a selected
single string. Ensure the board can display the selected frets (it currently
hardcodes 15 in `App`). Do not show off-position hints.
- This is pitch availability, not an optimized fingering path or a guarantee
about what string the microphone heard. State that limitation in setup/help.
- Empty pools produce an actionable error (“Widen the range or change key”),
never a fallback chromatic/out-of-range note. Generated melodies require at
least two distinct eligible pitches; generation loops must be bounded.
- Fixed scores keep their pitches, including chromatic notes outside the key's
scale. Key signature is a notation context, not a pitch filter for repertoire.
Show range conflicts and offer an explicit octave shift or range change; do
not remove notes. Require a playable selected
range before starting. Virtual keys must also cover that selected range.
### C2. Scales and spelling
Support 12 tonic pitch classes, not just the limited existing dropdown. Modes:
| Mode | Semitone offsets from tonic |
|---|---|
| Major / Ionian | 0, 2, 4, 5, 7, 9, 11 |
| Dorian | 0, 2, 3, 5, 7, 9, 10 |
| Phrygian | 0, 1, 3, 5, 7, 8, 10 |
| Lydian | 0, 2, 4, 6, 7, 9, 11 |
| Mixolydian | 0, 2, 4, 5, 7, 9, 10 |
| Natural minor / Aeolian | 0, 2, 3, 5, 7, 8, 10 |
| Locrian | 0, 1, 3, 5, 6, 8, 10 |
| Major pentatonic | 0, 2, 4, 7, 9 |
| Minor pentatonic | 0, 3, 5, 7, 10 |
Use a conventional enharmonic key spelling requiring at most seven signature
accidentals. The chosen **mode** affects the tonic spelling and parent signature;
do not force one spelling of every root across all modes. Present the resolved
name in the UI (e.g. C# minor, not a theoretical Db-minor signature).
Derive modal signatures from their parent major scale. Pentatonics use their
corresponding major/natural-minor signature and omit scale degrees. Preserve
E#, B#, Cb, and Fb where required; do not approximate using a sharp/flat chromatic
name array. Allow imported double accidentals in the spelling model.
### C3. Melody generator
API concept: `generateMelody(config, eligiblePitches, seed) -> Result<Score>`.
All randomness flows through the supplied seeded PRNG. Save the seed with the
session; Retry never calls generation. New melody explicitly changes the seed.
- Generated length: integer 1–8 bars, default 2; meter 4/4 default, 3/4 supported.
- Two rhythm levels for v1: **Simple** (quarter/half/dotted-half/whole notes as
meter permits) and **Mixed** (also paired eighths and dotted quarters).
Use validated per-bar rhythm templates, never an unbounded fill-and-retry loop.
Simple may use quarters internally and a longer final note; do not promise
both “quarters only” and “last note must be a half note”. Rests need not be
generated in v1, but the score/importer/trainer must support them.
- Prefer movement to an adjacent degree of the full chosen scale, then thirds;
larger moves are rarer. An “adjacent eligible pitch” is not necessarily an
adjacent scale degree in a sparse fret/string pool. Weight by actual interval.
- Prefer start/end on tonic, otherwise another available stable degree. Land
the final note on a beat and normally hold it for at least two quarter beats.
- Avoid more than two equal notes in a row where alternatives exist. Favor a
step back after a leap of at least five semitones when such a step is playable.
- Hard invariants: scale/range membership, exact bar totals, valid spelling,
determinism, and termination. Musical preferences are **soft** when sparse
ranges make them impossible. Do not write unconditional “70% steps” or leap
assertions that fail valid restricted ranges. Do test preferences over a
large seeded sample with a normal diatonic range.
- Large disconnected pools should yield the best available in-range path or a
clear “range too sparse for this exercise” result, not silent constraint escape.
- Motif/answer transformations are optional and must revalidate all constraints.
### C4. Scale drills (required, separate from random melodies)
- Options: direction up/down/up-and-down; coverage one octave, two octaves, or
**within position**. Same tonic/mode/range controls as generated melodies.
- For octave drills, choose the lowest eligible tonic whose complete requested
scale path fits the playable set. If none fits, explain and offer “within
position” or a wider range; never output an incomplete “one-octave scale”.
- Within-position drills traverse the eligible scale notes in order and are
labeled as such; they may start/end away from tonic. They are not a promise
of a conventional named fingering pattern.
- Default quarter notes, no duplicate turning-point note in up-and-down runs.
Derive bar count from the run, not the melody's bars setting; extend the final
note to complete a bar, splitting with ties if needed. Cap score sizes.
- Hints apply the same position filter as generation. No finger numbers/tab
are needed for v1. Step and tempo practice use the exact same score.
## D. Input, matching, and scoring
### D1. Fresh input contract
Add a separate raw-frame callback/subscription to the pitch-detection path:
`{ sequence, observedAtMs: performance.now(), midi: number|null, rms }`.
Emit it on **every** analysis tick, including null pitch. Preserve the existing
smoothed display outputs and instrument-specific detector algorithms.
Deliver frames directly to the phrase controller (or a small subscribed store),
not by pretending every React render is another audio sample. Ignore duplicate
sequence IDs; clear subscribers on unmount. Read raw RMS from the analyzer, not
the smoothed UI meter, if using it. Do not reconstruct releases from `pitchData`.
For timed practice, stamp a frame with the **phrase transport AudioContext's**
current time when its callback is delivered. This is detection-arrival time,
not a measured acoustic attack time. Do not compare timestamps from unrelated
AudioContexts or compare `Date.now()` to AudioContext seconds. Keep performance
time for freshness/gap checks. No latency calibration is claimed in v1.
Offer microphone or virtual input explicitly. Start in microphone mode waits
for capture to actually succeed; permission denial shows the existing error and
a virtual-input alternative. Virtual mode works without microphone permission.
### D2. Matching rules
Implement a phrase matcher/controller around or beside `MatchTracker`:
1. Match exact MIDI including octave. Require the existing 50 ms tolerant
matching span on fresh mic frames; reset the candidate and accumulated
release evidence on a sample gap over 100 ms or playback gating. A single old frame cannot become a held note.
2. Each logical event has a **latched**, one-way result. A second success for
that event is ignored. Never inspect the reset tracker at a window's end
and mistakenly conclude that an earlier success did not happen.
3. For consecutive untied equal pitches, a sustained signal must not satisfy
the next event. Re-arm after at least **80 ms** of fresh null/different-pitch
frames, then require a fresh match. Brief detector dropouts must not re-arm.
Track this even across rests and tempo boundaries; one sounding episode
cannot earn credit again when a later slot returns to the same pitch.
Matching a genuinely different pitch establishes a new episode.
4. Baseline v1 cannot reliably detect a same-pitch repluck with no detected gap.
Tell the user “For repeated notes, briefly release/mute between notes.”
Fast repeated notes may require a slower BPM, particularly with long bass
analysis windows. Do not invent a robust onset detector from smoothed volume.
A tested raw-RMS reattack detector is optional later, not a release condition.
5. A virtual click/tap is an explicit attack and can immediately match once;
repeated taps can match repeated equal pitches without a mic hold. Its
synthesized sound must not contribute a second microphone result.
6. At-your-pace mode: match advances immediately to the next logical note;
ties are one note; rests display but are automatically traversed, with no
silence requirement or timing score. Wrong notes wait. Skip marks a separate
skipped result and advances. Never label eventual completion “100% accuracy”.
7. With-tempo mode: event window is `[start, end)`. A confirmed pitch anywhere
within the window latches matched; otherwise mark missed at its end.
Confirmation must complete before `end`. There is no early/late allowance
crossing into neighbors. Finish expired windows **before** applying a frame
at a boundary; never credit one frame/event to multiple targets.
8. Timed rests consume their written duration, ignore playing, and are excluded
from the score. A tied note has the combined-duration window and counts
once. V1 does not score articulation, sustained duration, or silence at rests.
9. No scoring in ready, count-in, paused, preview, done, or error states.
Keep a run/session generation token so stale callbacks cannot affect a new run.
10. Carry the consumed-pitch/re-arm protection across automatic repeats; looping
a one-note exercise must not repeatedly score one held note.
Summary: tempo mode “Matched X of Y notes”; at-your-pace “Completed X notes,
skipped Y”. Count logical notes, not tied glyphs or rests. No live scoreboard in
Zen; small end-of-run feedback only.
## E. Transport, audio, and cancellation
Use a pure transition layer with an injected clock for tests, and a thin hook
for lifecycle/audio. States: `ready`, `countIn`, `playing`, `paused`, `preview`,
`done`, `error`. A score is generated/loaded once per explicit material change,
not whenever pitch, theme, BPM, or window size changes.
### E1. Timing and transitions
- Phrase BPM is independent of the single-note rhythm/timer controls: quarter
note = 30–180 BPM, default 60. Validate finite numbers in the controller too.
- Invoke audio-context creation/resume directly in the Start gesture, before
awaiting microphone permission (important for Safari autoplay policy). Await
both successful capture, when needed, and context resume before running.
Begin neither count-in nor timers on a suspended clock; show a recoverable
Start action.
- Use one transport/output AudioContext for phrase clicks, preview, and cursor
timing. A 25 ms scheduler with roughly 100 ms lookahead is appropriate for
audio scheduling only. Update cursor/state when `currentTime` reaches the
boundary, not when the future oscillator is queued.
- Derive each boundary from `runStart + tick / PPQ * 60 / bpm`, not repeated
floating additions or `setInterval` increments. Visual animation may use rAF.
- Count-in is one complete measure (3 or 4 quarter beats), also for a pickup.
At 60 BPM, a 4/4 count-in clicking at times 0, 1, 2, 3 starts the score at 4.
- At-your-pace Pause/Resume preserves the current logical target/results.
With-tempo Resume re-counts and restarts the current bar, clearing results
from that bar onward; a note tied into that bar becomes a fresh attack.
This is simpler and less confusing than resuming midway through a note.
- Retry resets results and restarts the identical selected music (count-in if
timed). New melody changes the seed. Library/import initially selects the
first two measures (or all if shorter); let the user select start and count,
capped at eight measures per practice range. Next advances to the next
selected-length bar range; at the end offer “Back to beginning”,
not an unrelated piece. Scale drills offer Repeat rather than random Next.
- A Repeat checkbox (off by default) retries the selected range automatically;
timed repeats get a fresh count-in. The user can always pause/stop it.
- Applying structural setup changes (key, range, source, meter, instrument,
tuning, pace) cancels the run and rebuilds to ready. Changing BPM while running
pauses; the next Resume recomputes timing. Do not retime a partly played note.
- Pause on document hidden, mic disconnection in mic mode, audio suspension,
opening a settings/help/import dialog, or an observed processing gap >250 ms.
Do not fast-forward a backgrounded phrase and mark everything missed.
### E2. Output ownership and mic isolation
Extend `AudioEngine` or add a shared-context phrase transport with:
`resume`, `now`, scheduled-note playback, and cancellable playback groups.
Keep existing single-note behavior intact. Track oscillator/gain nodes, not
just timers; clearing a timer cannot stop audio already queued by lookahead.
- **Preview:** pause practice, play the selected range at its written durations,
with no scoring. The button toggles Preview/Stop preview. On stop/end return
to ready/paused, never automatically to playing. Gate mic input through the
actual output plus the existing 400 ms tail. Cancel future sounds and remove
their future gate reservations; a canceled long preview must not leave the
mic gated until its former end time.
- **Virtual-note audio:** explicit taps count; their speaker sound does not.
Preserve the existing mic gate for pitched reference/virtual audio.
- **Metronome:** do not feed every click through that 400 ms pitched-audio gate.
Default click sound off (visual count-in/beat cue on). Audible clicks are an
explicit option with a headphones recommendation. Use a short non-tonal
click (e.g. <=10 ms noise burst), not the current 880 Hz tone. Exclude mic
samples for 30 ms after a scheduled audible click; do not count that blanked
interval as release evidence. Validate that normal notes still match with
clicks enabled. This is a modest bleed safeguard, not acoustic echo cancellation;
speakers/room reverb may still require headphones or silent clicks.
- Silent/zero-volume output must not create audible gates or tiny residual tones.
- Cancel all scheduled groups, rAFs, timers, and subscriptions on Stop, mode
switch, score replacement, or unmount. A canceled callback also checks its
run token. Do not create a new AudioContext for every render or note.
## F. Notation and view contract
Create a phrase renderer rather than forcing multi-note behavior into the
single-whole-note `SheetMusic` component. Share styling utilities if useful.
- Preserve source spelling; maintain accidental state by letter AND octave
within each measure, initialized from the key signature and reset at barlines.
Naturals cancel a signature accidental; reintroduced sharps/flats need a sign.
Example, `K:G`, `L:1/4`, `=F F ^F F | F4 |`: first two Fs natural, next two
sharp, next-bar F sharp by signature. The third note needs an explicit sharp.
- Use real note spacing, stems, dots, and beat-appropriate eighth-note beams.
Split long logical notes with ties. Support 3/4 and 4/4 without abusing
VexFlow soft voices to conceal malformed bar totals. Pickups are explicit.
- Use the active instrument/range clef/transpose. For piano grand staff, align
both staves to the same timeline, using invisible rhythmic placeholders where
needed; the score is still monophonic, not two simultaneously scored voices.
- Show a bounded window of measures: typically two, one on narrow screens,
up to four when space permits. Advance at a bar/page boundary, not on every
note; keep the current note and following material readable. Repeat clef/key
at each new displayed system. Show source bar numbers/range for orientation.
- Re-layout on score/range/width/theme changes. Do not rebuild the entire VexFlow
SVG at microphone frame rate. Memoize static notation; update current-note
indicator and result styles at event transitions. No live detected-note
“second measure” should push the phrase around.
- Current note uses a caret/underline or outline as well as color. Passed and
missed coloring remains subtle and legible in both themes. Honor reduced
motion; preserve the existing ledger-line and system-theme-change fixes.
- Small portraits may scroll a dense measure horizontally as a last resort,
but no clipping/illegibly tiny global scaling. Validate actual browser output,
not just a JSX smoke test. Give the score an accessible text description.
## G. ABC adapter, library, and local import
Keep the parser behind `parseAbc(text, options) -> Result<Score, Diagnostic[]>`.
A documented subset parser is acceptable; a maintained parser dependency is
also acceptable if its output is normalized/validated and the bundle impact
is justified. Do not implement full ABC from an optimistic line-count estimate.
Required subset:
- One tune/voice; headers `X`, `T`, `C`, `S`, `Z`, `M`, `L`, `Q`, `K`; blank
lines and `%` comments. Keys: tonic letter plus optional `#`/`b`, followed
by no suffix/`maj` (major), `m`/`min` (natural minor), or the modal suffixes
`ion`, `dor`, `phr`, `lyd`, `mix`, `aeo`, `loc`; e.g. `K:F#`, `K:Am`,
`K:Ddor`. Reject unrecognized key specifications, never fall back to C.
- `A`–`G`, `a`–`g`, comma/apostrophe octaves, `^`, `_`, `=`, and doubled
accidentals; durations via integer/fraction suffixes; rests `z`.
- `L:1/4` and `L:1/8`; supported resulting durations: eighth, quarter,
dotted quarter, half, dotted half, whole. Other values return a clear error.
Dots are encoded using ABC length multipliers, not invented note-dot syntax.
- `|`, `||`, `|]`; simple nonnested `|: ... :|` repeats exactly twice, expanded
before normalization. No alternate endings or implicit/nested repeats.
- Explicit `-` ties between equal pitches, including across barlines. Preserve
spelling/accidental carry while parsing, merge ties into logical events after.
- 3/4 and 4/4; optional first short pickup. A short final measure is allowed
if it complements that pickup; other short/overfull measures are errors.
Source barlines are validated, not guessed from an arbitrary stream of notes.
- `Q` is metadata/default suggestion; the user's phrase BPM controls practice.
No tempo/meter/key changes partway through a tune in v1.
Reject with line/column and a useful message: chords `[CEG]`, multiple tunes or
voices, tuplets, grace notes, unsupported decorations/directives, alternate
endings, malformed ties, unsupported lengths/meters, unknown syntax. An explicit
whitelist may ignore recognized nonmusical metadata; never silently strip
unknown notation. Single-voice extraction from a polyphonic file is not automatic.
Bounds: at most 256 KiB input, 256 expanded measures, and 4096 events. Apply
bounds during tokenization/repeat expansion, not only after allocating output.
No eval/HTML injection, fetched URLs, or external resource interpretation.
Render titles/errors as text. Invalid imports keep the previous score intact.
File loading is local/in-session and available via a setup-panel action.
Use this concert-pitch example as a parser/authoring fixture:
```abc
X:1
T:First steps
C:Original exercise
M:4/4
L:1/4
Q:1/4=60
K:C
C D E F | G2 E C |]
```
Expected MIDI: `60, 62, 64, 65, 67, 64, 60`; durations in ticks:
`480, 480, 480, 480, 960, 480, 480`; two complete bars. Also test
`C4- | C4 |` as one eight-quarter-note logical event, and a pickup such as
`G | C D E F | G3 |]` (with the same meter/unit/key).
Bundle files with a manifest giving title, composer, source URL/locator,
transcription rights, adaptation, and pitch convention. Use Vite `?raw` imports
or base-path-safe assets, not a dev-only filesystem fetch. Parse/validate every
bundled item in tests. Document the exact subset plus a copyable working file.
Content policy:
- Target 4–8 checked items, including original scale/interval/arpeggio exercises
and verified public-domain melody excerpts. Strive for a verified Giuliani
single-line adaptation, but do not fabricate provenance to satisfy a quota.
- Giuliani Op. 1 may require omitting simultaneous or sustained parts. Identify
which exercise/measures and precisely what was changed. A composer's old
work does not automatically clear a modern edition/arrangement/transcription.
- Original generic technical exercises are labeled “Original exercise”, not
“Giuliani No. X” or a guessed Sor/Carcassi excerpt. Keep a small SOURCES/readme
alongside the manifest. Quality and verifiability outweigh library size.
## H. App integration, settings, and shortcuts
Suggested responsibilities:
- Pure `music/` helpers: range resolution, scale/key spelling, seeded generators.
- `exercises/`: score model/validation, ABC adapter, library and source metadata.
- `game/`: phrase matcher and pure transport transitions.
- `audio/` plus a hook: cancellable scheduled audio and raw-frame/clock adapter.
- Components: phrase trainer, renderer, compact setup/transport.
Render **either** `SingleNoteTrainer` (owns `useGameLogic`) **or** `PhraseTrainer`.
Extracting the old UI/hook into a child is preferable to calling hooks
conditionally. Alternatively gate all old effects explicitly and test it.
App may continue to own shared microphone capture, theme, and global chrome.
Clean up pre-existing timers when extracting the old subtree.
Phrase configuration includes material (`melody|scale|library|import`), tonic
pitch class/mode, melody bars/meter/rhythm level, scale direction/coverage,
optional fret window, source/range/octave shift, pace, input, BPM, click sound,
and Repeat. Runtime status/seed/results are separate. Defaults: melody, C major,
2 bars, 4/4, Simple, at-your-pace, mic input, 60 BPM, click off, Repeat off.
Scale defaults: one octave/up-and-down, with an actionable error if it cannot fit.
Keep phrase controls independent from legacy `rhythm.active/autoAdvance/seconds`.
Hide/replace the legacy key/rhythm panels in phrase mode while keeping shared
instrument, tuning, range, tuner, and theme controls. Do not accidentally force
single-note autoplay for phrases. Settings changes must not regenerate scores
when merely toggling hints/theme or receiving a microphone frame.
Zen shows notation plus a discreet transport (Start/Pause, mic status/control,
Exit); at completion expose Retry/Next. Never require leaving Zen or using a
keyboard to recover from pause/completion/error. Setup/library UI stays hidden.
Keyboard policy: retain existing single-note shortcuts in those modes. In
Phrases: Space Start/Pause/Resume, P Preview/Stop preview, R Retry, N Next/New,
S Skip (at-your-pace only). M cycles all three modes; Z/H/V/L retain their roles.
Help reflects the active mode. Do not handle global shortcuts inside input,
textarea, select, button, contenteditable, or open dialog; ignore modifiers and
key auto-repeat for destructive/transport actions. Enter/Space on focused buttons
must trigger only their native action, not an additional global skip/start.
Optional persistence stores only validated versioned setup/selection; never
mic permission, autoplay/resume state, audio objects, raw frames, or live results.
Failure/unavailable storage must not prevent practice.
## I. Required verification and completion record
Run baseline and final commands:
```sh
npm run lint
npm run build
npm test -- --run
```
Minimum regression cases (automate pure logic/integration cases):
| Area | Required evidence |
|---|---|
| Theory | Every tonic × supported mode; valid spelling/signature, correct pitch classes, F# major E# and C# major B#, instrument octave conversions |
| Generation | At least 100 seeds per normal configuration; exact bar sums, allowed pitches, determinism; empty/single/sparse pools; alternate tunings and invalid fret ranges; zero unbounded retries |
| Scales | C major one octave gives C D E F G A B C in eligible register; down/both order; turning tonic once; full-octave refusal when a degree is unavailable; position-only hints |
| At your pace | C–D–E advances only in order; wrong pitches wait; C–C needs release/re-tap; 20 ms dropout does not re-arm; 80 ms release does; ties count once; rests don't stall; Skip is not a hit |
| Freshness | Duplicate/stale frames do not score; held display data on null raw frames cannot match; >100 ms sample gap resets candidate; mic-off/permission failure doesn't run a broken session |
| Tempo | Fake-clock count-in starts exactly at the notated boundary; queuing 100 ms ahead does not advance early; last-note hit latches; boundary samples belong only to new window; misses/rests advance on time; held C cannot score C–rest–C twice |
| Audio | Preview never scores; cancel removes future sounds/gates; repeated virtual taps work; zero-volume has no gate; synthetic metronome-only input does not score A5/other notes; clicks plus played notes remain usable |
| Lifecycle | Rapid Start/Stop/Retry and React StrictMode do not duplicate timers/notes; mode switch leaves exactly one trainer active; pause/resume/bar reset; tab hidden and audio/mic interruption pause rather than catch up |
| Import | Every bundled ABC validates; octave/accidental carry, naturals, dotted lengths, ties, pickups, bounded repeats; unsupported polyphony/tuplets, malicious title strings, malformed/oversized inputs fail safely |
| Range/repeat | Retry uses same seed/events; Next selects correct bars; clipped tie becomes attack; automatic Repeat cannot farm one sustained pitch; out-of-range material never silently changes pitch |
| Rendering/UX | Real browser: desktop, ~390px portrait, short landscape; light/dark/auto, treble/bass/grand, fret hints, mixed rhythm/ties; readable notation; no full SVG reconstruction at raw frame rate; keyboard and touch Zen completion |
| Legacy | Sight Reading and Ear Training, autoplay/quiet wait, tuner, microphone toggle, virtual instruments, theme, shortcuts, and old metronome continue to work |
Use synthetic microphone input in a real browser where feasible (a scripted
oscillator into `MediaStreamDestination`, substituting `getUserMedia`), not
only virtual clicks. Distinguish that from testing a physical guitar in a room.
Save reproducible tests/scripts rather than relying on one-off visual claims.
If a browser/device is unavailable, identify the missing coverage explicitly.
Delivery checklist must map R1–R14 to code/tests and state what was actually
verified, any content/parser/detection limitations, and remaining optional work.
Do not suppress lint/type errors, disable tests, silently reduce required scope,
or claim “no regressions by construction”.
Git: the reviewed baseline has local commits not on `origin/main`. Preserve
those; use `feature/phrase-mode` and ordinary checkpoint commits. Approved backup
means that branch to `origin` (git.0jm.de). GitHub Pages would be triggered by
updating GitHub `main`; deployment is not part of approval by default. Record
push failures; do not force-push or change remote configuration to bypass them.

View File

@@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'scripts']),
{
files: ['**/*.{ts,tsx}'],
extends: [

21
scripts/README.md Normal file
View File

@@ -0,0 +1,21 @@
# Browser QA scripts
Synthetic-microphone end-to-end checks for Phrase Mode (puppeteer-core +
system Chrome). They stub `getUserMedia` with a tunable oscillator feeding a
`MediaStreamDestination`, so note detection is exercised through the real
detection pipeline — not only through virtual taps.
Run:
```sh
npm i --no-save puppeteer-core # does not modify package.json
npm run build
npx vite preview --port 4199 & # serve dist
node scripts/phrase_e2e.cjs # matcher basics (first-note match)
node scripts/phrase_e2e2.cjs # repeated notes, release re-attack,
# timeout re-arm, tempo count-in/misses
```
In-app debug state is available with `localStorage.phraseDebug = '1'`
(`window.__phraseDebug()` returns phase, cursor, statuses, summary, pool,
frame count, mic-gate state). Only ever a QA aid, not part of the UI.

92
scripts/phrase_e2e.cjs Normal file
View File

@@ -0,0 +1,92 @@
/* eslint-disable */
/* Synthetic-mic end-to-end test for Phrase Mode (puppeteer-core + system Chrome).
* Feeds a sustained oscillator into a fake getUserMedia stream, then verifies
* the phrase trainer advances when the current note matches. */
const puppeteer = require('puppeteer-core');
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const URL = 'http://localhost:4199/';
const midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12);
(async () => {
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: ['--no-sandbox', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'],
});
const page = await browser.newPage();
const errors = [];
page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
page.on('pageerror', (err) => errors.push('PAGEERROR: ' + err.message));
await page.evaluateOnNewDocument(() => {
localStorage.setItem('phraseDebug', '1');
// Stub the microphone with an oscillator we can retune.
const ctx = new AudioContext();
const osc = ctx.createOscillator();
osc.type = 'triangle';
osc.frequency.value = 440;
const gain = ctx.createGain();
gain.gain.value = 0.5;
const dest = ctx.createMediaStreamDestination();
osc.connect(gain).connect(dest);
osc.start();
window.__setOscFreq = (hz) => { osc.frequency.value = hz; };
navigator.mediaDevices.getUserMedia = async () => dest.stream;
});
await page.goto(URL, { waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 800));
// 1. Turn the mic on (click the mic button) and verify detection works at all.
await page.click('button[aria-label="Start Listening"]');
await new Promise(r => setTimeout(r, 1500));
const readout = await page.$eval('.pitch-readout', el => el.textContent).catch(() => null);
console.log('STEP1 single-note readout (440Hz):', JSON.stringify(readout));
// 2. Switch to Phrases mode.
const buttons = await page.$$('.game-mode-toggle .toggle-option');
await buttons[2].click();
await new Promise(r => setTimeout(r, 1200));
let dbg = await page.evaluate(() => window.__phraseDebug ? window.__phraseDebug() : null);
console.log('STEP2 phrase debug:', JSON.stringify(dbg && { frames: dbg.frames, scoreOk: dbg.scoreOk, phase: dbg.phase, firstNote: dbg.firstNote, material: dbg.material }));
// 3. Retune the oscillator to the FIRST note of the phrase and press Start.
if (dbg && dbg.firstNote !== null) {
const hz = midiToFreq(dbg.firstNote);
await page.evaluate((f) => window.__setOscFreq(f), hz);
console.log('oscillator set to', hz, 'Hz (midi', dbg.firstNote, ')');
}
// Start button (first button in the transport row)
const startBtn = await page.evaluateHandle(() => {
const row = document.querySelector('.phrase-transport');
return row ? row.querySelector('button') : null;
});
await (startBtn.asElement ? startBtn.asElement() : startBtn).click();
await new Promise(r => setTimeout(r, 1500));
dbg = await page.evaluate(() => window.__phraseDebug());
console.log('STEP3 after Start:', JSON.stringify({ phase: dbg.phase, currentIdx: dbg.currentIdx, frames: dbg.frames, gate: dbg.micGate, blanked: dbg.micBlanked }));
// 4. Wait for the first note to match (hold 50ms on sustained oscillator).
let matched = false;
for (let i = 0; i < 40; i++) {
await new Promise(r => setTimeout(r, 250));
dbg = await page.evaluate(() => window.__phraseDebug());
if (dbg.currentIdx >= 1 || (dbg.statuses && dbg.statuses[0] === 'matched')) { matched = true; break; }
}
console.log('STEP4 first note matched:', matched, JSON.stringify({ phase: dbg.phase, currentIdx: dbg.currentIdx, statuses: dbg.statuses, summary: dbg.summary }));
// 5. Move the oscillator to the SECOND note and check further progress.
if (matched) {
// find second note pitch via debug statuses? read from events through a hack: statuses only.
// Instead: keep oscillator, and just report the state after another few seconds.
await new Promise(r => setTimeout(r, 2500));
dbg = await page.evaluate(() => window.__phraseDebug());
console.log('STEP5 after 2.5s more:', JSON.stringify({ phase: dbg.phase, currentIdx: dbg.currentIdx, summary: dbg.summary }));
}
console.log('CONSOLE ERRORS:', errors.length ? errors.slice(0, 6) : 'none');
await browser.close();
})().catch(e => { console.error('FATAL', e); process.exit(1); });

155
scripts/phrase_e2e2.cjs Normal file
View File

@@ -0,0 +1,155 @@
/* eslint-disable */
/* Synthetic-mic end-to-end tests, part 2: repeated notes, release re-attack,
* timeout re-arm, tempo mode. Run: node phrase_e2e2.cjs (preview on :4199) */
const puppeteer = require('puppeteer-core');
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const URL = 'http://localhost:4199/';
const midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12);
(async () => {
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: ['--no-sandbox', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'],
});
const page = await browser.newPage();
const errors = [];
page.on('console', (msg) => { if (msg.type() === 'error') errors.push(msg.text()); });
page.on('pageerror', (err) => errors.push('PAGEERROR: ' + err.message));
await page.evaluateOnNewDocument(() => {
localStorage.setItem('phraseDebug', '1');
const ctx = new AudioContext();
const osc = ctx.createOscillator();
osc.type = 'triangle';
const gain = ctx.createGain();
gain.gain.value = 0.5;
const dest = ctx.createMediaStreamDestination();
osc.connect(gain).connect(dest);
osc.start();
window.__setOscFreq = (hz) => { osc.frequency.value = hz; };
window.__setOscGain = (g) => { gain.gain.value = g; };
navigator.mediaDevices.getUserMedia = async () => dest.stream;
});
await page.goto(URL, { waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 500));
await page.click('button[aria-label="Start Listening"]');
await new Promise(r => setTimeout(r, 800));
const dbg = () => page.evaluate(() => window.__phraseDebug());
// Switch to Phrases, then select the Ode exercise via the setup panel.
const toggles = await page.$$('.game-mode-toggle .toggle-option');
await toggles[2].click();
await new Promise(r => setTimeout(r, 600));
// open setup panel and choose material=library, exercise=ode-to-joy
await page.evaluate(() => {
const summary = document.querySelector('.phrase-setup summary');
if (summary) summary.click();
});
await new Promise(r => setTimeout(r, 300));
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
const material = selects[0];
material.value = 'library';
material.dispatchEvent(new Event('change', { bubbles: true }));
});
await new Promise(r => setTimeout(r, 300));
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
const exSel = selects[1]; // Exercise select (material === library)
exSel.value = 'ode-to-joy';
exSel.dispatchEvent(new Event('change', { bubbles: true }));
});
await new Promise(r => setTimeout(r, 600));
let d = await dbg();
console.log('SETUP ode loaded:', d.scoreOk, 'firstNote(midi):', d.firstNote);
// Ode starts E E F G: play E4 (64) = 329.63 Hz
await page.evaluate((f) => window.__setOscFreq(f), midiToFreq(64));
const startBtn = await page.evaluateHandle(() => document.querySelector('.phrase-transport button'));
await (startBtn.asElement ? startBtn.asElement() : startBtn).click();
await new Promise(r => setTimeout(r, 1200));
d = await dbg();
console.log('A started:', d.phase, 'idx', d.currentIdx, 'statuses', JSON.stringify(d.statuses?.slice(0, 3)));
// A1: first E should match quickly
let ok1 = false;
for (let i = 0; i < 20; i++) { await new Promise(r => setTimeout(r, 200)); d = await dbg(); if (d.currentIdx >= 1) { ok1 = true; break; } }
console.log('A1 first E matched:', ok1, 'idx', d.currentIdx);
// A2: second E (same pitch, sustained) must NOT match while blocked (within ~1.5s)
await new Promise(r => setTimeout(r, 1500));
d = await dbg();
const blockedHeld = d.currentIdx === 1 && d.statuses[1] === 'pending';
console.log('A2 sustained E still blocked after 1.5s:', blockedHeld, 'idx', d.currentIdx);
// A3: release (mute 300ms), re-attack → should match
await page.evaluate(() => window.__setOscGain(0));
await new Promise(r => setTimeout(r, 400));
await page.evaluate(() => window.__setOscGain(0.5));
let ok3 = false;
for (let i = 0; i < 20; i++) { await new Promise(r => setTimeout(r, 200)); d = await dbg(); if (d.currentIdx >= 2) { ok3 = true; break; } }
console.log('A3 release + re-attack matched:', ok3, 'idx', d.currentIdx, JSON.stringify(d.statuses?.slice(0, 4)));
// A4: timeout re-arm — retry, hold E through the block; after ~2.6s it should unblock.
await page.evaluate(() => { const r = document.querySelector('.phrase-transport .hint-button'); });
await page.evaluate(() => {
const row = document.querySelector('.phrase-transport');
const buttons = [...row.querySelectorAll('button')];
const retry = buttons.find(b => b.textContent.includes('Retry'));
retry && retry.click();
});
await new Promise(r => setTimeout(r, 800));
d = await dbg();
// After retry the first E matches quickly (idx >= 1). Now hold E forever
// (never mute): the 2.5s timeout should re-arm and match note 1 (idx 2).
let ok4 = false;
for (let i = 0; i < 25; i++) { await new Promise(r => setTimeout(r, 250)); d = await dbg(); if (d.currentIdx >= 2) { ok4 = true; break; } }
const held = ok4; // matched without any release
console.log('A4 timeout re-arm matched held E (expect true, ~2.5s):', held, 'idx', d.currentIdx);
// Turn auto-continue OFF so the tempo run ends (default is ON).
await page.evaluate(() => {
const labels = [...document.querySelectorAll('.phrase-setup-grid .phrase-check')];
const ac = labels.find(l => l.textContent.includes('Auto-continue'));
const box = ac?.querySelector('input');
if (box && box.checked) { box.click(); }
});
await new Promise(r => setTimeout(r, 300));
// B: tempo mode — pause first (pace change while running pauses by design).
await page.evaluate(() => {
const row = document.querySelector('.phrase-transport');
[...row.querySelectorAll('button')].find(b => ['Pause', 'Resume', 'Start'].some(t => b.textContent.includes(t)))?.click();
});
await new Promise(r => setTimeout(r, 500));
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
const pace = selects.find(s => [...s.options].some(o => o.value === 'tempo'));
if (pace) { pace.value = 'tempo'; pace.dispatchEvent(new Event('change', { bubbles: true })); }
});
await new Promise(r => setTimeout(r, 600));
await page.evaluate(() => {
const row = document.querySelector('.phrase-transport');
row?.querySelector('button')?.click(); // first transport button (Start/Resume)
});
await new Promise(r => setTimeout(r, 1000));
d = await dbg();
console.log('B1 count-in:', d.phase);
// Mute the oscillator so nothing matches; count-in 4 beats @60bpm = 4s + run.
await page.evaluate(() => window.__setOscGain(0));
await new Promise(r => setTimeout(r, 1300));
d = await dbg();
console.log('B2 during/after count-in phase:', d.phase);
// Let it play through ~15s of phrase
await new Promise(r => setTimeout(r, 15000));
d = await dbg();
console.log('B3 after run:', JSON.stringify({ phase: d.phase, summary: d.summary, statuses: d.statuses?.slice(0, 6) }));
const allMissed = d.statuses && d.statuses.every(s => s === 'missed' || s === 'rest' || s === 'pending');
console.log('B4 windows advanced with silence (misses, no deadlock):', allMissed && d.summary.missed > 0);
console.log('CONSOLE ERRORS:', errors.length ? errors.slice(0, 6) : 'none');
await browser.close();
})().catch(e => { console.error('FATAL', e); process.exit(1); });

122
scripts/phrase_e2e3.cjs Normal file
View File

@@ -0,0 +1,122 @@
/* eslint-disable */
/* Tempo-mode timing test: play every note of the Ode opening exactly in its
* window; expect 8/8 matched. Also verifies the count-in has no extra click
* by checking the phase transition timing. Run: node scripts/phrase_e2e3.cjs */
const puppeteer = require('puppeteer-core');
const CHROME = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const URL = 'http://localhost:4199/';
const midiToFreq = (m) => 440 * Math.pow(2, (m - 69) / 12);
(async () => {
const browser = await puppeteer.launch({
executablePath: CHROME,
headless: 'new',
args: ['--no-sandbox', '--use-fake-ui-for-media-stream', '--autoplay-policy=no-user-gesture-required'],
});
const page = await browser.newPage();
const errors = [];
page.on('pageerror', (err) => errors.push('PAGEERROR: ' + err.message));
await page.evaluateOnNewDocument(() => {
localStorage.setItem('phraseDebug', '1');
const ctx = new AudioContext();
const osc = ctx.createOscillator();
osc.type = 'triangle';
const gain = ctx.createGain();
gain.gain.value = 0.5;
const dest = ctx.createMediaStreamDestination();
osc.connect(gain).connect(dest);
osc.start();
osc.frequency.value = 440;
window.__setOscFreq = (hz) => { osc.frequency.value = hz; };
window.__setOscGain = (g) => { gain.gain.value = g; };
navigator.mediaDevices.getUserMedia = async () => dest.stream;
});
await page.goto(URL, { waitUntil: 'networkidle2' });
await new Promise(r => setTimeout(r, 400));
await page.click('button[aria-label="Start Listening"]');
await new Promise(r => setTimeout(r, 700));
const dbg = () => page.evaluate(() => window.__phraseDebug());
await page.evaluate(() => {
[...document.querySelectorAll('.game-mode-toggle .toggle-option')][2].click();
});
await new Promise(r => setTimeout(r, 500));
await page.evaluate(() => document.querySelector('.phrase-setup summary')?.click());
await new Promise(r => setTimeout(r, 250));
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
selects[0].value = 'library';
selects[0].dispatchEvent(new Event('change', { bubbles: true }));
});
await new Promise(r => setTimeout(r, 250));
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
selects[1].value = 'ode-to-joy';
selects[1].dispatchEvent(new Event('change', { bubbles: true }));
});
// tempo pace + 60 bpm
await page.evaluate(() => {
const selects = [...document.querySelectorAll('.phrase-setup-grid select')];
const pace = selects.find(s => [...s.options].some(o => o.value === 'tempo'));
pace.value = 'tempo';
pace.dispatchEvent(new Event('change', { bubbles: true }));
});
await new Promise(r => setTimeout(r, 500));
const d0 = await dbg();
const midis = d0.midis; // sounding midi per event
console.log('loaded ode:', d0.scoreOk, 'events:', midis.length, midis.slice(0, 8));
// Start, detect phase transition to playing, then schedule per-note attacks.
await page.evaluate(() => {
document.querySelector('.phrase-transport button')?.click();
});
// poll until phase flips to playing (count-in ends)
let playAt = null;
for (let i = 0; i < 200; i++) {
await new Promise(r => setTimeout(r, 20));
const d = await dbg();
if (d.phase === 'playing') { playAt = performance.now(); break; }
if (d.phase === 'ready') { /* not started? */ }
}
console.log('phase playing at', playAt);
if (!playAt) throw new Error('never reached playing');
// Schedule note attacks exactly on the AUDIO clock. Map perf->audio once:
const dNow = await dbg();
const perfOffset = performance.now() - dNow.audioNow * 1000; // perfMs = audioSec*1000 + offset
await page.evaluate((cfg) => {
const { midis, offset, scoreStart, spb, n } = cfg;
const toPerf = (audioSec) => offset + audioSec * 1000 - performance.now();
let prev = null;
for (let i = 0; i < n; i++) {
const m = midis[i];
const startSec = scoreStart + (i * spb);
const startMs = offset + startSec * 1000;
if (prev !== null && m === prev) {
setTimeout(() => window.__setOscGain(0), startMs - 250 - performance.now());
setTimeout(() => { window.__setOscGain(0.5); window.__setOscFreq(440 * Math.pow(2, (m - 69) / 12)); }, startMs + 40 - performance.now());
} else {
setTimeout(() => window.__setOscFreq(440 * Math.pow(2, (m - 69) / 12)), startMs + 15 - performance.now());
}
prev = m;
}
setTimeout(() => window.__setOscGain(0), offset + (scoreStart + n * spb) * 1000 - performance.now() + 50);
}, { midis, offset: perfOffset, scoreStart: dNow.scoreStart, spb: dNow.spb, n: Math.min(8, midis.length) });
// wait for done
let final = null;
for (let i = 0; i < 150; i++) {
await new Promise(r => setTimeout(r, 200));
final = await dbg();
if (final.phase === 'done') break;
}
console.log('RESULT:', JSON.stringify(final ? { phase: final.phase, summary: final.summary, statuses: final.statuses } : null));
console.log('EXPECT matched 8 of 8');
console.log('CONSOLE ERRORS:', errors.length ? errors.slice(0, 5) : 'none');
await browser.close();
})().catch(e => { console.error('FATAL', e); process.exit(1); });

View File

@@ -203,7 +203,18 @@ button {
.pitch-readout {
display: flex;
flex-direction: column;
justify-content: center;
min-width: 100px;
/* Fixed height: the note display (3rem note + Hz line ≈ 70px) and the
"Listening..." placeholder differ in height — without a reserved height
everything below this element jumps up and down whenever a note
starts or stops sounding. */
height: 70px;
}
.pitch-readout .placeholder {
font-size: 0.9rem;
color: var(--color-text-muted);
}
.detected-note {
@@ -285,10 +296,18 @@ button {
.control-select {
padding: var(--spacing-sm);
border-radius: var(--radius-sm);
border: 1px solid #ddd;
border: 1px solid color-mix(in srgb, var(--color-text-main) 20%, transparent);
font-family: inherit;
font-size: 0.9rem;
background-color: var(--color-bg);
background-color: var(--color-surface);
color: var(--color-text-main);
}
.control-input {
color: var(--color-text-main);
background-color: var(--color-surface);
border: 1px solid color-mix(in srgb, var(--color-text-main) 20%, transparent);
border-radius: var(--radius-sm);
}
.control-button {
@@ -330,7 +349,9 @@ button {
width: 44px;
height: 24px;
border-radius: 99px;
background-color: #ddd;
/* Themed track: dark in dark mode, gray in light mode
(was hardcoded #ddd — glowed in dark mode) */
background-color: color-mix(in srgb, var(--color-text-main) 25%, transparent);
position: relative;
cursor: pointer;
transition: background-color 0.2s;
@@ -347,7 +368,8 @@ button {
.switch-thumb {
width: 18px;
height: 18px;
background-color: white;
/* Themed: contrasts with both the off-track and the active track */
background-color: var(--color-surface);
border-radius: 50%;
position: absolute;
left: 3px;
@@ -958,4 +980,246 @@ button {
/* Ensure FretboardHint container handles flexible width generic */
.fretboard-container {
width: 100%;
}
}
/* ------------------------- Phrase Mode ------------------------- */
.phrase-card {
max-width: 920px;
width: 100%;
}
.phrase-title-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--color-text-muted);
}
.phrase-title {
font-weight: 600;
font-size: 15px;
color: var(--color-text-main);
}
.phrase-range {
font-weight: 400;
color: var(--color-text-muted);
}
.phrase-status {
min-height: 1.4em;
}
.phrase-transport {
margin-top: 16px;
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
}
.phrase-error {
padding: 24px 8px;
color: var(--color-text-muted);
font-size: 14px;
text-align: center;
}
.phrase-warning {
font-size: 12px;
color: var(--color-warning);
text-align: center;
margin: 4px 0;
}
.phrase-note {
font-size: 11px;
opacity: 0.7;
margin: 6px 0 0 0;
}
.phrase-setup {
margin-bottom: 16px;
border: 1px solid color-mix(in srgb, var(--color-text-main) 18%, transparent);
border-radius: 10px;
background: color-mix(in srgb, var(--color-text-main) 4%, transparent);
}
.phrase-setup summary {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
color: var(--color-text-main);
padding: 12px 14px;
user-select: none;
list-style: none;
border-radius: 10px;
transition: background 0.15s ease;
}
/* Hide the default disclosure triangle — the chevron icon does the job */
.phrase-setup summary::-webkit-details-marker {
display: none;
}
.phrase-setup summary:hover {
background: color-mix(in srgb, var(--color-text-main) 7%, transparent);
}
.phrase-setup[open] summary {
border-bottom: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
border-radius: 10px 10px 0 0;
}
.phrase-setup summary svg {
transition: transform 0.15s ease;
}
.phrase-setup[open] summary svg {
transform: rotate(180deg);
}
.phrase-setup-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
gap: 10px 14px;
padding: 12px 14px 14px;
}
.phrase-setup-grid label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: var(--color-text-muted);
}
.phrase-setup-grid select,
.phrase-setup-grid input[type='number'] {
padding: 6px 8px;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--color-text-main) 20%, transparent);
background: var(--color-surface);
color: var(--color-text-main);
font-size: 13px;
}
.phrase-check {
flex-direction: row !important;
align-items: center;
gap: 6px !important;
font-size: 13px !important;
color: var(--color-text-main) !important;
align-self: end;
padding-bottom: 6px;
}
.phrase-sheet {
display: flex;
justify-content: center;
}
.live-note-staff {
flex: none;
opacity: 0.9;
}
.live-note-staff {
height: 64px; /* matches the mic button — vertical centering via the bar's align-items */
}
.live-note-staff svg {
display: block; /* inline SVG baseline gap made the box taller than the SVG */
overflow: visible; /* extreme ledger lines overlap neighbors instead of adding whitespace */
}
/* Zen mode: center the core UI vertically; the exit button is position:fixed
and stays at the top. `safe center` avoids clipping when content is taller
than the viewport. */
.app-container.zen-mode {
display: flex;
flex-direction: column;
}
.app-container.zen-mode .main-stage {
flex: 1;
justify-content: safe center;
justify-content: center;
}
/* ---- Assist Mode ------------------------------------------------------- */
/* Primitive practice companion: big detected note + live staff + optional
fretboard with alternative positions. No trainer actions. */
.assist-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-lg);
}
.assist-note-area {
display: flex;
align-items: center;
justify-content: center;
min-height: 110px;
}
.assist-note-name {
font-size: 5rem;
font-weight: 800;
line-height: 1;
color: var(--color-text-main);
font-variant-emoji: text;
}
.assist-note-name.placeholder {
color: var(--color-text-muted);
opacity: 0.5;
}
.assist-fretboard {
width: 100%;
display: flex;
justify-content: center;
overflow-x: auto;
}
/* ---- Transport step group (preview stepping) ---------------------------- */
.transport-step-group {
display: flex;
gap: 6px;
}
.step-button {
padding: 0.4rem 0.55rem;
}
/* ---- Display toggles (virtual instrument / hint show-hide) -------------- */
/* Separate row of pure show/hide switches, styled like the tuner and
metronome switches — visually distinct from transport actions. */
.display-toggles {
display: flex;
justify-content: center;
gap: var(--spacing-lg);
margin-top: 10px;
}
.display-toggle {
display: flex;
align-items: center;
gap: 8px;
}
.display-toggle-label {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: var(--color-text-muted);
}

View File

@@ -1,55 +1,66 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useState, useEffect, useLayoutEffect, useCallback, useMemo, useRef } from 'react';
import { Logo } from './components/Logo';
import { LandscapeSuggestion } from './components/LandscapeSuggestion';
import { SheetMusic } from './components/SheetMusic';
import { Controls } from './components/Controls';
import { SettingsModal } from './components/SettingsModal';
import { OpenSourceModal } from './components/OpenSourceModal';
import { SingleNoteTrainer, type SingleNoteHandle } from './components/SingleNoteTrainer';
import { LiveNoteStaff } from './components/LiveNoteStaff';
import { resolveClefTranspose } from './music/InstrumentConfigs';
import { PhraseTrainer, type PhraseHandle } from './components/PhraseTrainer';
import { AssistMode } from './components/AssistMode';
import { usePitchDetector } from './hooks/usePitchDetector';
import { useAudioPlayer } from './hooks/useAudioPlayer';
import { useSettings } from './context/SettingsContext';
import { useGameLogic } from './hooks/useGameLogic';
import { getNoteDetails } from './music/NoteUtils';
import {
TUNINGS,
getFretboardPositions,
} from './music/Tunings';
import { useSettings } from './context/useSettings';
import { audioEngine } from './audio/AudioEngine';
import { midiToFrequency } from './music/NoteUtils';
import { TUNINGS } from './music/Tunings';
import { INSTRUMENT_DEFINITIONS } from './music/InstrumentConfigs';
import { Fretboard } from './components/Fretboard';
import { PianoKeys } from './components/PianoKeys';
import { Mic, MicOff, SkipForward, HelpCircle, Volume2, X, Guitar, Settings, Maximize, Minimize } from 'lucide-react';
import { Mic, MicOff, HelpCircle, X, Settings, Maximize, Minimize } from 'lucide-react';
import './App.css';
import './styles/skip-button.css';
function App() {
const [listening, setListening] = useState(false);
const { playNote } = useAudioPlayer();
const { settings, updateSettings } = useSettings();
// Alias to keep existing code working with minimal changes
const setSettings = updateSettings;
const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(listening, settings.micSensitivity);
const isPhrase = settings.gameMode === 'phrase';
const isAssist = settings.gameMode === 'assist';
// Game Logic Hook
const {
targetMidi,
feedbackMessage,
revealed,
virtualNote,
generateNewNote,
handleVirtualInstrumentPlay
} = useGameLogic(pitchData);
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
// Lowest note the instrument can produce: the pitch analysis window adapts
// to it (shorter window = lower latency for instruments without deep bass)
const instrumentMinMidi = useMemo(() => {
let min = Infinity;
for (const r of currentInstrumentDef.ranges) {
if (r.min !== undefined) min = Math.min(min, r.min);
}
if (currentTuning) {
for (const stringMidi of currentTuning.strings) min = Math.min(min, stringMidi);
}
return min === Infinity ? 40 : min; // Fallback: guitar low E
}, [currentInstrumentDef, currentTuning]);
// One semitone of margin below the lowest note (detuned strings, flat playing)
const { pitchData, error, audioLevel, debugInfo, isListening } = usePitchDetector(
listening,
settings.micSensitivity,
midiToFrequency(instrumentMinMidi - 1)
);
// Live feedback shows only what the USER plays, gated while the app's own
// speaker output is audible (see SingleNoteTrainer / PhraseTrainer).
const displayedPitch = audioEngine.isAudible() ? null : pitchData;
const singleNoteRef = useRef<SingleNoteHandle>(null);
const phraseRef = useRef<PhraseHandle>(null);
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const [isOpenSourceModalOpen, setIsOpenSourceModalOpen] = useState(false);
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [windowWidth, setWindowWidth] = useState(window.innerWidth);
const [windowHeight, setWindowHeight] = useState(window.innerHeight);
@@ -63,8 +74,9 @@ function App() {
return () => window.removeEventListener('resize', handleResize);
}, []);
// Theme support
useEffect(() => {
// Theme support — MUST be a layout effect: children read the resolved theme
// colors from getComputedStyle inside their own passive effects.
useLayoutEffect(() => {
const root = document.documentElement;
const theme = settings.theme || 'auto';
if (theme === 'auto') {
@@ -94,31 +106,108 @@ function App() {
}
}, []);
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
// Resolve overrides
const currentRangeDef = useMemo(() => {
return currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
}, [currentInstrumentDef, settings.difficulty]);
// Determine active clef/transpose for rendering
const activeClef = currentRangeDef?.clef ?? currentInstrumentDef.clefMode;
const activeTranspose = currentRangeDef?.transpose ?? currentInstrumentDef.transpose;
/* Keyboard Shortcuts */
const [showHelp, setShowHelp] = useState(false);
// Opening dialogs pauses a running phrase run (contract E1/lifecycle).
const openSettings = useCallback(() => {
phraseRef.current?.pause();
setIsSettingsOpen(true);
}, []);
const openHelp = useCallback(() => {
phraseRef.current?.pause();
setShowHelp(true);
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const target = e.target as HTMLElement | null;
// 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 HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
!!target?.isContentEditable;
if (onTextEntry) return;
// prevent default for space to stop scrolling
if (e.code === 'Space') {
if (e.code === 'Escape') {
if (showHelp) setShowHelp(false);
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
return;
}
// Prevent default for space to stop scrolling (Space on a focused
// button activates it natively — preventDefault only applies when the
// focus is on the body, where the shortcut below takes over).
if (e.code === 'Space' && !(target instanceof HTMLButtonElement)) {
e.preventDefault();
}
if (isPhrase) {
switch (e.key.toLowerCase()) {
case 'h':
setSettings(s => ({ ...s, showHint: !s.showHint }));
break;
case 'z':
setSettings(s => ({ ...s, zenMode: !s.zenMode }));
break;
case 'r':
phraseRef.current?.retry();
break;
case 'p':
phraseRef.current?.previewToggle();
break;
case 'n':
phraseRef.current?.next();
break;
case 'arrowleft':
phraseRef.current?.previewStep(-1);
break;
case 'arrowright':
phraseRef.current?.previewStep(1);
break;
case 's':
phraseRef.current?.skip();
break;
case 'v':
setSettings(s => ({ ...s, showFretboard: !s.showFretboard }));
break;
case 'l':
setListening(l => !l);
break;
}
// 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();
}
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()) {
case 'h':
setSettings(s => ({ ...s, showHint: !s.showHint }));
@@ -128,9 +217,9 @@ function App() {
break;
case 'r': // Replay
case 'p': // Play
playNote(targetMidi, 1.0);
singleNoteRef.current?.playCurrent();
break;
case 'm': // Toggle Game Mode
case 'm': // Cycle game mode
setSettings(s => ({
...s,
gameMode: s.gameMode === 'sight_reading' ? 'ear_training' : 'sight_reading'
@@ -144,47 +233,36 @@ function App() {
break;
}
// Check non-character keys via code to avoid layout issues for function keys
if (e.code === 'Space' || e.code === 'Enter') {
generateNewNote();
}
if (e.code === 'Escape') {
if (showHelp) setShowHelp(false);
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
if ((e.code === 'Space' || e.code === 'Enter') && !(e.target instanceof HTMLButtonElement)) {
singleNoteRef.current?.skipNote();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [settings.zenMode, showHelp, generateNewNote, playNote, targetMidi]);
}, [settings, showHelp, isPhrase, isAssist, setSettings]);
// Hint text construction
const hintPositions = useMemo(() => {
// If showing full hint OR detecting via fretboard, we need positions?
// Actually, showHint=true renders the red dots.
// If showFretboard=true but showHint=false, we render empty board (interactive).
// 'm' cycles back to sight reading from phrase/assist modes.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key.toLowerCase() !== 'm') return;
const target = e.target as HTMLElement | null;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ||
target instanceof HTMLButtonElement || target instanceof HTMLSelectElement || target?.isContentEditable) return;
if (settings.gameMode === 'phrase' || settings.gameMode === 'assist') {
setSettings(s => ({ ...s, gameMode: 'sight_reading' }));
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [settings.gameMode, setSettings]);
if (!settings.showHint && !settings.showFretboard) return [];
if (!currentInstrumentDef.showTuning || !currentTuning) return [];
// If hint is hidden but board is explicit, we still need data if we want to support 'hint-on-hover' or similar later.
// But for now, if settings.showHint is FALSE, we pass EMPTY positions to Fretboard so it doesn't draw dots,
// UNLESS we want to decouple 'positions' prop from 'showHint' prop in the component.
// It's cleaner to pass the positions regardless and let the component decide based on a prop,
// OR filter here.
// The request says: "If hints are enabled simultaneously, they can be shown on the same fretboard"
// So if showHint is true -> pass positions. If false -> pass empty.
if (!settings.showHint) return [];
return getFretboardPositions(targetMidi, currentTuning);
}, [settings.showHint, settings.showFretboard, targetMidi, currentTuning, currentInstrumentDef]);
// Auto-enable mic when tuner is turned on
// Auto-enable mic when the tuner is turned on (rAF defers the state update
// out of the effect phase to avoid cascading renders)
useEffect(() => {
if (settings.showTuningMeter && !listening) {
setListening(true);
const id = requestAnimationFrame(() => setListening(true));
return () => cancelAnimationFrame(id);
}
}, [settings.showTuningMeter, listening]);
@@ -199,11 +277,19 @@ function App() {
<div className="header-controls">
<div className="game-mode-toggle">
<button
className={`toggle-option ${settings.gameMode === 'assist' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'assist' }))}
title="Practice companion: shows played notes while you work from paper"
>
Assist
</button>
<button
className={`toggle-option ${settings.gameMode === 'sight_reading' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'sight_reading' }))}
title="Learn to read notes from the staff"
>
Sight Reading
Learn Notes
</button>
<button
className={`toggle-option ${settings.gameMode === 'ear_training' ? 'active' : ''}`}
@@ -211,6 +297,13 @@ function App() {
>
Ear Training
</button>
<button
className={`toggle-option ${settings.gameMode === 'phrase' ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, gameMode: 'phrase' }))}
title="Play short phrases and melodies"
>
Phrases
</button>
</div>
<button
@@ -229,14 +322,14 @@ function App() {
</button>
<button
className="icon-button help-btn"
onClick={() => setShowHelp(true)}
onClick={openHelp}
title="Shortcuts Help"
>
<HelpCircle size={24} />
</button>
<button
className="icon-button"
onClick={() => setIsSettingsOpen(true)}
onClick={openSettings}
title="Settings"
>
<Settings size={24} />
@@ -246,129 +339,28 @@ function App() {
)}
<main className="main-stage">
<div className={`card sheet-music-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''} ${settings.zenMode ? 'zen-mode' : ''}`}>
<div className="sheet-music-container">
<SheetMusic
targetMidi={targetMidi}
playedMidi={virtualNote ?? pitchData?.midi}
keySignature={settings.keySignature}
clef={activeClef}
transpose={activeTranspose}
width={Math.min(windowWidth - 40, 500)}
height={
activeClef === 'grand'
? (windowHeight < 500 ? 190 : 260) // Compact Grand Staff if short screen
: (windowHeight < 500 ? 120 : 180) // Compact Single Staff
}
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
hoverMidi={settings.showHint ? hoveredMidi : null}
/>
</div>
{!settings.zenMode && (
<div className="feedback-area">
{feedbackMessage ? (
<div key={feedbackMessage} className="success-message animate-pop">
{feedbackMessage.startsWith("Good! ") ? (
<>
<div className="success-prefix">Good!</div>
<div className="success-note">{feedbackMessage.replace("Good! ", "")}</div>
</>
) : (
feedbackMessage
)}
</div>
) : (
<div className="instruction-text">
{settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"}
</div>
)}
</div>
)}
{(settings.showHint || settings.showFretboard) && (
<div className="hint-card">
{settings.showHint && (
<div className="hint-note landscape-hint-note">
{getNoteDetails(targetMidi + activeTranspose).scientific}
</div>
)}
{/* Virtual Instrument Display */}
{currentInstrumentDef.showTuning && (
currentInstrumentDef.id === 'piano' ? (
<PianoKeys
minMidi={36} // C2
maxMidi={84} // C6
markedNotes={settings.showHint ? [targetMidi] : []}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualInstrumentPlay}
onHover={setHoveredMidi}
/>
) : (
currentTuning && (
<Fretboard
tuning={currentTuning}
positions={hintPositions}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualInstrumentPlay}
onHover={setHoveredMidi}
showHints={settings.showHint}
maxFrets={15}
/>
)
)
)}
</div>
)}
{error && <div className="error-message">{error}</div>}
{!settings.zenMode && (
<div className="action-row" style={{ marginTop: '24px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
{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>
)}
<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>
<button
className="hint-button"
onClick={() => playNote(targetMidi, 1.0)}
title="Keyboard Shortcut: P or R"
>
<Volume2 size={18} />
Play Note
</button>
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
<SkipForward size={18} />
Skip Note
</button>
</div>
)}
</div>
{isPhrase ? (
<PhraseTrainer
ref={phraseRef}
listening={listening}
micError={error}
windowWidth={windowWidth}
/>
) : isAssist ? (
<AssistMode
pitchData={displayedPitch}
windowWidth={windowWidth}
/>
) : (
<SingleNoteTrainer
ref={singleNoteRef}
pitchData={pitchData}
windowWidth={windowWidth}
windowHeight={windowHeight}
/>
)}
{error && <div className="error-message">{error}</div>}
{
!settings.zenMode && (
@@ -382,13 +374,24 @@ function App() {
{listening ? <Mic size={28} /> : <MicOff size={28} />}
</button>
<div className={`pitch-readout ${pitchData ? 'active' : ''}`}>
{pitchData ? (
{/* Live staff omitted in Assist mode: the Assist card already
shows the played note large above the fretboard. */}
{!isAssist && (
<LiveNoteStaff
midi={displayedPitch ? displayedPitch.midi : null}
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
keySignature={settings.keySignature}
clef={currentInstrumentDef.clefMode === 'treble' ? 'treble' : currentInstrumentDef.clefMode === 'bass' ? 'bass' : undefined}
theme={settings.theme}
/>
)}
<div className={`pitch-readout ${displayedPitch ? 'active' : ''}`}>
{displayedPitch ? (
<>
<span className={`detected-note ${pitchData.midi === targetMidi ? 'match' : ''}`}>
{pitchData.note}
<span className="detected-note">
{displayedPitch.note}
</span>
<span className="detected-hz">{Math.round(pitchData.frequency)} Hz</span>
<span className="detected-hz">{Math.round(displayedPitch.frequency)} Hz</span>
</>
) : (
<span className="placeholder">{listening ? "Listening..." : "Mic Off"}</span>
@@ -402,7 +405,7 @@ function App() {
{!settings.zenMode && (
<footer className="settings-footer">
<Controls
currentPitch={pitchData ? { note: pitchData.note, cents: pitchData.cents } : null}
currentPitch={displayedPitch ? { note: displayedPitch.note, cents: displayedPitch.cents } : null}
/>
<div className="app-subtitle">
<button className="link-button" onClick={() => setIsOpenSourceModalOpen(true)}>Open Source Libraries</button>
@@ -434,38 +437,27 @@ function App() {
<button className="help-close" onClick={() => setShowHelp(false)}><X size={20} /></button>
<h2 style={{ marginTop: 0, marginBottom: '24px' }}>Keyboard Shortcuts</h2>
<div className="help-item">
<span>Skip Note</span>
<span className="shortcut-key">Space</span>
</div>
<div className="help-item">
<span>Toggle Hint</span>
<span className="shortcut-key">H</span>
</div>
<div className="help-item">
<span>Toggle Virtual Instrument</span>
<span className="shortcut-key">V</span>
</div>
<div className="help-item">
<span>Toggle Mic</span>
<span className="shortcut-key">L</span>
</div>
<div className="help-item">
<span>Toggle Zen Mode</span>
<span className="shortcut-key">Z</span>
</div>
<div className="help-item">
<span>Replay Note</span>
<span className="shortcut-key">R</span>
</div>
<div className="help-item">
<span>Toggle Game Mode</span>
<span className="shortcut-key">M</span>
</div>
<div className="help-item">
<span>Close Help / Exit Zen</span>
<span className="shortcut-key">Esc</span>
</div>
{isPhrase ? (
<>
<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>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>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</span><span className="shortcut-key">Space</span></div>
<div className="help-item"><span>Replay Note</span><span className="shortcut-key">R</span></div>
<div className="help-item"><span>Toggle Game Mode</span><span className="shortcut-key">M</span></div>
</>
)}
<div className="help-item"><span>Toggle Hint</span><span className="shortcut-key">H</span></div>
<div className="help-item"><span>Toggle Virtual Instrument</span><span className="shortcut-key">V</span></div>
<div className="help-item"><span>Toggle Mic</span><span className="shortcut-key">L</span></div>
<div className="help-item"><span>Toggle Zen Mode</span><span className="shortcut-key">Z</span></div>
<div className="help-item"><span>Close Help / Exit Zen</span><span className="shortcut-key">Esc</span></div>
</div>
</div>
)

View File

@@ -2,10 +2,73 @@
* Global application configuration
*/
// How long (in ms) a note must be held to be accepted as correct
// How long (in ms) a note must be held to be accepted as correct
// How long (in ms) the target note must be detected to count as correct.
// Protects against spurious single-frame detections.
export const NOTE_MATCH_THRESHOLD_MS = 50;
// Grace period (in ms): brief detection dropouts or wrong frames while holding
// a note do NOT reset the match timer. Pitch detectors (YIN) routinely flicker
// for a frame or two during note attacks and decays; a hard reset on every
// flicker makes the threshold above feel broken ("shows green but won't register").
export const NOTE_MATCH_GRACE_MS = 150;
// How long (in ms) after speaker playback ends that mic-based matching stays
// gated, so the app doesn't "hear itself" (ear-training auto-play, Play Note
// button, virtual instruments).
export const PLAYBACK_GATE_TAIL_MS = 400;
// Safety cap for the "wait for silence" option: if the mic never goes quiet
// (continuously detectable sound in the room), the next reference note plays
// anyway after this delay so the app can't stall indefinitely.
export const REFERENCE_QUIET_MAX_WAIT_MS = 8000;
// Display smoothing (exponential moving average factor, 0-1) applied to the
// cents reading and the audio level in the detection loop (~60 fps). Keeps
// the tuner needle and level meter readable without CSS transitions — a
// transition on a value that changes every frame is interrupted 60x per
// second, which Safari/WebKit handles by leaving the element stuck.
// 0.25 ≈ 80ms effective smoothing: damps jitter but stays very responsive.
export const DISPLAY_EMA_FACTOR = 0.25;
// How many consecutive detection frames a different note must persist before
// the displayed note name switches (~50ms at 60fps). Prevents the note name
// from flickering between adjacent notes when playing right at a semitone
// boundary. Matching is unaffected (it uses the raw midi value).
export const NOTE_DISPLAY_STABLE_FRAMES = 3;
// How long (in ms) the last detected pitch is kept on screen after the signal
// disappears, before the readout is cleared. Prevents flicker between frames
// without leaving stale notes hanging forever.
export const PITCH_READING_HOLD_MS = 200;
// Pitch analysis window (samples @ AudioContext rate).
// The analyzer adapts the window down from BUFFER_SIZE based on the lowest
// frequency that must be detected (~2 periods minimum). Smaller window =
// lower latency and 4x less CPU per analysis. 4096 covers bass E1 (~41 Hz).
export const PITCH_BUFFER_SIZE = 4096;
export const PITCH_MIN_WINDOW = 1024;
// Fallback for the lowest detectable frequency before the app configures
// the actual instrument range. ~70 Hz covers guitar low E with margin.
export const PITCH_DEFAULT_LOWEST_HZ = 70;
// Instruments whose lowest note is at/above this frequency use YIN instead of
// ACF2PLUS: ACF2PLUS picks subharmonics above ~2 kHz, while YIN's weakness
// below ~100 Hz is irrelevant for high registers (e.g. whistle).
export const PITCH_HIGH_REGISTER_HZ = 300;
// Target RMS for normalizing the analysis buffer before pitch detection
// (YIN/high-register path only — see selectDetector in PitchAnalyzer).
// pitchfinder's YIN force-sets yinBuffer[1] = 1 before normalization, so at
// low absolute amplitudes its difference function gets dwarfed and it latches
// onto tau=2 (~20 kHz garbage). A constant operating level fixes this.
export const PITCH_NORMALIZE_TARGET_RMS = 0.3;
// Detected pitches below (instrument floor × this factor) are rejected as
// implausible — e.g. 50/60 Hz mains hum showing up as a confident wrong note
// while a guitar (floor ~78 Hz) is not playing. ~4 semitones of margin.
export const PITCH_BELOW_FLOOR_REJECT_FACTOR = 2 ** (-4 / 12); // ≈ 0.794
// Microphone Sensitivity Configuration
// Sensitivity Scale: 0.0 (Low) to 1.0 (High)
// Linear mapping to dB thresholds

191
src/audio/AudioEngine.ts Normal file
View File

@@ -0,0 +1,191 @@
import { midiToFrequency } from '../music/NoteUtils';
import { PLAYBACK_GATE_TAIL_MS } from '../AppConfig';
/**
* Create an AudioContext with a typed webkit fallback (older Safari).
*/
export function createAudioContext(): AudioContext {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
return new Ctor({ latencyHint: 'interactive' });
}
/**
* Shared audio playback engine (singleton).
*
* All speaker output goes through a single AudioContext (previously App and
* useGameLogic each created their own via separate useAudioPlayer instances).
* Playback is tracked so pitch detection can ignore the microphone while the
* app is audible — otherwise the app hears its own reference notes through
* the speaker and scores them as correct (ear-training feedback loop).
*/
interface ActiveNote {
osc: OscillatorNode;
gain: GainNode;
/** Audio-clock time after which this note is no longer sounding. */
endsAt: number;
groupId: string;
}
class AudioEngine {
private ctx: AudioContext | null = null;
/** ctx.currentTime (seconds) until which output may still be audible, incl. gate tail */
private audibleUntil = 0;
/** Currently scheduled/playing notes, for cancellation (preview groups). */
private active: ActiveNote[] = [];
/** Mic blanking after non-tonal metronome clicks (seconds, audio clock). */
private clickBlankUntil = 0;
private noiseBuffer: AudioBuffer | null = null;
/** Audio-clock time; 0 when no context exists yet. */
now(): number {
return this.ctx ? this.ctx.currentTime : 0;
}
/** Create/resume the context (must be called from a user gesture). */
async ensureRunning(): Promise<void> {
this.ensureContext();
if (this.ctx && this.ctx.state === 'suspended') {
await this.ctx.resume();
}
}
isRunning(): boolean {
return !!this.ctx && this.ctx.state === 'running';
}
private ensureContext(): AudioContext {
if (!this.ctx) {
this.ctx = createAudioContext();
}
if (this.ctx.state === 'suspended') {
void this.ctx.resume();
}
return this.ctx;
}
playNote(midi: number, duration = 0.5, volume = 0.3, groupId = 'oneshot'): void {
const ctx = this.ensureContext();
const now = ctx.currentTime;
this.scheduleNote(midi, now, duration, volume, groupId);
}
/**
* Schedule a pitched note at an absolute audio-clock time (phrase preview).
* Registers the node so the group can be cancelled; the mic gate covers
* the whole scheduled span (see isAudible).
*/
scheduleNote(midi: number, startAt: number, duration: number, volume = 0.3, groupId = 'preview'): void {
const ctx = this.ensureContext();
const osc = ctx.createOscillator();
const gainNode = ctx.createGain();
osc.frequency.value = midiToFrequency(midi);
osc.type = 'triangle';
osc.connect(gainNode);
gainNode.connect(ctx.destination);
// Attack
gainNode.gain.setValueAtTime(0, startAt);
gainNode.gain.linearRampToValueAtTime(volume, startAt + 0.02);
// Sustain -> Release
gainNode.gain.exponentialRampToValueAtTime(0.001, startAt + duration);
osc.start(startAt);
osc.stop(startAt + duration + 0.1);
this.active.push({ osc, gain: gainNode, endsAt: startAt + duration, groupId });
// The note decays to -60dB at `startAt + duration`; keep the mic gated
// for an additional tail so room reverb / speaker bleed can't self-trigger.
this.audibleUntil = Math.max(
this.audibleUntil,
startAt + duration + PLAYBACK_GATE_TAIL_MS / 1000
);
}
/**
* Cancel a playback group (preview, metronome clicks). Stops scheduled
* nodes, removes them, and recomputes the mic gate from what remains —
* a cancelled long preview must not keep the mic gated until its former
* end time.
*/
cancelGroup(groupId: string): void {
if (!this.ctx) return;
const now = this.ctx.currentTime;
this.active = this.active.filter(n => {
if (n.groupId !== groupId) return true;
try {
n.gain.gain.cancelScheduledValues(now);
n.gain.gain.setValueAtTime(0, now);
n.osc.stop(now + 0.01);
} catch {
// Already stopped — nothing to do.
}
return false;
});
this.recomputeGate();
}
private recomputeGate(): void {
this.audibleUntil = this.active.reduce(
(max, n) => Math.max(max, n.endsAt + PLAYBACK_GATE_TAIL_MS / 1000),
0
);
}
/**
* 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
* 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, groupId?: string): void {
const ctx = this.ensureContext();
if (!this.noiseBuffer) {
const len = Math.floor(ctx.sampleRate * 0.01);
this.noiseBuffer = ctx.createBuffer(1, len, ctx.sampleRate);
const data = this.noiseBuffer.getChannelData(0);
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len);
}
const src = ctx.createBufferSource();
src.buffer = this.noiseBuffer;
const gainNode = ctx.createGain();
const v = accent ? Math.min(1, volume * 1.6) : volume;
gainNode.gain.setValueAtTime(v, time);
gainNode.gain.exponentialRampToValueAtTime(0.001, time + 0.01);
src.connect(gainNode);
gainNode.connect(ctx.destination);
src.start(time);
src.stop(time + 0.02);
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). */
isMicBlanked(): boolean {
return !!this.ctx && this.ctx.state === 'running' && this.ctx.currentTime < this.clickBlankUntil;
}
/**
* True while speaker output (plus gate tail) may still be audible at the
* microphone. Mic-based note matching should be ignored during this time.
*
* A suspended AudioContext (autoplay policy, no user gesture yet) reports
* currentTime = 0 forever — without the state check the gate would be
* stuck "audible" permanently and matching would never engage.
*/
isAudible(): boolean {
if (!this.ctx || this.ctx.state !== 'running') return false;
return this.ctx.currentTime < this.audibleUntil;
}
}
export const audioEngine = new AudioEngine();

View File

@@ -1,5 +1,15 @@
import { YIN } from "pitchfinder";
import { MIC_SENSITIVITY_DB_RANGE, AUDIO_CONSTRAINTS } from "../AppConfig";
import { YIN, ACF2PLUS } from "pitchfinder";
import {
MIC_SENSITIVITY_DB_RANGE,
AUDIO_CONSTRAINTS,
PITCH_BUFFER_SIZE,
PITCH_MIN_WINDOW,
PITCH_DEFAULT_LOWEST_HZ,
PITCH_HIGH_REGISTER_HZ,
PITCH_NORMALIZE_TARGET_RMS,
PITCH_BELOW_FLOOR_REJECT_FACTOR,
} from "../AppConfig";
import { createAudioContext } from "./AudioEngine";
export interface MicrophoneDebugInfo {
audioContextState: AudioContextState | 'inactive';
@@ -12,13 +22,85 @@ export interface MicrophoneDebugInfo {
isCapturing: boolean;
}
export type PitchDetectorFn = (buffer: Float32Array) => number | null;
export interface DetectorSpec {
detector: PitchDetectorFn;
/** Whether the analysis buffer must be RMS-normalized before detection */
normalize: boolean;
}
/**
* Smallest power-of-two analysis window that still fits ~2 periods of the
* lowest required frequency. Smaller window = lower latency and ~4x less CPU
* per analysis frame. Clamped to [PITCH_MIN_WINDOW, PITCH_BUFFER_SIZE].
*/
export function chooseAnalysisWindowSize(sampleRate: number, lowestHz: number): number {
const needed = Math.ceil((2 * sampleRate) / lowestHz);
const pow2 = 2 ** Math.ceil(Math.log2(Math.max(1, needed)));
return Math.max(PITCH_MIN_WINDOW, Math.min(PITCH_BUFFER_SIZE, pow2));
}
/**
* Pick a detector for the instrument's range.
*
* pitchfinder's YIN force-sets yinBuffer[1] = 1 before cumulative
* normalization, which makes its absolute-threshold search latch onto tau=2
* (~20 kHz garbage) for low fundamentals — below ~100 Hz it is unreliable no
* matter the window size. ACF2PLUS is robust from ~40 Hz up to ~2 kHz, but
* tends to pick subharmonics above that. So: ACF2PLUS for normal instruments,
* YIN only for high registers (e.g. whistle) where its low-end weakness is
* irrelevant.
*
* Only the YIN path gets normalized input: ACF2PLUS has an internal
* "rms < 0.01 -> unpitched" gate that must see the RAW signal — normalizing
* first would amplify room hum/noise floors into confident wrong pitches
* (the tuner then appears "stuck" on a note nobody played).
*/
export function selectDetector(sampleRate: number, lowestHz: number): DetectorSpec {
if (lowestHz >= PITCH_HIGH_REGISTER_HZ) {
return { detector: YIN({ sampleRate }), normalize: true };
}
return { detector: ACF2PLUS({ sampleRate }), normalize: false };
}
/** Kept for backwards compatibility with existing tests. */
export function createPitchDetector(sampleRate: number, lowestHz: number): PitchDetectorFn {
return selectDetector(sampleRate, lowestHz).detector;
}
/**
* Scale the buffer in place to a constant RMS operating level (clamped to
* [-1, 1]). Detectors are sensitive to absolute amplitude; without this,
* quiet low-frequency input makes YIN return ~20 kHz nonsense.
*/
export function normalizeSignal(buffer: Float32Array, targetRms?: number): void {
let rms = 0;
for (let i = 0; i < buffer.length; i++) rms += buffer[i] * buffer[i];
rms = Math.sqrt(rms / buffer.length);
if (rms < 1e-6) return;
const target = targetRms ?? PITCH_NORMALIZE_TARGET_RMS;
const gain = target / rms;
for (let i = 0; i < buffer.length; i++) {
const v = buffer[i] * gain;
buffer[i] = v > 1 ? 1 : v < -1 ? -1 : v;
}
}
export class PitchAnalyzer {
private detector: (buffer: Float32Array) => number | null;
private detector: PitchDetectorFn | null = null;
private normalizeInput = false;
private audioContext: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private mediaStream: MediaStream | null = null;
private source: MediaStreamAudioSourceNode | null = null;
private buffer: Float32Array;
/** Full-size backing buffer (fftSize samples) */
private buffer = new Float32Array(PITCH_BUFFER_SIZE);
/** Tail-view into `buffer`; only the most recent windowSize samples are analyzed */
private window: Float32Array<ArrayBuffer> = this.buffer;
private windowSize = PITCH_BUFFER_SIZE;
private lowestFrequency = PITCH_DEFAULT_LOWEST_HZ;
// Debug info
private inputDeviceLabel: string = 'Not detected';
@@ -26,11 +108,6 @@ export class PitchAnalyzer {
private permissionState: PermissionState | 'unknown' = 'unknown';
private currentRmsLevel: number = 0;
constructor() {
this.detector = YIN({ sampleRate: 44100 }); // Default, will update on start
this.buffer = new Float32Array(2048); // Standard size
}
private sensitivityThreshold = 0.03; // Default
/**
@@ -49,6 +126,43 @@ export class PitchAnalyzer {
this.sensitivityThreshold = Math.pow(10, db / 20);
}
/**
* Configure the lowest frequency that must be detectable (Hz) and shrink
* the analysis window accordingly.
*
* Pitch detection needs ~2 periods of the target frequency in the window.
* A fixed 4096-sample window always analyzes ~90ms of audio history, which
* makes the tuner feel sluggish. Guitar/piano/voice (>= ~65 Hz) only need
* 2048 samples (~45ms) — half the latency at a quarter of the CPU cost.
* Bass (E1 ~ 41 Hz) keeps the full 4096 window.
*/
setLowestFrequency(hz: number) {
if (hz > 0) {
this.lowestFrequency = hz;
this.updateWindowSize();
this.rebuildDetector();
}
}
private updateWindowSize() {
if (!this.audioContext) return; // applied on start()
const size = chooseAnalysisWindowSize(this.audioContext.sampleRate, this.lowestFrequency);
if (size !== this.windowSize) {
this.windowSize = size;
// View over the tail of the backing buffer: getFloatTimeDomainData
// fills it with the most recent `size` samples.
const byteOffset = (PITCH_BUFFER_SIZE - size) * Float32Array.BYTES_PER_ELEMENT;
this.window = new Float32Array(this.buffer.buffer, byteOffset, size);
}
}
private rebuildDetector() {
if (!this.audioContext) return; // applied on start()
const spec = selectDetector(this.audioContext.sampleRate, this.lowestFrequency);
this.detector = spec.detector;
this.normalizeInput = spec.normalize;
}
async start(): Promise<void> {
if (this.audioContext) return;
@@ -63,8 +177,9 @@ export class PitchAnalyzer {
this.permissionState = 'unknown';
}
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
this.detector = YIN({ sampleRate: this.audioContext.sampleRate });
this.audioContext = createAudioContext();
this.updateWindowSize();
this.rebuildDetector();
try {
// Use configured audio constraints
@@ -98,8 +213,7 @@ export class PitchAnalyzer {
this.source = this.audioContext.createMediaStreamSource(this.mediaStream);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 4096; // Higher FFT size for better resolution at low frequencies
this.buffer = new Float32Array(this.analyser.fftSize);
this.analyser.fftSize = PITCH_BUFFER_SIZE; // Large buffer; analyzed window adapts per instrument
this.source.connect(this.analyser);
@@ -123,6 +237,9 @@ export class PitchAnalyzer {
this.audioContext.close();
this.audioContext = null;
}
this.detector = null;
this.analyser = null;
this.source = null;
this.currentRmsLevel = 0;
this.inputDeviceLabel = 'Not detected';
this.inputDeviceId = '';
@@ -159,26 +276,43 @@ export class PitchAnalyzer {
* getPitch returns the detected frequency in Hz, or null if no pitch detected
*/
getPitch(): number | null {
if (!this.analyser) return null;
this.analyser.getFloatTimeDomainData(this.buffer as any);
if (!this.analyser || !this.detector) return null;
// Always copy the full fftSize buffer (universally supported usage),
// then analyze only the tail view = most recent windowSize samples.
// (Passing an undersized array directly relies on browser-specific
// behavior of getFloatTimeDomainData.)
this.analyser.getFloatTimeDomainData(this.buffer);
// Simple RMS volume check to avoid noise
// Simple RMS volume check to avoid noise (on the analyzed window)
let rms = 0;
for (let i = 0; i < this.buffer.length; i++) {
rms += this.buffer[i] * this.buffer[i];
for (let i = 0; i < this.window.length; i++) {
rms += this.window[i] * this.window[i];
}
rms = Math.sqrt(rms / this.buffer.length);
rms = Math.sqrt(rms / this.window.length);
// Store RMS for level meter
this.currentRmsLevel = rms;
if (rms < this.sensitivityThreshold) return null;
const pitch = this.detector(this.buffer);
// Normalize only on the YIN/high-register path. ACF2PLUS must see the
// raw signal so its internal "rms < 0.01 -> unpitched" gate keeps
// rejecting quiet decayed audio instead of amplifying it into a
// confident wrong pitch.
if (this.normalizeInput) {
normalizeSignal(this.window);
}
const pitch = this.detector(this.window);
// Widen range for Bass (E1 ~41Hz, but A0 is 27.5Hz) and Whistle (C8 ~4186Hz, Harmonics go higher)
// Range 25Hz - 8000Hz covers Piano A0 to well above highest fundamental
if (pitch && (pitch < 25 || pitch > 8000)) return null;
// (Also filters out the -1 some detectors return for "unpitched".)
if (pitch === null || pitch < 25 || pitch > 8000) return null;
// Implausible for the current instrument (e.g. 50/60 Hz mains hum
// while playing guitar): reject readings far below the configured floor.
if (pitch < this.lowestFrequency * PITCH_BELOW_FLOOR_REJECT_FACTOR) return null;
return pitch;
}

View File

@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest';
import {
chooseAnalysisWindowSize,
selectDetector,
normalizeSignal,
} from './PitchAnalyzer';
import {
PITCH_BUFFER_SIZE,
PITCH_MIN_WINDOW,
PITCH_BELOW_FLOOR_REJECT_FACTOR,
} from '../AppConfig';
/**
* Regression tests for the pitch detection pipeline.
*
* pitchfinder's YIN returns ~20 kHz garbage for low fundamentals (its
* forced yinBuffer[1] = 1 breaks the absolute-threshold search below
* ~100 Hz) and for quiet input. The app therefore uses ACF2PLUS for normal
* instruments, YIN only for high registers, and normalizes the analysis
* buffer to a constant RMS. These tests synthesize instrument-like signals
* (fundamental + harmonics + pluck decay, deterministic) and run them
* through the exact helpers PitchAnalyzer uses.
*/
/** Deterministic instrument-like test signal (no RNG → no flaky tests). */
function synth(freq: number, sampleRate: number, length: number, amp: number): Float32Array {
const buf = new Float32Array(length);
for (let i = 0; i < length; i++) {
const t = i / sampleRate;
const env = Math.exp(-t * 3); // pluck-like decay
buf[i] = amp * env * (
Math.sin(2 * Math.PI * freq * t) +
0.5 * Math.sin(2 * Math.PI * freq * 2 * t) +
0.25 * Math.sin(2 * Math.PI * freq * 3 * t) +
0.1 * Math.sin(2 * Math.PI * freq * 4 * t) +
0.02 * Math.sin(2 * Math.PI * 7.13 * i / length) // deterministic "noise" dither
);
}
return buf;
}
/** Mirror of PitchAnalyzer.getPitch, minus microphone plumbing. */
function detect(freq: number, sampleRate: number, lowestHz: number, amp = 0.3): number | null {
const size = chooseAnalysisWindowSize(sampleRate, lowestHz);
const buffer = synth(freq, sampleRate, size, amp);
const { detector, normalize } = selectDetector(sampleRate, lowestHz);
if (normalize) normalizeSignal(buffer);
const pitch = detector(buffer);
if (pitch === null || pitch < 25 || pitch > 8000) return null;
if (pitch < lowestHz * PITCH_BELOW_FLOOR_REJECT_FACTOR) return null;
return pitch;
}
function expectCloseTo(actual: number | null, expected: number) {
expect(actual).not.toBeNull();
expect(Math.abs(actual! - expected) / expected).toBeLessThan(0.03);
}
describe('chooseAnalysisWindowSize', () => {
it('adapts to the instrument floor', () => {
// Guitar low E2 (~78 Hz w/ margin) -> half window at 48k
expect(chooseAnalysisWindowSize(48000, 77.8)).toBe(2048);
// Bass low E1 (~39 Hz w/ margin) -> full window
expect(chooseAnalysisWindowSize(48000, 38.9)).toBe(PITCH_BUFFER_SIZE);
// Whistle register -> minimum window
expect(chooseAnalysisWindowSize(48000, 622)).toBe(PITCH_MIN_WINDOW);
});
});
describe('pitch detection across instruments (sample rate 48000)', () => {
const SR = 48000;
it('guitar (low E2)', () => {
const floor = 77.8; // E2 minus one semitone
expectCloseTo(detect(82.4, SR, floor), 82.4); // E2 — the regression case
expectCloseTo(detect(110.0, SR, floor), 110); // A2
expectCloseTo(detect(329.6, SR, floor), 329.6); // E4
});
it('guitar at low input level (quiet pluck tail)', () => {
expectCloseTo(detect(82.4, SR, 77.8, 0.05), 82.4);
expectCloseTo(detect(440, SR, 77.8, 0.05), 440);
});
it('bass (low E1)', () => {
const floor = 38.9;
expectCloseTo(detect(41.2, SR, floor), 41.2); // E1 — broken even before the rewrite
expectCloseTo(detect(55.0, SR, floor), 55); // A1
});
it('piano', () => {
const floor = 61.7; // C2 minus one semitone
expectCloseTo(detect(65.4, SR, floor), 65.4); // C2
expectCloseTo(detect(261.6, SR, floor), 261.6); // C4
expectCloseTo(detect(1046.5, SR, floor), 1046.5); // C6
});
it('whistle register (uses YIN, small window)', () => {
const floor = 622; // E5 minus one semitone
expectCloseTo(detect(659.3, SR, floor), 659.3); // E5
expectCloseTo(detect(2093.0, SR, floor), 2093); // C7
expectCloseTo(detect(4186.0, SR, floor), 4186); // C8
});
});
describe('pitch detection (sample rate 44100)', () => {
it('still works', () => {
expectCloseTo(detect(82.4, 44100, 77.8), 82.4);
expectCloseTo(detect(41.2, 44100, 38.9), 41.2);
expectCloseTo(detect(440, 44100, 77.8), 440);
});
});
describe('normalizeSignal', () => {
it('scales to the target RMS', () => {
const buf = synth(440, 48000, 2048, 0.02);
normalizeSignal(buf, 0.3);
let rms = 0;
for (const v of buf) rms += v * v;
rms = Math.sqrt(rms / buf.length);
expect(rms).toBeGreaterThan(0.2); // ~0.3 modulo decay envelope shape
});
it('leaves silence untouched', () => {
const buf = new Float32Array(1024);
normalizeSignal(buf);
expect(buf.every(v => v === 0)).toBe(true);
});
});
describe('noise-floor rejection (tuner freeze regression)', () => {
const SR = 48000;
it('rejects 50 Hz mains hum for guitar (floor ~78 Hz)', () => {
expect(detect(50, SR, 77.8)).toBeNull();
});
it('rejects 60 Hz mains hum for guitar', () => {
expect(detect(60, SR, 77.8)).toBeNull();
});
it('rejects hum for the whistle register too', () => {
expect(detect(50, SR, 622)).toBeNull();
expect(detect(120, SR, 622)).toBeNull();
});
it('returns null for very quiet decayed signal instead of a confident wrong pitch', () => {
// Raw RMS ~0.003: below ACF2PLUS's internal unpitched gate.
// (Normalization must NOT rescue this into a bogus reading.)
expect(detect(82.4, SR, 77.8, 0.005)).toBeNull();
});
it('still detects the string when it rings at moderate level', () => {
expectCloseTo(detect(82.4, SR, 77.8, 0.05), 82.4);
});
});

View File

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

View File

@@ -1,11 +1,11 @@
import { Settings, Guitar, Music, Gauge } from 'lucide-react';
import { TUNINGS, INSTRUMENT_TUNINGS } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { getTempoMarking } from '../music/TempoMarkings';
import React, { useEffect, useState } from 'react';
import { Gauge, SlidersHorizontal } from 'lucide-react';
import { TuningMeter } from './TuningMeter';
import { useSettings } from '../context/SettingsContext';
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
import { TargetNoteControls } from './TargetNoteControls';
import { MetronomeWidget } from './MetronomeWidget';
import { phraseRunBus } from '../hooks/phraseRunBus';
import { useSettings } from '../context/useSettings';
import { getPracticeFilter, type RhythmSettings } from '../types/SettingsTypes';
@@ -15,35 +15,13 @@ interface ControlsProps {
export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
const { settings, updateSettings } = useSettings();
// Alias to minimize refactor, or just use updateSettings.
const onUpdateSettings = updateSettings;
const handleDifficultyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onUpdateSettings({ ...settings, difficulty: e.target.value as Difficulty });
};
const handleTuningChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onUpdateSettings({ ...settings, tuningId: e.target.value });
};
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
});
};
// Phrase run gate: the metronome only ticks while the phrase trainer is
// counting in / playing (armed by the user toggle, fired by the run).
const [phraseRunActive, setPhraseRunActive] = useState(false);
useEffect(() => phraseRunBus.subscribe(setPhraseRunActive), []);
// Note-filter panel: hidden by default (minimal UI), toggled here.
const [filtersOpen, setFiltersOpen] = useState(false);
const updateRhythm = (updates: Partial<RhythmSettings>) => {
onUpdateSettings({
@@ -52,143 +30,46 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
});
};
// Cast instrument to specific key if needed, or use generic record access
const availableTunings = INSTRUMENT_TUNINGS[settings.instrument as 'guitar' | 'bass'] || [];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
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 (
<div className="controls-container">
{/* Top Section: Settings Grid (2 Columns) */}
<div className="settings-grid">
{/* Row 1, Col 1 */}
<div className="control-group">
<label className="control-label">
<Music size={18} />
<span>Instrument</span>
</label>
<select
value={settings.instrument}
onChange={handleInstrumentChange}
className="control-select"
>
{Object.values(INSTRUMENT_DEFINITIONS).map(def => (
<option key={def.id} value={def.id}>{def.displayName}</option>
))}
</select>
</div>
{/* Row 1, Col 2 (Conditional) */}
{currentInstrumentDef.showTuning ? (
<div className="control-group">
<label className="control-label">
<Guitar size={18} />
<span>Tuning</span>
</label>
<select
value={settings.tuningId}
onChange={handleTuningChange}
className="control-select"
>
{availableTunings.map(id => (
<option key={id} value={id}>{TUNINGS[id].name}</option>
))}
</select>
</div>
) : <div />} {/* Spacer to maintain grid flow if needed, or omit to let items flow. User asked for specific rows.
If I omit, Note Set goes here. Let's omit for "Dense" feel, or keep empty div for strict rows?
The request "Top row: instrument and tuning" implies if tuning is missing, maybe just 1 item?
I'll output an empty div if I want to FORCE strict placement, but generally flow is better.
However, "Second row: note set..." suggests structure.
I will use an empty div if tuning is hidden to push Note Set to next row?
Actually, grid-template-columns: 1fr 1fr.
If Tuning is hidden, Note Set becomes Item 2.
I'll forgo complexity and just render what's available. */}
{/* Row 2, Col 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 */}
<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>
{/* 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', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
/>
</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', borderRadius: '4px', border: '1px solid rgba(255,255,255,0.2)', background: 'rgba(0,0,0,0.2)', color: 'white' }}
/>
</div>
</div>
</div>
)}
{/* Note filter: collapsed behind a button — visible only when needed */}
{showFilterButton && (
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '8px' }}>
<button
className={`hint-button ${filtersOpen ? 'active' : ''}`}
onClick={() => setFiltersOpen(o => !o)}
title="Limit the notes used for exercises (key, strings, fret window)"
>
<SlidersHorizontal size={16} />
Filter
{activeFilterCount > 0 && (
<span style={{
display: 'inline-block', minWidth: '18px', padding: '1px 5px',
marginLeft: '4px', borderRadius: '9px', fontSize: '11px',
background: 'var(--color-primary)', color: 'var(--color-surface)',
fontWeight: 600, textAlign: 'center'
}}>{activeFilterCount}</span>
)}
</button>
</div>
)}
{shouldShowFilters && <TargetNoteControls />}
{/* Bottom Section: Tools Grid (2 Columns now) */}
<div className="tools-grid">
{/* Bottom Section: Tools Grid (stacked in all modes) */}
<div className="tools-grid" style={{ gridTemplateColumns: '1fr' }}>
{/* Tool 1: Tuner */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
@@ -204,136 +85,27 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
<div className="switch-thumb" />
</button>
</div>
{settings.showTuningMeter && currentPitch && (
<div style={{ paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
<TuningMeter cents={currentPitch.cents} noteName={currentPitch.note} />
</div>
)}
{settings.showTuningMeter && !currentPitch && (
<div style={{ paddingTop: '8px', borderTop: '1px solid rgba(255,255,255,0.1)', fontSize: '11px', opacity: 0.5, textAlign: 'center' }}>
Listening...
{settings.showTuningMeter && (
<div style={{ paddingTop: '8px', borderTop: '1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent)', display: 'flex', justifyContent: 'center' }}>
<TuningMeter
cents={currentPitch ? currentPitch.cents : null}
noteName={currentPitch ? currentPitch.note : null}
/>
</div>
)}
</div>
{/* Tool 2: Metronome */}
{/* Tool 2: Metronome (all modes) */}
<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' }}>
<label className="control-label" style={{ marginBottom: 0 }}>
<span>Metronome</span>
</label>
<button
className={`switch-button ${settings.rhythm.active ? 'active' : ''}`}
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',
borderRadius: '4px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(0,0,0,0.2)',
color: 'white'
}}
/>
<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 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>
)}
<MetronomeWidget
rhythm={settings.rhythm}
onUpdate={updateRhythm}
showAutoAdvance={!inPhraseMode}
syncMode={inPhraseMode ? 'option' : null}
gate={inPhraseMode ? phraseRunActive : null}
/>
</div>
{/* Tool 3: Auto-play */}
</div>
</div>
);

View File

@@ -0,0 +1,173 @@
/**
* Tiny always-visible staff showing the currently DETECTED note (the same
* display pipeline as the single-note "played note" measure): written pitch
* (sounding + display transpose), active key signature, theme-aware.
*
* The box matches the mic button (64px) and is vertically centered with it.
* Notes outside the staff's comfortable range are drawn one octave in with a
* standard 8va/8vb marker instead of clipping or reserving whitespace —
* the same convention real notation uses for out-of-range passages. Extreme
* cases still overflow the box slightly (CSS overflow: visible).
*/
import React, { useEffect, useRef, useState } from 'react';
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, Annotation } from 'vexflow';
import { getNoteInKey } from '../music/NoteUtils';
interface LiveNoteStaffProps {
midi: number | null;
transpose: number;
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;
/** Box height. 64px matches the mic bar; larger boxes (e.g. 88px in
* Assist mode) give ledger lines room inside instead of being clipped
* by the parent's overflow: hidden. Ignored when `range` is set. */
height?: number;
/** Written-pitch range the box must fit WITHOUT octave shifting.
* When set, notes render at their true staff position with real ledger
* lines (no 8va/8vb marker) and the box height is computed from the
* range so every note in it stays inside the box. */
range?: { min: number; max: number };
theme?: 'light' | 'dark' | 'auto';
}
const BOX_H = 64; // mic button height — vertical centering via the bar
// VexFlow draws the first stave line ~40.5px BELOW the constructor y
// (default space_above_staff_ln = 4 line-spaces). To center the ~38px staff
// in a box of height h the constructor y must be negative. For h = 64 this
// yields -27.5 ≈ -28 (the original hardcoded value).
const staveYFor = (h: number) => (h - 38) / 2 - 40.5;
// Diatonic index (C0 = 0, one step per letter name) for ledger-line math.
const DI_STEP = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
const diFromMidi = (m: number) => Math.floor(m / 12 - 1) * 7 + DI_STEP[m % 12];
// Top/bottom staff-line diatonic index per clef (treble F5/E4, bass A3/G2).
const CLEF_LINES: Record<'treble' | 'bass', { top: number; bottom: number }> = {
treble: { top: 38, bottom: 30 },
bass: { top: 26, bottom: 18 },
};
// One diatonic step = half a staff space = 5px (standard 10px spaces).
// Box that fits a whole written range: staff anchored below the top
// headroom so the highest note has room for its ledger lines, lowest note
// the same at the bottom.
const rangeLayout = (range: { min: number; max: number }, clef: 'treble' | 'bass') => {
const lines = CLEF_LINES[clef];
const aboveLines = Math.max(0, Math.ceil((diFromMidi(range.max) - lines.top) / 2));
const belowLines = Math.max(0, Math.ceil((lines.bottom - diFromMidi(range.min)) / 2));
const abovePx = aboveLines * 10 + 10; // + notehead margin
const belowPx = belowLines * 10 + 10;
return { height: abovePx + 38 + belowPx, y: abovePx - 40.5 };
};
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
midi,
transpose = 0,
keySignature = 'C',
clef: clefOverride,
width = 130,
height = BOX_H,
range,
theme = 'auto',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
// Box geometry: range-fit wins over the fixed height prop.
const fit = range
? rangeLayout(range, clefOverride ?? 'treble')
: { height, y: staveYFor(height) };
const boxH = fit.height;
const staveY = fit.y;
const [schemeVersion, setSchemeVersion] = useState(0);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setSchemeVersion(v => v + 1);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
container.innerHTML = '';
const renderer = new Renderer(container, Renderer.Backends.SVG);
renderer.resize(width, boxH);
const context = renderer.getContext();
const resolvedColor =
getComputedStyle(document.documentElement)
.getPropertyValue('--color-text-main')
.trim() || '#333333';
context.setFillStyle(resolvedColor);
context.setStrokeStyle(resolvedColor);
const stave = new Stave(0, staveY, width - 2);
stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 });
const written0 = midi !== null ? midi + transpose : null;
// Instrument-fixed clef when provided (guitar always treble, bass
// always bass); otherwise piano-style split at middle C.
const clef: 'treble' | 'bass' = clefOverride ?? (written0 !== null && written0 < 60 ? 'bass' : 'treble');
stave.addClef(clef);
if (keySignature) stave.addKeySignature(keySignature);
stave.setContext(context).draw();
if (midi !== null) {
// Normalize into the staff with a standard 8va/8vb marker so the
// note is always visible in the compact box:
// treble: staff E4..F5 (+ up to two ledger lines each way)
// bass: staff G2..A3 (+ up to two ledger lines each way)
let written = written0!;
// Range-fit mode renders the TRUE written position with real
// ledger lines — no 8va/8vb shifting.
let octaveMark: '8va' | '8vb' | null = null;
if (!range) {
if (clef === 'treble') {
while (written < 58) { written += 12; octaveMark = '8vb'; }
while (written > 81) { written -= 12; octaveMark = '8va'; }
} else {
while (written < 40) { written += 12; octaveMark = '8vb'; }
while (written > 61) { written -= 12; octaveMark = '8va'; }
}
}
const spec = getNoteInKey(written, keySignature);
const note = new StaveNote({
keys: spec.keys,
duration: 'w',
clef,
});
note.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
if (spec.accidental) note.addModifier(new Accidental(spec.accidental));
if (octaveMark) {
try {
note.addModifier(
new Annotation(octaveMark)
.setVerticalJustification(Annotation.VerticalJustify.CENTER)
.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor }),
);
} catch {
// Marker is a courtesy cue.
}
}
const voice = new Voice({ numBeats: 4, beatValue: 4 });
voice.setMode(Voice.Mode.SOFT);
voice.addTickable(note);
new Formatter().joinVoices([voice]).format([voice], width - 40);
voice.draw(context, stave);
}
}, [midi, transpose, keySignature, clefOverride, width, boxH, staveY, range, theme, schemeVersion]);
return (
<div
ref={containerRef}
className="live-note-staff"
style={{ width, height: boxH }}
role="img"
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
/>
);
};
export default LiveNoteStaff;

View File

@@ -0,0 +1,239 @@
/**
* 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;
// Phrase mode (syncMode 'option') has no on/off switch — the sync
// checkbox replaces it (unchecked = free metronome, checked = synced to
// the exercise), so the metronome is always armed there. Gating on
// rhythm.active would leave phrase users with no way to enable clicks.
const armed = syncMode === 'option' ? true : rhythm.active;
// Sync: pendulum/click only while the run is active (the run's scheduler
// 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 : armed;
// Timer mode = one beat per N seconds: effective BPM 60/seconds so
// clicks and pendulum match the trainer's auto-advance cadence.
const effectiveBpm = rhythm.mode === 'bpm' ? rhythm.bpm : 60 / Math.max(1, rhythm.seconds);
const { restart } = useMetronome({
bpm: effectiveBpm,
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, effectiveBpm);
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

@@ -0,0 +1,394 @@
/**
* Multi-bar phrase renderer (contract F).
*
* Renders a Score (ties pre-merged) as one or more systems of 1–4 bars.
* - Preserves source spelling; accidental display is measure-aware per
* (step, octave), seeded from the key signature and reset at barlines.
* - Splits tie-merged events into tied glyphs at barlines (rendering only —
* statuses stay per logical event).
* - Colors: current note = --color-primary (same accent as the existing
* fretboard hints) + a caret annotation (non-color cue), matched =
* --color-success, missed = --color-error, skipped = muted, rest = text.
* - Re-renders on note transitions and structural changes only, never at
* microphone frame rate.
*/
import React, { useEffect, useRef, useState } from 'react';
import { Renderer, Stave, StaveNote, GhostNote, Accidental, Voice, Formatter, StaveConnector, Beam, StaveTie, Annotation, Dot } from 'vexflow';
import { scoreEvents, type Score } from '../score/model';
import { STEP_LETTERS, type StepLetter } from '../score/model';
import type { NoteStatus } from '../game/PhraseMatcher';
import { keyFor, isMode, spelledPitch } from '../music/scales';
interface PhraseSheetMusicProps {
score: Score;
/** Logical event index currently up (-1 = none). */
currentIdx: number;
statuses: NoteStatus[];
clef: 'treble' | 'bass' | 'grand';
/** Display transpose applied ONCE, at render time (guitar +12 etc.). */
transpose: number;
width: number;
theme?: 'light' | 'dark' | 'auto';
}
interface Fragment {
note: StaveNote;
eventIdx: number;
/** Absolute tick of this fragment's start. */
start: number;
/** Fragment duration in ticks. */
ticks: number;
isAttack: boolean;
isEighth: boolean;
isSixteenth: boolean;
staveKey: 'treble' | 'bass';
rest: boolean;
}
// Duration ticks → VexFlow duration code (subset the model guarantees).
function durationCode(ticks: number): string | null {
switch (ticks) {
case 120: return '16';
case 240: return '8';
case 360: return '8d';
case 480: return '4';
case 720: return '4d';
case 960: return '2';
case 1440: return '2d';
case 1920: return 'w';
default: return null;
}
}
function accidentalGlyph(alter: number): string | undefined {
switch (alter) {
case 1: return '#';
case -1: return 'b';
case 2: return '##';
case -2: return 'bb';
default: return undefined;
}
}
function keyString(step: StepLetter, alter: number, octave: number): string {
const alterStr = alter === 1 ? '#' : alter === -1 ? 'b' : alter === 2 ? '##' : alter === -2 ? 'bb' : '';
return `${step.toLowerCase()}${alterStr}/${octave}`;
}
function signatureAlterMap(score: Score): Record<string, number> {
const map: Record<string, number> = {};
for (const s of STEP_LETTERS) map[s] = 0;
const mode = isMode(score.key.mode) ? score.key.mode : 'major';
const key = keyFor(score.key.tonic, mode);
for (const [step, alter] of Object.entries(key.signatureAlter)) map[step] = alter;
return map;
}
export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
score,
currentIdx,
statuses,
clef,
transpose,
width,
theme = 'auto',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [schemeVersion, setSchemeVersion] = useState(0);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setSchemeVersion(v => v + 1);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
const events = scoreEvents(score);
const totalBars = score.measures.length;
// 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 isGrand = clef === 'grand';
const rowHeight = isGrand ? 250 : 140;
const height = rowCount * rowHeight + 30;
useEffect(() => {
const container = containerRef.current;
if (!container || events.length === 0) return;
container.innerHTML = '';
const renderer = new Renderer(container, Renderer.Backends.SVG);
renderer.resize(width, height);
const context = renderer.getContext();
const css = getComputedStyle(document.documentElement);
const resolve = (name: string, fallback: string) => css.getPropertyValue(name).trim() || fallback;
const textColor = resolve('--color-text-main', '#333333');
// --color-primary is near-black in light mode — invisible as a highlight.
// Use the warm accent that reads clearly in both themes.
const currentColor = resolve('--color-warning', '#f59e0b');
const successColor = resolve('--color-success', '#22c55e');
const errorColor = resolve('--color-error', '#ef4444');
const mutedColor = resolve('--color-text-muted', '#888888');
context.setFillStyle(textColor);
context.setStrokeStyle(textColor);
const ledgerStyle = { strokeStyle: textColor, lineWidth: 2 };
const sigAlter = signatureAlterMap(score);
const mode = isMode(score.key.mode) ? score.key.mode : 'major';
let vexKeySpec = 'C';
try { vexKeySpec = keyFor(score.key.tonic, mode).signature; } catch { /* default */ }
const pad = 10;
const usable = width - 2 * pad;
const barWidth = usable / Math.min(barsPerRow, Math.max(1, totalBars));
// Per-row drawing data, built first so ties can connect across bars.
const rowFragmentList: Fragment[][] = [];
for (let m = 0; m < totalBars; m++) {
const rowIdx = Math.floor(m / barsPerRow);
while (rowFragmentList.length <= rowIdx) rowFragmentList.push([]);
const measure = score.measures[m];
const measureStart = measure.startTick;
const measureEnd = measureStart + measure.durationTicks;
const measureState: Record<string, number> = {};
for (let e = 0; e < events.length; e++) {
const ev = events[e];
const evStart = ev.startTick;
const evEnd = evStart + ev.durationTicks;
if (evEnd <= measureStart || evStart >= measureEnd) continue;
let cursor = Math.max(evStart, measureStart);
while (cursor < Math.min(evEnd, measureEnd)) {
const fragEnd = Math.min(evEnd, measureEnd);
const ticks = fragEnd - cursor;
const code = durationCode(ticks);
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 staveKey: 'treble' | 'bass' = rest
? (isGrand ? 'treble' : (clef as 'treble' | 'bass'))
: (isGrand ? (ev.pitch!.midi + transpose >= 60 ? 'treble' : 'bass') : (clef as 'treble' | 'bass'));
const isAttack = cursor === evStart;
let note: StaveNote;
if (rest) {
note = new StaveNote({ keys: ['b/4'], duration: `${baseCode}r`, clef: staveKey });
} else {
const p = ev.pitch!;
// WRITTEN pitch for the staff: sounding midi + display
// transpose, octave recomputed via the shared helper —
// the same sounding+transpose semantics as single-note
// SheetMusic (guitar reads an octave above sounding).
let written: { step: StepLetter; alter: number; octave: number };
try {
written = spelledPitch(p.midi + transpose, p.step, p.alter);
} catch {
// Non-octave transposes would need respelling; all
// configured transposes are octave multiples.
written = { step: p.step, alter: p.alter, octave: p.octave + Math.round(transpose / 12) };
}
const stateKey = `${written.step}${written.octave}`;
const expected = measureState[stateKey] ?? sigAlter[written.step];
const glyph = written.alter !== expected ? accidentalGlyph(written.alter) : undefined;
measureState[stateKey] = written.alter;
note = new StaveNote({
keys: [keyString(written.step, written.alter, written.octave)],
duration: baseCode,
clef: staveKey,
});
if (isDotted) Dot.buildAndAttach([note]);
if (glyph) note.addModifier(new Accidental(glyph));
}
const status = statuses[e] ?? 'pending';
const isCurrent = e === currentIdx;
let color = textColor;
if (isCurrent) color = currentColor;
else if (status === 'matched') color = successColor;
else if (status === 'missed') color = errorColor;
else if (status === 'skipped') color = mutedColor;
note.setStyle({ fillStyle: color, strokeStyle: color });
if (isCurrent && !rest) {
try {
note.addModifier(
new Annotation('▲')
.setVerticalJustification(Annotation.VerticalJustify.TOP)
.setStyle({ fillStyle: color, strokeStyle: color }),
);
} catch {
// Caret is a courtesy cue; color still marks the note.
}
}
rowFragmentList[rowIdx].push({
note,
eventIdx: e,
start: cursor,
ticks,
isAttack,
isEighth: code === '8' && !rest,
isSixteenth: code === '16' && !rest,
staveKey,
rest,
});
cursor = fragEnd;
}
}
}
// ---- Staves, voices, formatting, drawing -----------------------------
for (let r = 0; r < rowFragmentList.length; r++) {
// VexFlow draws stave lines ~40.5px below the constructor y —
// center the staff in the row and leave ledger headroom below.
const y = r * rowHeight + 9;
const rowMeasureNumbers: number[] = [];
for (let b = 0; b < barsPerRow; b++) {
const m = r * barsPerRow + b;
if (m < totalBars) rowMeasureNumbers.push(m);
}
for (const m of rowMeasureNumbers) {
const b = m % barsPerRow;
const measure = score.measures[m];
const measureStart = measure.startTick;
const measureEnd = measureStart + measure.durationTicks;
const x = pad + b * barWidth;
const isFirstOfRow = b === 0;
const frags = rowFragmentList[r].filter(f => f.start >= measureStart && f.start < measureEnd);
// --- Staves ---
const treble = new Stave(x, y, barWidth);
treble.setDefaultLedgerLineStyle(ledgerStyle);
if (isFirstOfRow) {
treble.addClef('treble').addKeySignature(vexKeySpec);
if (r === 0) treble.addTimeSignature(`${score.meter.numerator}/${score.meter.denominator}`);
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;
if (isGrand) {
bass = new Stave(x, y + 130, barWidth);
bass.setDefaultLedgerLineStyle(ledgerStyle);
if (isFirstOfRow) {
bass.addClef('bass').addKeySignature(vexKeySpec);
if (r === 0) bass.addTimeSignature(`${score.meter.numerator}/${score.meter.denominator}`);
}
}
// --- Voices (grand: split per stave with ghost placeholders) ---
const buildVoice = (stave: Stave, sel: (f: Fragment) => boolean): { voice: Voice; stave: Stave } => {
const voice = new Voice({ numBeats: score.meter.numerator, beatValue: 4 });
voice.setMode(Voice.Mode.SOFT); // pickups/closers are validated in the model
const all = frags.filter(sel).sort((a, b2) => a.start - b2.start);
let cursor = measureStart;
for (const f of all) {
if (f.start > cursor) {
const gapCode = durationCode(f.start - cursor);
if (gapCode) voice.addTickable(new GhostNote({ duration: gapCode }));
}
voice.addTickable(f.note);
cursor = f.start + f.ticks;
}
if (measureEnd > cursor) {
const tailCode = durationCode(measureEnd - cursor);
if (tailCode) voice.addTickable(new GhostNote({ duration: tailCode }));
}
return { voice, stave };
};
const voices: { voice: Voice; stave: Stave }[] = [];
if (isGrand) {
voices.push(buildVoice(treble, f => f.staveKey === 'treble'));
voices.push(buildVoice(bass!, f => f.staveKey === 'bass'));
} else {
voices.push(buildVoice(treble, () => true));
}
const formatWidth = Math.max(60, barWidth - (isFirstOfRow ? 100 : 24));
const formatter = new Formatter();
formatter.joinVoices(voices.map(v => v.voice));
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();
if (bass) bass.setContext(context).draw();
for (const { voice, stave } of voices) voice.draw(context, stave);
for (const beam of beams) beam.setContext(context).draw();
if (isGrand && isFirstOfRow) {
const brace = new StaveConnector(treble, bass!);
brace.setType(StaveConnector.type.BRACE);
brace.setContext(context).draw();
const line = new StaveConnector(treble, bass!);
line.setType(StaveConnector.type.SINGLE_LEFT);
line.setContext(context).draw();
}
}
// --- Ties between consecutive fragments of one event (this row) ---
const rowFrags = [...rowFragmentList[r]].sort((a, b) => a.start - b.start);
for (let i = 0; i + 1 < rowFrags.length; i++) {
const a = rowFrags[i], b = rowFrags[i + 1];
if (a.eventIdx === b.eventIdx && a.staveKey === b.staveKey && !a.rest && !b.rest) {
const tie = new StaveTie({ firstNote: a.note, lastNote: b.note });
tie.setContext(context).draw();
}
}
}
}, [score, currentIdx, statuses, clef, transpose, width, theme, schemeVersion, events, barsPerRow, rowCount, rowHeight, height, isGrand, totalBars]);
return (
<div
ref={containerRef}
className="sheet-music-container phrase-sheet"
style={{ width: '100%', minHeight: 60 }}
role="img"
aria-label={`Notation: ${score.title}, ${score.measures.length} bars, key of ${score.key.tonic}`}
/>
);
};
export default PhraseSheetMusic;

View File

@@ -0,0 +1,931 @@
/**
* Phrase Mode main component (contracts D–F, H).
*
* Owns: material selection (generated melodies / scale drills / library /
* local import), the practice-range selection, the setup panel, transport
* controls, and the hint instruments. Playback state lives in usePhraseTrainer.
*
* Deviation from the contract (recorded): range conflicts for fixed material
* show a warning and an explicit octave-shift control rather than blocking
* the run; notes outside the playable range simply cannot be matched by mic.
*/
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useSettings } from '../context/useSettings';
import { useAudioPlayer } from '../hooks/useAudioPlayer';
import { usePhraseTrainer, type PhraseTrainerApi, type PhrasePhase } from '../hooks/usePhraseTrainer';
import { getPhraseSettings, getPracticeFilter, type PhraseSettings } from '../types/SettingsTypes';
import { rawFrameCount } from '../hooks/rawFrameBus';
import { audioEngine } from '../audio/AudioEngine';
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow } from '../music/playableRange';
import { generateMelody } from '../music/melodyGenerator';
import { generateScaleDrill } from '../music/scaleDrills';
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 { getNoteDetails } from '../music/NoteUtils';
import { EXERCISES, loadExercise } from '../exercises/library';
import { parseAbc } from '../exercises/abcParser';
import { PhraseSheetMusic } from './PhraseSheetMusic';
import { TargetNoteControls } from './TargetNoteControls';
import { Fretboard } from './Fretboard';
import { PianoKeys } from './PianoKeys';
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
const AUTO_CONTINUE_DELAY_MS = 1500;
import { Play, Pause, RotateCcw, SkipForward, SkipBack, Volume2, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react';
export interface PhraseHandle {
pauseToggle: () => void;
previewToggle: () => void;
previewStep: (dir: 1 | -1) => void;
retry: () => void;
skip: () => void;
next: () => void;
pause: () => void;
}
interface PhraseTrainerProps {
listening: boolean;
micError: string | null;
windowWidth: number;
}
/** Shift a fixed score by whole octaves (explicit user action only). */
function shiftScoreOctaves(score: Score, octaves: number): Score {
if (octaves === 0) return score;
const events: ScoreEvent[] = scoreEvents(score).map((e, i) => {
if (!e.pitch) return e;
return {
...e,
id: `os${i}`,
pitch: { ...e.pitch, midi: e.pitch.midi + 12 * octaves, octave: e.pitch.octave + octaves },
};
});
return { ...score, voices: [{ id: 'melody', events }] };
}
export const PhraseTrainer = forwardRef<PhraseHandle, PhraseTrainerProps>(
function PhraseTrainer({ listening, micError, windowWidth }, ref) {
const { settings, updateSettings } = useSettings();
const setSettings = updateSettings;
const { playNote } = useAudioPlayer();
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.
// Phrase mode has no metronome on/off switch (the sync checkbox
// replaces it, see MetronomeWidget), so rhythm.active must NOT gate
// clicks here — with the default active:false a synced run would
// stay silent with no way to fix it from this mode.
// - Sync (tempo): run clicks when sound is on (count-in included);
// the widget stays silent while idle.
// - Free metronome (step): the widget clicks freely; the run stays
// silent to avoid doubling.
phrase.clickSound = settings.rhythm.sound;
const setPhrase = useCallback((updates: Partial<PhraseSettings>) => {
updateSettings(s => ({ ...s, phrase: { ...getPhraseSettings(s), ...updates } }));
}, [updateSettings]);
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const currentTuning = TUNINGS[settings.tuningId];
const fretted = isFrettedInstrument(settings.instrument) && !!currentTuning;
// ---- Playable pool ----------------------------------------------------
const pool = useMemo(() => {
// Unified filter: strings ∩ fret window, computed identically to
// 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({
instrumentId: settings.instrument,
difficulty: settings.difficulty,
tuningId: settings.tuningId,
customMinFret: settings.customMinFret,
customMaxFret: settings.customMaxFret,
});
}, [pf.strings, pf.fretWindowEnabled, pf.fretMin, pf.fretMax, fretted, currentTuning,
settings.instrument, settings.difficulty, settings.tuningId, settings.customMinFret, settings.customMaxFret]);
// ---- Imported score (session-only) --------------------------------------
const [importedScore, setImportedScore] = useState<Score | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [octaveShift, setOctaveShift] = useState(0);
const [melodySeed, setMelodySeed] = useState(() => Math.floor(Math.random() * 100000));
const fileInputRef = useRef<HTMLInputElement>(null);
const pendingAutoStart = useRef(false);
const handleImportFile = useCallback((file: File) => {
setImportError(null);
if (file.size > 256 * 1024) {
setImportError('File is larger than 256 KiB.');
return;
}
file.text().then(text => {
const result = parseAbc(text);
if (result.ok) {
setImportedScore(result.value);
setOctaveShift(0);
setPhrase({ material: 'import', startBar: 1 });
} else {
setImportError(result.error);
}
}).catch(() => setImportError('Could not read the file.'));
}, [setPhrase]);
// ---- Material score ----------------------------------------------------
const baseScore: Result<Score> | null = useMemo(() => {
if (phrase.material === 'melody') {
const result = generateMelody({
keyTonic: pf.keyTonic,
keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
bars: phrase.bars,
meter: { numerator: phrase.meterNumerator, denominator: 4 },
rhythmLevel: phrase.rhythmLevel,
seed: melodySeed,
}, pool);
return result.ok ? { ok: true as const, value: result.value.score } : result;
}
if (phrase.material === 'scale') {
return generateScaleDrill({
keyTonic: pf.keyTonic,
keyMode: (isMode(pf.keyMode) ? pf.keyMode : 'major') as ModeId,
direction: phrase.scaleDirection,
coverage: phrase.scaleCoverage,
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);
}
if (phrase.material === 'library') {
const id = phrase.libraryId || EXERCISES[0]?.id;
if (!id) return { ok: false, error: 'No exercises available.' };
return loadExercise(id);
}
if (phrase.material === 'import') {
if (!importedScore) return { ok: false, error: 'No file imported yet — pick an .abc file in the setup panel.' };
return { ok: true, value: importedScore };
}
return null;
}, [phrase.material, pf.keyTonic, pf.keyMode, phrase.bars, phrase.meterNumerator,
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]);
const fullScore: Result<Score> | null = useMemo(() => {
if (!baseScore) return null;
if (!baseScore.ok) return baseScore;
if (octaveShift !== 0 && (phrase.material === 'library' || phrase.material === 'import')) {
return { ok: true, value: shiftScoreOctaves(baseScore.value, octaveShift) };
}
return baseScore;
}, [baseScore, octaveShift, phrase.material]);
const fixedMaterial = phrase.material === 'library' || phrase.material === 'import';
const scoreOk = !!fullScore && fullScore.ok;
const selection = useMemo(() => ({
startBar: fixedMaterial ? phrase.startBar : 1,
barCount: fixedMaterial ? phrase.barCount : (fullScore && fullScore.ok ? fullScore.value.measures.length : 8),
}), [fixedMaterial, phrase.startBar, phrase.barCount, fullScore]);
const trainer: PhraseTrainerApi | null = usePhraseTrainer(
fullScore && fullScore.ok ? fullScore.value : EMPTY_SCORE,
selection,
{
pace: phrase.pace,
bpm: phrase.bpm,
clickSound: phrase.clickSound,
inputMode: phrase.inputMode,
previewVolume: settings.autoPlayVolume ?? 0.4,
autoStartOnNote: phrase.autoStartOnNote,
},
listening,
micError,
);
// Auto-continue: when a run finishes, keep practice flowing —
// fixed material advances to the next section (wrapping), generated
// melodies roll a new seed, scale drills restart. Tempo mode only.
const prevPhaseRef = useRef<PhrasePhase>('ready');
useEffect(() => {
const was = prevPhaseRef.current;
prevPhaseRef.current = trainer.phase;
if (trainer.phase === 'done' && was !== 'done' && phrase.autoContinue && scoreOk && phrase.pace === 'tempo') {
const material = phrase.material;
const full = fullScore && fullScore.ok ? fullScore.value : null;
if (material === 'scale') {
const timer = setTimeout(() => trainer.start(), AUTO_CONTINUE_DELAY_MS);
return () => clearTimeout(timer);
}
const timer = setTimeout(() => {
if (material === 'library' || material === 'import') {
if (!full) return;
const total = full.measures.length;
const nextStart = phrase.startBar + phrase.barCount;
pendingAutoStart.current = true;
setPhrase(nextStart > total ? { startBar: 1 } : { startBar: nextStart });
} else {
pendingAutoStart.current = true;
setMelodySeed(s => (s + 1) % 999983);
}
}, AUTO_CONTINUE_DELAY_MS);
return () => clearTimeout(timer);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [trainer.phase, phrase.pace, phrase.autoContinue, phrase.material, phrase.startBar, phrase.barCount, scoreOk, fullScore, setPhrase]);
// Auto-start after a structural change triggered by auto-continue.
useEffect(() => {
if (pendingAutoStart.current && trainer.phase === 'ready' && scoreOk) {
pendingAutoStart.current = false;
trainer.start();
}
});
const materialError = fullScore && !fullScore.ok ? fullScore.error : null;
// Range-conflict warning for fixed material.
const rangeWarning = useMemo(() => {
if (!scoreOk || !fixedMaterial) return null;
const set = new Set(pool);
const out = scoreEvents(fullScore!.value).filter(e => e.pitch && !set.has(e.pitch.midi));
return out.length > 0
? `${out.length} of ${scoreEvents(fullScore!.value).length} notes are outside the current playable range — change the range, or use the octave control.`
: null;
}, [scoreOk, fixedMaterial, pool, fullScore]);
const nextAction = useCallback(() => {
if (fixedMaterial && scoreOk) {
const total = fullScore!.value.measures.length;
const nextStart = phrase.startBar + phrase.barCount;
if (nextStart > total) {
setPhrase({ startBar: 1 }); // back to beginning
} else {
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]);
// ---- Ref API -----------------------------------------------------------
useImperativeHandle(ref, () => ({
pauseToggle: () => trainer?.pauseToggle(),
previewToggle: () => trainer?.previewToggle(),
previewStep: (dir) => trainer?.previewStep(dir),
retry: () => trainer?.retry(),
skip: () => trainer?.skip(),
pause: () => trainer?.pause(),
next: () => nextAction(),
}));
// ---- Hints --------------------------------------------------------------
const activeScore = trainer.activeScore;
const events = scoreEvents(activeScore);
// While previewing, the hint instruments follow the preview cursor so
// 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;
const hintPositions = useMemo(() => {
if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return [];
if (pf.fretWindowEnabled) {
return positionsWithinWindow(currentMidi, currentTuning, pf.fretMin, pf.fretMax);
}
return getFretboardPositions(currentMidi, currentTuning);
}, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, pf.fretWindowEnabled, pf.fretMin, pf.fretMax]);
const handleVirtualPlay = useCallback((midi: number) => {
if (!settings.virtualGuitarMute) {
playNote(midi, 0.5, settings.virtualGuitarVolume ?? 0.5);
}
trainer?.virtualTap(midi);
}, [settings.virtualGuitarMute, settings.virtualGuitarVolume, playNote, trainer]);
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null); // accepted by Fretboard/PianoKeys; phrase renderer has no hover preview
void hoveredMidi;
// Shared clef/transpose resolution (single-note mode uses the same).
const { clef: activeClef, transpose: activeTranspose } = useMemo(() =>
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
[currentInstrumentDef, settings.difficulty]);
// ---- Status line ----------------------------------------------------------
const statusText = useMemo(() => {
if (!scoreOk) return materialError ?? 'No material.';
const p = trainer.phase;
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 === 'paused') return 'Paused';
if (p === 'done') {
if (phrase.pace === 'tempo') return `Matched ${trainer.summary.matched} of ${trainer.summary.total} notes`;
return `Completed ${trainer.summary.total} notes, skipped ${trainer.summary.skipped}`;
}
if (p === 'playing') {
if (phrase.pace === 'tempo') return `${trainer.summary.matched} matched · ${trainer.summary.missed + trainer.summary.skipped} missed`;
if (trainer.repeatedNoteBlocked) return 'Same note again — release, then strike it once more';
return 'Play the highlighted note';
}
return phrase.pace === 'tempo' ? 'Press start — one bar of count-in, then play in time.' : 'Press start, then play the highlighted notes.';
}, [scoreOk, materialError, trainer.phase, trainer.countInLeft, trainer.summary, phrase.pace, trainer.repeatedNoteBlocked]);
const startLabel = useMemo(() => {
const p = trainer.phase;
if (p === 'playing' || p === 'countIn') return 'Pause';
if (p === 'paused') return 'Resume';
if (p === 'preview') return 'Pause';
if (p === 'previewPaused') return 'Resume';
return 'Start';
}, [trainer.phase]);
const startAction = useMemo(() => {
if (trainer.phase === 'preview' || trainer.phase === 'previewPaused') return trainer.previewToggle;
return trainer.pauseToggle;
}, [trainer]);
const staffWidth = Math.min(windowWidth - 40, 860);
// Debug hook for real-browser QA (enable with localStorage.phraseDebug = '1')
useEffect(() => {
if (typeof window === 'undefined') return;
const w = window as unknown as { __phraseDebug?: unknown };
if (localStorage.getItem('phraseDebug') === '1') {
w.__phraseDebug = () => ({
frames: rawFrameCount(),
audioNow: audioEngine.now(),
scoreStart: trainer.scoreStart,
spb: trainer.spb,
micGate: audioEngine.isAudible(),
micBlanked: audioEngine.isMicBlanked(),
phase: trainer.phase,
currentIdx: trainer.currentIdx,
statuses: [...trainer.statuses],
summary: trainer.summary,
pool: [...pool],
firstNote: events[0]?.pitch?.midi ?? null,
scoreOk,
material: phrase.material,
midis: events.map(e => (e.pitch ? e.pitch.midi : null)),
});
} else {
delete w.__phraseDebug;
}
});
// ---- Render ---------------------------------------------------------------
return (
<div className={`card sheet-music-card phrase-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''}`}>
<div className="phrase-title-row">
<Music2 size={18} />
<span className="phrase-title">
{scoreOk ? fullScore!.value.title : 'Phrase Mode'}
{fixedMaterial && scoreOk && (
<span className="phrase-range">
{' '}— bars {selection.startBar}–{Math.min(selection.startBar + selection.barCount - 1, fullScore!.value.measures.length)}
</span>
)}
</span>
</div>
{/* Setup panel — above the notation, holds material options
plus the shared note filter (key/strings/fret window) */}
{!settings.zenMode && (
<details className="phrase-setup">
<summary><ChevronDown size={16} /> Setup</summary>
<div className="phrase-setup-grid">
<label>Material
<select value={phrase.material} onChange={e => setPhrase({ material: e.target.value as PhraseSettings['material'] })}>
<option value="melody">Melodies (generated)</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="import">Imported file</option>
</select>
</label>
{phrase.material === 'melody' && (
<>
<label>Bars
<select value={phrase.bars} onChange={e => setPhrase({ bars: Number(e.target.value) })}>
{[1, 2, 3, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)}
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
<option value={6}>6/8</option>
</select>
</label>
<label>Rhythm
<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={2}>Mixed</option>
<option value={3}>Elaborate (16ths)</option>
</select>
</label>
</>
)}
{phrase.material === 'scale' && (
<>
<label>Coverage
<select value={phrase.scaleCoverage} onChange={e => setPhrase({ scaleCoverage: e.target.value as PhraseSettings['scaleCoverage'] })}>
<option value="one-octave">One octave</option>
<option value="two-octave">Two octaves</option>
<option value="position">Within position</option>
</select>
</label>
<label>Direction
<select value={phrase.scaleDirection} onChange={e => setPhrase({ scaleDirection: e.target.value as PhraseSettings['scaleDirection'] })}>
<option value="up">Up</option>
<option value="down">Down</option>
<option value="updown">Up &amp; down</option>
</select>
</label>
<label>Meter
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Math.max(3, Math.min(6, Number(e.target.value))) as 3 | 4 | 6 })}>
<option value={4}>4/4</option>
<option value={3}>3/4</option>
<option value={6}>6/8</option>
</select>
</label>
</>
)}
{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' && (
<label>Exercise
<select value={phrase.libraryId || EXERCISES[0].id} onChange={e => setPhrase({ libraryId: e.target.value, startBar: 1 })}>
{EXERCISES.map(ex => (
<option key={ex.id} value={ex.id}>{ex.label} ({ex.level})</option>
))}
</select>
</label>
)}
{fixedMaterial && scoreOk && (
<>
<label>Start bar
<select value={phrase.startBar} onChange={e => setPhrase({ startBar: Number(e.target.value) })}>
{Array.from({ length: fullScore!.value.measures.length }, (_, i) => i + 1).map(b => (
<option key={b} value={b}>{b}</option>
))}
</select>
</label>
<label>Bars at a time
<select value={phrase.barCount} onChange={e => setPhrase({ barCount: Number(e.target.value) })}>
{[1, 2, 4, 6, 8].map(b => <option key={b} value={b}>{b}</option>)}
</select>
</label>
<label>Transpose
<select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}>
<option value={-2}>-2 oct</option>
<option value={-1}>-1 oct</option>
<option value={0}>0</option>
<option value={1}>+1 oct</option>
<option value={2}>+2 oct</option>
</select>
</label>
</>
)}
{phrase.material === 'import' && (
<label>File
<button className="hint-button" onClick={() => fileInputRef.current?.click()}>
<Upload size={16} /> Choose .abc file
</button>
<input
ref={fileInputRef}
type="file"
accept=".abc,.txt"
style={{ display: 'none' }}
onChange={e => { const f = e.target.files?.[0]; if (f) handleImportFile(f); e.target.value = ''; }}
/>
</label>
)}
{/* Pace lives in the metronome widget now:
'Sync to exercise' checked = tempo pace,
unchecked = at-your-pace (step). */}
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.inputMode === 'virtual'}
onChange={e => setPhrase({ inputMode: e.target.checked ? 'virtual' : 'mic' })}
/>
Virtual input only
</label>
<label className="phrase-check">
<input
type="checkbox"
checked={phrase.autoContinue}
onChange={e => setPhrase({ autoContinue: e.target.checked })}
/>
Auto-continue
</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>
{phrase.pace === 'tempo' && (
<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">
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.
</p>
<TargetNoteControls />
</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 */}
{(settings.showHint || settings.showFretboard) && currentInstrumentDef.showTuning && (
<div className="hint-card">
{settings.showHint && currentMidi !== null && (
<div className="hint-note landscape-hint-note">
{getNoteDetails(currentMidi + activeTranspose).scientific}
</div>
)}
{currentInstrumentDef.id === 'piano' ? (
<PianoKeys
minMidi={36}
maxMidi={84}
markedNotes={settings.showHint && currentMidi !== null ? [currentMidi] : []}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualPlay}
onHover={setHoveredMidi}
/>
) : (
currentTuning && (
<Fretboard
tuning={currentTuning}
positions={hintPositions}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualPlay}
onHover={setHoveredMidi}
showHints={settings.showHint}
maxFrets={Math.max(15, pf.fretWindowEnabled ? pf.fretMax + 1 : 0)}
/>
)
)}
</div>
)}
{phrase.material === 'import' && importedScore && !importError && (
<p className="phrase-note">Imported “{importedScore.title}” — this stays in this browser tab only.</p>
)}
</div>
);
}
);
const EMPTY_SCORE: Score = {
version: 1,
id: 'empty',
title: '',
meter: { numerator: 4, denominator: 4 },
key: { tonic: 'C', mode: 'major', signature: 'C' },
measures: [],
voices: [{ id: 'melody', events: [] }],
};
export default PhraseTrainer;

View File

@@ -145,7 +145,7 @@ export function PianoKeys({
// Sort specifically so black keys are last (on top in SVG z-order)
return rects.sort((a, b) => (a.isBlack === b.isBlack) ? 0 : a.isBlack ? 1 : -1);
}, [keys, whiteKeyWidth, viewMode]);
}, [keys, whiteKeyWidth]);
return (

View File

@@ -1,8 +1,32 @@
import React, { useState } from 'react';
import { X, Volume2, VolumeX, ChevronDown, ChevronUp, Mic, MicOff, Sun, Moon, Monitor } 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 { MIC_SENSITIVITY_DB_RANGE, MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
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 {
isOpen: boolean;
@@ -19,6 +43,9 @@ interface SettingsModalProps {
const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isActive }) => {
// Convert RMS to dB for display, then normalize to 0-100%
// RMS of 0.001 = -60dB, RMS of 1.0 = 0dB
// `level` is already EMA-smoothed in the detection loop — no CSS transition
// here (a transition on a value that changes every frame gets interrupted
// 60x/sec, which Safari/WebKit handles by leaving the bar stuck).
const db = level > 0 ? 20 * Math.log10(level) : -120;
// Map full range from -100dB to 0dB => 0% to 100%
@@ -47,8 +74,7 @@ const LevelMeter: React.FC<{ level: number; isActive: boolean }> = ({ level, isA
height: '100%',
width: `${percentage}%`,
background: percentage > 80 ? '#ef4444' : percentage > 50 ? '#f59e0b' : '#22c55e',
borderRadius: '6px',
transition: 'width 0.05s ease-out'
borderRadius: '6px'
}} />
</div>
<span style={{ fontSize: '11px', opacity: 0.7, minWidth: '45px', textAlign: 'right' }}>
@@ -76,7 +102,52 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
<div className="help-popup-overlay" onClick={onClose}>
<div className="help-popup" onClick={e => e.stopPropagation()}>
<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 */}
<div className="control-group" style={{ marginBottom: '24px' }}>
@@ -91,7 +162,7 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
].map(mode => (
<button
key={mode.id}
onClick={() => onUpdateSettings({ ...settings, theme: mode.id as any })}
onClick={() => onUpdateSettings({ ...settings, theme: mode.id as AppSettings['theme'] })}
style={{
flex: 1,
display: 'flex',
@@ -117,8 +188,6 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</div>
</div>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} />
{/* Animation Settings */}
<div className="control-group" style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
@@ -138,7 +207,9 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
</p>
</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 style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
@@ -213,7 +284,55 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
: "Automatically plays the note audio when a new note appears."}
</p>
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} />
{/* Reference note duration */}
<div className="control-group" style={{ marginTop: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<label className="control-label" style={{ marginBottom: 0, fontSize: '16px' }}>
<span>Reference Note Duration</span>
</label>
<span style={{ fontSize: '14px', fontWeight: 600, minWidth: '40px', textAlign: 'right' }}>
{settings.referenceNoteDuration ?? 1.5}s
</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<Clock size={20} style={{ opacity: 0.7 }} />
<input
type="range"
min="0.5"
max="4"
step="0.25"
value={settings.referenceNoteDuration ?? 1.5}
onChange={(e) => onUpdateSettings({ ...settings, referenceNoteDuration: parseFloat(e.target.value) })}
style={{ flex: 1 }}
aria-label="Reference note duration"
/>
</div>
</div>
{/* Wait for silence before the next reference note */}
<div className="control-group" style={{ marginTop: '16px' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '8px' }}>
<label className="control-label" style={{ marginBottom: 0, fontSize: '16px' }}>
<span>Wait for Silence</span>
</label>
<button
className={`switch-button ${settings.waitForQuiet ? 'active' : ''}`}
onClick={() => onUpdateSettings({ ...settings, waitForQuiet: !settings.waitForQuiet })}
title={settings.waitForQuiet ? "Play the next reference note immediately" : "Hold the next reference note until you stop playing"}
aria-label="Wait for silence"
>
<div className="switch-thumb" />
</button>
</div>
<p style={{ fontSize: '12px', opacity: 0.7, margin: 0 }}>
Holds the next reference note until your instrument has gone quiet,
so a still-ringing string can't mask it. (Requires the microphone.)
</p>
</div>
</Section>
<Section title="Microphone" defaultOpen>
{/* Microphone Toggle */}
<div className="control-group" style={{ marginBottom: '16px' }}>
@@ -386,6 +505,8 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
)}
</div>
)}
</Section>
</div>
</div>

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useRef } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Renderer, Stave, StaveNote, Accidental, Voice, Formatter, StaveConnector, GhostNote, Annotation } from 'vexflow';
import { getNoteInKey } from '../music/NoteUtils';
@@ -13,6 +13,7 @@ interface SheetMusicProps {
keySignature?: string;
hideTargetNote?: boolean;
hoverMidi?: number | null;
theme?: 'light' | 'dark' | 'auto';
}
export const SheetMusic: React.FC<SheetMusicProps> = ({
@@ -24,10 +25,23 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
transpose = 12, // Default to +1 octave (Guitar Notation)
keySignature = 'C',
hideTargetNote = false,
hoverMidi = null
hoverMidi = null,
theme = 'auto'
}) => {
const containerRef = useRef<HTMLDivElement>(null);
// SVG presentation attributes cannot resolve CSS var() (VexFlow writes
// colors as `fill="..."` attributes), so the actual color is read from
// getComputedStyle at draw time. Re-render when the theme changes —
// including system scheme flips while theme === 'auto'.
const [schemeVersion, setSchemeVersion] = useState(0);
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const onChange = () => setSchemeVersion(v => v + 1);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
useEffect(() => {
if (!containerRef.current) return;
@@ -38,9 +52,24 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
renderer.resize(width, height);
const context = renderer.getContext();
// IMPORTANT: Set global styles for the context to ensure everything draws with correct color
context.setFillStyle("var(--color-text-main)");
context.setStrokeStyle("var(--color-text-main)");
// IMPORTANT: resolve the theme color to a concrete value — SVG
// attributes don't support var(). Without this the notation falls
// back to black, which is unreadable in dark mode.
const resolvedColor =
getComputedStyle(document.documentElement)
.getPropertyValue('--color-text-main')
.trim() || '#333333';
// Set global styles for the context to ensure everything draws with correct color
context.setFillStyle(resolvedColor);
context.setStrokeStyle(resolvedColor);
// VexFlow hardcodes ledger lines ("the extra lines" above/below the
// staff) to a dark gray default that is unreadable in dark mode.
// getDefaultLedgerLineStyle() merges this default OVER the stave style,
// so it must be overridden explicitly on every stave we create.
const ledgerStyle = { strokeStyle: resolvedColor, lineWidth: 2 };
const applyLedgerStyle = (stave: Stave) => stave.setDefaultLedgerLineStyle(ledgerStyle);
// --- Helper: Decide which clef a note belongs to in Grand Staff ---
const getGrandStaffClef = (midi: number): 'treble' | 'bass' => {
@@ -72,11 +101,13 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
if (clef === 'grand') {
// Measure 1: Treble
const m1Treble = new Stave(startX, trebleY, measureWidth);
applyLedgerStyle(m1Treble);
m1Treble.addClef('treble').addKeySignature(keySignature);
m1Treble.setContext(context).draw();
// Measure 1: Bass
const m1Bass = new Stave(startX, bassY, measureWidth);
applyLedgerStyle(m1Bass);
m1Bass.addClef('bass').addKeySignature(keySignature);
m1Bass.setContext(context).draw();
@@ -99,6 +130,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
if (numMeasures === 2) {
// Measure 2: Treble
const m2Treble = new Stave(startX + measureWidth, trebleY, measureWidth);
applyLedgerStyle(m2Treble);
// No clef/key sig repeated typically for just next measure in same system,
// UNLESS it's a new system. Here it's same system.
// But VexFlow might require setting context context.
@@ -106,6 +138,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
// Measure 2: Bass
const m2Bass = new Stave(startX + measureWidth, bassY, measureWidth);
applyLedgerStyle(m2Bass);
m2Bass.setContext(context).draw();
stavesMeasure2 = { treble: m2Treble, bass: m2Bass };
@@ -126,6 +159,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
// Single Stave
const staveY = isCompact ? 10 : 30;
const m1Stave = new Stave(startX, staveY, measureWidth);
applyLedgerStyle(m1Stave);
m1Stave.addClef(clef).addKeySignature(keySignature);
m1Stave.setContext(context).draw();
@@ -133,6 +167,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
if (numMeasures === 2) {
const m2Stave = new Stave(startX + measureWidth, staveY, measureWidth);
applyLedgerStyle(m2Stave);
m2Stave.setContext(context).draw();
stavesMeasure2 = { [clef]: m2Stave };
}
@@ -157,7 +192,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
});
// Set styles
staveNote.setStyle({ fillStyle: "var(--color-text-main)", strokeStyle: "var(--color-text-main)" });
staveNote.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
if (data.accidental) {
const accidental = new Accidental(data.accidental);
@@ -166,7 +201,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
if (type === 'played') {
// Always use default style (text color), no green/red distinction
staveNote.setStyle({ fillStyle: "var(--color-text-main)", strokeStyle: "var(--color-text-main)" });
staveNote.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
}
return { note: staveNote, clef: noteClef };
@@ -235,7 +270,7 @@ export const SheetMusic: React.FC<SheetMusicProps> = ({
});
}, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote]);
}, [targetMidi, playedMidi, hoverMidi, clef, width, height, transpose, keySignature, hideTargetNote, theme, schemeVersion]);
return <div ref={containerRef} className="sheet-music-container" />;
};

View File

@@ -0,0 +1,209 @@
/**
* The original single-note experience (Sight Reading / Ear Training),
* extracted from App so its game-logic effects unmount entirely while
* Phrase Mode is active (contract: "no background scoring after leaving
* the mode" — hiding the UI alone would keep useGameLogic running).
*/
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { SheetMusic } from './SheetMusic';
import { useSettings } from '../context/useSettings';
import { useAudioPlayer } from '../hooks/useAudioPlayer';
import { useGameLogic } from '../hooks/useGameLogic';
import { getNoteDetails } from '../music/NoteUtils';
import { TUNINGS, getFretboardPositions } from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS, resolveClefTranspose } from '../music/InstrumentConfigs';
import { Fretboard } from './Fretboard';
import { PianoKeys } from './PianoKeys';
import { audioEngine } from '../audio/AudioEngine';
import { Guitar, HelpCircle, Volume2, SkipForward } from 'lucide-react';
export interface SingleNoteHandle {
skipNote: () => void;
playCurrent: () => void;
}
interface SingleNoteTrainerProps {
/** Raw detection (gating for display happens inside). */
pitchData: { midi: number; note: string; cents: number; frequency: number } | null;
windowWidth: number;
windowHeight: number;
onSetHoveredMidi?: (midi: number | null) => void;
}
export const SingleNoteTrainer = forwardRef<SingleNoteHandle, SingleNoteTrainerProps>(
function SingleNoteTrainer({ pitchData, windowWidth, windowHeight, onSetHoveredMidi }, ref) {
const { settings, updateSettings } = useSettings();
const setSettings = updateSettings;
const { playNote } = useAudioPlayer();
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const {
targetMidi,
feedbackMessage,
revealed,
virtualNote,
generateNewNote,
handleVirtualInstrumentPlay
} = useGameLogic(pitchData);
useImperativeHandle(ref, () => ({
skipNote: () => generateNewNote(),
playCurrent: () => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5),
}), [generateNewNote, playNote, targetMidi, settings.referenceNoteDuration, settings.autoPlayVolume]);
const [hoveredMidi, setHoveredMidi] = useState<number | null>(null);
useEffect(() => { onSetHoveredMidi?.(hoveredMidi); }, [hoveredMidi, onSetHoveredMidi]);
// Mic feedback is gated while the app's speaker output is audible.
const displayedPitch = audioEngine.isAudible() ? null : pitchData;
const { clef: activeClef, transpose: activeTranspose } = useMemo(() =>
resolveClefTranspose(currentInstrumentDef, settings.difficulty),
[currentInstrumentDef, settings.difficulty]);
const hintPositions = useMemo(() => {
if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning) return [];
return getFretboardPositions(targetMidi, currentTuning);
}, [settings.showHint, targetMidi, currentInstrumentDef, currentTuning]);
return (
<div className={`card sheet-music-card ${(settings.showHint || settings.showFretboard) ? 'has-hint' : ''} ${settings.zenMode ? 'zen-mode' : ''}`}>
<div className="sheet-music-container">
<SheetMusic
targetMidi={targetMidi}
playedMidi={virtualNote ?? displayedPitch?.midi}
keySignature={settings.keySignature}
clef={activeClef}
transpose={activeTranspose}
width={Math.min(windowWidth - 40, 500)}
height={
activeClef === 'grand'
? (windowHeight < 500 ? 190 : 260)
: (windowHeight < 500 ? 120 : 180)
}
hideTargetNote={settings.gameMode === 'ear_training' && !revealed}
hoverMidi={settings.showHint ? hoveredMidi : null}
theme={settings.theme}
/>
</div>
{!settings.zenMode && (
<div className="feedback-area">
{feedbackMessage ? (
<div key={feedbackMessage} className="success-message animate-pop">
{feedbackMessage.startsWith("Good! ") ? (
<>
<div className="success-prefix">Good!</div>
<div className="success-note">{feedbackMessage.replace("Good! ", "")}</div>
</>
) : (
feedbackMessage
)}
</div>
) : (
<div className="instruction-text">
{settings.gameMode === 'ear_training' ? "Listen and play the note" : "Play the note above"}
</div>
)}
</div>
)}
{(settings.showHint || settings.showFretboard) && (
<div className="hint-card">
{settings.showHint && (
<div className="hint-note landscape-hint-note">
{getNoteDetails(targetMidi + activeTranspose).scientific}
</div>
)}
{currentInstrumentDef.showTuning && (
currentInstrumentDef.id === 'piano' ? (
<PianoKeys
minMidi={36}
maxMidi={84}
markedNotes={settings.showHint ? [targetMidi] : []}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualInstrumentPlay}
onHover={setHoveredMidi}
/>
) : (
currentTuning && (
<Fretboard
tuning={currentTuning}
positions={hintPositions}
interactive={settings.showFretboard || settings.showHint}
showTooltips={settings.showHint}
displayTranspose={activeTranspose}
onPlayNote={handleVirtualInstrumentPlay}
onHover={setHoveredMidi}
showHints={settings.showHint}
maxFrets={15}
/>
)
)
)}
</div>
)}
{!settings.zenMode && (
<>
{/* Display toggles — switch style (like phrase mode):
pure show/hide, no trainer actions. */}
<div className="display-toggles" style={{ marginTop: '24px' }}>
{currentInstrumentDef.showTuning && (
<div className="display-toggle">
<span className="display-toggle-label">
<Guitar size={15} />
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
</span>
<button
className={`switch-button ${settings.showFretboard ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
aria-label={`Show virtual ${currentInstrumentDef.displayName}`}
>
<div className="switch-thumb" />
</button>
</div>
)}
<div className="display-toggle">
<span className="display-toggle-label">
<HelpCircle size={15} />
Hint
</span>
<button
className={`switch-button ${settings.showHint ? 'active' : ''}`}
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
title="Keyboard Shortcut: H"
aria-label="Show hint"
>
<div className="switch-thumb" />
</button>
</div>
</div>
<div className="action-row" style={{ marginTop: '16px', display: 'flex', justifyContent: 'center', gap: '16px' }}>
<button
className="hint-button"
onClick={() => playNote(targetMidi, settings.referenceNoteDuration ?? 1.5, settings.autoPlayVolume ?? 0.5)}
title="Keyboard Shortcut: P or R"
>
<Volume2 size={18} />
Play Note
</button>
<button className="skip-button" onClick={() => generateNewNote()} title="Keyboard Shortcut: Space">
<SkipForward size={18} />
Skip Note
</button>
</div>
</>
)}
</div>
);
}
);
export default SingleNoteTrainer;

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

@@ -1,21 +1,31 @@
/* Theme-aware colors: the tuner renders on a translucent surface in both
themes, so all tints derive from --color-text-main instead of hardcoding
white (unreadable in light mode) or black (unreadable in dark mode). */
.tuning-meter-container {
display: flex;
flex-direction: column;
align-items: center;
width: 260px;
background: rgba(0, 0, 0, 0.4);
background: color-mix(in srgb, var(--color-text-main) 6%, transparent);
padding: 12px;
border-radius: 12px;
backdrop-filter: blur(4px);
border: 1px solid rgba(255, 255, 255, 0.1);
border: 1px solid color-mix(in srgb, var(--color-text-main) 10%, transparent);
margin-top: 10px;
transition: opacity 0.2s ease;
}
/* No-signal state: widget stays visible but clearly idle */
.tuning-meter-container.inactive {
opacity: 0.45;
}
.meter-scale {
position: relative;
width: 100%;
height: 24px;
background: rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--color-text-main) 10%, transparent);
border-radius: 12px;
margin-bottom: 8px;
overflow: hidden;
@@ -25,7 +35,7 @@
position: absolute;
top: 4px;
font-size: 10px;
color: rgba(255, 255, 255, 0.5);
color: var(--color-text-muted);
transform: translateX(-50%);
}
@@ -47,7 +57,8 @@
top: 0;
bottom: 0;
width: 2px;
background: rgba(255, 255, 255, 0.3);
background: var(--color-text-muted);
opacity: 0.35;
transform: translateX(-50%);
z-index: 1;
}
@@ -57,35 +68,55 @@
top: 0;
bottom: 0;
width: 4px;
background: white;
background: var(--color-text-main);
transform: translateX(-50%);
transition: left 0.1s ease-out;
/* No CSS transition: `left` is updated directly on every detection
frame (~60 fps). Interrupting/restarting a transition 60x per second
is handled poorly by some engines (Safari leaves the needle stuck). */
z-index: 2;
border-radius: 2px;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.5);
box-shadow: 0 0 8px color-mix(in srgb, var(--color-text-main) 50%, transparent);
}
/* Status colors — the theme's mid-luminance success/warning/error vars are
readable on light AND dark backgrounds */
.meter-needle.perfect {
background-color: #4ade80;
box-shadow: 0 0 10px #4ade80;
background-color: var(--color-success);
box-shadow: 0 0 10px var(--color-success);
}
/* Tailwind Green-400 */
.meter-needle.good {
background-color: #86efac;
background-color: color-mix(in srgb, var(--color-success) 60%, var(--color-text-main));
}
/* Tailwind Green-300 */
.meter-needle.okay {
background-color: #facc15;
background-color: var(--color-warning);
}
/* Tailwind Yellow-400 */
.meter-needle.poor {
background-color: #f87171;
background-color: var(--color-error);
}
/* Tailwind Red-400 */
.tuning-readout .cents-value.perfect {
color: var(--color-success);
}
.tuning-readout .cents-value.good {
color: color-mix(in srgb, var(--color-success) 60%, var(--color-text-main));
}
.tuning-readout .cents-value.okay {
color: var(--color-warning);
}
.tuning-readout .cents-value.poor {
color: var(--color-error);
}
.tuning-meter-container.inactive .meter-needle {
background: var(--color-text-muted);
box-shadow: none;
}
.tuning-readout {
display: flex;
@@ -97,22 +128,10 @@
.tuning-readout .note-name {
font-size: 1.25rem;
font-weight: bold;
color: white;
color: var(--color-text-main);
}
.tuning-readout .cents-value {
font-size: 0.9rem;
color: rgba(255, 255, 255, 0.7);
color: var(--color-text-muted);
}
.tuning-readout .cents-value.perfect {
color: #4ade80;
}
.tuning-readout .cents-value.okay {
color: #facc15;
}
.tuning-readout .cents-value.poor {
color: #f87171;
}

View File

@@ -1,36 +1,40 @@
import React from 'react';
import './TuningMeter.css';
interface TuningMeterProps {
cents: number; // Deviation in cents from nearest note (-50 to +50 usually)
noteName: string; // The detected note name (e.g. "A4")
/** Deviation in cents from nearest note (-50 to +50 usually), already
* smoothed by the detection loop (display-only value). Null = no signal. */
cents: number | null;
/** The detected note name (e.g. "A4"). Null = no signal. */
noteName: string | null;
}
export const TuningMeter: React.FC<TuningMeterProps> = ({ cents, noteName }) => {
// Clamp cents for display (-50 to 50)
const clampedCents = Math.max(-50, Math.min(50, cents));
const hasSignal = cents !== null && noteName !== null;
// Map -50..50 to 0..100% for position
// -50 cents -> 0%
// 0 cents -> 50%
// 50 cents -> 100%
// Clamp for display (-50 to 50), then map to 0..100%.
// No CSS transition: cents updates every detection frame (~60 fps) and is
// EMA-smoothed upstream — see usePitchDetector / AppConfig. When there is
// no signal the needle parks in the middle and the widget dims.
const clampedCents = Math.max(-50, Math.min(50, cents ?? 0));
const positionPercent = ((clampedCents + 50) / 100) * 100;
// Color determination
// Status color reacts instantly to the raw (unsmoothed) value
let statusClass = 'neutral';
if (Math.abs(cents) < 5) {
statusClass = 'perfect'; // Green
} else if (Math.abs(cents) < 15) {
statusClass = 'good'; // Green-ish
} else if (Math.abs(cents) < 30) {
statusClass = 'okay'; // Yellow
} else {
statusClass = 'poor'; // Red
if (hasSignal) {
if (Math.abs(cents!) < 5) {
statusClass = 'perfect'; // Green
} else if (Math.abs(cents!) < 15) {
statusClass = 'good'; // Green-ish
} else if (Math.abs(cents!) < 30) {
statusClass = 'okay'; // Yellow
} else {
statusClass = 'poor'; // Red
}
}
return (
<div className="tuning-meter-container">
<div className={`tuning-meter-container ${hasSignal ? '' : 'inactive'}`}>
<div className="meter-scale">
<div className="tick tick-left">-50</div>
<div className="tick tick-center">0</div>
@@ -39,7 +43,7 @@ export const TuningMeter: React.FC<TuningMeterProps> = ({ cents, noteName }) =>
{/* Center Marker Line */}
<div className="center-line"></div>
{/* The Needle/Indicator */}
{/* The Needle/Indicator — position updated directly per frame */}
<div
className={`meter-needle ${statusClass}`}
style={{ left: `${positionPercent}%` }}
@@ -48,9 +52,9 @@ export const TuningMeter: React.FC<TuningMeterProps> = ({ cents, noteName }) =>
</div>
</div>
<div className="tuning-readout">
<span className="note-name">{noteName}</span>
<span className="note-name">{hasSignal ? noteName : '—'}</span>
<span className={`cents-value ${statusClass}`}>
{cents > 0 ? '+' : ''}{Math.round(cents)} ct
{hasSignal ? `${cents! > 0 ? '+' : ''}${Math.round(cents!)} ct` : '—'}
</span>
</div>
</div>

View File

@@ -1,36 +0,0 @@
import React, { createContext, useContext, useState, type ReactNode } from 'react';
import { type AppSettings, DEFAULT_SETTINGS } from '../types/SettingsTypes';
interface SettingsContextType {
settings: AppSettings;
updateSettings: (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => void;
updateSetting: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => void;
}
const SettingsContext = createContext<SettingsContextType | undefined>(undefined);
export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
const updateSettings = (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => {
setSettings(newSettings);
};
const updateSetting = <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => {
setSettings(prev => ({ ...prev, [key]: value }));
};
return (
<SettingsContext.Provider value={{ settings, updateSettings, updateSetting }}>
{children}
</SettingsContext.Provider>
);
};
export const useSettings = () => {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};

View File

@@ -0,0 +1,27 @@
import React, { useCallback, useMemo, useState, type ReactNode } from 'react';
import { type AppSettings, DEFAULT_SETTINGS } from '../types/SettingsTypes';
import { SettingsContext, type SettingsContextType } from './settings-context';
export const SettingsProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [settings, setSettings] = useState<AppSettings>(DEFAULT_SETTINGS);
// Stable identities so consumers can safely use these in effect deps
const updateSettings = useCallback<SettingsContextType['updateSettings']>((newSettings) => {
setSettings(newSettings);
}, []);
const updateSetting = useCallback<SettingsContextType['updateSetting']>((key, value) => {
setSettings(prev => ({ ...prev, [key]: value }));
}, []);
const value = useMemo<SettingsContextType>(
() => ({ settings, updateSettings, updateSetting }),
[settings, updateSettings, updateSetting]
);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -0,0 +1,10 @@
import { createContext } from 'react';
import { type AppSettings } from '../types/SettingsTypes';
export interface SettingsContextType {
settings: AppSettings;
updateSettings: (newSettings: AppSettings | ((prev: AppSettings) => AppSettings)) => void;
updateSetting: <K extends keyof AppSettings>(key: K, value: AppSettings[K]) => void;
}
export const SettingsContext = createContext<SettingsContextType | undefined>(undefined);

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import { SettingsContext } from './settings-context';
export const useSettings = () => {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};

View File

@@ -0,0 +1,427 @@
import { describe, it, expect } from 'vitest';
import { parseAbc, type AbcParseOptions } from './abcParser';
import { PPQ, validateScore, scoreEvents, scoreFitsPool, type Score } from '../score/model';
/** The §G concert-pitch fixture. */
const FIRST_STEPS = [
'X:1',
'T:First steps',
'C:Original exercise',
'M:4/4',
'L:1/4',
'Q:1/4=60',
'K:C',
'C D E F | G2 E C |]',
].join('\n');
/** Full header block (M: 4/4, L: 1/4, K: C by default); body starts at line 3. */
const HEADERS = 'M:4/4\nL:1/4\nK:C';
const tune = (body: string, headers: string = HEADERS) => `X:1\n${headers}\n${body}`;
const tuneWithKey = (key: string) => tune('C D E F |', `M:4/4\nL:1/4\nK:${key}`);
function mustParse(text: string, options?: AbcParseOptions): Score {
const result = parseAbc(text, options);
if (!result.ok) throw new Error(`expected successful parse, got: ${result.error}`);
return result.value;
}
function mustFail(text: string, pattern: RegExp, options?: AbcParseOptions): void {
const result = parseAbc(text, options);
if (result.ok) {
throw new Error(`expected parse failure matching ${pattern}, but parsing succeeded`);
}
expect(result.error).toMatch(pattern);
}
const midis = (score: Score) => scoreEvents(score).map(e => e.pitch?.midi ?? null);
const durations = (score: Score) => scoreEvents(score).map(e => e.durationTicks);
describe('parseAbc — §G concert-pitch fixture', () => {
it('parses the fixture with the exact expected pitches and ticks', () => {
const score = mustParse(FIRST_STEPS);
expect(midis(score)).toEqual([60, 62, 64, 65, 67, 64, 60]);
expect(durations(score)).toEqual([480, 480, 480, 480, 960, 480, 480]);
expect(score.measures).toHaveLength(2);
expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]);
expect(validateScore(score)).toEqual([]);
});
it('carries full metadata from the headers', () => {
const score = mustParse(FIRST_STEPS);
expect(score.version).toBe(1);
expect(score.id).toBe('1');
expect(score.title).toBe('First steps');
expect(score.meter).toEqual({ numerator: 4, denominator: 4 });
expect(score.key).toEqual({ tonic: 'C', mode: 'major', signature: 'C' });
expect(score.voices).toHaveLength(1);
expect(score.voices[0].id).toBe('melody');
expect(score.source?.composer).toBe('Original exercise');
expect(score.source?.rights).toContain('CC0');
expect(score.source?.rights).toContain('Original exercise');
expect(score.source?.writtenToSoundingSemitones).toBe(0);
});
it('fits the pitch pool of its own notes', () => {
const score = mustParse(FIRST_STEPS);
expect(scoreFitsPool(score, [60, 62, 64, 65, 67])).toBe(true);
expect(scoreFitsPool(score, [60, 62, 64])).toBe(false);
});
});
describe('parseAbc — headers', () => {
it('defaults L to 1/8 when absent', () => {
const score = mustParse('X:1\nM:4/4\nK:C\nC D E F G A B c | C D E F G A B c |');
expect(durations(score)).toEqual(Array(16).fill(240));
expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]);
});
it('ignores unknown headers, comments and blank lines silently', () => {
const text = [
'X:1',
'% a full-line comment',
'Y:unknown header ignored',
'w:lyrics line ignored',
'',
'M:4/4',
'L:1/4',
'K:C',
'C D E F | G2 E C | % trailing comment',
].join('\n');
const score = mustParse(text);
expect(midis(score)).toEqual([60, 62, 64, 65, 67, 64, 60]);
});
it('uses the Q: header as metadata only (ignored)', () => {
const score = mustParse(`${FIRST_STEPS}\n`);
expect(score).toBeDefined(); // Q: did not break parsing (covered by fixture)
});
it('requires M: and K: before the body', () => {
mustFail('X:1\nK:C\nC D E F |', /M:/);
mustFail('X:1\nM:4/4\nC D E F |', /K:/);
});
it('rejects unsupported meters and unit lengths', () => {
mustFail(tune('C D E F |', 'M:6/8\nL:1/4\nK:C'), /6\/8|meter/i);
mustFail(tune('C D E F |', 'M:C\nL:1/4\nK:C'), /meter/i);
mustFail(tune('C D E F |', 'M:4/4\nL:1/16\nK:C'), /unit|1\/16/i);
});
it('rejects duplicate M:/K: (no changes mid-tune) and a second X:', () => {
mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nM:3/4\nC D E |', /M:/);
mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nK:G\nC D E F |', /K:/);
mustFail('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\nX:2', /multiple tunes|X:/i);
});
});
describe('parseAbc — key signatures', () => {
it('resolves tonic, mode and parent signature', () => {
expect(mustParse(tuneWithKey('Am')).key).toEqual({ tonic: 'A', mode: 'minor', signature: 'C' });
expect(mustParse(tuneWithKey('Ddor')).key).toEqual({ tonic: 'D', mode: 'dorian', signature: 'C' });
expect(mustParse(tuneWithKey('Gmix')).key).toEqual({ tonic: 'G', mode: 'mixolydian', signature: 'C' });
expect(mustParse(tuneWithKey('Eb')).key).toEqual({ tonic: 'Eb', mode: 'major', signature: 'Eb' });
expect(mustParse(tuneWithKey('F#')).key).toEqual({ tonic: 'F#', mode: 'major', signature: 'F#' });
expect(mustParse(tuneWithKey('Bb')).key).toEqual({ tonic: 'Bb', mode: 'major', signature: 'Bb' });
expect(mustParse(tuneWithKey('_B')).key).toEqual({ tonic: 'Bb', mode: 'major', signature: 'Bb' });
expect(mustParse(tuneWithKey('Gmaj')).key.mode).toBe('major');
expect(mustParse(tuneWithKey('Amin')).key.mode).toBe('minor');
expect(mustParse(tuneWithKey('Eaeo')).key.mode).toBe('minor');
});
it('rejects unknown keys and never falls back to C', () => {
mustFail(tuneWithKey('H'), /unrecognized key/i);
mustFail(tuneWithKey('Fb'), /unrecognized key/i);
mustFail(tuneWithKey(''), /unrecognized key/i);
mustFail(tuneWithKey('Gbmin7'), /unrecognized key/i);
mustFail(tuneWithKey('C##'), /unrecognized key/i);
});
});
describe('parseAbc — notes, octaves, accidentals', () => {
it('applies ABC octave conventions (C = C4 = 60)', () => {
const score = mustParse(tune('C,, C, C c | c\' c\'\' C c |'));
expect(midis(score)).toEqual([36, 48, 60, 72, 84, 96, 60, 72]);
expect(scoreEvents(score)[2].pitch?.step).toBe('C');
expect(scoreEvents(score)[2].pitch?.octave).toBe(4);
});
it('carries accidentals per (letter, octave) within a measure, seeded from the key', () => {
// §F example: K:G, L:1/4 — signature sharpens F; "=F" naturals it, carry
// keeps F natural, "^F" sharpens it again; next bar starts sharp.
const score = mustParse('X:1\nM:4/4\nL:1/4\nK:G\n=F F ^F F | F4 |');
expect(midis(score)).toEqual([65, 65, 66, 66, 66]);
expect(validateScore(score)).toEqual([]);
});
it('keeps accidentals letter- and octave-specific within the measure', () => {
// ^f is F5 (78); c is C5 (72) — the sharp must not affect the C
const score = mustParse(tune('^f c ^f c |'));
expect(midis(score)).toEqual([78, 72, 78, 72]);
});
it('supports doubled accidentals (^^ / __) per §G', () => {
// Note: the task's error-test bullet lists "^^F" as an error, but §G
// explicitly requires doubled accidentals; spec wins, "^^F" is alter +2.
const sharp = mustParse(tune('^^F F F F |'));
expect(sharp.voices[0].events[0].pitch?.alter).toBe(2);
expect(midis(sharp)).toEqual([67, 67, 67, 67]);
const flat = mustParse(tune('__B B B B |'));
expect(flat.voices[0].events[0].pitch?.alter).toBe(-2);
expect(midis(flat)).toEqual([69, 69, 69, 69]);
});
it('rejects triple and conflicting accidental runs', () => {
mustFail(tune('^^^F C D E |'), /unsupported accidental/i);
mustFail(tune('^_F C D E |'), /conflicting accidentals/i);
mustFail(tune('=^F C D E |'), /conflicting accidentals/i);
});
it('rejects stray accidentals and accidentals on rests', () => {
mustFail(tune('^ C D E F |'), /stray accidental/i);
mustFail(tune('C D E ^z |'), /rest/i);
});
});
describe('parseAbc — durations', () => {
it('supports the exact allowed resulting durations with L:1/4', () => {
// eighth 240, quarter 480, dotted quarter 720, half 960, dotted half 1440,
// whole 1920 — every measure exactly full (4/4 = 1920 ticks)
const score = mustParse(tune('A/2 A A3/2 A | A2 A A | A3 A | A4 |'));
expect(durations(score)).toEqual([240, 480, 720, 480, 960, 480, 480, 1440, 480, 1920]);
expect(validateScore(score)).toEqual([]);
});
it('writes dotted quarters with L:1/8 via multiplier 3 ("A3" = 720)', () => {
const score = mustParse('X:1\nM:4/4\nL:1/8\nK:C\nA3 A A A A A |');
expect(durations(score)).toEqual([720, 240, 240, 240, 240, 240]);
});
it('rejects resulting durations outside the supported table', () => {
// "A3/2" with L:1/8 = 360 ticks (dotted eighth) — not in the supported
// table; the §G example calling it a "dotted quarter" is inconsistent
// with the "multiplier × L ticks" rule, which is authoritative here.
mustFail('X:1\nM:4/4\nL:1/8\nK:C\nA3/2 A A A A A A |', /360|unsupported duration/i);
mustFail(tune('C/4 C D E F |'), /120|unsupported duration/i);
mustFail(tune('A6 A B C D |'), /2880|unsupported duration/i);
mustFail(tune('A0 C D E F |'), /zero duration/i);
});
it('supports rests z with the same duration rules', () => {
const score = mustParse(tune('C z D z |'));
expect(midis(score)).toEqual([60, null, 62, null]);
expect(durations(score)).toEqual([480, 480, 480, 480]);
expect(validateScore(score)).toEqual([]);
});
});
describe('parseAbc — ties', () => {
it('merges a tie across a barline into ONE logical event', () => {
// §G: "one eight-quarter-note logical event" (each C4 = 4 quarters,
// sum = 8 quarters = 3840 ticks; the task bullet's "1920" contradicts
// the merge-sum rule and the contract text).
const score = mustParse(tune('C4- | C4 |'));
expect(scoreEvents(score)).toHaveLength(1);
expect(scoreEvents(score)[0].durationTicks).toBe(8 * PPQ);
expect(scoreEvents(score)[0].startTick).toBe(0);
expect(score.measures.map(m => m.durationTicks)).toEqual([4 * PPQ, 4 * PPQ]);
expect(validateScore(score)).toEqual([]);
});
it('merges a tie chain inside one measure', () => {
const score = mustParse('X:1\nM:4/4\nL:1/8\nK:C\nC2- C2- C4 |');
expect(scoreEvents(score)).toHaveLength(1);
expect(scoreEvents(score)[0].durationTicks).toBe(4 * PPQ);
expect(validateScore(score)).toEqual([]);
});
it('rejects dangling ties and ties to rests', () => {
mustFail(tune('C D E F- |'), /dangling tie|no note follows/i);
mustFail(tune('C4-'), /dangling tie|no note follows/i);
mustFail(tune('C2 D2- | z2 F2 |'), /tie must connect two notes|rest/i);
});
it('rejects ties between different sounding pitches', () => {
mustFail(tune('C2 D2- | E2 F2 |'), /different sounding pitches/i);
mustFail(tune('F4- | ^F4 |'), /different sounding pitches/i);
});
it('rejects a doubled tie marker', () => {
mustFail(tune('C2- -C2 D2 E2 |'), /already tied|unexpected "-"/i);
});
});
describe('parseAbc — repeats', () => {
it('expands |: ... :| so the segment sounds twice', () => {
const score = mustParse(tune('C D E F |: G A B c :| C D E F |'));
expect(midis(score)).toEqual([
60, 62, 64, 65, 67, 69, 71, 72, 67, 69, 71, 72, 60, 62, 64, 65,
]);
expect(score.measures).toHaveLength(4);
expect(validateScore(score)).toEqual([]);
});
it('repeats from the beginning of the body for an unmatched ":|"', () => {
const score = mustParse(tune('C D E F :|'));
expect(midis(score)).toEqual([60, 62, 64, 65, 60, 62, 64, 65]);
expect(score.measures).toHaveLength(2);
});
it('treats an unclosed "|:" as repeating to the end', () => {
const score = mustParse(tune('C D E F |: G A B c |'));
expect(midis(score)).toEqual([60, 62, 64, 65, 67, 69, 71, 72, 67, 69, 71, 72]);
expect(score.measures).toHaveLength(3);
});
it('rejects nested repeats and alternate endings', () => {
mustFail(tune('C D E F |: G A B c |: C D E F :|'), /nested repeats/i);
mustFail(tune('C D E F |1 G A B c :|2 C D E F |'), /alternate endings/i);
});
it('supports ||, |] and :|]', () => {
const a = mustParse(tune('C D E F || G A B c |]'));
expect(a.measures).toHaveLength(2);
// ":|]": repeat close + final barline; unmatched close repeats from start
const b = mustParse(tune('C D E F :|]'));
expect(b.measures).toHaveLength(2);
expect(midis(b)).toEqual([60, 62, 64, 65, 60, 62, 64, 65]);
});
});
describe('parseAbc — pickups and measure validation', () => {
it('accepts a pickup complemented by the final measure', () => {
const score = mustParse(tune('G | C D E F | G3 |'));
expect(score.measures).toHaveLength(3);
expect(score.measures.map(m => m.durationTicks)).toEqual([480, 4 * PPQ, 3 * PPQ]);
expect(midis(score)).toEqual([67, 60, 62, 64, 65, 67]); // "G3" = dotted half G4
expect(validateScore(score)).toEqual([]);
});
it('rejects short middle measures with the measure number', () => {
mustFail(tune('C D E F | C D E | C D E F |'), /measure 2/);
});
it('rejects overfull measures', () => {
mustFail(tune('C D E F G | C D E F |'), /overfull|measure 1/);
});
it('rejects a short final measure without a pickup', () => {
mustFail(tune('C D E F | G3 |'), /short|measure 2/);
});
it('rejects a single short measure and a pickup without complement', () => {
mustFail(tune('C D E |'), /measure 1 is short/);
mustFail(tune('G | C D E F | G A B C |'), /not complemented/i);
});
it('supports 3/4 meter end to end', () => {
const score = mustParse('X:1\nM:3/4\nL:1/4\nK:C\nC D E | C D E |');
expect(score.meter.numerator).toBe(3);
expect(score.measures.map(m => m.durationTicks)).toEqual([3 * PPQ, 3 * PPQ]);
expect(validateScore(score)).toEqual([]);
});
});
describe('parseAbc — rejections of unsupported syntax', () => {
it('rejects chords, inline fields, tuplets, grace notes, decorations, lyrics, overlays', () => {
mustFail(tune('[CEG] C D E F |'), /chord|inline field/i);
mustFail(tune('[K:G] C D E F |'), /chord|inline field/i);
mustFail(tune('(3 C D E F |'), /tuplet/i);
mustFail(tune('{g} C D E F |'), /grace/i);
mustFail(tune('!trill! C D E F |'), /decoration/i);
mustFail(tune('+accent+ C D E F |'), /decoration/i);
mustFail(tune('C D "words" E F |'), /lyrics/i);
mustFail(tune('C & D E F G |'), /overlay|unsupported/i);
});
it('rejects unsupported characters and stray tokens', () => {
mustFail(tune('C D E H |'), /unsupported note letter/i);
mustFail(tune('C D E Z |'), /unsupported note letter/i);
mustFail(tune('| 3 C D E F |'), /unsupported/i);
mustFail(tune('C2, D E F |'), /unsupported/i);
});
it('reports line and column for body errors', () => {
const result = parseAbc('X:1\nM:4/4\nL:1/4\nK:C\n[CEG] C D E F |');
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toMatch(/\(line 5, column 1\)/);
expect(result.error).toMatch(/chord|inline field/i);
}
const result2 = parseAbc('X:1\nM:4/4\nL:1/4\nK:C\nC D E F | G [CEG] |\n');
expect(result2.ok).toBe(false);
if (!result2.ok) {
// '[' at column 13 of line 5
expect(result2.error).toMatch(/\(line 5, column 13\)/);
}
});
});
describe('parseAbc — bounds', () => {
it('rejects oversized input before parsing', () => {
mustFail('C '.repeat(200 * 1024), /too large|256/i);
});
it('rejects more than 256 measures', () => {
mustFail(`X:1\nM:4/4\nL:1/4\nK:C\n${'C D E F | '.repeat(300)}`, /too many measures/i);
});
});
describe('parseAbc — writtenToSoundingSemitones', () => {
it('applies an octave transposition while preserving the spelling (§B.6)', () => {
const score = mustParse(tune('E, A, C E |'), { writtenToSoundingSemitones: -12 });
// written E3/A3/C4/E4 (52, 57, 60, 64) sounds an octave lower
expect(midis(score)).toEqual([40, 45, 48, 52]);
expect(scoreEvents(score).map(e => [e.pitch?.step, e.pitch?.octave])).toEqual([
['E', 2], ['A', 2], ['C', 3], ['E', 3],
]);
expect(score.source?.writtenToSoundingSemitones).toBe(-12);
expect(validateScore(score)).toEqual([]);
});
it('rejects non-octave transpositions (spelling cannot be preserved)', () => {
mustFail(tune('C D E F |'), /multiple of 12/i, { writtenToSoundingSemitones: -1 });
});
});
describe('parseAbc — source metadata', () => {
it('collects C, S, Z into the source record', () => {
const text = [
'X:5',
'T:Test tune',
'C:Jane Composer',
'S:Public domain collection',
'Z:Simplified from the original',
'M:3/4',
'L:1/4',
'K:G',
'G B d | G B d |',
].join('\n');
const score = mustParse(text);
expect(score.id).toBe('5');
expect(score.title).toBe('Test tune');
expect(score.source?.composer).toBe('Jane Composer');
expect(score.source?.rights).toContain('Public domain collection');
expect(score.source?.adaptation).toBe('Simplified from the original');
});
it('falls back to a placeholder source when neither S: nor C: is given', () => {
const score = mustParse('X:1\nM:4/4\nL:1/4\nK:C\nC D E F |');
expect(score.source?.rights).toContain('unspecified');
expect(score.id).toBe('1');
});
it('uses the abc-untitled fallback id when X: is absent', () => {
const score = mustParse('M:4/4\nL:1/4\nK:C\nC D E F |');
expect(score.id).toBe('abc-untitled');
expect(score.title).toBe('Untitled');
});
it('rejects an empty body (headers only) and rest-only tunes', () => {
mustFail('X:1\nM:4/4\nL:1/4\nK:C\n', /no notes|empty body/i);
mustFail(tune('z z z z |'), /only rests|no notes/i);
});
it('rejects a repeat close before any music', () => {
mustFail(tune(':| C D E F |'), /no music before it/i);
});
});

671
src/exercises/abcParser.ts Normal file
View File

@@ -0,0 +1,671 @@
/**
* ABC-notation subset parser for the Sheet Music Trainer's Phrase Mode.
*
* Implements the subset specified in docs/PHRASE_MODE_IMPLEMENTATION.md §G and
* produces the normalized Score model from src/score/model.ts (§B). The parser
* is pure: no globals, no network, no eval, no I/O.
*
* Supported subset:
* - Headers X, T, C, S, Z, M, L, Q, K; blank lines and %-comments are ignored;
* unknown `word:` headers are ignored silently. M: and K: are required
* before the tune body; L: defaults to 1/8; X is optional.
* - M: exactly 3/4 or 4/4; L: exactly 1/4 or 1/8.
* - K: tonic letter A-G with an optional accidental (prefix ^/_/^^/__, or
* suffix #/b) and an optional mode suffix (none/maj = major, m/min = minor,
* or ion/dor/phr/lyd/mix/aeo/loc). Unknown keys are rejected — never a
* silent fallback to C.
* - Notes a-g/A-G with octave marks (',' down, "'" up), accidentals, and
* duration multipliers (integer, /n, n/m — dots are written as multipliers)
* relative to L. Supported resulting durations ONLY: 240 (eighth), 480
* (quarter), 720 (dotted quarter), 960 (half), 1440 (dotted half), 1920
* (whole) ticks at PPQ=480.
* - Rests z with the same duration rules -> events with pitch null.
* - Ties '-' between two notes of the SAME sounding pitch (also across
* barlines), merged into ONE ScoreEvent whose duration is the sum.
* - Barlines '|', '||', '|]'; simple non-nested '|: ... :|' repeats play the
* segment twice (expanded); an unmatched ':|' repeats from the beginning of
* the body; an unclosed '|:' repeats to the end (standard ABC behaviour).
* - Accidental carry per (letter, octave) within each measure, seeded at each
* new measure from the key signature (all octaves), reset at every barline.
* - Measure validation: every measure full (numerator × 480), EXCEPT a short
* first pickup measure which must be complemented by the final measure
* (pickup + final = full). Short middle / overfull measures are errors.
*
* Deliberate readings of spec ambiguities (see the task handoff notes):
* - Durations are standard ABC: multiplier × L ticks. The §G example
* "A3/2 with L:1/8 = dotted quarter" is arithmetically inconsistent with
* that rule (1.5 × 240 = 360 ticks, a dotted eighth, which is NOT in the
* supported table); with L:1/4, A3/2 IS the dotted quarter (720), and with
* L:1/8, A3 is the dotted quarter. The absolute supported-ticks table is
* authoritative.
* - Doubled accidentals ^^/__ are SUPPORTED (§G explicitly requires "doubled
* accidentals"); "^^F" is therefore valid despite one bullet in the task's
* error-test list. Triple or conflicting accidental runs are rejected.
* - writtenToSoundingSemitones is ADDED to the written midi (§B.6: -12 turns
* written E3/52 into sounding E2/40 while keeping the spelling); the task
* text's word "subtract" would flip the sign for the same example value.
* - "C4- | C4 |" (L:1/4) merges into ONE event of 3840 ticks — eight quarter
* notes, per §G ("one eight-quarter-note logical event") and the merge-sum
* rule; 1920 ticks would contradict both.
*/
import { PPQ, LETTER_PC, STEP_LETTERS, validateScore } from '../score/model';
import type {
Result,
Score,
ScoreMeasure,
SpelledPitch,
StepLetter,
} from '../score/model';
import { TONICS, keyFor, spelledPitch } from '../music/scales';
import type { KeyContext, ModeId } from '../music/scales';
export interface AbcParseOptions {
/**
* Semitones from WRITTEN source pitch to sounding pitch (§B.6), e.g. -12
* for written guitar/bass material. The parser adds this value to the
* written midi while keeping the spelling (recomputing the octave).
* Must be an integer multiple of 12 in this subset. Default 0 (concert
* pitch).
*/
writtenToSoundingSemitones?: number;
}
/** Input/measure/event bounds (§G), enforced during parsing, not after. */
const MAX_INPUT_CHARS = 256 * 1024;
const MAX_MEASURES = 256;
const MAX_EVENTS = 4096;
/** The only note lengths the subset supports (ticks at PPQ = 480). */
const SUPPORTED_DURATION_TICKS: readonly number[] = [240, 480, 720, 960, 1440, 1920];
interface Pos {
readonly line: number;
readonly col: number;
}
/** A parse failure; when no position applies the message stands alone. */
const fail = (message: string, pos?: Pos): { ok: false; error: string } => ({
ok: false,
error: pos ? `${message} (line ${pos.line}, column ${pos.col})` : message,
});
/** One note or rest as written in the body (pitch already resolved). */
interface NoteSpec {
readonly isRest: boolean;
readonly pitch: SpelledPitch | null;
readonly durationTicks: number;
/** True when a '-' tie follows this note (checked/merged at build time). */
tieToNext: boolean;
readonly pos: Pos;
}
/** Names for rejected syntax, used in error messages. */
const UNSUPPORTED_NOTATION: Record<string, string> = {
'[': 'chords and inline fields (e.g. [CEG] or [K:...])',
'(': 'tuplets and slurs (e.g. (3)',
'{': 'grace notes',
'"': 'lyrics and annotations',
'!': 'decorations (e.g. !trill!)',
'+': 'decorations (e.g. +accent+)',
'&': 'voice overlays',
};
const describePitch = (p: SpelledPitch): string => {
const acc = p.alter === 2 ? '##' : p.alter === 1 ? '#' : p.alter === -2 ? 'bb' : p.alter === -1 ? 'b' : '';
return `${p.step}${acc}${p.octave}`;
};
export function parseAbc(text: string, options: AbcParseOptions = {}): Result<Score> {
const shift = options.writtenToSoundingSemitones ?? 0;
if (!Number.isInteger(shift) || ((shift % 12) + 12) % 12 !== 0) {
return fail(
`writtenToSoundingSemitones must be an integer multiple of 12 (spelling-preserving octave shift), got ${shift}`,
);
}
if (text.length > MAX_INPUT_CHARS) {
return fail(`ABC input is too large (${text.length} characters; the limit is 256 KiB)`);
}
// ---------------------------------------------------------------- headers
const h = {
x: null as string | null,
t: null as string | null,
c: null as string | null,
s: null as string | null,
z: null as string | null,
q: null as string | null,
meterNumerator: 0 as 0 | 3 | 4,
unitTicks: 240, // default L:1/8
key: null as KeyContext | null,
seen: new Set<string>(),
};
const parseKeyHeader = (value: string, pos: Pos): { ok: false; error: string } | null => {
const spec = value.trim();
let i = 0;
let prefixAlter = 0;
while (i < spec.length && (spec[i] === '^' || spec[i] === '_')) {
prefixAlter += spec[i] === '^' ? 1 : -1;
i++;
}
if (i >= spec.length || !/^[A-G]$/.test(spec[i])) {
return fail(
`unrecognized key "${value}" — expected a tonic letter A-G with an optional accidental and mode suffix (e.g. K:G, K:F#, K:Am, K:Ddor, K:Eb)`,
pos,
);
}
const letter = spec[i].toUpperCase();
i++;
let suffixAlter = 0;
while (i < spec.length && (spec[i] === '#' || spec[i] === 'b')) {
suffixAlter += spec[i] === '#' ? 1 : -1;
i++;
}
if (prefixAlter !== 0 && suffixAlter !== 0) {
return fail(`unrecognized key "${value}" — mixing prefix and suffix accidentals`, pos);
}
const totalAlter = prefixAlter + suffixAlter;
if (Math.abs(totalAlter) > 1) {
return fail(`unrecognized key "${value}" — double accidentals are not supported in key signatures`, pos);
}
const modeWord = spec.slice(i).trim().toLowerCase();
const MODE_MAP: Record<string, ModeId> = {
'': 'major', maj: 'major', ion: 'major',
m: 'minor', min: 'minor', aeo: 'minor',
dor: 'dorian', phr: 'phrygian', lyd: 'lydian',
mix: 'mixolydian', loc: 'locrian',
};
const mode = MODE_MAP[modeWord];
if (!mode) {
return fail(
`unrecognized key "${value}" — unknown mode suffix "${modeWord}" (supported: none, maj, m, min, ion, dor, phr, lyd, mix, aeo, loc)`,
pos,
);
}
const tonicName = totalAlter === 1 ? `${letter}#` : totalAlter === -1 ? `${letter}b` : letter;
if (!(tonicName in TONICS)) {
return fail(`unrecognized key "${value}" — no conventional signature for tonic "${tonicName}"`, pos);
}
h.key = keyFor(tonicName, mode);
return null;
};
const handleHeader = (letter: string, value: string, pos: Pos): { ok: false; error: string } | null => {
switch (letter) {
case 'x':
if (h.seen.has('x')) {
return fail('multiple tunes are not supported (second "X:" header)', pos);
}
h.seen.add('x');
h.x = value;
return null;
case 'm': {
if (h.seen.has('m')) {
return fail('duplicate "M:" header (meter changes mid-tune are not supported)', pos);
}
h.seen.add('m');
const match = /^([34])\/4$/.exec(value);
if (!match) {
return fail(`unsupported meter "${value || '(empty)'}" — only M:3/4 and M:4/4 are supported`, pos);
}
h.meterNumerator = Number(match[1]) as 3 | 4;
return null;
}
case 'l': {
if (h.seen.has('l')) return fail('duplicate "L:" header', pos);
h.seen.add('l');
const match = /^1\/([48])$/.exec(value);
if (!match) {
return fail(`unsupported unit length "${value || '(empty)'}" — only L:1/4 and L:1/8 are supported`, pos);
}
h.unitTicks = Number(match[1]) === 4 ? PPQ : PPQ / 2;
return null;
}
case 'k': {
if (h.seen.has('k')) {
return fail('duplicate "K:" header (key changes mid-tune are not supported)', pos);
}
h.seen.add('k');
return parseKeyHeader(value, pos);
}
case 't':
case 'c':
case 's':
case 'z':
case 'q': {
// Metadata: the first occurrence wins, later ones are ignored.
if (!h.seen.has(letter)) {
h.seen.add(letter);
h[letter] = value;
}
return null;
}
default:
return null; // unknown header: ignored silently (§G)
}
};
// ------------------------------------------------------------------ body
/** Accidental carry per (letter, octave) within the current measure. */
let carry = new Map<string, number>();
let cur: NoteSpec[] = [];
const measures: NoteSpec[][] = [];
let repeatStart: number | null = null;
let bodySeen = false;
const pushMeasure = (pos: Pos): { ok: false; error: string } | null => {
if (cur.length === 0) return null; // redundant/leading barline: no measure
if (measures.length >= MAX_MEASURES) {
return fail(`too many measures (limit ${MAX_MEASURES})`, pos);
}
measures.push(cur);
cur = [];
carry = new Map(); // accidental carry resets at every barline
return null;
};
const closeRepeat = (pos: Pos): { ok: false; error: string } | null => {
const pushed = pushMeasure(pos);
if (pushed) return pushed;
const start = repeatStart ?? 0; // unmatched ":|" repeats from the body start
const segment = measures.slice(start);
if (segment.length === 0) return fail('empty repeat section', pos);
for (const segmentMeasure of segment) {
if (measures.length >= MAX_MEASURES) {
return fail(`too many measures after repeat expansion (limit ${MAX_MEASURES})`, pos);
}
measures.push(segmentMeasure);
}
repeatStart = null;
return null;
};
const parseDuration = (
raw: string,
start: number,
pos: Pos,
): { ok: true; nextIndex: number; ticks: number } | { ok: false; error: string } => {
let i = start;
const n = raw.length;
let numStr = '';
while (i < n && raw[i] >= '0' && raw[i] <= '9') {
numStr += raw[i];
i++;
}
let hasSlash = false;
let denStr = '';
if (i < n && raw[i] === '/') {
hasSlash = true;
i++;
while (i < n && raw[i] >= '0' && raw[i] <= '9') {
denStr += raw[i];
i++;
}
}
if (numStr.length === 0 && !hasSlash) {
return { ok: true, nextIndex: start, ticks: h.unitTicks };
}
if (numStr.length > 6 || denStr.length > 6) {
return fail(`duration "${raw.slice(start, i)}" is out of range`, pos);
}
const num = numStr.length > 0 ? Number(numStr) : 1;
const den = hasSlash ? (denStr.length > 0 ? Number(denStr) : 2) : 1;
if (num === 0) return fail(`zero duration "${raw.slice(start, i)}"`, pos);
const product = num * h.unitTicks;
if (product % den !== 0) {
return fail(`unsupported duration "${raw.slice(start, i)}" — it does not resolve to whole ticks`, pos);
}
const ticks = product / den;
if (!SUPPORTED_DURATION_TICKS.includes(ticks)) {
return fail(
`unsupported duration "${raw.slice(start, i)}" = ${ticks} ticks — supported: eighth 240, quarter 480, dotted quarter 720, half 960, dotted half 1440, whole 1920`,
pos,
);
}
return { ok: true, nextIndex: i, ticks };
};
const parseNoteToken = (
raw: string,
start: number,
line: number,
key: KeyContext,
): { ok: true; nextIndex: number } | { ok: false; error: string } => {
let i = start;
const n = raw.length;
const pos = (): Pos => ({ line, col: start + 1 });
// Optional accidental prefix: ^ ^^ __ _ = (no mixing of kinds).
let explicitAlter: number | null = null;
if (raw[i] === '^' || raw[i] === '_' || raw[i] === '=') {
let sharps = 0;
let flats = 0;
let naturals = 0;
while (i < n && (raw[i] === '^' || raw[i] === '_' || raw[i] === '=')) {
if (raw[i] === '^') sharps++;
else if (raw[i] === '_') flats++;
else naturals++;
i++;
}
if (naturals > 0 && (sharps > 0 || flats > 0)) {
return fail('conflicting accidentals — "=" cannot be combined with "^" or "_"', pos());
}
if (sharps > 0 && flats > 0) {
return fail('conflicting accidentals — "^" and "_" cannot be combined', pos());
}
if (naturals > 1) return fail('repeated "=" accidental', pos());
if (sharps > 2 || flats > 2) {
return fail('unsupported accidental — at most doubled ("^^" or "__")', pos());
}
explicitAlter = sharps - flats;
}
if (i >= n || !/[a-zA-Z]/.test(raw[i])) {
if (explicitAlter !== null) {
return fail('stray accidental — expected a note letter after the accidental', pos());
}
return fail(`unexpected character "${raw[i] ?? ''}"`, pos());
}
const ch = raw[i];
if (ch === 'z') {
if (explicitAlter !== null) {
return fail('accidentals apply to notes, not rests', pos());
}
const dur = parseDuration(raw, i + 1, pos());
if (!dur.ok) return dur;
cur.push({ isRest: true, pitch: null, durationTicks: dur.ticks, tieToNext: false, pos: pos() });
return { ok: true, nextIndex: dur.nextIndex };
}
if (!/^[a-gA-G]$/.test(ch)) {
return fail(`unsupported note letter "${ch}" — only A-G/a-g (and rests z) are supported`, pos());
}
const isLower = ch >= 'a' && ch <= 'g';
const letter = (isLower ? ch.toUpperCase() : ch) as StepLetter;
let octave = isLower ? 5 : 4; // ABC: C = C4 (midi 60), c = C5 (72)
i++;
while (i < n && (raw[i] === ',' || raw[i] === "'")) {
octave += raw[i] === ',' ? -1 : 1;
i++;
}
const dur = parseDuration(raw, i, { line, col: i + 1 });
if (!dur.ok) return dur;
// Accidental carry: explicit accidental wins, else this measure's
// carried value for (letter, octave), else the key signature.
const carryKey = `${letter}${octave}`;
const alter = explicitAlter ?? carry.get(carryKey) ?? key.signatureAlter[letter];
const writtenMidi = 12 * (octave + 1) + LETTER_PC[STEP_LETTERS.indexOf(letter)] + alter;
const soundingMidi = writtenMidi + shift;
if (soundingMidi < 0 || soundingMidi > 127) {
return fail(`note "${raw.slice(start, i)}" resolves to MIDI ${soundingMidi}, outside the range 0-127`, pos());
}
let pitch: SpelledPitch;
try {
pitch = spelledPitch(soundingMidi, letter, alter);
} catch {
return fail(`internal spelling error for "${raw.slice(start, i)}"`, pos());
}
if (explicitAlter !== null) carry.set(carryKey, explicitAlter);
cur.push({ isRest: false, pitch, durationTicks: dur.ticks, tieToNext: false, pos: pos() });
return { ok: true, nextIndex: dur.nextIndex };
};
const tokenizeLine = (raw: string, line: number): { ok: false; error: string } | null => {
let i = 0;
const n = raw.length;
while (i < n) {
const ch = raw[i];
const col = i + 1;
if (ch === ' ' || ch === '\t') {
i++;
continue;
}
if (ch === '%') break; // comment to end of line
if (!bodySeen) {
if (h.meterNumerator === 0) {
return fail('missing "M:" header before the tune body', { line, col });
}
if (!h.key) {
return fail('missing "K:" header before the tune body', { line, col });
}
bodySeen = true;
}
const here: Pos = { line, col };
if (ch === '|') {
const next = raw[i + 1] ?? '';
if (next === '|' || next === ']') {
const pushed = pushMeasure(here);
if (pushed) return pushed;
i += 2;
continue;
}
if (next === ':') {
if (repeatStart !== null) {
return fail('nested repeats are not supported', here);
}
const pushed = pushMeasure(here);
if (pushed) return pushed;
repeatStart = measures.length;
i += 2;
continue;
}
if (next >= '0' && next <= '9') {
return fail('alternate endings (|1, |2) are not supported', here);
}
const pushed = pushMeasure(here);
if (pushed) return pushed;
i++;
continue;
}
if (ch === ':') {
if (raw[i + 1] === '|') {
let j = i + 2;
if (raw[j] === ']') j++; // ":|]" = repeat close + final barline
if (repeatStart === null && measures.length === 0 && cur.length === 0) {
return fail('repeat close ":|" with no music before it', here);
}
const closed = closeRepeat(here);
if (closed) return closed;
i = j;
continue;
}
return fail('unexpected ":" — only ":|" repeat closes are supported', here);
}
if (ch === '-') {
const last = cur.length > 0 ? cur[cur.length - 1] : null;
if (!last || last.isRest) {
return fail('unexpected "-" — ties connect two notes', here);
}
if (last.tieToNext) {
return fail('unexpected "-" — this note is already tied', here);
}
last.tieToNext = true;
i++;
continue;
}
if (/[a-gA-Gz^_=]/.test(ch)) {
const key = h.key; // non-null: bodySeen was checked above
if (!key) return fail('missing "K:" header before the tune body', here);
const parsed = parseNoteToken(raw, i, line, key);
if (!parsed.ok) return parsed;
i = parsed.nextIndex;
continue;
}
const named = UNSUPPORTED_NOTATION[ch];
if (named) {
return fail(`unsupported syntax "${ch}" — ${named} are not supported in this subset`, here);
}
if (/^[a-zA-Z]$/.test(ch)) {
return fail(`unsupported note letter "${ch}" — only A-G/a-g (and rests z) are supported`, here);
}
return fail(`unsupported syntax "${ch}" — this character is not supported in this subset`, here);
}
return null;
};
const lines = text.split(/\r?\n/);
for (let li = 0; li < lines.length; li++) {
const raw = lines[li];
const line = li + 1;
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.startsWith('%')) continue; // blank/comment
const headerMatch = /^\s*([A-Za-z]+):(.*)$/.exec(raw);
if (headerMatch) {
const letter = headerMatch[1].toLowerCase();
const value = headerMatch[2].trim();
const pos: Pos = { line, col: raw.indexOf(':') + 2 };
const err = handleHeader(letter, value, pos);
if (err) return err;
continue;
}
const err = tokenizeLine(raw, line);
if (err) return err;
}
// ------------------------------------------------- finalize / expansion
const endPos: Pos = { line: lines.length, col: 1 };
if (repeatStart !== null) {
// Unclosed "|:" repeats to the end of the tune (standard ABC).
const pushed = pushMeasure(endPos);
if (pushed) return pushed;
const segment = measures.slice(repeatStart);
if (segment.length === 0) return fail('empty repeat section');
for (const segmentMeasure of segment) {
if (measures.length >= MAX_MEASURES) {
return fail(`too many measures after repeat expansion (limit ${MAX_MEASURES})`, endPos);
}
measures.push(segmentMeasure);
}
repeatStart = null;
} else {
const pushed = pushMeasure(endPos);
if (pushed) return pushed;
}
if (measures.length === 0) {
return fail('the tune contains no notes (empty body)');
}
// ------------------------------------------------------ measure checks
const numMeasures = measures.length;
const full = h.meterNumerator * PPQ;
const sums = measures.map(ms => ms.reduce((total, note) => total + note.durationTicks, 0));
for (let m = 0; m < numMeasures; m++) {
if (sums[m] > full) {
return fail(
`measure ${m + 1} is overfull: ${sums[m]} ticks (expected ${full} for ${h.meterNumerator}/4)`,
);
}
}
if (numMeasures === 1) {
if (sums[0] !== full) {
return fail(`measure 1 is short: ${sums[0]} ticks (expected ${full} for ${h.meterNumerator}/4)`);
}
} else {
for (let m = 1; m < numMeasures; m++) {
const mayComplement = m === numMeasures - 1 && sums[0] < full;
if (sums[m] < full && !mayComplement) {
return fail(
`measure ${m + 1} is short: ${sums[m]} ticks (expected ${full} for ${h.meterNumerator}/4)`,
);
}
}
if (sums[0] < full && sums[0] + sums[numMeasures - 1] !== full) {
return fail(
`pickup measure is not complemented: measure 1 (${sums[0]}) + final measure (${sums[numMeasures - 1]}) != ${full} ticks`,
);
}
}
// ------------------------------------------------------- event building
interface MutableEvent {
id: string;
startTick: number;
durationTicks: number;
pitch: SpelledPitch | null;
}
const events: MutableEvent[] = [];
let tick = 0;
let eventSeq = 0;
let tieOpen: NoteSpec | null = null;
for (let m = 0; m < numMeasures; m++) {
for (const note of measures[m]) {
if (tieOpen) {
if (!note.pitch || !tieOpen.pitch) {
return fail('a tie must connect two notes, but a rest follows the tied note', note.pos);
}
if (tieOpen.pitch.midi !== note.pitch.midi) {
return fail(
`tie connects different sounding pitches (${describePitch(tieOpen.pitch)} != ${describePitch(note.pitch)})`,
note.pos,
);
}
const merged = events[events.length - 1];
merged.durationTicks += note.durationTicks;
tieOpen = note.tieToNext ? note : null;
} else {
if (events.length >= MAX_EVENTS) {
return fail(`too many events (limit ${MAX_EVENTS})`, note.pos);
}
eventSeq++;
events.push({
id: `ev${eventSeq}`,
startTick: tick,
durationTicks: note.durationTicks,
pitch: note.pitch,
});
if (note.tieToNext) tieOpen = note;
}
tick += note.durationTicks;
}
}
if (tieOpen) {
return fail('dangling tie: no note follows the tied note', tieOpen.pos);
}
if (events.every(e => e.pitch === null)) {
return fail('the tune contains no notes (only rests)');
}
const outMeasures: ScoreMeasure[] = [];
let measureStart = 0;
for (let m = 0; m < numMeasures; m++) {
outMeasures.push({ number: m + 1, startTick: measureStart, durationTicks: sums[m] });
measureStart += sums[m];
}
if (!h.key) {
// Unreachable when measures exist (the body cannot start without K:),
// but keeps the type checker honest.
return fail('missing "K:" header before the tune body');
}
const score: Score = {
version: 1,
id: h.x !== null && h.x.length > 0 ? h.x : 'abc-untitled',
title: h.t !== null && h.t.length > 0 ? h.t : 'Untitled',
meter: { numerator: h.meterNumerator as 3 | 4, denominator: 4 },
key: { tonic: h.key.tonic, mode: h.key.mode, signature: h.key.signature },
measures: outMeasures,
voices: [{ id: 'melody', events }],
source: {
rights: `Transcription by project contributor (CC0); source: ${h.s || h.c || 'unspecified'}`,
writtenToSoundingSemitones: shift,
...(h.c ? { composer: h.c } : {}),
...(h.z ? { adaptation: h.z } : {}),
},
};
const problems = validateScore(score);
if (problems.length > 0) {
return fail(`parsed tune failed score validation: ${problems.join('; ')}`);
}
return { ok: true, value: score };
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { EXERCISES, findExercise, loadExercise } from './library';
import { validateScore, scoreEvents } from '../score/model';
describe('bundled exercise library', () => {
it('has a non-empty, uniquely-id\'d catalog', () => {
expect(EXERCISES.length).toBeGreaterThanOrEqual(4);
const ids = new Set(EXERCISES.map(e => e.id));
expect(ids.size).toBe(EXERCISES.length);
for (const ex of EXERCISES) {
expect(ex.label.length).toBeGreaterThan(0);
expect(ex.abc.length).toBeGreaterThan(0);
}
});
it('every bundled file parses and validates', () => {
for (const ex of EXERCISES) {
const res = loadExercise(ex.id);
expect(res.ok, `${ex.id}: ${res.ok ? '' : res.error}`).toBe(true);
if (!res.ok) continue;
const problems = validateScore(res.value);
expect(problems, `${ex.id}: ${problems.join('; ')}`).toEqual([]);
expect(scoreEvents(res.value).length).toBeGreaterThan(0);
expect(res.value.title.length).toBeGreaterThan(0);
expect(res.value.source).toBeDefined();
expect(res.value.source!.rights.length).toBeGreaterThan(0);
}
});
it('exercise sources declare provenance (composer or original)', () => {
for (const ex of EXERCISES) {
const res = loadExercise(ex.id);
if (!res.ok) continue;
const src = res.value.source!;
expect(src.composer || '').toBeTruthy();
expect(src.rights).toBeTruthy();
}
});
it('Ode to Joy theme has the expected note count and midis', () => {
const res = loadExercise('ode-to-joy');
expect(res.ok).toBe(true);
if (!res.ok) return;
const evts = scoreEvents(res.value);
expect(evts).toHaveLength(30); // two 4-bar phrases
expect(res.value.measures).toHaveLength(8);
// First phrase: E E F G G F E D C C D E
expect(evts.slice(0, 12).map(e => e.pitch!.midi)).toEqual([64, 64, 65, 67, 67, 65, 64, 62, 60, 60, 62, 64]);
// Bar 4 dotted rhythm: E3/2 D/2 D2 → 720, 240, 960
expect(evts[12].durationTicks).toBe(720);
expect(evts[13].durationTicks).toBe(240);
expect(evts[14].durationTicks).toBe(960);
});
it('unknown exercise ids fail cleanly', () => {
expect(loadExercise('nope').ok).toBe(false);
expect(findExercise('nope')).toBeUndefined();
expect(findExercise('ode-to-joy')?.id).toBe('ode-to-joy');
});
});

80
src/exercises/library.ts Normal file
View File

@@ -0,0 +1,80 @@
/**
* Bundled exercise library (contract G).
*
* Files live in src/exercises/library/*.abc and are parsed at runtime by the
* ABC subset parser. Every file is validated by a unit test. Provenance and
* adaptation notes are embedded in the files themselves and mirrored in
* SOURCES.md. Content policy: verified public-domain melodies + original
* technical exercises only — nothing is attributed to a historical composer
* without a verifiable source (see docs/PHRASE_MODE_IMPLEMENTATION.md).
*/
import { parseAbc, type AbcParseOptions } from './abcParser';
import type { Result } from '../score/model';
import type { Score } from '../score/model';
import odeRaw from './library/ode-to-joy.abc?raw';
import twinkleRaw from './library/twinkle.abc?raw';
import firstPositionRaw from './library/first-position-study.abc?raw';
import eMinorRaw from './library/e-minor-study.abc?raw';
import dottedRaw from './library/dotted-rhythm-study.abc?raw';
import brokenChordRaw from './library/broken-chord-study.abc?raw';
import jingleRaw from './library/jingle-bells.abc?raw';
import maryRaw from './library/mary-had-a-little-lamb.abc?raw';
import londonRaw from './library/london-bridge.abc?raw';
import cMajorRunRaw from './library/c-major-run.abc?raw';
import thirdsRaw from './library/thirds-study.abc?raw';
import aMinorArpRaw from './library/a-minor-arpeggio-study.abc?raw';
import waltzRaw from './library/waltz-study.abc?raw';
import eMinorPentRaw from './library/e-minor-pentatonic-run.abc?raw';
import dottedEighthRaw from './library/dotted-eighth-study.abc?raw';
import mixedRhythmRaw from './library/mixed-rhythm-study.abc?raw';
import fMajorRaw from './library/f-major-study.abc?raw';
import bbMajorRaw from './library/bb-major-study.abc?raw';
import descendingRaw from './library/descending-study.abc?raw';
export interface ExerciseInfo {
readonly id: string;
readonly label: string;
/** Difficulty hint shown in the picker. */
readonly level: 'beginner' | 'intermediate';
readonly abc: string;
}
export const EXERCISES: ExerciseInfo[] = [
{ id: 'ode-to-joy', label: 'Ode to Joy (Theme)', level: 'beginner', abc: odeRaw },
{ id: 'twinkle', label: 'Ah! vous dirai-je, maman', level: 'beginner', abc: twinkleRaw },
{ id: 'jingle-bells', label: 'Jingle Bells (Opening)', level: 'beginner', abc: jingleRaw },
{ id: 'mary-had-a-little-lamb', label: 'Mary Had a Little Lamb', level: 'beginner', abc: maryRaw },
{ id: 'london-bridge', label: 'London Bridge (First Phrase)', level: 'beginner', abc: londonRaw },
{ id: 'first-position-study', label: 'First Position Study', level: 'beginner', abc: firstPositionRaw },
{ id: 'c-major-run', label: 'C Major Run', level: 'beginner', abc: cMajorRunRaw },
{ id: 'thirds-study', label: 'Thirds Study', level: 'beginner', abc: thirdsRaw },
{ id: 'waltz-study', label: 'Waltz Study (3/4)', level: 'beginner', abc: waltzRaw },
{ id: 'e-minor-study', label: 'E Minor Study', level: 'intermediate', abc: eMinorRaw },
{ id: 'e-minor-pentatonic-run', label: 'E Minor Pentatonic Run', level: 'intermediate', abc: eMinorPentRaw },
{ id: 'dotted-rhythm-study', label: 'Dotted Rhythm Study', level: 'intermediate', abc: dottedRaw },
{ id: 'dotted-eighth-study', label: 'Dotted Eighth Study', level: 'intermediate', abc: dottedEighthRaw },
{ id: 'mixed-rhythm-study', label: 'Mixed Rhythm Study', level: 'intermediate', abc: mixedRhythmRaw },
{ id: 'f-major-study', label: 'F Major Study', level: 'intermediate', abc: fMajorRaw },
{ id: 'bb-major-study', label: 'Bb Major Study', level: 'intermediate', abc: bbMajorRaw },
{ id: 'a-minor-arpeggio-study', label: 'A Minor Arpeggio Study', level: 'intermediate', abc: aMinorArpRaw },
{ id: 'descending-study', label: 'Descending Study', level: 'intermediate', abc: descendingRaw },
{ id: 'broken-chord-study', label: 'Broken Chord Study', level: 'intermediate', abc: brokenChordRaw },
];
export function findExercise(id: string): ExerciseInfo | undefined {
return EXERCISES.find(e => e.id === id);
}
const cache = new Map<string, Result<Score>>();
/** Parse a bundled exercise (memoized; failures are cached too). */
export function loadExercise(id: string, options?: AbcParseOptions): Result<Score> {
const key = id + (options?.writtenToSoundingSemitones ?? 0);
const hit = cache.get(key);
if (hit) return hit;
const info = findExercise(id);
const result = info ? parseAbc(info.abc, options) : { ok: false as const, error: `Unknown exercise: ${id}` };
cache.set(key, result);
return result;
}

View File

@@ -0,0 +1,10 @@
% Original technical exercise: A minor broken chords (project contributor, 2026, CC0).
X:12
T:A Minor Arpeggio Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:Am
A, C E C | A, C E A | G, B, D B, | G, B, D G | F, A, C A, | F, A, C F | E, G, B, G, | E, G, B, E |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise in _B major (two flats) (project contributor, 2026, CC0).
X:18
T:_B Major Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:_B
_B, C D _E | F G F D | _B, C D F | G F D _B, | C D _E F | G F _E C | D _E F G | F2 D2 |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise: broken C-G-Am-F chords as a single line.
% Written for this project, 2026, CC0.
X:6
T:Broken Chord Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:C
C E G E | C E G c | G, B, D B, | G, B, D G | A, C E C | A, C E A | F, A, C A, | F, A, C F |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise (project contributor, 2026, CC0).
% C major, steady quarters — a gentle reading warm-up.
X:10
T:C Major Run
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:C
C D E F | G A B c | c B A G | F E D C |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise: descending lines in A minor (project contributor, 2026, CC0).
X:19
T:Descending Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:Am
c B A G | F E D C | B, C D E | F E D C | A G F E | D C B, A, | B, C D E | A2 z2 |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise: dotted quarter + eighth figures (project contributor, 2026, CC0).
X:15
T:Dotted Eighth Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=70
K:G
G3/2 A/2 B G | D3/2 E/2 G B | A3/2 B/2 c A | E3/2 ^F/2 A c | B3/2 A/2 G ^F | E3/2 D/2 B, A, | G,3/2 A,/2 B, D | G2 z2 |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise: dotted quarter / eighth patterns.
% Written for this project, 2026, CC0.
X:5
T:Dotted Rhythm Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=70
K:C
C3/2 E/2 E F | G3/2 E/2 C2 | D3/2 E/2 F G | E3/2 D/2 C2 | C3/2 E/2 E F | G3/2 E/2 G2 | A3/2 G/2 F E | D3/2 C/2 C2 |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise: E minor pentatonic (project contributor, 2026, CC0).
X:14
T:E Minor Pentatonic Run
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=90
K:Em
E G A B | d B A G | E D B, A, | B, D E2 | G A B d | B A G E | D B, A, B, | E2 z2 |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise (not attributed to any historical composer).
% Written for this project, 2026, CC0. E minor + D major arpeggios.
X:4
T:E Minor Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:Em
E, G, B, E | G B E2 | E, G, B, E | G B E2 | D F A D | F A D2 | E, G, B, E | E, B, E2 |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise in F major (one flat) (project contributor, 2026, CC0).
X:17
T:F Major Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:F
F G A _B | c d c A | F G A c | d c A F | G A _B c | d c _B G | A _B c d | c2 A2 |]

View File

@@ -0,0 +1,11 @@
% Original technical exercise (not attributed to any historical composer).
% Written for this project, 2026, CC0. G major, first-position friendly.
X:3
T:First Position Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:G
G A B D | D B A G | E G A B | A G G2 | D E G A | B A G E | D E G A | G D G2 |]

View File

@@ -0,0 +1,13 @@
% Jingle Bells — opening phrase. James Lord Pierpont, 1857; public domain.
% Transcription: project contributor, 2026 (CC0). Opening 4 bars only.
% Pitch convention: concert pitch.
X:7
T:Jingle Bells (Opening)
C:James Lord Pierpont (1857)
S:Public domain since publication; opening phrase
Z:Project contributor, 2026 (CC0 transcription)
M:4/4
L:1/4
Q:1/4=120
K:C
E E E2 | E E E2 | E G C D | E2 z2 |]

View File

@@ -0,0 +1,13 @@
% London Bridge Is Falling Down — traditional English nursery rhyme.
% Traditional tune: public domain. Transcription: project contributor, 2026 (CC0).
% Adaptation: the first two-measure phrase, repeated as the tune itself does.
X:9
T:London Bridge (First Phrase)
C:Traditional (English nursery rhyme)
S:Common version of the tune
Z:Project contributor, 2026 (CC0 transcription); adaptation: first phrase, repeated
M:4/4
L:1/4
Q:1/4=100
K:C
G A G F | E F G2 | D E F2 | E F G2 | G A G F | E F G2 | D E F2 | E F G2 |]

View File

@@ -0,0 +1,12 @@
% Mary Had a Little Lamb — traditional nursery melody (19th century).
% Traditional tune: public domain. Transcription: project contributor, 2026 (CC0).
X:8
T:Mary Had a Little Lamb
C:Traditional
S:Common nursery-rhyme version
Z:Project contributor, 2026 (CC0 transcription)
M:4/4
L:1/4
Q:1/4=100
K:C
E D C D | E E E2 | D D D2 | E G G2 | E D C D | E E E E | D D E D | C2 z2 |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise mixing paired eighths and quarters (project contributor, 2026, CC0).
X:16
T:Mixed Rhythm Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:C
C C D/2 E/2 F | G G A/2 B/2 c | c B A/2 G/2 F | E E D2 | F F G/2 A/2 B | c c B/2 A/2 G | A G F/2 E/2 D | C2 z2 |]

View File

@@ -0,0 +1,15 @@
% Ode to Joy — main theme of Beethoven's Symphony No. 9 (1824).
% Composer died 1827; the work itself is public domain worldwide.
% Transcription: project contributor, 2026 (CC0). Single-voice adaptation:
% only the lead melody line; the accompanying harmonies are omitted.
% Pitch convention: concert pitch.
X:1
T:Ode to Joy (Theme)
C:Ludwig van Beethoven (1770-1827)
S:Symphony No. 9 in D minor, Op. 125 (1824), fourth movement main theme
Z:Project contributor, 2026 (CC0 transcription); adaptation: melody line only
M:4/4
L:1/4
Q:1/4=100
K:C
E E F G | G F E D | C C D E | E3/2 D/2 D2 | E E F G | G F E D | C C D E | D3/2 C/2 C2 |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise: melody in thirds (project contributor, 2026, CC0).
X:11
T:Thirds Study
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:4/4
L:1/4
Q:1/4=80
K:C
C E D F | E G F A | G B A c | B c B2 |]

View File

@@ -0,0 +1,13 @@
% Ah! vous dirai-je, maman — traditional French melody (published 1761).
% Traditional tune: public domain. Transcription: project contributor, 2026 (CC0).
% Pitch convention: concert pitch.
X:2
T:Ah! vous dirai-je, maman (Twinkle, Twinkle)
C:Traditional (French melody, published 1761)
S:Common version of the tune; no known composer
Z:Project contributor, 2026 (CC0 transcription)
M:4/4
L:1/4
Q:1/4=100
K:C
C C G G | A A G2 | F F E E | D D C2 | G G F F | E E D2 | G G F F | E E D2 | C C G G | A A G2 | F F E E | D D C2 |]

View File

@@ -0,0 +1,10 @@
% Original technical exercise in 3/4 (project contributor, 2026, CC0).
X:13
T:Waltz Study (3/4)
C:Original exercise (project contributor)
Z:Project contributor, 2026 (CC0)
M:3/4
L:1/4
Q:1/4=90
K:C
C E G | c2 G | A G F | E2 C | D F A | c2 A | G F E | D2 C |]

View File

@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { MatchTracker } from './MatchTracker';
const THRESHOLD = 50;
const GRACE = 150;
describe('MatchTracker', () => {
it('does not trigger before the threshold', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
expect(t.update(true, 0)).toBe(false);
expect(t.update(true, 49)).toBe(false);
});
it('triggers once the threshold is reached', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
expect(t.update(true, 0)).toBe(false);
expect(t.update(true, 50)).toBe(true);
});
it('triggers exactly once, then starts fresh', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
t.update(true, 0);
expect(t.update(true, 60)).toBe(true);
// After triggering, tracker is reset: next frame must not re-trigger
expect(t.update(true, 70)).toBe(false);
});
it('survives a brief dropout within the grace period', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
t.update(true, 0); // start matching
t.update(false, 30); // one flickered frame
expect(t.update(true, 60)).toBe(true); // 60ms elapsed > 50ms threshold
});
it('survives several short dropouts', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
t.update(true, 0);
t.update(false, 16);
t.update(true, 32);
t.update(false, 48);
expect(t.update(true, 64)).toBe(true);
});
it('resets when the interruption exceeds the grace period', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
t.update(true, 0);
t.update(false, 10);
// 200ms of silence later the user plays the note again:
// must NOT inherit the 200ms-old start time (that would fire instantly)
expect(t.update(true, 210)).toBe(false);
expect(t.update(true, 259)).toBe(false);
expect(t.update(true, 260)).toBe(true);
});
it('resets when a wrong note persists beyond grace', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
t.update(true, 0);
t.update(false, 20);
t.update(false, 200); // wrong note held: grace exceeded
expect(t.update(true, 210)).toBe(false); // restart from ~210
expect(t.update(true, 261)).toBe(true);
});
it('never triggers on non-matching frames alone', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
expect(t.update(false, 0)).toBe(false);
expect(t.update(false, 1000)).toBe(false);
});
it('handles frames arriving long after the last one (throttled tab)', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
// No frames for a while, then a single matching frame: fresh start.
expect(t.update(true, 5000)).toBe(false);
});
it('reset() clears accumulated progress', () => {
const t = new MatchTracker(THRESHOLD, GRACE);
t.update(true, 0);
t.update(true, 40);
t.reset();
expect(t.update(true, 50)).toBe(false); // restarted at 50
expect(t.update(true, 100)).toBe(true);
});
});

60
src/game/MatchTracker.ts Normal file
View File

@@ -0,0 +1,60 @@
/**
* Tracks whether a target note has been held long enough to count as a match.
*
* Pitch detection flickers: YIN regularly drops a frame or reports a wrong
* octave for 1-2 frames during note attacks and decays. A naive "consecutive
* frames" timer resets on every flicker, so the note display shows green
* while the match timer keeps restarting and success never fires.
*
* This tracker instead tolerates mismatches/dropouts up to `graceMs`:
* progress survives short interruptions, but an interruption longer than
* the grace period resets the timer. The timer only *advances* on matching
* frames, so success can never fire during silence or a wrong note.
*/
export class MatchTracker {
private readonly thresholdMs: number;
private readonly graceMs: number;
private matchStart: number | null = null;
private mismatchSince: number | null = null;
constructor(thresholdMs: number, graceMs: number) {
this.thresholdMs = thresholdMs;
this.graceMs = graceMs;
}
/**
* Feed one detection frame.
*
* @param matching whether the detected pitch currently equals the target
* @param now timestamp in ms (e.g. Date.now())
* @returns true exactly once when the note has been held for thresholdMs
*/
update(matching: boolean, now: number): boolean {
if (matching) {
const interruptedTooLong =
this.mismatchSince !== null && now - this.mismatchSince > this.graceMs;
if (this.matchStart === null || interruptedTooLong) {
this.matchStart = now;
}
this.mismatchSince = null;
if (now - this.matchStart >= this.thresholdMs) {
this.reset();
return true;
}
} else if (this.matchStart !== null && this.mismatchSince === null) {
// Start of a potential interruption. Whether it is fatal is decided
// lazily on the next matching frame, so no timer callback is needed
// when no frames arrive at all.
this.mismatchSince = now;
}
return false;
}
/** Forget all accumulated progress (e.g. when the target note changes). */
reset(): void {
this.matchStart = null;
this.mismatchSince = null;
}
}

View File

@@ -0,0 +1,248 @@
import { describe, it, expect } from 'vitest';
import { PhraseMatcher, RELEASE_EVIDENCE_MS, FRAME_GAP_RESET_MS } from './PhraseMatcher';
import { buildSchedule, sanitizeBpm, tickToAudioTime } from './PhraseTransport';
import { generateMelody } from '../music/melodyGenerator';
// Frame cadence of the real detector: ~16.7 ms at 60 fps.
const DT = 16.7;
function frames(matcher: PhraseMatcher, midis: (number | null)[], from: number, idx: number) {
let t = from;
for (const m of midis) {
matcher.feed(m, t, idx);
t += DT;
}
return t;
}
describe('PhraseMatcher — at-your-pace behavior', () => {
it('C–D–E advances only in order; wrong notes wait', () => {
const m = new PhraseMatcher([60, 62, 64]);
// Play E first — nothing happens.
let out = m.feed(64, 100, 0);
expect(out).toBeNull();
frames(m, [64, 64, 64, 64, 64], 116, 0);
expect(m.status(0)).toBe('pending');
// Play C → matches note 0 after ~50ms hold.
let t = 100;
for (let i = 0; i < 8; i++) {
out = m.feed(60, t, 0);
t += DT;
if (out) break;
}
expect(out).toEqual({ matched: 0 });
expect(m.status(0)).toBe('matched');
// D next.
for (let i = 0; i < 8; i++) {
out = m.feed(62, t, 1);
t += DT;
if (out) break;
}
expect(out).toEqual({ matched: 1 });
});
it('a single stale frame cannot satisfy the hold', () => {
const m = new PhraseMatcher([60]);
expect(m.feed(60, 100, 0)).toBeNull();
expect(m.status(0)).toBe('pending');
});
it('repeated C–C: held note does not score twice; release + replay does', () => {
const m = new PhraseMatcher([60, 60]);
let t = 100;
for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; }
expect(m.status(0)).toBe('matched');
// Switch to note 1 (same pitch) and keep holding.
m.setCurrent(1);
let out = m.feed(60, t, 1);
expect(out).toBeNull(); // blocked: same sounding episode
t = frames(m, [60, 60, 60, 60, 60, 60], t + DT, 1);
expect(m.status(1)).toBe('pending');
// Release: >=80ms of fresh null frames.
t = frames(m, [null, null, null, null, null, null], t + DT, 1);
// Replay C: fresh attack → match.
for (let i = 0; i < 6 && !out; i++) { out = m.feed(60, t + DT * i, 1); }
expect(out).toEqual({ matched: 1 });
expect(m.status(1)).toBe('matched');
});
it('a 20ms dropout does not re-arm; 80ms release does (contract D)', () => {
const m = new PhraseMatcher([60, 60]);
let t = 100;
for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; }
expect(m.status(0)).toBe('matched');
m.setCurrent(1);
// 1-frame dropout (17ms) — flicker, still blocked.
m.feed(null, t, 1);
t += DT;
let out = m.feed(60, t, 1);
expect(out).toBeNull();
t = frames(m, [60, 60, 60, 60, 60], t + DT, 1);
expect(m.status(1)).toBe('pending');
// Now a real 85ms release.
t = frames(m, [null, null, null, null, null, null], t + DT, 1); // ~100ms null
for (let i = 0; i < 6 && !out; i++) { out = m.feed(60, t + DT * i, 1); }
expect(out).toEqual({ matched: 1 });
});
it('a sample gap >100ms resets hold and release evidence', () => {
const m = new PhraseMatcher([60]);
// Hold progress, then a processing gap of 500ms.
m.feed(60, 100, 0);
m.feed(60, 116, 0);
m.feed(60, 116 + 500, 0); // gap → reset, fresh start
expect(m.status(0)).toBe('pending');
// 50ms of hold after the gap is required again.
let matched = false;
let t = 616;
for (let i = 0; i < 8; i++) { matched = matched || m.feed(60, t, 0) !== null; t += DT; }
expect(matched).toBe(true);
});
it('a genuinely different pitch establishes a new episode (fast P–Q–P)', () => {
const m = new PhraseMatcher([60, 60]);
let t = 100;
for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; }
expect(m.status(0)).toBe('matched');
m.setCurrent(1);
// Play Q (64) for 3+ frames: new episode → unblocked.
t = frames(m, [64, 64, 64], t, 1);
// Now play C: fresh attack → should match.
let out: { matched: number } | null = null;
for (let i = 0; i < 6 && !out; i++) { out = m.feed(60, t + DT * i, 1); }
expect(out).toEqual({ matched: 1 });
});
it('a one-frame flicker to a wrong pitch does not unblock', () => {
const m = new PhraseMatcher([60, 60]);
let t = 100;
for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; }
expect(m.status(0)).toBe('matched');
m.setCurrent(1);
// Single flicker frame at 64 — must not establish a new episode.
m.feed(64, t, 1);
m.feed(60, t + DT, 1);
t = frames(m, [60, 60, 60, 60, 60], t + 2 * DT, 1);
expect(m.status(1)).toBe('pending'); // still blocked
});
it('virtual taps: repeated taps match repeated notes, no hold needed', () => {
const m = new PhraseMatcher([60, 60, 67]);
expect(m.tap(62, 0)).toBeNull(); // wrong pitch
expect(m.tap(60, 0)).toEqual({ matched: 0 });
expect(m.tap(60, 1)).toEqual({ matched: 1 }); // re-tap works immediately
expect(m.tap(67, 2)).toEqual({ matched: 2 });
});
it('latched results never change; skip is not a hit', () => {
const m = new PhraseMatcher([60, 62]);
m.setStatus(0, 'skipped');
m.setStatus(0, 'matched'); // ignored — skip is final
expect(m.status(0)).toBe('skipped');
expect(m.summary()).toEqual({ total: 2, matched: 0, missed: 0, skipped: 1, rest: 0 });
});
it('rests auto-resolve and are excluded from the summary', () => {
const m = new PhraseMatcher([60, null, 62]);
expect(m.status(1)).toBe('rest');
expect(m.summary().total).toBe(2);
});
});
describe('PhraseMatcher — tempo behavior', () => {
it('expireWindow marks pending notes missed, keeps latched matches, resolves rests', () => {
const m = new PhraseMatcher([60, null, 62]);
// User plays 62 during window 0: no early match.
m.feed(62, 100, 0);
m.expireWindow(0);
expect(m.status(0)).toBe('missed');
m.expireWindow(1);
expect(m.status(1)).toBe('rest');
// Play 62 → match window 2 before it expires.
let hit = false;
for (let i = 0; i < 5; i++) { hit = hit || m.feed(62, 200 + 16 * i, 2) !== null; }
m.expireWindow(2);
expect(m.status(2)).toBe('matched');
});
it('a held C cannot score C–rest–C twice (contract D.3)', () => {
const m = new PhraseMatcher([60, null, 60]);
// Sustain C across all three windows.
let t = 100;
for (let i = 0; i < 6; i++) { m.feed(60, t, 0); t += DT; }
expect(m.status(0)).toBe('matched');
m.setCurrent(1); // rest window: frames continue
t = frames(m, [60, 60, 60, 60, 60, 60, 60, 60], t, 1);
m.setCurrent(2);
const out = m.feed(60, t, 2);
expect(out).toBeNull(); // blocked — still the same sounding episode
t = frames(m, [60, 60, 60, 60, 60, 60], t + DT, 2);
expect(m.status(2)).toBe('pending');
});
it('early match latches before the window ends (tempo)', () => {
const m = new PhraseMatcher([64]);
let out: { matched: number } | null = null;
let t = 100;
for (let i = 0; i < 6 && !out; i++) { out = m.feed(64, t, 0); t += DT; }
expect(out).toEqual({ matched: 0 });
m.expireWindow(0);
expect(m.status(0)).toBe('matched'); // not overwritten by the miss pass
});
});
describe('PhraseTransport — schedule math', () => {
const pool = Array.from({ length: 25 }, (_, i) => 48 + i);
const meter = { numerator: 4 as const, denominator: 4 as const };
it('at 60 BPM a 4/4 count-in starts the score exactly at +4s', () => {
const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 2, meter, rhythmLevel: 1, seed: 1 }, pool);
if (!res.ok) throw new Error(res.error);
const schedule = buildSchedule(res.value.score, 60, 0, 4);
const countIns = schedule.filter(e => e.kind === 'count-in-beat');
expect(countIns.map(e => e.at)).toEqual([0, 1, 2, 3]);
const firstNoteEnd = schedule.find(e => e.kind === 'note-end')!;
// First note starts at score start = 4s; ends at 4s + dur.
const firstEvent = res.value.score.voices[0].events[0];
expect(firstNoteEnd.at).toBeCloseTo(4 + (firstEvent.durationTicks / 480), 5);
});
it('schedules every note end and a done entry at the score end', () => {
const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 4, meter, rhythmLevel: 1, seed: 5 }, pool);
if (!res.ok) throw new Error(res.error);
const schedule = buildSchedule(res.value.score, 90, 10, 0);
const noteEnds = schedule.filter(e => e.kind === 'note-end');
expect(noteEnds).toHaveLength(res.value.score.voices[0].events.length);
const done = schedule.find(e => e.kind === 'done')!;
expect(done.at).toBeCloseTo(10 + (16 * 480 / 480) * (60 / 90), 5); // 4 bars = 16 beats
});
it('no count-in beats when countInBeats = 0', () => {
const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 1, meter, rhythmLevel: 1, seed: 1 }, pool);
if (!res.ok) throw new Error(res.error);
expect(buildSchedule(res.value.score, 60, 0, 0).filter(e => e.kind === 'count-in-beat')).toHaveLength(0);
});
it('schedule is sorted and beats cover the whole score', () => {
const res = generateMelody({ keyTonic: 'C', keyMode: 'major', bars: 2, meter, rhythmLevel: 2, seed: 9 }, pool);
if (!res.ok) throw new Error(res.error);
const schedule = buildSchedule(res.value.score, 120, 0, 3);
for (let i = 1; i < schedule.length; i++) expect(schedule[i].at).toBeGreaterThanOrEqual(schedule[i - 1].at);
const beats = schedule.filter(e => e.kind === 'beat');
expect(beats).toHaveLength(8); // 2 bars × 4 beats
});
it('sanitizeBpm clamps nonsense', () => {
expect(sanitizeBpm(NaN)).toBe(60);
expect(sanitizeBpm(10)).toBe(30);
expect(sanitizeBpm(999)).toBe(180);
expect(tickToAudioTime(480, 60, 0)).toBe(1);
});
});
describe('PhraseMatcher — timing constants are as specified', () => {
it('uses the contract values', () => {
expect(RELEASE_EVIDENCE_MS).toBe(80);
expect(FRAME_GAP_RESET_MS).toBe(100);
});
});

254
src/game/PhraseMatcher.ts Normal file
View File

@@ -0,0 +1,254 @@
/**
* Phrase-mode note matcher (contract D).
*
* Extends the single-note MatchTracker philosophy to sequences:
* - Per-logical-note one-way LATCHED results (a note can only resolve once).
* - "Episode" tracking: one sustained sounding pitch cannot earn credit for a
* later note on the same pitch. After a note with pitch P is (attempted)
* sounded, a following note with the same pitch stays BLOCKED until the
* user provides release evidence: >= 80 ms of fresh null frames, or a
* genuinely different pitch (2-frame stable), which establishes a new
* attack/episode.
* - Flicker tolerance: single-frame dropouts neither re-arm nor unblock; a
* sample gap > 100 ms resets the hold timer and release accumulation.
* - Exact octave matching, reusing the existing MatchTracker (50 ms hold,
* 150 ms grace) for hold confirmation.
*
* Virtual (on-screen) taps are handled by the trainer as discrete attacks,
* not through feed(): each tap is its own episode and may match immediately.
*/
import { MatchTracker } from './MatchTracker';
import { NOTE_MATCH_GRACE_MS, NOTE_MATCH_THRESHOLD_MS } from '../AppConfig';
export type NoteStatus = 'pending' | 'matched' | 'missed' | 'skipped' | 'rest';
/** Freshness window: frames older than this reset the hold timer. */
export const FRAME_GAP_RESET_MS = 100;
/** Release evidence: sustained null frames needed to re-arm a repeated pitch. */
export const RELEASE_EVIDENCE_MS = 80;
/** A different pitch must persist this many frames to establish a new episode. */
const EPISODE_STABLE_FRAMES = 2;
export interface FeedOutcome {
/** Index of the note that just got matched (latched). */
readonly matched: number;
}
export interface PhraseMatcherOptions {
/**
* At-your-pace fallback: if the user keeps a repeated pitch sounding for
* longer than this, assume a re-attack happened and re-arm (a real
* guitarist/vocalist rarely holds one pitch that long while waiting).
* null = never (tempo mode stays strict).
*/
readonly unblockAfterMs?: number | null;
}
export class PhraseMatcher {
private readonly targetMidis: (number | null)[];
private readonly statuses: NoteStatus[];
private readonly tracker = new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS);
private readonly unblockAfterMs: number | null;
private blockedSince: number | null = null;
private lastFrameAt = 0;
private nullAccumMs = 0;
/** Pitch of the user's current sounding episode (null = released). */
private episodePitch: number | null = null;
private candidatePitch: number | null = null;
private candidateFrames = 0;
/** True while the current note needs release evidence (repeated pitch). */
private blocked = false;
private currentIndex = -1;
constructor(targetMidis: (number | null)[], options: PhraseMatcherOptions = {}) {
this.targetMidis = [...targetMidis];
this.unblockAfterMs = options.unblockAfterMs ?? null;
this.statuses = targetMidis.map(midi => (midi === null ? 'rest' : 'pending'));
}
reset(): void {
this.statuses.fill('pending');
for (let i = 0; i < this.statuses.length; i++) {
if (this.targetMidis[i] === null) this.statuses[i] = 'rest';
}
this.tracker.reset();
this.lastFrameAt = 0;
this.nullAccumMs = 0;
this.episodePitch = null;
this.candidatePitch = null;
this.candidateFrames = 0;
this.blocked = false;
this.blockedSince = null;
this.currentIndex = -1;
}
status(idx: number): NoteStatus {
return this.statuses[idx] ?? 'pending';
}
/** Snapshot of all statuses (for React state mirroring). */
allStatuses(): NoteStatus[] {
return [...this.statuses];
}
setStatus(idx: number, status: NoteStatus): void {
// One-way latch: a resolved note never changes.
if (this.statuses[idx] === 'pending' || this.statuses[idx] === 'rest') {
this.statuses[idx] = status;
}
}
get firstPending(): number {
for (let i = 0; i < this.statuses.length; i++) {
if (this.statuses[i] === 'pending') return i;
}
return -1;
}
summary(): { total: number; matched: number; missed: number; skipped: number; rest: number } {
let matched = 0, missed = 0, skipped = 0, rest = 0, total = 0;
for (let i = 0; i < this.statuses.length; i++) {
if (this.targetMidis[i] === null) { rest++; continue; }
total++;
if (this.statuses[i] === 'matched') matched++;
else if (this.statuses[i] === 'missed') missed++;
else if (this.statuses[i] === 'skipped') skipped++;
}
return { total, matched, missed, skipped, rest };
}
/**
* Announce which note is currently "up" (cursor). Recomputes the
* repeated-pitch block from the current episode.
*/
setCurrent(idx: number): void {
if (idx === this.currentIndex) return;
this.currentIndex = idx;
this.tracker.reset();
this.updateBlock();
}
private updateBlock(): void {
const target = this.currentIndex >= 0 ? this.targetMidis[this.currentIndex] : null;
// Blocked when the target pitch was ALREADY sounding when the note
// became current: a repeated pitch needs release/re-attack evidence.
// (A fresh attack of the target is handled by the episode logic below,
// which clears the block when a new episode establishes.)
this.blocked =
this.currentIndex >= 0 &&
target !== null &&
this.episodePitch === target;
this.blockedSince = this.blocked ? (this.lastFrameAt || null) : null;
}
/** Whether the current note is blocked by a sustained same-pitch episode. */
isBlocked(): boolean {
return this.blocked;
}
/**
* Feed one FRESH raw detection frame (caller must exclude gated/blanked
* frames — speaker output and metronome clicks).
*
* @param midi raw detected pitch (null = silence/unpitched)
* @param atMs frame arrival time (performance clock, ms)
* @param currentIdx the note currently up
* @returns match outcome when a note latched, else null
*/
feed(midi: number | null, atMs: number, currentIdx: number): FeedOutcome | null {
if (currentIdx !== this.currentIndex) this.setCurrent(currentIdx);
const gap = this.lastFrameAt === 0 ? 0 : atMs - this.lastFrameAt;
this.lastFrameAt = atMs;
if (gap > FRAME_GAP_RESET_MS) {
// Processing gap (throttled tab, gated period): forget the hold
// timer and accumulated release evidence.
this.tracker.reset();
this.nullAccumMs = 0;
}
// At-your-pace fallback: a very long sustained same-pitch episode is
// most likely a re-attack the detector could not separate (contract
// D2.4). Re-arm so the user is never permanently stuck.
if (
this.blocked &&
this.unblockAfterMs !== null &&
this.blockedSince !== null &&
atMs - this.blockedSince > this.unblockAfterMs
) {
this.blocked = false;
this.blockedSince = null;
}
if (midi === null) {
this.nullAccumMs += Math.max(0, gap);
if (this.nullAccumMs >= RELEASE_EVIDENCE_MS) {
this.episodePitch = null;
this.candidatePitch = null;
this.candidateFrames = 0;
this.blocked = false; // released → re-attack allowed
}
this.tracker.update(false, atMs);
return null;
}
this.nullAccumMs = 0;
if (midi === this.episodePitch) {
this.candidatePitch = null;
this.candidateFrames = 0;
} else if (midi === this.candidatePitch) {
if (++this.candidateFrames >= EPISODE_STABLE_FRAMES) {
// A genuinely different pitch establishes a new episode —
// both an attack of another note and a re-attack after a
// quick pitch change. A new episode is a new attack: any
// pending repeated-pitch block is cleared.
this.episodePitch = midi;
this.candidatePitch = null;
this.candidateFrames = 0;
this.blocked = false;
}
} else {
this.candidatePitch = midi;
this.candidateFrames = 1;
}
const target = this.currentIndex >= 0 ? this.targetMidis[this.currentIndex] : null;
if (target === null || this.statuses[this.currentIndex] !== 'pending' || this.blocked) {
this.tracker.update(false, atMs);
return null;
}
const matching = midi === target;
if (this.tracker.update(matching, atMs)) {
this.statuses[this.currentIndex] = 'matched';
return { matched: this.currentIndex };
}
return null;
}
/** Virtual input: a discrete attack (tap). May match immediately. */
tap(midi: number, currentIdx: number): FeedOutcome | null {
this.setCurrent(currentIdx);
// Every tap is a fresh attack/episode.
this.episodePitch = midi;
this.candidatePitch = null;
this.candidateFrames = 0;
this.nullAccumMs = 0;
const target = this.currentIndex >= 0 ? this.targetMidis[this.currentIndex] : null;
if (target === midi && this.statuses[this.currentIndex] === 'pending') {
this.statuses[this.currentIndex] = 'matched';
this.tracker.reset();
return { matched: this.currentIndex };
}
return null;
}
/** Tempo mode: resolve an expired window (call at each event end). */
expireWindow(idx: number): void {
if (this.statuses[idx] === 'pending') {
this.statuses[idx] = this.targetMidis[idx] === null ? 'rest' : 'missed';
}
}
}

View File

@@ -0,0 +1,77 @@
/**
* Pure schedule math for phrase tempo practice (contract E).
*
* The trainer hook builds a Schedule once per run (from the audio-clock
* start time) and processes it in lookahead order. Scheduling sounds early
* (lookahead) is fine, but state transitions fire only when the clock
* reaches the boundary — this module produces those boundaries.
*/
import { PPQ, scoreEvents, type Score } from '../score/model';
export type ScheduleEntryKind = 'count-in-beat' | 'beat' | 'note-end' | 'done';
export interface ScheduleEntry {
readonly kind: ScheduleEntryKind;
/** Absolute time on the transport AudioContext clock (seconds). */
readonly at: number;
/** Beat number within the count-in (count-in-beat only). */
readonly beat?: number;
readonly accent?: boolean;
/** Event index (note-end only). */
readonly index?: number;
}
export const MIN_PHRASE_BPM = 30;
export const MAX_PHRASE_BPM = 180;
export function sanitizeBpm(bpm: number): number {
if (!Number.isFinite(bpm)) return 60;
return Math.max(MIN_PHRASE_BPM, Math.min(MAX_PHRASE_BPM, Math.round(bpm)));
}
/**
* Build the transport schedule for a run.
*
* @param score the practice slice
* @param bpm quarter-note tempo
* @param startAt audio-clock time at which the count-in begins
* @param countInBeats full measure of count-in beats (0 = no count-in)
*/
export function buildSchedule(score: Score, bpm: number, startAt: number, countInBeats: number): ScheduleEntry[] {
const spb = 60 / sanitizeBpm(bpm); // seconds per quarter beat
const entries: ScheduleEntry[] = [];
for (let i = 0; i < countInBeats; i++) {
entries.push({ kind: 'count-in-beat', at: startAt + i * spb, beat: i, accent: i === 0 });
}
const scoreStart = startAt + countInBeats * spb;
const totalTicks = score.measures.reduce((a, m) => a + m.durationTicks, 0);
if (totalTicks === 0) return entries;
// Quarter-note beats while playing (downbeat accent at bar starts).
const beatCount = Math.floor(totalTicks / PPQ);
const barTicks = score.meter.numerator * PPQ;
for (let i = 0; i < beatCount; i++) {
const tick = i * PPQ;
entries.push({ kind: 'beat', at: scoreStart + (tick / PPQ) * spb, beat: i, accent: tick % barTicks === 0, index: i });
}
scoreEvents(score).forEach((e, i) => {
entries.push({
kind: 'note-end',
at: scoreStart + ((e.startTick + e.durationTicks) / PPQ) * spb,
index: i,
});
});
entries.push({ kind: 'done', at: scoreStart + (totalTicks / PPQ) * spb });
entries.sort((a, b) => a.at - b.at);
return entries;
}
/** Audio-clock time at which a tick would sound (for tests and the trainer). */
export function tickToAudioTime(tick: number, bpm: number, scoreStart: number): number {
const spb = 60 / sanitizeBpm(bpm);
return scoreStart + (tick / PPQ) * spb;
}

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

38
src/hooks/rawFrameBus.ts Normal file
View File

@@ -0,0 +1,38 @@
/**
* Minimal pub/sub for RAW pitch-detection frames (contract D1).
*
* The detector's rAF loop emits one frame per analysis tick — including
* silence (midi === null) — which is what phrase matching needs. The
* smoothed React state (pitchData) is display-only and keeps its 200 ms
* hold; it must never be used as a release signal.
*
* A tiny module-level bus is appropriate here: exactly one detector and at
* most one trainer exist at any time.
*/
export interface RawPitchFrame {
/** Raw detected midi (no smoothing, no hysteresis), or null. */
readonly midi: number | null;
/** performance.now() at frame emission. */
readonly at: number;
}
type Listener = (frame: RawPitchFrame) => void;
const listeners = new Set<Listener>();
let emitCount = 0;
export function emitRawFrame(frame: RawPitchFrame): void {
emitCount++;
for (const l of listeners) l(frame);
}
/** Total frames emitted since load (diagnostics). */
export function rawFrameCount(): number {
return emitCount;
}
export function subscribeRawFrames(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}

View File

@@ -1,61 +1,18 @@
import { useRef, useEffect, useCallback } from 'react';
import { midiToFrequency } from '../music/NoteUtils';
import { useCallback } from 'react';
import { audioEngine } from '../audio/AudioEngine';
interface AudioPlayerHandle {
playNote: (midi: number, duration?: number, volume?: number) => void;
}
/**
* Thin hook wrapper around the shared AudioEngine singleton.
* All playback goes through one AudioContext and is tracked there, so pitch
* detection can gate itself while the app's own speaker output is audible.
*/
export function useAudioPlayer(): AudioPlayerHandle {
const audioContext = useRef<AudioContext | null>(null);
// Initialize AudioContext lazily or on user interaction if possible,
// but here we just ensure it exists when we try to play.
const playNote = useCallback((midi: number, duration: number = 0.5, volume: number = 0.3) => {
if (!audioContext.current) {
audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
const ctx = audioContext.current;
if (ctx.state === 'suspended') {
ctx.resume();
}
const osc = ctx.createOscillator();
const gainNode = ctx.createGain();
const freq = midiToFrequency(midi);
osc.frequency.value = freq;
// Triangle wave is often nicer than sine for music training
osc.type = 'triangle';
osc.connect(gainNode);
gainNode.connect(ctx.destination);
const now = ctx.currentTime;
// Attack
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(volume, now + 0.02);
// Sustain -> Release
// Note: For a "pluck" or single hit, we just ramp down.
gainNode.gain.exponentialRampToValueAtTime(0.001, now + duration);
osc.start(now);
osc.stop(now + duration + 0.1); // Stop slightly after fade out
}, []);
// Cleanup
useEffect(() => {
return () => {
if (audioContext.current) {
audioContext.current.close();
audioContext.current = null;
}
};
audioEngine.playNote(midi, duration, volume);
}, []);
return { playNote };

View File

@@ -1,18 +1,20 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useSettings } from '../context/SettingsContext';
import { useSettings } from '../context/useSettings';
import { useAudioPlayer } from './useAudioPlayer';
import { useMetronome } from './useMetronome';
import { audioEngine } from '../audio/AudioEngine';
import { MatchTracker } from '../game/MatchTracker';
import {
getRandomNote,
getNoteDetails
} from '../music/NoteUtils';
import { computeTargetPool } from '../music/targetPool';
import { getPracticeFilter } from '../types/SettingsTypes';
import {
TUNINGS,
getOpenStringNotes,
getFirstPositionNotes,
} from '../music/Tunings';
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
import { NOTE_MATCH_THRESHOLD_MS } from '../AppConfig';
NOTE_MATCH_THRESHOLD_MS,
NOTE_MATCH_GRACE_MS,
REFERENCE_QUIET_MAX_WAIT_MS,
} from '../AppConfig';
export const useGameLogic = (
pitchData: { midi: number; note: string; cents: number; frequency: number } | null
@@ -21,72 +23,33 @@ export const useGameLogic = (
const { playNote } = useAudioPlayer();
const [targetMidi, setTargetMidi] = useState<number>(60);
const [matchStartTime, setMatchStartTime] = useState<number | null>(null);
const [feedbackMessage, setFeedbackMessage] = useState<string>("");
const [revealed, setRevealed] = useState(false);
const [virtualNote, setVirtualNote] = useState<number | null>(null);
const feedbackTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Flicker-tolerant match timer: brief detection dropouts don't reset progress
const [tracker] = useState(() => new MatchTracker(NOTE_MATCH_THRESHOLD_MS, NOTE_MATCH_GRACE_MS));
// --- Valid Notes Calculation ---
const currentTuning = TUNINGS[settings.tuningId];
const currentInstrumentDef = INSTRUMENT_DEFINITIONS[settings.instrument];
const validNotes = useMemo(() => {
const rangeConfig = currentInstrumentDef.ranges.find(r => r.id === settings.difficulty);
const getNotesFromConfig = (config: typeof rangeConfig) => {
if (!config) return [];
if (config.type === 'open_strings') {
if (currentTuning) return getOpenStringNotes(currentTuning);
return config.notes || [];
}
if (config.type === 'first_position') {
if (currentTuning) return getFirstPositionNotes(currentTuning);
return config.notes || [];
}
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);
}
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]);
const pf = getPracticeFilter(settings);
return computeTargetPool({
instrumentId: settings.instrument,
difficulty: settings.difficulty,
tuningId: settings.tuningId,
customMinFret: settings.customMinFret,
customMaxFret: settings.customMaxFret,
keyEnabled: pf.keyEnabled,
keyTonic: pf.keyTonic,
keyMode: pf.keyMode,
strings: pf.strings,
fretWindowEnabled: pf.fretWindowEnabled,
fretMin: pf.fretMin,
fretMax: pf.fretMax,
});
}, [settings]);
// --- Note Generation ---
const generateNewNote = useCallback((keepFeedback = false) => {
@@ -94,19 +57,18 @@ export const useGameLogic = (
const min = validNotes[0];
const max = validNotes[validNotes.length - 1];
const newNote = getRandomNote(min, max, validNotes);
if (newNote === targetMidi && validNotes.length > 1) {
const retry = getRandomNote(min, max, validNotes);
setTargetMidi(retry);
} else {
setTargetMidi(newNote);
}
setMatchStartTime(null);
// Avoid repeating the previous note (a single retry could still land on it)
const candidates = validNotes.length > 1
? validNotes.filter(n => n !== targetMidi)
: validNotes;
setTargetMidi(getRandomNote(min, max, candidates));
tracker.reset();
if (!keepFeedback) {
setFeedbackMessage("");
}
setRevealed(false);
}, [validNotes, targetMidi]);
}, [validNotes, targetMidi, tracker]);
// --- Metronome Logic ---
const effectiveBpm = useMemo(() => {
@@ -123,22 +85,64 @@ export const useGameLogic = (
const { restart: restartMetronome } = useMetronome({
bpm: effectiveBpm,
volume: settings.rhythm.sound ? settings.rhythm.volume : 0,
// Audible clicks come from the shared footer widget (Controls) in
// non-zen single-note modes — this scheduler is only the tick source
// for auto-advance. Sounding it here too doubles every beat. In zen
// mode the widget isn't rendered, so this scheduler IS the click.
volume: settings.zenMode && settings.rhythm.sound ? settings.rhythm.volume : 0,
playing: settings.rhythm.active,
onTick: handleTick
});
// --- Audio Auto-Play ---
useEffect(() => {
const shouldAutoPlay = settings.gameMode === 'ear_training' || (settings.gameMode === 'sight_reading' && settings.autoPlaySightReading);
// Boolean summary of "is the mic currently detecting a sounding note".
// The effect below depends on this BOOLEAN rather than on `pitchData`
// itself (which is a new object every frame — it would reset the 100ms
// play timer 60 times per second and the note would never play).
const isMicQuiet = pitchData === null;
if (shouldAutoPlay && !revealed) {
const timer = setTimeout(() => {
playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5);
}, 100);
return () => clearTimeout(timer);
// Remembers which target the reference note was already played for,
// so re-running the effect (e.g. the quiet-state flipping) can't play it twice.
const autoPlayedForRef = useRef<number | null>(null);
useEffect(() => {
const shouldAutoPlay =
settings.gameMode === 'ear_training' ||
(settings.gameMode === 'sight_reading' && settings.autoPlaySightReading);
if (!shouldAutoPlay || revealed) return;
if (autoPlayedForRef.current === targetMidi) return; // already played
const duration = settings.referenceNoteDuration ?? 1.5;
const volume = settings.autoPlayVolume ?? 0.5;
// "Wait for silence": hold the next reference note while the user's
// instrument is still ringing, so it can't mask the new note.
if (settings.waitForQuiet && !isMicQuiet) {
const cap = setTimeout(() => {
if (autoPlayedForRef.current === targetMidi) return;
autoPlayedForRef.current = targetMidi;
playNote(targetMidi, duration, volume);
}, REFERENCE_QUIET_MAX_WAIT_MS);
return () => clearTimeout(cap);
}
}, [targetMidi, settings.gameMode, settings.autoPlaySightReading, revealed, playNote, settings.autoPlayVolume]);
const timer = setTimeout(() => {
autoPlayedForRef.current = targetMidi;
playNote(targetMidi, duration, volume);
}, 100);
return () => clearTimeout(timer);
}, [
targetMidi,
revealed,
isMicQuiet,
settings.gameMode,
settings.autoPlaySightReading,
settings.referenceNoteDuration,
settings.autoPlayVolume,
settings.waitForQuiet,
playNote,
]);
// --- Init / Reset ---
useEffect(() => {
@@ -156,7 +160,6 @@ export const useGameLogic = (
const noteDetails = getNoteDetails(targetMidi);
setFeedbackMessage(`Good! ${noteDetails.name}`);
setRevealed(true);
setMatchStartTime(null);
const isRhythmActive = settings.rhythm.active && settings.rhythm.autoAdvance;
const isTimerMode = settings.rhythm.mode === 'seconds';
@@ -178,7 +181,6 @@ export const useGameLogic = (
feedbackTimeoutRef.current = setTimeout(() => {
setFeedbackMessage("");
}, 1500);
setMatchStartTime(null);
}
}
}, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]);
@@ -201,25 +203,22 @@ export const useGameLogic = (
// --- Match Checking Loop ---
// Tolerant of flicker: MatchTracker keeps accumulating hold time across
// brief dropouts instead of resetting on every single bad frame.
useEffect(() => {
if (!pitchData) {
setMatchStartTime(null);
// Ignore the microphone while the app itself is making sound — the
// speaker output would otherwise be picked up and scored as correct
// (ear-training auto-play, Play Note button, virtual instruments).
if (audioEngine.isAudible()) {
tracker.reset();
return;
}
if (pitchData.midi === targetMidi) {
if (matchStartTime === null) {
setMatchStartTime(Date.now());
} else {
const duration = Date.now() - matchStartTime;
if (duration > NOTE_MATCH_THRESHOLD_MS) {
handleMatchSuccess();
}
}
} else {
setMatchStartTime(null);
const matching = pitchData !== null && pitchData.midi === targetMidi;
if (tracker.update(matching, Date.now())) {
handleMatchSuccess();
}
}, [pitchData, targetMidi, matchStartTime, handleMatchSuccess]);
}, [pitchData, targetMidi, handleMatchSuccess, tracker]);
return {
targetMidi,

View File

@@ -1,4 +1,5 @@
import { useRef, useEffect, useCallback } from 'react';
import { createAudioContext } from '../audio/AudioEngine';
interface MetronomeOptions {
bpm: number;
@@ -33,6 +34,8 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
const playClick = useCallback((time: number) => {
if (!audioContext.current) return;
onTickRef.current(currentBeat.current);
if (volume <= 0) return; // silent tick source — no audio nodes needed
const osc = audioContext.current.createOscillator();
const gainNode = audioContext.current.createGain();
@@ -53,6 +56,10 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
onTickRef.current(currentBeat.current);
}, [volume]);
// Indirection so the scheduler can reschedule itself without referencing
// its own variable during initialization
const schedulerRef = useRef<() => void>(() => { });
const scheduler = useCallback(() => {
if (!audioContext.current) return;
@@ -62,9 +69,13 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
playClick(nextNoteTime.current);
nextNote();
}
timerID.current = window.setTimeout(scheduler, lookahead);
timerID.current = window.setTimeout(() => schedulerRef.current(), lookahead);
}, [nextNote, playClick]);
useEffect(() => {
schedulerRef.current = scheduler;
}, [scheduler]);
const restart = useCallback(() => {
if (audioContext.current) {
// Reset to play one beat interval from NOW
@@ -78,7 +89,7 @@ export function useMetronome({ bpm, volume, playing, onTick }: MetronomeOptions)
useEffect(() => {
if (playing) {
if (!audioContext.current) {
audioContext.current = new (window.AudioContext || (window as any).webkitAudioContext)();
audioContext.current = createAudioContext();
}
// Resume if suspended (browser autoplay policy)

View File

@@ -0,0 +1,716 @@
/**
* Phrase-mode trainer: the state machine from the implementation contract
* (sections D/E). Pure logic lives in PhraseMatcher / PhraseTransport; this
* hook wires them to the audio clock, the raw frame bus, and React state.
*
* Phases: ready → countIn (tempo only) → playing → done, with paused/preview
* reachable from playing. Structural changes (score/selection/pace/bpm/…)
* cancel the run and return to ready — never retime a half-played note.
*
* The hook receives the FULL score plus a bar selection; it slices internally
* so a tempo resume can restart the current bar (ties entering the slice
* become fresh attacks — sliceScore does exactly that).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { scoreEvents, sliceScore, type Score } from '../score/model';
import { PhraseMatcher, type NoteStatus } from '../game/PhraseMatcher';
import { buildSchedule, sanitizeBpm, type ScheduleEntry } from '../game/PhraseTransport';
import { PPQ } from '../score/model';
import { audioEngine } from '../audio/AudioEngine';
import { subscribeRawFrames } from './rawFrameBus';
import { phraseRunBus } from './phraseRunBus';
import { phraseBeatBus } from './phraseBeatBus';
export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | 'previewPaused' | 'done';
export interface PhraseSelection {
readonly startBar: number;
readonly barCount: number;
}
export interface PhraseTrainerConfig {
readonly pace: 'step' | 'tempo';
readonly bpm: number;
readonly clickSound: boolean;
readonly inputMode: 'mic' | 'virtual';
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 {
readonly total: number;
readonly matched: number;
readonly missed: number;
readonly skipped: number;
}
const TICK_MS = 25;
const LOOKAHEAD_S = 0.1;
const CLICK_VOLUME = 0.4;
const PREVIEW_GROUP = 'phrase-preview';
const CLICK_GROUP = 'phrase-clicks';
/** Longest a sustained episode may block a repeated note in at-your-pace mode. */
const REARM_AFTER_SUSTAINED_MS = 2500;
export interface PhraseTrainerApi {
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). */
readonly activeScore: Score;
readonly statuses: NoteStatus[];
/** Event index currently up (cursor for renderer/hints). */
readonly currentIdx: number;
readonly countInLeft: number;
/** Audio-clock start of the current run's score (diagnostics/QA). */
readonly scoreStart: number;
/** Seconds per quarter beat (diagnostics/QA). */
readonly spb: number;
readonly error: string | null;
readonly summary: PhraseSummary;
/** True when the current note is a repeated pitch still blocked by the
* user's sustained episode (status-line hint: release and strike again). */
readonly repeatedNoteBlocked: boolean;
start: () => void;
pauseToggle: () => void;
retry: () => void;
skip: () => 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;
/** External pause (settings dialog, visibility change handled internally). */
pause: () => void;
stop: () => void;
}
export function usePhraseTrainer(
fullScore: Score,
selection: PhraseSelection,
config: PhraseTrainerConfig,
micActive: boolean,
micError: string | null,
): PhraseTrainerApi {
const [phase, setPhase] = useState<PhrasePhase>('ready');
const [statuses, setStatuses] = useState<NoteStatus[]>([]);
const [currentIdx, setCurrentIdx] = useState(-1);
const [countInLeft, setCountInLeft] = useState(0);
const [error, setError] = useState<string | null>(null);
const [repeatedNoteBlocked, setRepeatedNoteBlocked] = useState(false);
const [previewIdx, setPreviewIdx] = useState(-1);
const [accumulated, setAccumulated] = useState<PhraseSummary>({ total: 0, matched: 0, missed: 0, skipped: 0 });
// ---- Active slice (resume tails replace it) ---------------------------
const [activeScore, setActiveScore] = useState<Score>(() => sliceScore(fullScore, selection.startBar, selection.barCount));
const matcherRef = useRef<PhraseMatcher | null>(null);
const repeatBlockedRef = useRef(false);
const previewIdxRef = useRef(-1);
const previewToggleRef = useRef<(() => void) | null>(null);
const phaseRef = useRef<PhrasePhase>('ready');
const paceRef = useRef(config.pace);
const configRef = useRef(config);
const micActiveRef = useRef(micActive);
const scheduleRef = useRef<ScheduleEntry[]>([]);
const boundaryIdxRef = useRef(0);
const clickIdxRef = useRef(0);
const windowIdxRef = useRef(0);
const scoreStartRef = useRef(0);
const spbRef = useRef(1);
const countInBeatsRef = useRef(0);
const runTokenRef = useRef(0);
const repeatTimerRef = 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 setPhaseBoth = useCallback((p: PhrasePhase) => {
phaseRef.current = 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(() => { configRef.current = config; }, [config]);
useEffect(() => { micActiveRef.current = micActive; }, [micActive]);
// ---- Matcher lifecycle ------------------------------------------------
const resetMatcher = useCallback((score: Score) => {
matcherRef.current = new PhraseMatcher(
scoreEvents(score).map(e => (e.pitch ? e.pitch.midi : null)),
// At-your-pace: assume a re-attack after a long sustained episode
// so repeated notes can never deadlock the run (tempo stays strict).
{ unblockAfterMs: paceRef.current === 'step' ? REARM_AFTER_SUSTAINED_MS : null },
);
setStatuses(matcherRef.current.allStatuses());
}, []);
const makeSlice = useCallback(() =>
sliceScore(fullScore, selection.startBar, selection.barCount), [fullScore, selection.startBar, selection.barCount]);
// Structural change: new score or selection → full reset to ready.
useEffect(() => {
const slice = makeSlice();
setActiveScore(slice);
resetMatcher(slice);
setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 });
audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP);
if (previewTickRef.current) { clearInterval(previewTickRef.current); previewTickRef.current = null; }
previewIdxRef.current = -1;
setPreviewIdx(-1);
setPhaseBoth('ready');
setCurrentIdx(-1);
setCountInLeft(0);
setError(null);
runTokenRef.current++;
}, [makeSlice, resetMatcher, setPhaseBoth]);
// Config change while running → pause (never retime mid-note).
const prevConfigRef = useRef(config);
useEffect(() => {
const prev = prevConfigRef.current;
prevConfigRef.current = config;
if (
(prev.pace !== config.pace || prev.bpm !== config.bpm ||
prev.clickSound !== config.clickSound || prev.inputMode !== config.inputMode) &&
(phaseRef.current === 'playing' || phaseRef.current === 'countIn')
) {
pauseInternalRef.current?.();
}
}, [config]);
// ---- Transport tick ----------------------------------------------------
const tickRef = useRef<() => void>(() => { });
const finishRun = useCallback(() => {
audioEngine.cancelGroup(CLICK_GROUP);
const m = matcherRef.current;
if (m) setStatuses(m.allStatuses());
setCurrentIdx(-1);
setPhaseBoth('done');
}, [setPhaseBoth]);
const processBoundaries = useCallback((now: number) => {
const schedule = scheduleRef.current;
const token = runTokenRef.current;
if (!schedule) return;
// Schedule click audio ahead of time (no state change yet).
while (
clickIdxRef.current < schedule.length &&
schedule[clickIdxRef.current].at < now + LOOKAHEAD_S
) {
const e = schedule[clickIdxRef.current];
// With a count-in, the downbeat of bar 1 is already the LAST
// count-in click — an extra click at scoreStart would make the
// player count "1 2 3 4 | 1" and start one beat early.
const isRedundantDownbeat =
e.kind === 'beat' &&
countInBeatsRef.current > 0 &&
e.at <= scoreStartRef.current + 0.02;
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.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++;
}
// Fire state boundaries only when the clock reaches them.
while (boundaryIdxRef.current < schedule.length && schedule[boundaryIdxRef.current].at <= now) {
if (runTokenRef.current !== token) return;
const e = schedule[boundaryIdxRef.current];
if (e.kind === 'count-in-beat') {
if (phaseRef.current === 'countIn') {
const left = Math.max(0, Math.ceil((scoreStartRef.current - now) / spbRef.current));
setCountInLeft(left);
}
} else if (e.kind === 'note-end' && e.index !== undefined) {
matcherRef.current?.expireWindow(e.index);
windowIdxRef.current = e.index + 1;
const n = events.length;
const nextIdx = Math.min(windowIdxRef.current, Math.max(0, n - 1));
matcherRef.current?.setCurrent(nextIdx);
if (n > 0) setCurrentIdx(nextIdx);
const m = matcherRef.current;
if (m) setStatuses(m.allStatuses());
} else if (e.kind === 'done') {
finishRun();
return;
}
boundaryIdxRef.current++;
}
if (phaseRef.current === 'countIn' && now >= scoreStartRef.current) {
setPhaseBoth('playing');
setCountInLeft(0);
}
}, [events.length, finishRun, setPhaseBoth]);
useEffect(() => {
tickRef.current = () => {
const now = audioEngine.now();
processBoundaries(now);
};
}, [processBoundaries]);
// Interval only while time is flowing.
useEffect(() => {
if (phase !== 'countIn' && phase !== 'playing') return;
const id = setInterval(() => tickRef.current(), TICK_MS);
return () => clearInterval(id);
}, [phase]);
const advanceStep = useCallback((m: PhraseMatcher) => {
const next = m.firstPending;
if (next === -1) {
finishRun();
} else {
setCurrentIdx(next);
}
const blockedNow = m.isBlocked();
if (blockedNow !== repeatBlockedRef.current) {
repeatBlockedRef.current = blockedNow;
setRepeatedNoteBlocked(blockedNow);
}
}, [finishRun]);
// ---- Frame handling ----------------------------------------------------
useEffect(() => {
const unsubscribe = subscribeRawFrames(frame => {
if (configRef.current.inputMode !== 'mic') return;
const p = phaseRef.current;
// 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
// be scored; while audible the matcher sees no frames at all.
if (audioEngine.isAudible()) return;
if (paceRef.current === 'tempo') {
// Finish expired windows before applying the frame.
processBoundaries(audioEngine.now());
if (phaseRef.current !== 'countIn' && phaseRef.current !== 'playing') return;
}
// Click blanking: skip the frame entirely (no episode update).
if (audioEngine.isMicBlanked()) return;
const m = matcherRef.current;
if (!m) return;
let idx: number;
if (paceRef.current === 'step') {
idx = m.firstPending;
if (idx === -1) return;
} else if (phaseRef.current === 'countIn') {
// Count-in: episode tracking only, NO scoring (contract D.9).
idx = -1;
} else {
idx = Math.min(windowIdxRef.current, Math.max(0, events.length - 1));
}
const out = m.feed(frame.midi, frame.at, idx);
const blockedNow = m.isBlocked();
if (blockedNow !== repeatBlockedRef.current) {
repeatBlockedRef.current = blockedNow;
setRepeatedNoteBlocked(blockedNow);
}
if (out) {
setStatuses(m.allStatuses());
if (paceRef.current === 'step') advanceStep(m);
}
});
return unsubscribe;
}, [events.length, processBoundaries, advanceStep]);
// ---- Actions -----------------------------------------------------------
const startRef = useRef<(() => void) | null>(null);
const start = useCallback(() => {
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP);
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
runTokenRef.current++;
const token = runTokenRef.current;
setError(null);
const score = activeScore;
const m = matcherRef.current;
if (!m) return;
m.reset();
setStatuses(m.allStatuses());
setAccumulated(prev => (prev.total === 0 ? prev : prev)); // keep; retry resets separately
const bpm = sanitizeBpm(configRef.current.bpm);
spbRef.current = 60 / bpm;
if (paceRef.current === 'tempo') {
void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token) return;
if (!audioEngine.isRunning()) {
setError('Audio could not start — try again.');
setPhaseBoth('ready');
return;
}
const now = audioEngine.now();
const countIn = score.meter.numerator;
countInBeatsRef.current = countIn;
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
scheduleRef.current = buildSchedule(score, bpm, now + 0.15, countIn);
boundaryIdxRef.current = 0;
clickIdxRef.current = 0;
windowIdxRef.current = 0;
m.setCurrent(scoreEvents(score).length > 0 ? 0 : -1);
setCurrentIdx(0);
setCountInLeft(countIn);
setPhaseBoth('countIn');
tickRef.current();
});
} else {
// At-your-pace needs no audio context at all.
scheduleRef.current = [];
const first = m.firstPending;
if (first === -1) { finishRun(); return; }
m.setCurrent(first);
setCurrentIdx(first);
setPhaseBoth('playing');
}
}, [activeScore, finishRun, setPhaseBoth]);
startRef.current = start;
const pauseInternalRef = useRef<(() => void) | null>(null);
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 (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.current = null; }
audioEngine.cancelGroup(CLICK_GROUP);
setPhaseBoth('paused');
}, [setPhaseBoth]);
pauseInternalRef.current = pause;
const resumeTempo = useCallback(() => {
const m = matcherRef.current;
if (!m) return;
const evts = events;
if (evts.length === 0) { start(); return; }
// Current bar = bar of the window we were in (or the last note's bar).
const idx = Math.min(windowIdxRef.current, evts.length - 1);
const ev = evts[Math.max(0, idx)];
const bar = activeScore.measures.find(
m2 => ev.startTick >= m2.startTick && ev.startTick < m2.startTick + m2.durationTicks
) ?? activeScore.measures[activeScore.measures.length - 1];
// Accumulate resolved results of the DROPPED part (events fully
// before the bar we restart from).
const barStart = bar.startTick;
let droppedTotal = 0, droppedMatched = 0, droppedMissed = 0, droppedSkipped = 0;
evts.forEach((e, i) => {
if (e.startTick + e.durationTicks <= barStart) {
const st = m.status(i);
if (!e.pitch) return;
droppedTotal++;
if (st === 'matched') droppedMatched++;
else if (st === 'missed') droppedMissed++;
else if (st === 'skipped') droppedSkipped++;
}
});
const dropped: PhraseSummary = { total: droppedTotal, matched: droppedMatched, missed: droppedMissed, skipped: droppedSkipped };
setAccumulated(prev => ({
total: prev.total + dropped.total,
matched: prev.matched + dropped.matched,
missed: prev.missed + dropped.missed,
skipped: prev.skipped + dropped.skipped,
}));
// Re-slice from that bar: ties entering the slice become fresh attacks.
const remainingBarCount = selection.startBar + selection.barCount - bar.number;
const tail = sliceScore(fullScore, bar.number, Math.max(1, remainingBarCount));
setActiveScore(tail);
resetMatcher(tail);
runTokenRef.current++;
const token = runTokenRef.current;
const bpm = sanitizeBpm(configRef.current.bpm);
spbRef.current = 60 / bpm;
void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token) return;
const now = audioEngine.now();
const countIn = tail.meter.numerator;
countInBeatsRef.current = countIn;
scoreStartRef.current = now + 0.15 + countIn * spbRef.current;
scheduleRef.current = buildSchedule(tail, bpm, now + 0.15, countIn);
boundaryIdxRef.current = 0;
clickIdxRef.current = 0;
windowIdxRef.current = 0;
matcherRef.current?.setCurrent(scoreEvents(tail).length > 0 ? 0 : -1);
setCurrentIdx(0);
setCountInLeft(countIn);
setPhaseBoth('countIn');
tickRef.current();
});
}, [activeScore, events, fullScore, resetMatcher, selection.barCount, selection.startBar, setPhaseBoth, start]);
const pauseToggle = useCallback(() => {
const p = phaseRef.current;
if (p === 'playing' || p === 'countIn') pause();
else if (p === 'paused') {
if (paceRef.current === 'tempo') resumeTempo();
else setPhaseBoth('playing');
} else if (p === 'ready' || p === 'done') start();
else if (p === 'preview' || p === 'previewPaused') previewToggleRef.current?.();
}, [pause, resumeTempo, setPhaseBoth, start]);
const retry = useCallback(() => {
const score = makeSlice();
setActiveScore(score);
resetMatcher(score);
setAccumulated({ total: 0, matched: 0, missed: 0, skipped: 0 });
start();
}, [makeSlice, resetMatcher, start]);
const skip = useCallback(() => {
if (phaseRef.current !== 'playing') return;
if (paceRef.current !== 'step') return; // skip is at-your-pace only
const m = matcherRef.current;
if (!m) return;
const idx = m.firstPending;
if (idx === -1) return;
m.setStatus(idx, 'skipped');
setStatuses(m.allStatuses());
advanceStep(m);
}, [advanceStep]);
/** Preview from event `fromIdx` to the end. A cursor interval mirrors
* playback time so the notation indicator follows the sounding note. */
const playPreviewFrom = useCallback((fromIdx: number, token: number) => {
void audioEngine.ensureRunning().then(() => {
if (runTokenRef.current !== token) return;
const bpm = sanitizeBpm(configRef.current.bpm);
const spb = 60 / bpm;
const t0 = audioEngine.now() + 0.15;
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) {
if (!e.pitch) continue;
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');
// Cursor: advance previewIdx by audio time (event start ticks).
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');
}
}, 60);
});
}, [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 p = phaseRef.current;
if (p !== 'playing') return; // no scoring during count-in/preview/done
const m = matcherRef.current;
if (!m) return;
let idx: number;
if (paceRef.current === 'step') {
idx = m.firstPending;
if (idx === -1) return;
} else {
idx = Math.min(windowIdxRef.current, Math.max(0, events.length - 1));
}
const out = m.tap(midi, idx);
if (out) {
setStatuses(m.allStatuses());
if (paceRef.current === 'step') advanceStep(m);
}
}, [advanceStep, events.length]);
const stop = useCallback(() => {
if (repeatTimerRef.current) { clearTimeout(repeatTimerRef.current); repeatTimerRef.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(CLICK_GROUP);
runTokenRef.current++;
setPhaseBoth('ready');
}, [setPhaseBoth]);
// ---- Lifecycle guards ---------------------------------------------------
// Pause when the tab hides or the microphone fails mid-run.
useEffect(() => {
const onVisibility = () => {
if (document.hidden) pause();
};
document.addEventListener('visibilitychange', onVisibility);
return () => document.removeEventListener('visibilitychange', onVisibility);
}, [pause]);
useEffect(() => {
if (micError && (phaseRef.current === 'playing' || phaseRef.current === 'countIn')) pause();
}, [micError, pause]);
useEffect(() => {
if (phaseRef.current !== 'ready' && !micActive && configRef.current.inputMode === 'mic') pause();
}, [micActive, pause]);
useEffect(() => () => {
// Unmount: cancel all scheduled audio and timers.
if (repeatTimerRef.current) clearTimeout(repeatTimerRef.current);
if (previewTimerRef.current) clearTimeout(previewTimerRef.current);
if (previewTickRef.current) clearInterval(previewTickRef.current);
audioEngine.cancelGroup(PREVIEW_GROUP);
audioEngine.cancelGroup(CLICK_GROUP);
}, []);
const summary = useMemo<PhraseSummary>(() => {
let total = accumulated.total;
let matched = accumulated.matched;
let missed = accumulated.missed;
let skipped = accumulated.skipped;
for (let i = 0; i < statuses.length; i++) {
if (events[i]?.pitch === null || events[i]?.pitch === undefined) continue;
if (statuses[i] === 'pending' || statuses[i] === 'rest') continue;
total++;
if (statuses[i] === 'matched') matched++;
else if (statuses[i] === 'missed') missed++;
else if (statuses[i] === 'skipped') skipped++;
}
return { total, matched, missed, skipped };
}, [accumulated, statuses, events]);
return {
phase,
activeScore,
statuses,
currentIdx,
countInLeft,
previewIdx,
scoreStart: scoreStartRef.current,
spb: spbRef.current,
error,
summary,
start,
pauseToggle,
retry,
skip,
previewToggle,
previewStep,
virtualTap,
pause,
stop,
repeatedNoteBlocked,
};
}

View File

@@ -1,8 +1,15 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useEffect, useRef, useState } from 'react';
import { PitchAnalyzer, type MicrophoneDebugInfo } from '../audio/PitchAnalyzer';
import { frequencyToMidi, getNoteDetails, getCentDifference } from '../music/NoteUtils';
import { emitRawFrame } from './rawFrameBus';
import {
MIC_DEFAULT_SENSITIVITY,
PITCH_READING_HOLD_MS,
DISPLAY_EMA_FACTOR,
NOTE_DISPLAY_STABLE_FRAMES,
} from '../AppConfig';
interface PitchData {
export interface PitchData {
frequency: number;
midi: number;
note: string;
@@ -10,98 +17,191 @@ interface PitchData {
clarity: number; // Placeholder for now, maybe uses probability if YIN exposes it
}
import { MIC_DEFAULT_SENSITIVITY } from '../AppConfig';
export type { MicrophoneDebugInfo };
export function usePitchDetector(active: boolean, sensitivity: number = MIC_DEFAULT_SENSITIVITY) {
export function usePitchDetector(
active: boolean,
sensitivity: number = MIC_DEFAULT_SENSITIVITY,
minFrequencyHz?: number
) {
const analyzerRef = useRef<PitchAnalyzer | null>(null);
const [pitchData, setPitchData] = useState<PitchData | null>(null);
const [isListening, setIsListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const [audioLevel, setAudioLevel] = useState<number>(0);
const [debugInfo, setDebugInfo] = useState<MicrophoneDebugInfo | null>(null);
const animationRef = useRef<number | null>(null);
// Update sensitivity when it changes
// Latest values for the capture effect (refs keep its dep list minimal so
// changing them does not restart the microphone)
const sensitivityRef = useRef(sensitivity);
const minFrequencyRef = useRef(minFrequencyHz);
// Apply sensitivity live without restarting capture
useEffect(() => {
if (analyzerRef.current) {
analyzerRef.current.setSensitivity(sensitivity);
}
sensitivityRef.current = sensitivity;
analyzerRef.current?.setSensitivity(sensitivity);
}, [sensitivity]);
const updatePitch = useCallback(() => {
if (!analyzerRef.current) return;
const freq = analyzerRef.current.getPitch();
if (freq) {
const midi = frequencyToMidi(freq);
const { scientific } = getNoteDetails(midi);
const cents = getCentDifference(freq, midi);
setPitchData({
frequency: freq,
midi,
note: scientific,
cents,
clarity: 1
});
// Adapt the analysis window to the instrument's lowest note, live
useEffect(() => {
minFrequencyRef.current = minFrequencyHz;
if (minFrequencyHz) {
analyzerRef.current?.setLowestFrequency(minFrequencyHz);
}
// Always update level and debug info
setAudioLevel(analyzerRef.current.getCurrentLevel());
setDebugInfo(analyzerRef.current.getDebugInfo());
animationRef.current = requestAnimationFrame(updatePitch);
}, []);
}, [minFrequencyHz]);
useEffect(() => {
if (active) {
if (!analyzerRef.current) {
analyzerRef.current = new PitchAnalyzer();
analyzerRef.current.setSensitivity(sensitivity);
if (!active) {
setIsListening(false);
setPitchData(null);
setAudioLevel(0);
setDebugInfo(null);
return;
}
let cancelled = false;
let rafId = 0;
let frameCount = 0;
let lastPitchAt = 0;
// Display smoothing: pitch/cents/level arrive on every detection frame
// (~60 fps) and jitter slightly. A short exponential moving average keeps
// the tuner needle and level meter readable. Done here (in the producer)
// so the display components can render directly without CSS transitions —
// transitions on values that change every frame get interrupted 60x/sec,
// which some engines (Safari/WebKit) punish with a visually stuck needle.
let smoothedCents: number | null = null;
let smoothedLevel = 0;
// Note-name stability: the displayed note only switches after the raw
// detection reports a different note for several consecutive frames.
// Prevents D#2/E2-style flicker at semitone boundaries. The raw midi
// (used for matching) is unaffected.
let stableMidi: number | null = null;
let candidateMidi: number | null = null;
let candidateCount = 0;
const analyzer = new PitchAnalyzer();
analyzer.setSensitivity(sensitivityRef.current);
if (minFrequencyRef.current) {
analyzer.setLowestFrequency(minFrequencyRef.current);
}
analyzerRef.current = analyzer;
function tick() {
if (cancelled) return;
const freq = analyzer.getPitch();
const now = performance.now();
// RAW frame for sequence matching (phrase mode): one per tick,
// silence included. Not batched through React state — matching
// must see nulls immediately and must not depend on render rate.
emitRawFrame({ midi: freq ? frequencyToMidi(freq) : null, at: now });
if (freq) {
lastPitchAt = now;
const midi = frequencyToMidi(freq);
// Note-name hysteresis: only switch the displayed note after
// the new one persists for a few consecutive frames.
if (midi === stableMidi) {
candidateMidi = null;
candidateCount = 0;
} else if (midi === candidateMidi) {
if (++candidateCount >= NOTE_DISPLAY_STABLE_FRAMES) {
stableMidi = midi;
candidateMidi = null;
candidateCount = 0;
// Re-anchor the cents average: a new note means a new
// reference (the needle must not sweep across the scale).
smoothedCents = null;
}
} else {
candidateMidi = midi;
candidateCount = 1;
}
// Cents are displayed relative to the STABLE note (like real
// tuners): the needle sweeps to ±50 and only then the note flips.
const displayMidi = stableMidi ?? midi;
const { scientific } = getNoteDetails(displayMidi);
const cents = getCentDifference(freq, displayMidi);
smoothedCents = smoothedCents === null
? cents
: smoothedCents + (cents - smoothedCents) * DISPLAY_EMA_FACTOR;
setPitchData({
frequency: freq,
midi, // raw — matching uses this and must stay unsmoothed
note: scientific,
cents: smoothedCents, // smoothed — display only
clarity: 1
});
} else if (lastPitchAt !== 0 && now - lastPitchAt > PITCH_READING_HOLD_MS) {
// Clear stale readings so the UI doesn't keep displaying
// (and matching against) a note that stopped sounding long ago.
lastPitchAt = 0;
smoothedCents = null;
stableMidi = null;
candidateMidi = null;
candidateCount = 0;
setPitchData(null);
}
analyzerRef.current.start()
.then(() => {
setIsListening(true);
setError(null);
updatePitch();
})
.catch(err => {
console.error(err);
// Provide more specific error messages
if (err.name === 'NotAllowedError') {
setError("Microphone access denied. Please allow microphone access.");
} else if (err.name === 'NotFoundError') {
setError("No microphone found. Please connect a microphone.");
} else if (err.name === 'NotReadableError') {
setError("Microphone is in use by another app.");
} else {
setError("Could not access microphone.");
}
setIsListening(false);
});
} else {
if (analyzerRef.current) {
analyzerRef.current.stop();
// Always update level (smoothed)
smoothedLevel += (analyzer.getCurrentLevel() - smoothedLevel) * DISPLAY_EMA_FACTOR;
setAudioLevel(smoothedLevel);
// Debug info is only shown in the settings modal; ~4 Hz is plenty
// and avoids a third state update (full re-render) every frame.
if (++frameCount % 15 === 0) {
setDebugInfo(analyzer.getDebugInfo());
}
rafId = requestAnimationFrame(tick);
}
analyzer.start()
.then(() => {
if (cancelled) {
// StrictMode double-mount or quick toggle-off while
// getUserMedia was pending: don't leave the mic running.
analyzer.stop();
return;
}
setIsListening(true);
setError(null);
tick();
})
.catch((err: Error) => {
if (cancelled) return;
console.error(err);
// Provide more specific error messages
if (err.name === 'NotAllowedError') {
setError("Microphone access denied. Please allow microphone access.");
} else if (err.name === 'NotFoundError') {
setError("No microphone found. Please connect a microphone.");
} else if (err.name === 'NotReadableError') {
setError("Microphone is in use by another app.");
} else {
setError("Could not access microphone.");
}
setIsListening(false);
});
return () => {
cancelled = true;
cancelAnimationFrame(rafId);
analyzer.stop();
if (analyzerRef.current === analyzer) {
analyzerRef.current = null;
}
setIsListening(false);
setPitchData(null);
setAudioLevel(0);
setDebugInfo(null);
if (animationRef.current) cancelAnimationFrame(animationRef.current);
}
return () => {
if (animationRef.current) cancelAnimationFrame(animationRef.current);
if (analyzerRef.current) {
analyzerRef.current.stop();
}
};
}, [active, updatePitch]);
}, [active]);
return { pitchData, isListening, error, audioLevel, debugInfo };
}

View File

@@ -3,7 +3,7 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/variables.css'
import App from './App.tsx'
import { SettingsProvider } from './context/SettingsContext.tsx'
import { SettingsProvider } from './context/SettingsProvider.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>

View File

@@ -137,3 +137,18 @@ export const INSTRUMENT_DEFINITIONS: Record<string, InstrumentDefinition> = {
]
}
};
/**
* Clef + display transpose for rendering, shared by the single-note and
* phrase renderers (identical semantics: sounding midi + transpose = written).
*/
export function resolveClefTranspose(
instrumentDef: InstrumentDefinition,
difficulty: string,
): { clef: ClefMode; transpose: number } {
const rangeDef = instrumentDef.ranges.find(r => r.id === difficulty);
return {
clef: rangeDef?.clef ?? instrumentDef.clefMode,
transpose: rangeDef?.transpose ?? instrumentDef.transpose,
};
}

View File

@@ -111,8 +111,8 @@ export function getNoteInKey(midi: number, keySignature: string): VexFlowNoteSpe
// In start keys (C, Am), no accidentals in key.
// Parse the rawName: "F#" -> step "F", mod "#"
let step = rawName.charAt(0);
let mod = rawName.length > 1 ? rawName.charAt(1) : "";
const step = rawName.charAt(0);
const mod = rawName.length > 1 ? rawName.charAt(1) : "";
// VexFlow Key Format: "c/4", "f/4", "fb/4"
// We need to return the accidental to *display*.

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,190 @@
import { describe, it, expect } from 'vitest';
import { generateMelody } from './melodyGenerator';
import { generateScaleDrill } from './scaleDrills';
import { validateScore, scoreEvents, scoreFitsPool, PPQ } from '../score/model';
import { keyFor } from './scales';
const GUITAR_FIRST_POS = [40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68];
describe('generateMelody — invariants over many seeds', () => {
const configBase = { keyTonic: 'C', keyMode: 'major' as const, bars: 4, meter: { numerator: 4 as const, denominator: 4 as const }, rhythmLevel: 2 as const };
it('every seed yields a valid score in scale and pool', () => {
for (let seed = 1; seed <= 100; seed++) {
const res = generateMelody({ ...configBase, seed }, GUITAR_FIRST_POS);
expect(res.ok).toBe(true);
if (!res.ok) continue;
const { score } = res.value;
expect(validateScore(score)).toEqual([]);
expect(scoreFitsPool(score, GUITAR_FIRST_POS)).toBe(true);
const key = keyFor('C', 'major');
for (const e of scoreEvents(score)) {
if (!e.pitch) continue;
expect(key.pitchClasses).toContain(((e.pitch.midi % 12) + 12) % 12);
}
}
});
it('is deterministic under the same seed', () => {
const a = generateMelody({ ...configBase, seed: 42 }, GUITAR_FIRST_POS);
const b = generateMelody({ ...configBase, seed: 42 }, GUITAR_FIRST_POS);
expect(a).toEqual(b);
});
it('different seeds usually differ', () => {
const a = generateMelody({ ...configBase, seed: 1 }, GUITAR_FIRST_POS);
const b = generateMelody({ ...configBase, seed: 2 }, GUITAR_FIRST_POS);
expect(a).not.toEqual(b);
});
it('respects bar count and exact bar totals for 1..8 bars and both meters', () => {
for (const numerator of [3, 4] as const) {
for (const bars of [1, 3, 8]) {
const res = generateMelody({ ...configBase, meter: { numerator, denominator: 4 }, bars, seed: 7 }, GUITAR_FIRST_POS);
expect(res.ok).toBe(true);
if (!res.ok) continue;
const { score } = res.value;
expect(score.measures).toHaveLength(bars);
expect(validateScore(score)).toEqual([]);
}
}
});
it('final note lasts at least two quarter beats (soft, on normal pool)', () => {
for (let seed = 1; seed <= 50; seed++) {
const res = generateMelody({ ...configBase, seed }, GUITAR_FIRST_POS);
if (!res.ok) throw new Error(res.error);
const evts = scoreEvents(res.value.score);
expect(evts[evts.length - 1].durationTicks).toBeGreaterThanOrEqual(2 * PPQ);
}
});
it('ends on the tonic when the tonic is available (soft, majority of seeds)', () => {
let tonicEndings = 0;
for (let seed = 1; seed <= 50; seed++) {
const res = generateMelody({ ...configBase, seed }, GUITAR_FIRST_POS);
if (!res.ok) throw new Error(res.error);
const evts = scoreEvents(res.value.score);
const last = evts[evts.length - 1].pitch;
if (last && last.midi % 12 === 0) tonicEndings++;
}
expect(tonicEndings).toBeGreaterThan(40);
});
it('rejects empty or single-pitch pools with actionable errors', () => {
expect(generateMelody({ ...configBase, seed: 1 }, []).ok).toBe(false);
expect(generateMelody({ ...configBase, seed: 1 }, [60]).ok).toBe(false);
// Two identical entries count as one pitch.
expect(generateMelody({ ...configBase, seed: 1 }, [60, 60]).ok).toBe(false);
});
it('generates for every supported key/mode combination', () => {
const tonics = ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'Db', 'Ab', 'Eb', 'Bb', 'F'];
const modes = ['major', 'minor', 'dorian', 'phrygian', 'lydian', 'mixolydian', 'locrian', 'major-pentatonic', 'minor-pentatonic'] as const;
for (const t of tonics) {
for (const m of modes) {
const key = keyFor(t, m);
// Wide chromatic pool so every key is testable.
const pool = Array.from({ length: 40 }, (_, i) => 48 + i).filter(midi =>
key.pitchClasses.includes(midi % 12));
if (pool.length < 2) continue;
const res = generateMelody({ keyTonic: t, keyMode: m, bars: 2, meter: { numerator: 4, denominator: 4 }, rhythmLevel: 1, seed: 3 }, pool);
expect(res.ok).toBe(true);
if (!res.ok) continue;
expect(validateScore(res.value.score)).toEqual([]);
expect(scoreFitsPool(res.value.score, pool)).toBe(true);
}
}
});
it('survives a sparse pool (guitar high position) without escaping it', () => {
// Standard tuning, frets 9-12: 4 pitches per string × 6 strings.
const sparse = [49, 50, 51, 52, 54, 55, 56, 57, 59, 60, 61, 62, 64, 65, 66, 67, 69, 70, 71, 72, 74, 75, 76, 77];
const inKey = sparse.filter(m => [0, 2, 4, 5, 7, 9, 11].includes(m % 12));
for (let seed = 1; seed <= 50; seed++) {
const res = generateMelody({ ...configBase, seed }, sparse);
expect(res.ok).toBe(true);
if (!res.ok) continue;
expect(scoreFitsPool(res.value.score, sparse)).toBe(true);
expect(validateScore(res.value.score)).toEqual([]);
}
expect(inKey.length).toBeGreaterThan(0);
});
it('rejects invalid bar counts', () => {
expect(generateMelody({ ...configBase, bars: 0, seed: 1 }, GUITAR_FIRST_POS).ok).toBe(false);
expect(generateMelody({ ...configBase, bars: 9, seed: 1 }, GUITAR_FIRST_POS).ok).toBe(false);
});
});
describe('generateScaleDrill', () => {
it('produces the expected C-major one-octave run in an eligible register', () => {
const res = generateScaleDrill(
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'one-octave', meter: { numerator: 4, denominator: 4 } },
GUITAR_FIRST_POS,
);
expect(res.ok).toBe(true);
if (!res.ok) return;
const evts = scoreEvents(res.value);
// Lowest complete C-octave in first position: C3(48)? E on 2nd string 1st... 48..60 available?
expect(evts.map(e => e.pitch!.midi)).toEqual([48, 50, 52, 53, 55, 57, 59, 60]);
expect(evts.every(e => e.durationTicks === PPQ || e === evts[evts.length - 1])).toBe(true);
expect(validateScore(res.value)).toEqual([]);
});
it('up-and-down has a single turning tonic', () => {
const res = generateScaleDrill(
{ keyTonic: 'G', keyMode: 'major', direction: 'updown', coverage: 'one-octave', meter: { numerator: 3, denominator: 4 } },
GUITAR_FIRST_POS,
);
expect(res.ok).toBe(true);
if (!res.ok) return;
const midis = scoreEvents(res.value).map(e => e.pitch!.midi);
// Lowest complete G octave in first position is G2 (43): up then down without repeating the top tonic
expect(midis).toEqual([43, 45, 47, 48, 50, 52, 54, 55, 54, 52, 50, 48, 47, 45, 43]);
expect(validateScore(res.value)).toEqual([]);
});
it('extends the final note to complete its bar', () => {
const res = generateScaleDrill(
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'one-octave', meter: { numerator: 4, denominator: 4 } },
GUITAR_FIRST_POS,
);
if (!res.ok) throw new Error(res.error);
const evts = scoreEvents(res.value);
expect(evts[evts.length - 1].durationTicks).toBe(PPQ); // 8 notes → exactly 2 bars, no extension needed
expect(validateScore(res.value)).toEqual([]);
});
it('refuses an incomplete one-octave scale with an actionable error', () => {
// C major but missing E (52): one octave C3→C4 impossible.
const pool = GUITAR_FIRST_POS.filter(m => m !== 52);
const res = generateScaleDrill(
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'one-octave', meter: { numerator: 4, denominator: 4 } },
pool,
);
expect(res.ok).toBe(false);
if (!res.ok) expect(res.error).toMatch(/one-octave/i);
});
it('refuses a two-octave drill when it does not fit', () => {
const res = generateScaleDrill(
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'two-octave', meter: { numerator: 4, denominator: 4 } },
GUITAR_FIRST_POS,
);
expect(res.ok).toBe(false);
});
it('position drills traverse eligible notes in order', () => {
const res = generateScaleDrill(
{ keyTonic: 'C', keyMode: 'major', direction: 'up', coverage: 'position', meter: { numerator: 4, denominator: 4 } },
GUITAR_FIRST_POS,
);
expect(res.ok).toBe(true);
if (!res.ok) return;
const midis = scoreEvents(res.value).map(e => e.pitch!.midi);
const expected = GUITAR_FIRST_POS.filter(m => [0, 2, 4, 5, 7, 9, 11].includes(m % 12));
expect(midis).toEqual(expected);
expect(validateScore(res.value)).toEqual([]);
});
});

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

@@ -0,0 +1,263 @@
/**
* Seeded melody generator (Phrase Mode).
*
* Deterministic: all randomness flows through a supplied seed so Retry can
* replay the identical phrase (the trainer never regenerates) and tests can
* assert invariants over many seeds (contract C3).
*
* Hard invariants (tested): every pitch ∈ scale ∩ playable pool, exact bar
* totals, valid spelling, determinism, bounded loops.
* Musical preferences (steps, small contours, tonic endings) are SOFT — a
* sparse fretboard pool can make them impossible, and the generator must
* still terminate and stay in the pool rather than escape its constraints.
*/
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
import { keyFor, scalePitches, type ModeId } from './scales';
/** mulberry32 — small, fast, deterministic. */
export function makeRng(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
export interface MelodyConfig {
readonly keyTonic: string;
readonly keyMode: ModeId;
/** 1..8 */
readonly bars: number;
readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
/** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters),
* 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */
readonly rhythmLevel: 1 | 2 | 3;
readonly seed: number;
}
// Rhythm templates in ticks (quarter = 480). Each template fills one full bar.
const TEMPLATES_4_L1: number[][] = [
[480, 480, 480, 480],
[960, 960],
[480, 480, 960],
[960, 480, 480],
[480, 960, 480],
[1920],
[1440, 480],
];
const TEMPLATES_4_L2: number[][] = [
[480, 480, 240, 240, 480],
[240, 240, 480, 480, 480],
[480, 480, 480, 240, 240],
[720, 720, 480],
[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[][] = [
[480, 480, 480],
[960, 480],
[480, 960],
[1440],
];
const TEMPLATES_3_L2: number[][] = [
[240, 240, 480, 480],
[480, 240, 240, 480],
[720, 720],
[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 | 6 }, level: 1 | 2 | 3): number[][] {
let base: number[][];
if (meter.numerator === 4) {
base = level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3];
} 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). */
function motionWeight(delta: number, afterLeap: boolean, repeats: number): number {
const ad = Math.abs(delta);
if (delta === 0) return repeats >= 2 ? 0 : 6; // never more than two in a row
if (ad <= 2) return afterLeap ? 240 : 70; // steps (preferred, esp. after leaps)
if (ad <= 4) return afterLeap ? 120 : 20; // thirds
if (ad <= 7) return 8; // fourth/fifth
if (ad <= 12) return 2; // sixth/octave
return 0; // out of pool handled separately
}
export function generateMelody(
config: MelodyConfig,
eligiblePitches: number[],
): Result<{ score: Score; seed: number }> {
const { bars, meter, rhythmLevel, seed } = config;
if (!Number.isInteger(bars) || bars < 1 || bars > 8) {
return { ok: false, error: 'Bars must be 1–8.' };
}
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 or change the key.' };
const scale = scalePitches(key, pool[0], pool[pool.length - 1]).filter(p => pool.includes(p.midi));
if (scale.length < 2) {
return { ok: false, error: 'The selected range/key combination leaves fewer than two usable pitches — widen the range or change the key.' };
}
const templates = templatesFor(meter, rhythmLevel);
const endingTemplates = templates.filter(t => t[t.length - 1] >= 960);
const finalTemplates = endingTemplates.length > 0 ? endingTemplates : templates;
const rng = makeRng(seed);
const pick = <T,>(arr: T[]): T => arr[Math.floor(rng() * arr.length)];
// ---- Build the rhythm (bar by bar) -------------------------------------
const rhythm: number[] = [];
for (let bar = 0; bar < bars; bar++) {
const t = bar === bars - 1 ? finalTemplates : templates;
rhythm.push(...pick(t));
}
// ---- Build the pitches -------------------------------------------------
// Weighted random walk over the eligible scale notes with soft musical
// preferences. Bounded: exactly one decision per rhythm slot.
const tonicPc = key.pitchClasses[0];
const thirdPc = key.pitchClasses[2];
const fifthPc = key.pitchClasses[4];
const events: ScoreEvent[] = [];
let idx = Math.floor(rng() * scale.length); // start anywhere; endpoints preferred next
// Preferred start: tonic, else 3rd/5th.
const startCandidates = scale
.map((p, i) => ({ p, i }))
.filter(({ p }) => p.pitch.midi % 12 === tonicPc);
if (startCandidates.length > 0) idx = pick(startCandidates).i;
else {
const alt = scale.map((p, i) => ({ p, i })).filter(({ p }) => [thirdPc, fifthPc].includes(p.pitch.midi % 12));
if (alt.length > 0) idx = pick(alt).i;
}
let lastWasLeap = false; // previous motion was a leap ≥ 5 semitones
let leapFromMidi = 0; // origin pitch of that leap (step-back target)
let repeats = 0;
let cursor = 0;
for (let slot = 0; slot < rhythm.length; slot++) {
const dur = rhythm[slot];
const isFinal = slot === rhythm.length - 1;
const cur = scale[idx];
let chosen = idx;
if (isFinal) {
// Soft: end on tonic, else 3rd/5th, else nearest to current.
const finals = scale.map((p, i) => ({ p, i })).filter(({ p }) => p.pitch.midi % 12 === tonicPc);
const alt = scale.map((p, i) => ({ p, i })).filter(({ p }) => [thirdPc, fifthPc].includes(p.pitch.midi % 12));
const cand = finals.length > 0 ? finals : alt;
if (cand.length > 0) {
chosen = pick(cand).i;
}
} else {
const weights = scale.map((p, i) => {
if (i === idx) return motionWeight(0, false, repeats);
const delta = p.midi - cur.midi;
let w = motionWeight(delta, lastWasLeap, repeats);
// A leap (≥5) prefers a subsequent step back toward its origin.
if (lastWasLeap && Math.abs(delta) <= 2) {
const stepBackToward = Math.sign(delta) === Math.sign(leapFromMidi - cur.midi) || leapFromMidi === cur.midi;
if (stepBackToward) w = Math.max(w, 240);
}
return w;
});
const total = weights.reduce((a, b) => a + b, 0);
if (total > 0) {
let r = rng() * total;
for (let i = 0; i < weights.length; i++) {
r -= weights[i];
if (r <= 0) { chosen = i; break; }
}
} else {
chosen = Math.floor(rng() * scale.length);
}
}
const note = scale[chosen];
const moved = Math.abs(note.midi - cur.midi);
if (moved >= 5) { lastWasLeap = true; leapFromMidi = cur.midi; }
else { lastWasLeap = false; leapFromMidi = 0; }
if (note.midi === cur.midi) {
if (!isFinal) repeats++;
} else {
repeats = 0;
}
events.push({
id: `n${slot}`,
startTick: cursor,
durationTicks: dur,
pitch: { ...note.pitch },
});
cursor += dur;
idx = chosen;
}
const score: Score = {
version: 1,
id: `melody-${config.keyTonic}-${config.keyMode}-${meter.numerator}4-${bars}b-${rhythmLevel}-s${seed}`,
title: `${key.tonic} ${key.degrees.length === 5 ? (config.keyMode === 'major-pentatonic' ? 'Major Pentatonic' : 'Minor Pentatonic') : 'Melody'}`,
meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: bars }, (_, i) => ({
number: i + 1,
startTick: i * meter.numerator * PPQ,
durationTicks: meter.numerator * PPQ,
})),
voices: [{ id: 'melody', events }],
};
const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal generator error: ${problems[0]}` };
return { ok: true, value: { score, seed } };
}

View File

@@ -0,0 +1,96 @@
/**
* Playable-note resolution, extracted from useGameLogic so both the
* single-note modes and Phrase Mode share one tested implementation
* (single-note behavior is unchanged).
*/
import { TUNINGS, getOpenStringNotes, getFirstPositionNotes, type Tuning, type FretPosition } from './Tunings';
import { INSTRUMENT_DEFINITIONS } from './InstrumentConfigs';
export interface PlayableRangeInput {
readonly instrumentId: string;
readonly difficulty: string;
readonly tuningId: string;
readonly customMinFret?: number;
readonly customMaxFret?: number;
}
const sanitizeFret = (v: number | undefined, fallback: number): number => {
if (v === undefined || v === null || Number.isNaN(v)) return fallback;
return Math.max(0, Math.min(24, Math.round(v)));
};
/** Sorted list of playable MIDI notes for the current instrument/range setup. */
export function computePlayableNotes(input: PlayableRangeInput): number[] {
const def = INSTRUMENT_DEFINITIONS[input.instrumentId];
if (!def) return [];
const tuning = TUNINGS[input.tuningId];
const config = def.ranges.find(r => r.id === input.difficulty) ?? def.ranges[0];
if (!config) return [];
if (config.type === 'open_strings') {
if (tuning) return getOpenStringNotes(tuning);
return config.notes ?? [];
}
if (config.type === 'first_position') {
if (tuning) return getFirstPositionNotes(tuning);
return config.notes ?? [];
}
if (config.type === 'custom_fret') {
if (!tuning) return [];
const minFret = sanitizeFret(input.customMinFret ?? config.defaultMinFret, 0);
const maxFret = sanitizeFret(input.customMaxFret ?? config.defaultMaxFret, 12);
return fretWindowNotes(tuning, minFret, maxFret);
}
if (config.type === 'specific_string') {
if (!tuning || config.stringIndex === undefined) return [];
const openNote = tuning.strings[config.stringIndex];
if (openNote === undefined) return [];
const notes: number[] = [];
for (let i = 0; i <= 12; i++) notes.push(openNote + i);
return notes;
}
if (config.notes) return config.notes;
if (config.min !== undefined && config.max !== undefined) {
const min = Math.max(0, Math.min(127, Math.round(config.min)));
const max = Math.max(0, Math.min(127, Math.round(config.max)));
return Array.from({ length: max - min + 1 }, (_, i) => min + i);
}
return [];
}
/** All notes reachable within a fret window on the given tuning. */
export function fretWindowNotes(tuning: Tuning, minFret: number, maxFret: number): number[] {
const lo = sanitizeFret(minFret, 0);
const hi = Math.max(lo, sanitizeFret(maxFret, 12));
const notes = new Set<number>();
for (const open of tuning.strings) {
for (let fret = lo; fret <= hi; fret++) notes.add(open + fret);
}
return Array.from(notes).sort((a, b) => a - b);
}
/**
* Fretboard positions for a note, restricted to a fret window
* (Phrase Mode hints must respect the selected position).
*/
export function positionsWithinWindow(
midiNote: number,
tuning: Tuning,
minFret: number,
maxFret: number,
): FretPosition[] {
const lo = sanitizeFret(minFret, 0);
const hi = Math.max(lo, sanitizeFret(maxFret, 12));
const positions: FretPosition[] = [];
tuning.strings.forEach((open, stringIndex) => {
const fret = midiNote - open;
if (fret >= lo && fret <= hi) positions.push({ stringIndex, fret });
});
return positions;
}
/** True when the instrument has a fretted tuning (guitar or bass). */
export function isFrettedInstrument(instrumentId: string): boolean {
const def = INSTRUMENT_DEFINITIONS[instrumentId];
return !!def && (def.id === 'guitar' || def.id === 'bass');
}

134
src/music/scaleDrills.ts Normal file
View File

@@ -0,0 +1,134 @@
/**
* Scale drill generator (Phrase Mode) — required material, contract C4.
*
* Produces scale runs (up / down / up-and-down) as quarter notes with the
* final note extended (with ties if needed) to complete its bar. One- and
* two-octave drills require the COMPLETE scale path to fit the playable
* pool — an incomplete "one-octave scale" is never emitted; a clear,
* actionable error is returned instead.
*/
import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model';
import { keyFor, scalePitches, type KeyContext, type ModeId } from './scales';
export type DrillDirection = 'up' | 'down' | 'updown';
export type DrillCoverage = 'one-octave' | 'two-octave' | 'position';
export type DrillRhythm = 'quarters' | 'eighths';
export interface DrillConfig {
readonly keyTonic: string;
readonly keyMode: ModeId;
readonly direction: DrillDirection;
readonly coverage: DrillCoverage;
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. */
function octavePath(key: KeyContext, pool: number[], octaves: number, startMidi: number): number[] | null {
const set = new Set(pool);
const path: number[] = [startMidi];
let cur = startMidi;
for (let o = 0; o < octaves; o++) {
// Skip the tonic at the start of each octave (it is already in `path`).
for (let i = (o === 0 ? 1 : 0); i < key.pitchClasses.length; i++) {
const pc = key.pitchClasses[i];
const offset = ((pc - (cur % 12)) + 12) % 12;
const candidate = cur + (offset === 0 ? 12 : offset);
if (!set.has(candidate)) return null;
path.push(candidate);
cur = candidate;
}
}
// Top tonic completes the requested octave span.
if (!set.has(startMidi + 12 * octaves)) return null;
path.push(startMidi + 12 * octaves);
return path;
}
export function generateScaleDrill(
config: DrillConfig,
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 scaleInPool = scalePitches(key, pool[0], pool[pool.length - 1]).filter(p => pool.includes(p.midi));
if (scaleInPool.length === 0) return { ok: false, error: 'The selected key has no notes in the playable range — widen the range or change the key.' };
let path: number[];
if (config.coverage === 'position') {
// Traverse the eligible scale notes in order (may start/end away from
// the tonic — labeled as such in the UI, not a conventional fingering).
const asc = scaleInPool.map(p => p.midi);
if (asc.length < 2) return { ok: false, error: 'The selected position contains fewer than two scale notes — widen the fret window or change the key.' };
if (config.direction === 'up') path = asc;
else if (config.direction === 'down') path = [...asc].reverse();
else path = [...asc, ...[...asc].reverse().slice(1)]; // up-down, no duplicate top
} else {
const octaves = config.coverage === 'one-octave' ? 1 : 2;
// Find the lowest tonic start whose complete path fits the pool.
const tonics = scaleInPool.filter(p => p.pitch.midi % 12 === key.pitchClasses[0]).map(p => p.midi);
let found: number[] | null = null;
for (const t of tonics) {
found = octavePath(key, pool, octaves, t);
if (found) break;
}
if (!found) {
return {
ok: false,
error: octaves === 1
? 'The complete one-octave scale does not fit the playable range — widen the range, or use the "within position" drill.'
: 'The complete two-octave scale does not fit the playable range — use one octave or widen the range.',
};
}
const up = found;
if (config.direction === 'up') path = up;
else if (config.direction === 'down') path = [...up].reverse();
else path = [...up, ...[...up].reverse().slice(1)]; // no duplicate turning tonic
}
// ---- Rhythm: quarters (default) or eighths; final note extended ---------
const noteDur = config.rhythm === 'eighths' ? PPQ / 2 : PPQ;
const full = config.meter.numerator * PPQ;
if (path.length * noteDur > 8 * full) {
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 * noteDur) / full));
const totalTicks = barCount * full;
const lastDuration = noteDur + (totalTicks - path.length * noteDur);
const events: ScoreEvent[] = [];
let cursor = 0;
const spellFor = (midi: number) => {
const sp = scalePitches(key, midi, midi);
const found = sp.find(p => p.midi === midi && pool.includes(p.midi));
return found ? found.pitch : sp[0]?.pitch ?? null;
};
for (let i = 0; i < path.length; i++) {
const dur = i === path.length - 1 ? lastDuration : noteDur;
const pitch = spellFor(path[i]);
if (!pitch) return { ok: false, error: 'Internal error spelling drill pitch.' };
events.push({ id: `s${i}`, startTick: cursor, durationTicks: dur, pitch });
cursor += dur;
}
const score: Score = {
version: 1,
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}`,
meter: config.meter,
key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature },
measures: Array.from({ length: barCount }, (_, i) => ({
number: i + 1,
startTick: i * full,
durationTicks: full,
})),
voices: [{ id: 'melody', events }],
};
const problems = validateScore(score);
if (problems.length > 0) return { ok: false, error: `Internal drill error: ${problems[0]}` };
return { ok: true, value: score };
}

113
src/music/scales.test.ts Normal file
View File

@@ -0,0 +1,113 @@
import { describe, it, expect } from 'vitest';
import { keyFor, scalePitches, spelledPitch, spellPcInKey, MODE_LABELS, isMode } from './scales';
import { validateScore } from '../score/model';
describe('keyFor — signatures and spelling', () => {
it('derives parent signatures for all 12 tonics (major)', () => {
const expected: Record<string, string> = {
C: 'C', G: 'G', D: 'D', A: 'A', E: 'E', B: 'B',
'F#': 'F#', Db: 'Db', Ab: 'Ab', Eb: 'Eb', Bb: 'Bb', F: 'F',
};
for (const [tonic, sig] of Object.entries(expected)) {
expect(keyFor(tonic, 'major').signature).toBe(sig);
}
});
it('derives correct parent signatures for minor and modes', () => {
expect(keyFor('A', 'minor').signature).toBe('C'); // A minor ↔ C major
expect(keyFor('E', 'minor').signature).toBe('G');
expect(keyFor('B', 'minor').signature).toBe('D');
expect(keyFor('D', 'dorian').signature).toBe('C'); // D dorian ↔ C major
expect(keyFor('G', 'mixolydian').signature).toBe('C');
expect(keyFor('A', 'minor-pentatonic').signature).toBe('C');
expect(keyFor('C', 'major-pentatonic').signature).toBe('C');
});
it('spells E# in F# major and B# in C# major (contract I)', () => {
const fsharp = keyFor('F#', 'major');
expect(fsharp.signatureAlter.F).toBe(1);
const eSharp = fsharp.degrees.find(d => d.semitones === 11);
expect(eSharp).toEqual({ step: 'E', alter: 1, semitones: 11 });
const csharp = keyFor('C#', 'minor'); // parent E major: D# in signature
expect(csharp.signature).toBe('E');
// C# natural minor 7th = B → spelled B (signature has no B accidental)
const seventh = csharp.degrees.find(d => d.semitones === 10);
expect(seventh).toEqual({ step: 'B', alter: 0, semitones: 10 });
});
it('produces correct pitch classes for representative scales', () => {
expect(keyFor('C', 'major').pitchClasses).toEqual([0, 2, 4, 5, 7, 9, 11]);
expect(keyFor('A', 'minor').pitchClasses).toEqual([9, 11, 0, 2, 4, 5, 7]);
expect(keyFor('A', 'minor-pentatonic').pitchClasses).toEqual([9, 0, 2, 4, 7]);
expect(keyFor('C', 'major-pentatonic').pitchClasses).toEqual([0, 2, 4, 7, 9]);
expect(keyFor('D', 'lydian').pitchClasses).toEqual([2, 4, 6, 8, 9, 11, 1]);
expect(keyFor('E', 'phrygian').pitchClasses).toEqual([4, 5, 7, 9, 11, 0, 2]);
});
it('spells all degrees consistently with pitch classes', () => {
for (const mode of Object.keys(MODE_LABELS)) {
if (!isMode(mode)) continue;
for (const tonic of ['C', 'G', 'D', 'A', 'E', 'B', 'F#', 'Db', 'Ab', 'Eb', 'Bb', 'F']) {
const key = keyFor(tonic, mode);
for (const d of key.degrees) {
expect(Math.abs(d.alter)).toBeLessThanOrEqual(2);
expect(d.step).toBeTruthy();
}
}
}
});
it('spellPcInKey returns degree spelling or null', () => {
const g = keyFor('G', 'major');
const fSharp = spellPcInKey(6, g);
expect(fSharp).toEqual({ step: 'F', alter: 1 });
expect(spellPcInKey(5, g)).toBeNull(); // F natural not in G major
});
});
describe('scalePitches', () => {
it('enumerates C major across a range with correct spelling and octaves', () => {
const key = keyFor('C', 'major');
const pitches = scalePitches(key, 60, 72);
expect(pitches.map(p => p.midi)).toEqual([60, 62, 64, 65, 67, 69, 71, 72]);
expect(pitches[7].pitch.step).toBe('C');
expect(pitches[7].pitch.octave).toBe(5);
expect(pitches[0].degreeIndex).toBe(0);
expect(pitches[7].degreeIndex).toBe(0); // octave-up tonic maps to degree 0
});
it('spells octave correctly at boundaries (B3/C4)', () => {
const key = keyFor('C', 'major');
const b = scalePitches(key, 59, 60);
expect(b[0].pitch.step).toBe('B');
expect(b[0].pitch.octave).toBe(3);
expect(b[1].pitch.step).toBe('C');
expect(b[1].pitch.octave).toBe(4);
});
it('spelledPitch rejects mismatches', () => {
expect(() => spelledPitch(61, 'C', 0)).toThrow();
expect(spelledPitch(61, 'C', 1)).toMatchObject({ midi: 61, step: 'C', alter: 1, octave: 4 });
});
it('scale pitches can build a valid score (integration sanity)', () => {
const key = keyFor('Eb', 'major');
const pitches = scalePitches(key, 60, 71).slice(0, 7); // C D E F G A B
const events = pitches.map((p, i) => ({
id: `e${i}`,
startTick: i * 480,
durationTicks: 480,
pitch: p.pitch,
}));
const score = {
version: 1 as const,
id: 'x', title: 'x',
meter: { numerator: 4 as const, denominator: 4 as const },
key: { tonic: 'Eb', mode: 'major', signature: key.signature },
measures: [{ number: 1, startTick: 0, durationTicks: 1920 }, { number: 2, startTick: 1920, durationTicks: 1920 }],
voices: [{ id: 'melody' as const, events: [...events, { id: 'pad', startTick: 7 * 480, durationTicks: 8 * 480 - 7 * 480, pitch: null }] }],
};
expect(validateScore(score)).toEqual([]);
});
});

183
src/music/scales.ts Normal file
View File

@@ -0,0 +1,183 @@
/**
* Scales, modes and key spelling for Phrase Mode.
*
* All 12 tonic pitch classes are supported. Modes are defined by
* {letterOffset, semitones} degree pairs so enharmonic spelling is derived
* (E# in F# major, B# in C# major, Cb in Ab major, ...) instead of guessed
* from a chromatic name array — the existing `getNoteInKey` in NoteUtils.ts
* is limited to 8 major + 3 minor signatures and stays untouched for the
* single-note modes.
*/
import { STEP_LETTERS, LETTER_PC, type StepLetter, type SpelledPitch } from '../score/model';
export type ModeId =
| 'major' | 'minor'
| 'dorian' | 'phrygian' | 'lydian' | 'mixolydian' | 'locrian'
| 'major-pentatonic' | 'minor-pentatonic';
export interface Degree {
/** Letter distance from the tonic letter (0 = tonic). */
readonly letterOffset: number;
/** Semitone distance from the (spelled) tonic pitch class. */
readonly semitones: number;
}
export const SCALE_MODES: Record<ModeId, Degree[]> = {
major: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 11 }],
minor: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 8 }, { letterOffset: 6, semitones: 10 }],
dorian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 10 }],
phrygian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 1 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 8 }, { letterOffset: 6, semitones: 10 }],
lydian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 3, semitones: 6 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 11 }],
mixolydian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }, { letterOffset: 6, semitones: 10 }],
locrian: [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 1 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 6 }, { letterOffset: 5, semitones: 8 }, { letterOffset: 6, semitones: 10 }],
'major-pentatonic': [{ letterOffset: 0, semitones: 0 }, { letterOffset: 1, semitones: 2 }, { letterOffset: 2, semitones: 4 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 5, semitones: 9 }],
'minor-pentatonic': [{ letterOffset: 0, semitones: 0 }, { letterOffset: 2, semitones: 3 }, { letterOffset: 3, semitones: 5 }, { letterOffset: 4, semitones: 7 }, { letterOffset: 6, semitones: 10 }],
};
export const MODE_LABELS: Record<ModeId, string> = {
major: 'Major',
minor: 'Minor',
dorian: 'Dorian',
phrygian: 'Phrygian',
lydian: 'Lydian',
mixolydian: 'Mixolydian',
locrian: 'Locrian',
'major-pentatonic': 'Major Pentatonic',
'minor-pentatonic': 'Minor Pentatonic',
};
/** Parent-major offset per mode: parentPc = (tonicPc - offset + 12) % 12. */
const MODE_PARENT_OFFSET: Record<ModeId, number> = {
major: 0, minor: 9, dorian: 2, phrygian: 4, lydian: 5, mixolydian: 7,
locrian: 11, 'major-pentatonic': 0, 'minor-pentatonic': 9,
};
/** Conventional tonic names covering all 12 pitch classes. */
export const TONICS: Record<string, { pc: number; step: StepLetter; alter: number }> = {
'C': { pc: 0, step: 'C', alter: 0 },
'G': { pc: 7, step: 'G', alter: 0 },
'D': { pc: 2, step: 'D', alter: 0 },
'A': { pc: 9, step: 'A', alter: 0 },
'E': { pc: 4, step: 'E', alter: 0 },
'B': { pc: 11, step: 'B', alter: 0 },
'F#': { pc: 6, step: 'F', alter: 1 },
'Db': { pc: 1, step: 'D', alter: -1 },
'Ab': { pc: 8, step: 'A', alter: -1 },
'Eb': { pc: 3, step: 'E', alter: -1 },
'Bb': { pc: 10, step: 'B', alter: -1 },
'F': { pc: 5, step: 'F', alter: 0 },
// Conventional aliases accepted in settings/imports.
'C#': { pc: 1, step: 'C', alter: 1 },
'Gb': { pc: 6, step: 'G', alter: -1 },
'D#': { pc: 3, step: 'D', alter: 1 },
'G#': { pc: 8, step: 'G', alter: 1 },
'A#': { pc: 10, step: 'A', alter: 1 },
};
/** Major key signatures by pitch class of the tonic (max 7 accidentals). */
const MAJOR_SIGNATURES: Record<number, { name: string; sharps: string[]; flats: string[] }> = {
0: { name: 'C', sharps: [], flats: [] },
7: { name: 'G', sharps: ['F#'], flats: [] },
2: { name: 'D', sharps: ['F#', 'C#'], flats: [] },
9: { name: 'A', sharps: ['F#', 'C#', 'G#'], flats: [] },
4: { name: 'E', sharps: ['F#', 'C#', 'G#', 'D#'], flats: [] },
11: { name: 'B', sharps: ['F#', 'C#', 'G#', 'D#', 'A#'], flats: [] },
6: { name: 'F#', sharps: ['F#', 'C#', 'G#', 'D#', 'A#', 'E#'], flats: [] },
1: { name: 'Db', sharps: [], flats: ['Bb', 'Eb', 'Ab', 'Db', 'Gb'] },
8: { name: 'Ab', sharps: [], flats: ['Bb', 'Eb', 'Ab', 'Db'] },
3: { name: 'Eb', sharps: [], flats: ['Bb', 'Eb', 'Ab'] },
10: { name: 'Bb', sharps: [], flats: ['Bb', 'Eb'] },
5: { name: 'F', sharps: [], flats: ['Bb'] },
};
export interface KeyContext {
readonly tonic: string;
readonly mode: ModeId;
/** Parent major key name (also the VexFlow key-signature spec). */
readonly signature: string;
/** Alter expected by the key signature per step letter. */
readonly signatureAlter: Record<StepLetter, number>;
/** Spelled degrees of the scale (length 5 or 7). */
readonly degrees: Array<{ step: StepLetter; alter: number; semitones: number }>;
/** Pitch classes (sounding) of the scale. */
readonly pitchClasses: number[];
}
export function isTonic(name: string): boolean {
return name in TONICS;
}
export function isMode(mode: string): mode is ModeId {
return mode in SCALE_MODES;
}
export function keyFor(tonicName: string, modeId: ModeId): KeyContext {
const tonic = TONICS[tonicName];
if (!tonic) throw new Error(`Unknown tonic: ${tonicName}`);
const parentPc = (tonic.pc - MODE_PARENT_OFFSET[modeId] + 12) % 12;
const sig = MAJOR_SIGNATURES[parentPc];
const signatureAlter = {} as Record<StepLetter, number>;
for (const s of STEP_LETTERS) signatureAlter[s] = 0;
for (const acc of sig.sharps) signatureAlter[acc[0] as StepLetter] = 1;
for (const acc of sig.flats) signatureAlter[acc[0] as StepLetter] = -1;
const tonicLetterIdx = STEP_LETTERS.indexOf(tonic.step);
const tonicPc = (LETTER_PC[STEP_LETTERS.indexOf(tonic.step)] + tonic.alter + 12) % 12;
const degrees = SCALE_MODES[modeId].map(d => {
const step = STEP_LETTERS[(tonicLetterIdx + d.letterOffset) % 7];
const targetPc = (tonicPc + d.semitones) % 12;
const stepPc = LETTER_PC[STEP_LETTERS.indexOf(step)];
let alter = ((targetPc - stepPc) % 12 + 12) % 12;
if (alter > 6) alter -= 12;
return { step, alter, semitones: d.semitones };
});
const pitchClasses = degrees.map(d => (LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 12) % 12);
return { tonic: tonicName, mode: modeId, signature: sig.name, signatureAlter, degrees, pitchClasses };
}
/** VexFlow key-signature spec for the parent major. */
export function vexflowKeySpec(key: KeyContext): string {
return key.signature;
}
/** Spell a sounding midi note as the given (step, alter), computing octave. */
export function spelledPitch(midi: number, step: StepLetter, alter: number): SpelledPitch {
const pc = ((LETTER_PC[STEP_LETTERS.indexOf(step)] + alter) % 12 + 12) % 12;
const octave = (midi - pc) / 12 - 1;
if (!Number.isInteger(octave)) throw new Error(`Spelling ${step}${alter} does not match midi ${midi}`);
return { midi, step, alter, octave };
}
/** Spelling of a scale pitch class in the key (degree spelling). */
export function spellPcInKey(pc: number, key: KeyContext): { step: StepLetter; alter: number } | null {
const normalized = ((pc % 12) + 12) % 12;
const degree = key.degrees.find(d => (LETTER_PC[STEP_LETTERS.indexOf(d.step)] + d.alter + 12) % 12 === normalized);
return degree ? { step: degree.step, alter: degree.alter } : null;
}
export interface ScalePitch {
readonly midi: number;
readonly pitch: SpelledPitch;
/** Index into key.degrees (used for step-motion preference). */
readonly degreeIndex: number;
}
/**
* All scale notes in [minMidi, maxMidi], spelled in the key.
* Returned ascending. Each entry knows its scale-degree index so the melody
* generator can prefer adjacent-degree motion.
*/
export function scalePitches(key: KeyContext, minMidi: number, maxMidi: number): ScalePitch[] {
const out: ScalePitch[] = [];
if (maxMidi < minMidi) return out;
for (let midi = Math.max(0, Math.floor(minMidi)); midi <= Math.min(127, Math.ceil(maxMidi)); midi++) {
const pc = ((midi % 12) + 12) % 12;
const degreeIndex = key.pitchClasses.indexOf(pc);
if (degreeIndex === -1) continue;
const d = key.degrees[degreeIndex];
out.push({ midi, pitch: spelledPitch(midi, d.step, d.alter), degreeIndex });
}
return out;
}

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

127
src/score/model.test.ts Normal file
View File

@@ -0,0 +1,127 @@
import { describe, it, expect } from 'vitest';
import { PPQ, validateScore, sliceScore, scoreFitsPool, type Score, type ScoreMeasure, type ScoreEvent } from './model';
import { spelledPitch } from '../music/scales';
const C = (octave: number, alter = 0) => spelledPitch(12 * (octave + 1) + alter, 'C', alter);
function simpleScore(): Score {
// 4/4, two full bars of quarters: C4 D4 E4 F4 | G4 (half) A4 (half)
return {
version: 1,
id: 'test',
title: 'Test',
meter: { numerator: 4, denominator: 4 },
key: { tonic: 'C', mode: 'major', signature: 'C' },
measures: [
{ number: 1, startTick: 0, durationTicks: 4 * PPQ },
{ number: 2, startTick: 4 * PPQ, durationTicks: 4 * PPQ },
],
voices: [{
id: 'melody',
events: [
{ id: 'a', startTick: 0, durationTicks: PPQ, pitch: C(4) },
{ id: 'b', startTick: PPQ, durationTicks: PPQ, pitch: C(4) },
{ id: 'c', startTick: 2 * PPQ, durationTicks: PPQ, pitch: C(4) },
{ id: 'd', startTick: 3 * PPQ, durationTicks: PPQ, pitch: C(4) },
{ id: 'e', startTick: 4 * PPQ, durationTicks: 2 * PPQ, pitch: C(5) },
{ id: 'f', startTick: 6 * PPQ, durationTicks: 2 * PPQ, pitch: C(5) },
],
}],
};
}
describe('validateScore', () => {
it('accepts a well-formed score', () => {
expect(validateScore(simpleScore())).toEqual([]);
});
it('rejects non-contiguous events', () => {
const s = simpleScore();
s.voices[0].events[1] = { ...s.voices[0].events[1], startTick: 3 * PPQ };
expect(validateScore(s).length).toBeGreaterThan(0);
});
it('rejects empty and all-rest scores', () => {
const s = simpleScore();
const rests = s.voices[0].events.map((e, i) => ({ ...e, id: `r${i}`, pitch: null }));
const allRests: Score = { ...s, voices: [{ id: 'melody', events: rests }] };
expect(validateScore(allRests).length).toBeGreaterThan(0);
expect(validateScore({ ...s, voices: [{ id: 'melody', events: [] }] }).length).toBeGreaterThan(0);
});
it('rejects short middle measures', () => {
const s: Score = { ...simpleScore() };
(s as { measures: ScoreMeasure[] }).measures = [
{ number: 1, startTick: 0, durationTicks: 4 * PPQ },
{ number: 2, startTick: 4 * PPQ, durationTicks: 3 * PPQ },
];
expect(validateScore(s).some(p => p.includes('measure 2'))).toBe(true);
});
it('accepts pickup + complementary final measure', () => {
const s: Score = { ...simpleScore() };
// 1-beat pickup, final bar 3 beats. Measures: 1 + 4 + 3 = 8 beats.
(s as { measures: ScoreMeasure[] }).measures = [
{ number: 1, startTick: 0, durationTicks: PPQ },
{ number: 2, startTick: PPQ, durationTicks: 4 * PPQ },
{ number: 3, startTick: 5 * PPQ, durationTicks: 3 * PPQ },
];
expect(validateScore(s)).toEqual([]);
});
it('rejects a short final measure without pickup', () => {
const s: Score = { ...simpleScore() };
(s as { measures: ScoreMeasure[] }).measures = [
{ number: 1, startTick: 0, durationTicks: 4 * PPQ },
{ number: 2, startTick: 4 * PPQ, durationTicks: 3 * PPQ },
];
expect(validateScore(s).some(p => p.includes('final measure'))).toBe(true);
});
});
describe('sliceScore', () => {
it('keeps the whole score and rebases tick 0', () => {
const sliced = sliceScore(simpleScore(), 1, 2);
expect(sliced.measures).toHaveLength(2);
expect(sliced.measures[0].startTick).toBe(0);
expect(sliced.measures[1].startTick).toBe(4 * PPQ);
expect(sliceScore(simpleScore(), 1, 2).voices[0].events).toHaveLength(6);
});
it('slices a single middle bar and clips overlapping events', () => {
const sliced = sliceScore(simpleScore(), 2, 1);
expect(sliced.measures).toHaveLength(1);
expect(sliced.measures[0].number).toBe(2);
const evts = sliced.voices[0].events;
// Bar 2 originally: half G4 (clip to 2 beats? no: slice keeps whole bar) + half A4
expect(evts).toHaveLength(2);
expect(evts[0].startTick).toBe(0);
expect(evts[0].durationTicks).toBe(2 * PPQ);
});
it('turns a tie entering the slice into a fresh attack at tick 0', () => {
// One event spanning both bars (a merged tie), slice bar 2.
const s: Score = { ...simpleScore() };
(s.voices[0] as { events: ScoreEvent[] }).events = [
{ id: 'long', startTick: 0, durationTicks: 8 * PPQ, pitch: C(4) },
];
const sliced = sliceScore(s, 2, 1);
expect(sliced.voices[0].events).toHaveLength(1);
expect(sliced.voices[0].events[0].startTick).toBe(0);
expect(sliced.voices[0].events[0].durationTicks).toBe(4 * PPQ);
});
it('preserves bar numbers and id suffix', () => {
const sliced = sliceScore(simpleScore(), 2, 1);
expect(sliced.id).toContain(':2+1');
expect(sliced.measures[0].number).toBe(2);
});
});
describe('scoreFitsPool', () => {
it('detects out-of-pool pitches', () => {
const s = simpleScore();
expect(scoreFitsPool(s, [60, 62, 64, 65, 67, 69, 72])).toBe(true);
expect(scoreFitsPool(s, [60])).toBe(false);
});
});

250
src/score/model.ts Normal file
View File

@@ -0,0 +1,250 @@
/**
* Normalized score model (Phrase Mode).
*
* Versioned, interchange-independent representation of a single-voice musical
* line. Both the generators (melody/scale drills) and the ABC import adapter
* produce it; the VexFlow renderer and the phrase matcher consume it — neither
* ever sees raw ABC. This keeps a future MIDI/MusicXML adapter possible
* without touching the trainer or renderer.
*
* Key decisions (see docs/PHRASE_MODE_IMPLEMENTATION.md, section B):
* - Integer ticks at 480 per quarter note (PPQ). No floating beat arithmetic.
* - Ties are ALWAYS pre-merged: one ScoreEvent = one required attack ("logical
* note"). The renderer re-splits such events into tied glyphs at barlines;
* the matcher and the summary count them exactly once.
* - Pitches are stored SOUNDING, with preserved enharmonic spelling. The
* instrument display transpose is applied once, at render time only.
* - Playback state (cursor, results, seed, ...) NEVER lives on the score.
*/
/** Ticks per quarter note. */
export const PPQ = 480;
/** Simple result wrapper used by generators and the import adapter. */
export type Result<T> = { ok: true; value: T } | { ok: false; error: string };
export const STEP_LETTERS = ['C', 'D', 'E', 'F', 'G', 'A', 'B'] as const;
/** Sounding pitch class of each natural letter (C=0 ... B=11). */
export const LETTER_PC = [0, 2, 4, 5, 7, 9, 11] as const;
export type StepLetter = (typeof STEP_LETTERS)[number];
/** Written (notated) pitch with explicit enharmonic spelling. */
export interface SpelledPitch {
/** Sounding MIDI note number (0..127). */
readonly midi: number;
readonly step: StepLetter;
/** -2..2; 0 = natural, 1 = sharp, -1 = flat, ±2 = double. */
readonly alter: number;
/** Spelling octave: written midi = 12 * (octave + 1) + stepPc + alter. */
readonly octave: number;
}
/** One required attack (a "logical note"); null pitch = rest. */
export interface ScoreEvent {
/** Stable identity through rendering/retries (unique within a score). */
readonly id: string;
readonly startTick: number;
readonly durationTicks: number;
readonly pitch: SpelledPitch | null;
}
export interface ScoreMeasure {
/** 1-based original bar number (kept through slicing for orientation). */
readonly number: number;
readonly startTick: number;
readonly durationTicks: number;
}
export interface ScoreKey {
/** Tonic spelling as displayed, e.g. "C", "F#", "Bb". */
readonly tonic: string;
/** Mode id, e.g. "major", "minor", "dorian", "major-pentatonic". */
readonly mode: string;
/** Parent major key signature name for notation (e.g. "Bb" for G minor). */
readonly signature: string;
}
export interface ScoreSource {
readonly composer?: string;
readonly work?: string;
/** Identification of the exact part (exercise number, measures, ...). */
readonly locator?: string;
readonly url?: string;
/** Rights of BOTH source work and this transcription. */
readonly rights: string;
/** What was changed relative to the source, if anything. */
readonly adaptation?: string;
/**
* Semitones from WRITTEN source pitch to sounding pitch that the import
* already applied (e.g. -12 for explicit guitar-convention input).
* Renderer/matching still operate on the stored sounding `midi`.
*/
readonly writtenToSoundingSemitones?: number;
}
export interface Score {
readonly version: 1;
readonly id: string;
readonly title: string;
readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 };
readonly key: ScoreKey;
readonly measures: ScoreMeasure[];
/** Exactly one voice in v1. */
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;
}
export const FULL_MEASURE_TICKS = (meter: { numerator: number }): number =>
meter.numerator * PPQ;
export function scoreEvents(score: Score): ScoreEvent[] {
return score.voices[0]?.events ?? [];
}
export function totalDurationTicks(score: Score): number {
const events = scoreEvents(score);
if (events.length === 0) return 0;
const last = events[events.length - 1];
return last.startTick + last.durationTicks;
}
/**
* Structural validation. Returns a list of problems (empty = valid).
* Imported and generated scores both pass through this before use.
*/
export function validateScore(score: Score): string[] {
const problems: string[] = [];
const events = scoreEvents(score);
const full = FULL_MEASURE_TICKS(score.meter);
if (score.voices.length !== 1) problems.push('expected exactly one voice');
if (score.measures.length === 0) problems.push('score has no measures');
let expectedStart = 0;
for (const e of events) {
if (!Number.isInteger(e.startTick) || e.startTick < 0) {
problems.push(`event ${e.id}: invalid startTick ${e.startTick}`);
}
if (!Number.isInteger(e.durationTicks) || e.durationTicks <= 0) {
problems.push(`event ${e.id}: invalid durationTicks ${e.durationTicks}`);
}
if (e.startTick !== expectedStart) {
problems.push(`event ${e.id}: expected start ${expectedStart}, got ${e.startTick} (events must be contiguous)`);
}
expectedStart = e.startTick + e.durationTicks;
if (e.pitch) {
const { midi, step, alter, octave } = e.pitch;
if (!Number.isInteger(midi) || midi < 0 || midi > 127) {
problems.push(`event ${e.id}: midi ${midi} out of range`);
}
if (alter < -2 || alter > 2) {
problems.push(`event ${e.id}: alter ${alter} out of range`);
}
const written = 12 * (octave + 1) + (LETTER_PC[STEP_LETTERS.indexOf(step)] + alter);
if (written % 12 !== ((midi % 12) + 12) % 12) {
problems.push(`event ${e.id}: spelling does not match midi (${step}${alter || ''} octave ${octave} vs midi ${midi})`);
}
}
}
for (const m of score.measures) {
if (!Number.isInteger(m.startTick) || m.startTick < 0) {
problems.push(`measure ${m.number}: invalid startTick`);
}
if (m.startTick % full !== 0 && m.durationTicks >= full) {
// Only pickups/closers may be misaligned; flagged via duration below.
}
}
// Measure durations: all full, or first+last complement each other.
if (score.measures.length > 0) {
const first = score.measures[0];
const last = score.measures[score.measures.length - 1];
const others = score.measures.slice(1, -1);
for (const m of others) {
if (m.durationTicks !== full) {
problems.push(`measure ${m.number}: ${m.durationTicks} ticks (expected ${full} for ${score.meter.numerator}/4)`);
}
}
if (score.measures.length === 1 && first.durationTicks !== full) {
problems.push(`measure ${first.number}: single measure must be full`);
}
if (score.measures.length > 1) {
if (first.durationTicks !== full && last.durationTicks !== full && first.durationTicks + last.durationTicks !== full) {
problems.push(
`pickup/final measures must be full or complement (${first.durationTicks} + ${last.durationTicks} != ${full})`
);
}
if (first.durationTicks === full && last.durationTicks !== full) {
// A short final bar without a pickup is only valid if it complements... reject.
problems.push(`final measure ${last.number} is short but there is no pickup measure`);
}
}
// Contiguity of measures.
let mStart = 0;
for (const m of score.measures) {
if (m.startTick !== mStart) {
problems.push(`measure ${m.number}: expected start ${mStart}, got ${m.startTick}`);
}
mStart += m.durationTicks;
}
// The last measure must end exactly at the last event's end.
const endTick = mStart;
if (events.length > 0 && endTick !== expectedStart) {
problems.push(`events end at ${expectedStart} but measures end at ${endTick}`);
}
}
// Monophonic by construction (contiguity above covers overlap). Rests-only:
if (events.length === 0 || events.every(e => e.pitch === null)) {
problems.push('score contains no playable notes');
}
return problems;
}
/**
* Extract a practice range: measures [startBar, startBar+barCount).
* Events overlapping the range are clipped and rebased to tick 0; a tie
* entering the range becomes a fresh attack (its clipped fragment), exactly
* as specified in the contract. Original bar numbers are preserved.
*/
export function sliceScore(score: Score, startBar: number, barCount: number): Score {
const total = score.measures.length;
const from = Math.max(1, Math.min(startBar, total));
const count = Math.max(1, Math.min(barCount, total - from + 1));
const measures = score.measures.slice(from - 1, from - 1 + count);
if (measures.length === 0) return { ...score, measures: [], voices: [{ id: 'melody', events: [] }] };
const startTick = measures[0].startTick;
const endTick = measures[measures.length - 1].startTick + measures[measures.length - 1].durationTicks;
const events: ScoreEvent[] = [];
for (const e of scoreEvents(score)) {
const eStart = e.startTick;
const eEnd = e.startTick + e.durationTicks;
if (eEnd <= startTick || eStart >= endTick) continue;
const clippedStart = Math.max(eStart, startTick);
const clippedEnd = Math.min(eEnd, endTick);
events.push({
...e,
startTick: clippedStart - startTick,
durationTicks: clippedEnd - clippedStart,
});
}
return {
...score,
id: `${score.id}:${from}+${count}`,
measures: measures.map(m => ({ ...m, startTick: m.startTick - startTick })),
voices: [{ id: 'melody', events }],
};
}
/** True when every event of `score` lies inside the sorted pitch pool. */
export function scoreFitsPool(score: Score, pool: number[]): boolean {
const set = new Set(pool);
return scoreEvents(score).every(e => e.pitch === null || set.has(e.pitch.midi));
}

View File

@@ -1,6 +1,10 @@
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;700&display=swap');
:root {
/* Native form controls (select dropdowns, checkboxes, number spinners,
range sliders) follow this instead of keeping black-on-black in dark mode */
color-scheme: light;
/* HSL Colors for easy manipulation */
/* Dark Red / Brownish Tone */
--hue-primary: 0;
@@ -54,23 +58,25 @@
/* Auto Dark Mode (System Preference) */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: hsl(30, 20%, 10%);
--color-surface: hsl(30, 20%, 15%);
--color-text-main: hsl(30, 10%, 90%);
--color-text-muted: hsl(30, 10%, 60%);
--color-primary: hsl(0, 0%, 90%);
--color-primary-dark: hsl(0, 0%, 100%);
color-scheme: dark;
--color-bg: hsl(220, 6%, 10%);
--color-surface: hsl(220, 6%, 15%);
--color-text-main: hsl(220, 4%, 90%);
--color-text-muted: hsl(220, 4%, 60%);
--color-primary: hsl(220, 4%, 90%);
--color-primary-dark: hsl(220, 4%, 100%);
}
}
/* Manual Dark Mode Override */
html[data-theme="dark"] {
--color-bg: hsl(30, 20%, 10%);
--color-surface: hsl(30, 20%, 15%);
--color-text-main: hsl(30, 10%, 90%);
--color-text-muted: hsl(30, 10%, 60%);
--color-primary: hsl(0, 0%, 90%);
--color-primary-dark: hsl(0, 0%, 100%);
color-scheme: dark;
--color-bg: hsl(220, 6%, 10%);
--color-surface: hsl(220, 6%, 15%);
--color-text-main: hsl(220, 4%, 90%);
--color-text-muted: hsl(220, 4%, 60%);
--color-primary: hsl(220, 4%, 90%);
--color-primary-dark: hsl(220, 4%, 100%);
}
/* Manual Light Mode Override */

View File

@@ -1,30 +1,154 @@
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 {
mode: 'bpm' | 'seconds';
bpm: number;
seconds: number;
active: boolean;
/** Phrase Mode: sync the metronome to the exercise (starts with it,
* silent while idle) instead of running freely. */
syncToExercise: boolean;
autoAdvance: boolean;
sound: boolean;
volume: number;
}
export type PhraseMaterial = 'melody' | 'scale' | 'arpeggio' | 'giuliani' | 'library' | 'import';
export type PhrasePace = 'step' | 'tempo';
export interface PhraseSettings {
material: PhraseMaterial;
libraryId: string;
/** Imported score id (the parsed score lives in component state, session-only). */
bars: number; // 1–8 (melodies)
meterNumerator: 3 | 4 | 6;
rhythmLevel: 1 | 2 | 3;
scaleCoverage: 'one-octave' | 'two-octave' | 'position';
scaleDirection: 'up' | 'down' | 'updown';
scaleRhythm: 'quarters' | 'eighths';
/** Arpeggio settings (material = 'arpeggio'). */
arpeggioDegree: string; // ArpeggioDegree (single-chord mode / sequence start)
/** 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). */
startBar: number;
barCount: number;
pace: PhrasePace;
bpm: number;
clickSound: boolean;
/** When a run finishes, automatically continue (next exercise section /
* new melody) after a short pause — keeps practice flowing. */
autoContinue: boolean;
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 = {
material: 'melody',
libraryId: '',
bars: 2,
meterNumerator: 4,
rhythmLevel: 1,
scaleCoverage: 'one-octave',
scaleDirection: 'updown',
scaleRhythm: 'quarters',
arpeggioDegree: 'I',
arpeggioChordSelection: [],
arpeggioPattern: 'up',
arpeggioCoverage: 'one-octave',
arpeggioBars: 1,
arpeggioProgression: 'functional',
giulianiPattern: 'pima',
giulianiBars: 4,
startBar: 1,
barCount: 2,
pace: 'step',
bpm: 60,
clickSound: false,
autoContinue: true,
inputMode: 'mic',
autoStartOnNote: false,
customArpeggioPattern: '',
giulianiAlternateBass: false,
};
export function getPhraseSettings(s: AppSettings): PhraseSettings {
return { ...DEFAULT_PHRASE_SETTINGS, ...(s.phrase ?? {}) };
}
export interface AppSettings {
phrase?: PhraseSettings;
/** Unified target-note filter (key + fret window), all modes. */
practice?: PracticeFilter;
difficulty: Difficulty;
showHint: boolean;
showFretboard: boolean;
showTuningMeter: boolean;
tuningId: string;
keySignature: string;
/** Keep display key signature in sync with the target-key control. */
keyFollowsTarget?: boolean;
instrument: string;
rhythm: RhythmSettings;
zenMode: boolean;
gameMode: 'sight_reading' | 'ear_training';
/**
* TODO: Rename 'sight_reading' to 'learn_notes' internally (UI label is
* already "Learn Notes"). Deferred because the value is persisted in
* localStorage — needs a settings migration for existing users.
*/
gameMode: 'sight_reading' | 'ear_training' | 'phrase' | 'assist';
customMinFret?: number;
customMaxFret?: number;
autoPlaySightReading?: boolean;
autoPlayVolume?: number;
/** Length of the played reference note in seconds */
referenceNoteDuration?: number;
/** Hold the next reference note until the microphone detects silence,
* so a still-ringing instrument doesn't mask it */
waitForQuiet?: boolean;
virtualGuitarVolume?: number;
virtualGuitarMute?: boolean;
micSensitivity?: number;
@@ -39,22 +163,28 @@ export const DEFAULT_SETTINGS: AppSettings = {
showTuningMeter: false,
tuningId: 'standard',
keySignature: 'C',
keyFollowsTarget: false,
instrument: 'guitar',
rhythm: {
mode: 'bpm',
bpm: 60,
seconds: 5,
active: false,
syncToExercise: true,
autoAdvance: false,
sound: true,
volume: 0.5
},
zenMode: false,
gameMode: 'sight_reading',
phrase: DEFAULT_PHRASE_SETTINGS,
practice: DEFAULT_PRACTICE_FILTER,
customMinFret: 0,
customMaxFret: 12,
autoPlaySightReading: false,
autoPlayVolume: 0.5,
referenceNoteDuration: 1.5,
waitForQuiet: false,
virtualGuitarVolume: 0.5,
virtualGuitarMute: false,
micSensitivity: 0.5,

View File

@@ -4,11 +4,14 @@ declare module 'pitchfinder' {
frequency?: number;
minFrequency?: number;
maxFrequency?: number;
threshold?: number;
probabilityThreshold?: number;
}
type Detector = (float32Array: Float32Array) => number | null;
export function YIN(config?: PitchFinderConfig): Detector;
export function AMDF(config?: PitchFinderConfig): Detector;
export function MACLEOD(config?: PitchFinderConfig): Detector;
export function ACF2PLUS(config?: PitchFinderConfig): Detector;
export function Macleod(config?: PitchFinderConfig): (buffer: Float32Array) => { frequency: number; probability: number } | null;
}