mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Compare commits
21 Commits
d8602e0bd8
...
feature/ph
| Author | SHA1 | Date | |
|---|---|---|---|
| 173c4c56bc | |||
| 7d4c71539a | |||
| 9fb3f91b92 | |||
| fff512b4dd | |||
| 4f2ee8798a | |||
| 82c105bcbf | |||
| 909c30192f | |||
| 486f3c5dcd | |||
| 9cb78b39d5 | |||
| 0520cb08b4 | |||
| 4d6e205b07 | |||
| 821f88d43a | |||
| c7f1fa170b | |||
| 7c5632fab6 | |||
| 8e20098102 | |||
| 6e24b94a91 | |||
| 0d11570b58 | |||
| 141487e3df | |||
| 3cc9a0c43d | |||
| 6e60ced477 | |||
| 079d574b66 |
147
PHRASE_MODE_PLAN.md
Normal file
147
PHRASE_MODE_PLAN.md
Normal 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.
|
||||
11
README.md
11
README.md
@@ -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
|
||||
|
||||
@@ -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,14 +36,29 @@ 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.)
|
||||
├── 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
|
||||
|
||||
77
docs/PHRASE_DELIVERY.md
Normal file
77
docs/PHRASE_DELIVERY.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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) |
|
||||
|
||||
## 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.
|
||||
550
docs/PHRASE_MODE_IMPLEMENTATION.md
Normal file
550
docs/PHRASE_MODE_IMPLEMENTATION.md
Normal 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.
|
||||
@@ -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
21
scripts/README.md
Normal 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
92
scripts/phrase_e2e.cjs
Normal 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
155
scripts/phrase_e2e2.cjs
Normal 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
122
scripts/phrase_e2e3.cjs
Normal 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); });
|
||||
175
src/App.css
175
src/App.css
@@ -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;
|
||||
@@ -959,3 +981,148 @@ button {
|
||||
.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-top: 16px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--color-text-main) 12%, transparent);
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.phrase-setup summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-muted);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.phrase-setup summary:hover {
|
||||
color: var(--color-text-main);
|
||||
}
|
||||
|
||||
.phrase-setup-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px 14px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
416
src/App.tsx
416
src/App.tsx
@@ -1,55 +1,64 @@
|
||||
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 { 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';
|
||||
|
||||
// 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 +72,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 +104,77 @@ 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;
|
||||
const onInteractive =
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLTextAreaElement ||
|
||||
target instanceof HTMLButtonElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
!!target?.isContentEditable;
|
||||
|
||||
// prevent default for space to stop scrolling
|
||||
if (e.code === 'Escape') {
|
||||
if (showHelp) setShowHelp(false);
|
||||
else if (settings.zenMode) setSettings(s => ({ ...s, zenMode: false }));
|
||||
return;
|
||||
}
|
||||
// Focused buttons handle their own keys (native activation);
|
||||
// global shortcuts must not double-fire or block them.
|
||||
if (onInteractive) return;
|
||||
|
||||
// Prevent default for space to stop scrolling
|
||||
if (e.code === 'Space') {
|
||||
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 's':
|
||||
phraseRef.current?.skip();
|
||||
break;
|
||||
case 'v':
|
||||
setSettings(s => ({ ...s, showFretboard: !s.showFretboard }));
|
||||
break;
|
||||
case 'l':
|
||||
setListening(l => !l);
|
||||
break;
|
||||
}
|
||||
if (e.code === 'Space' || e.code === 'Enter') {
|
||||
phraseRef.current?.pauseToggle();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'h':
|
||||
setSettings(s => ({ ...s, showHint: !s.showHint }));
|
||||
@@ -128,9 +184,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 +200,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 }));
|
||||
singleNoteRef.current?.skipNote();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [settings.zenMode, showHelp, generateNewNote, playNote, targetMidi]);
|
||||
}, [settings, showHelp, isPhrase, 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 all three modes from phrase mode too.
|
||||
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') {
|
||||
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]);
|
||||
|
||||
@@ -211,6 +256,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 +281,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,130 +298,24 @@ 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}
|
||||
{isPhrase ? (
|
||||
<PhraseTrainer
|
||||
ref={phraseRef}
|
||||
listening={listening}
|
||||
micError={error}
|
||||
windowWidth={windowWidth}
|
||||
/>
|
||||
) : (
|
||||
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}
|
||||
<SingleNoteTrainer
|
||||
ref={singleNoteRef}
|
||||
pitchData={pitchData}
|
||||
windowWidth={windowWidth}
|
||||
windowHeight={windowHeight}
|
||||
/>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</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>
|
||||
|
||||
|
||||
{
|
||||
!settings.zenMode && (
|
||||
<div className="pitch-monitor-bar">
|
||||
@@ -382,13 +328,19 @@ function App() {
|
||||
{listening ? <Mic size={28} /> : <MicOff size={28} />}
|
||||
</button>
|
||||
|
||||
<div className={`pitch-readout ${pitchData ? 'active' : ''}`}>
|
||||
{pitchData ? (
|
||||
<LiveNoteStaff
|
||||
midi={displayedPitch ? displayedPitch.midi : null}
|
||||
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
|
||||
keySignature={settings.keySignature}
|
||||
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 +354,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 +386,26 @@ 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>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>
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
184
src/audio/AudioEngine.ts
Normal file
184
src/audio/AudioEngine.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
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).
|
||||
*/
|
||||
playClickAt(time: number, volume = 0.5, accent = false): 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);
|
||||
}
|
||||
|
||||
/** 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();
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
156
src/audio/pitchDetection.test.ts
Normal file
156
src/audio/pitchDetection.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
||||
import { getTempoMarking } from '../music/TempoMarkings';
|
||||
import { TuningMeter } from './TuningMeter';
|
||||
|
||||
import { useSettings } from '../context/SettingsContext';
|
||||
import { useSettings } from '../context/useSettings';
|
||||
import { type Difficulty, type RhythmSettings } from '../types/SettingsTypes';
|
||||
|
||||
|
||||
@@ -58,6 +58,10 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
|
||||
|
||||
|
||||
// Phrase Mode brings its own key/tempo machinery; the single-note
|
||||
// key-signature select and metronome tool would be competing controls.
|
||||
const inPhraseMode = settings.gameMode === 'phrase';
|
||||
|
||||
return (
|
||||
<div className="controls-container">
|
||||
{/* Top Section: Settings Grid (2 Columns) */}
|
||||
@@ -124,6 +128,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
</div>
|
||||
|
||||
{/* Row 2, Col 2 */}
|
||||
{!inPhraseMode && (
|
||||
<div className="control-group">
|
||||
<label className="control-label">
|
||||
<span>Key signature</span>
|
||||
@@ -150,6 +155,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Row 3: Fret Range (Full Width) */}
|
||||
{currentInstrumentDef.ranges.find(r => r.id === settings.difficulty)?.type === 'custom_fret' && (
|
||||
@@ -167,7 +173,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
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' }}
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
@@ -179,7 +185,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
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' }}
|
||||
style={{ width: '100%', padding: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +194,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
</div>
|
||||
|
||||
{/* Bottom Section: Tools Grid (2 Columns now) */}
|
||||
<div className="tools-grid">
|
||||
<div className="tools-grid" style={inPhraseMode ? { gridTemplateColumns: '1fr' } : undefined}>
|
||||
{/* 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,19 +210,18 @@ 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)' }}>
|
||||
<TuningMeter
|
||||
cents={currentPitch ? currentPitch.cents : null}
|
||||
noteName={currentPitch ? currentPitch.note : null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool 2: Metronome */}
|
||||
{/* Tool 2: Metronome (single-note modes only) */}
|
||||
{!inPhraseMode && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', border: '1px solid rgba(128,128,128,0.2)', padding: '12px', borderRadius: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<label className="control-label" style={{ marginBottom: 0 }}>
|
||||
@@ -266,11 +271,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
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'
|
||||
padding: '2px'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
@@ -332,7 +333,7 @@ export const Controls: React.FC<ControlsProps> = ({ currentPitch }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool 3: Auto-play */}
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
125
src/components/LiveNoteStaff.tsx
Normal file
125
src/components/LiveNoteStaff.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 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 {
|
||||
/** Sounding midi of the live detected pitch, or null when silent. */
|
||||
midi: number | null;
|
||||
transpose: number;
|
||||
keySignature: string;
|
||||
width?: 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 a 38px staff in
|
||||
// the 64px box the constructor y must be negative.
|
||||
const STAVE_Y = -28;
|
||||
|
||||
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||
midi,
|
||||
transpose = 0,
|
||||
keySignature = 'C',
|
||||
width = 130,
|
||||
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);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
|
||||
const renderer = new Renderer(container, Renderer.Backends.SVG);
|
||||
renderer.resize(width, BOX_H);
|
||||
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, STAVE_Y, width - 2);
|
||||
stave.setDefaultLedgerLineStyle({ strokeStyle: resolvedColor, lineWidth: 2 });
|
||||
const written0 = midi !== null ? midi + transpose : null;
|
||||
// Clef like the grand-staff split: below middle C reads bass clef.
|
||||
const clef: 'treble' | 'bass' = 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!;
|
||||
let octaveMark: '8va' | '8vb' | null = null;
|
||||
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, width, theme, schemeVersion]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="live-note-staff"
|
||||
style={{ width, height: BOX_H }}
|
||||
role="img"
|
||||
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LiveNoteStaff;
|
||||
360
src/components/PhraseSheetMusic.tsx
Normal file
360
src/components/PhraseSheetMusic.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* 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 } 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;
|
||||
staveKey: 'treble' | 'bass';
|
||||
rest: boolean;
|
||||
}
|
||||
|
||||
// Duration ticks → VexFlow duration code (subset the model guarantees).
|
||||
function durationCode(ticks: number): string | null {
|
||||
switch (ticks) {
|
||||
case 240: return '8';
|
||||
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;
|
||||
const barsPerRow = Math.max(1, Math.min(4, Math.floor((width - 40) / 210)));
|
||||
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; }
|
||||
|
||||
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: `${code}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: code,
|
||||
clef: staveKey,
|
||||
});
|
||||
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,
|
||||
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);
|
||||
}
|
||||
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 ? 92 : 16));
|
||||
const formatter = new Formatter();
|
||||
formatter.joinVoices(voices.map(v => v.voice));
|
||||
formatter.format(voices.map(v => v.voice), formatWidth);
|
||||
|
||||
treble.setContext(context).draw();
|
||||
if (bass) bass.setContext(context).draw();
|
||||
for (const { voice, stave } of voices) voice.draw(context, stave);
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// --- Beams: consecutive eighth pairs within one beat ---
|
||||
const eighths = frags.filter(f => f.isEighth && f.note instanceof StaveNote);
|
||||
for (let i = 0; i + 1 < eighths.length; i++) {
|
||||
const a = eighths[i], b2 = eighths[i + 1];
|
||||
if (a.staveKey !== b2.staveKey) continue;
|
||||
if (b2.start - a.start !== 240) continue;
|
||||
const beatA = Math.floor((a.start - measureStart) / 480);
|
||||
const beatB = Math.floor((b2.start - measureStart) / 480);
|
||||
if (beatA === beatB) {
|
||||
const beam = new Beam([a.note, b2.note], false);
|
||||
beam.setContext(context).draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Ties between consecutive fragments of one event (this row) ---
|
||||
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;
|
||||
755
src/components/PhraseTrainer.tsx
Normal file
755
src/components/PhraseTrainer.tsx
Normal file
@@ -0,0 +1,755 @@
|
||||
/**
|
||||
* 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, type PhraseSettings } from '../types/SettingsTypes';
|
||||
import { rawFrameCount } from '../hooks/rawFrameBus';
|
||||
import { audioEngine } from '../audio/AudioEngine';
|
||||
import { computePlayableNotes, isFrettedInstrument, positionsWithinWindow, fretWindowNotes } from '../music/playableRange';
|
||||
import { generateMelody } from '../music/melodyGenerator';
|
||||
import { generateScaleDrill } from '../music/scaleDrills';
|
||||
import { isMode, MODE_LABELS, TONICS, type ModeId } from '../music/scales';
|
||||
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 { 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, Volume2, Square, ChevronDown, Music2, Upload, HelpCircle, Guitar } from 'lucide-react';
|
||||
|
||||
export interface PhraseHandle {
|
||||
pauseToggle: () => void;
|
||||
previewToggle: () => 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);
|
||||
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(() => {
|
||||
if (phrase.fretWindowEnabled && fretted && currentTuning) {
|
||||
return fretWindowNotes(currentTuning, phrase.fretMin, phrase.fretMax);
|
||||
}
|
||||
return computePlayableNotes({
|
||||
instrumentId: settings.instrument,
|
||||
difficulty: settings.difficulty,
|
||||
tuningId: settings.tuningId,
|
||||
customMinFret: settings.customMinFret,
|
||||
customMaxFret: settings.customMaxFret,
|
||||
});
|
||||
}, [phrase.fretWindowEnabled, phrase.fretMin, phrase.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);
|
||||
// BPM text field: editing stays free-form while typing; commits on
|
||||
// blur/Enter (single-note widget behavior).
|
||||
const [bpmText, setBpmText] = useState(String(phrase.bpm));
|
||||
const [lastBpm, setLastBpm] = useState(phrase.bpm);
|
||||
if (phrase.bpm !== lastBpm) {
|
||||
// React's "adjust state when a prop changes" pattern (no effect).
|
||||
setLastBpm(phrase.bpm);
|
||||
setBpmText(String(phrase.bpm));
|
||||
}
|
||||
const commitBpm = useCallback(() => {
|
||||
const v = parseInt(bpmText, 10);
|
||||
if (Number.isNaN(v)) { setBpmText(String(phrase.bpm)); return; }
|
||||
setPhrase({ bpm: Math.max(30, Math.min(180, v)) });
|
||||
}, [bpmText, phrase.bpm, setPhrase]);
|
||||
|
||||
const handleImportFile = useCallback((file: File) => {
|
||||
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: phrase.keyTonic,
|
||||
keyMode: (isMode(phrase.keyMode) ? phrase.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: phrase.keyTonic,
|
||||
keyMode: (isMode(phrase.keyMode) ? phrase.keyMode : 'major') as ModeId,
|
||||
direction: phrase.scaleDirection,
|
||||
coverage: phrase.scaleCoverage,
|
||||
meter: { numerator: phrase.meterNumerator, denominator: 4 },
|
||||
}, 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, phrase.keyTonic, phrase.keyMode, phrase.bars, phrase.meterNumerator,
|
||||
phrase.rhythmLevel, phrase.scaleDirection, phrase.scaleCoverage, phrase.libraryId,
|
||||
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,
|
||||
},
|
||||
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 (phrase.material === 'melody') {
|
||||
setMelodySeed(s => (s + 1) % 999983);
|
||||
} else 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 });
|
||||
}
|
||||
}
|
||||
}, [phrase.material, phrase.startBar, phrase.barCount, fixedMaterial, scoreOk, fullScore, setPhrase]);
|
||||
|
||||
// ---- Ref API -----------------------------------------------------------
|
||||
useImperativeHandle(ref, () => ({
|
||||
pauseToggle: () => trainer?.pauseToggle(),
|
||||
previewToggle: () => trainer?.previewToggle(),
|
||||
retry: () => trainer?.retry(),
|
||||
skip: () => trainer?.skip(),
|
||||
pause: () => trainer?.pause(),
|
||||
next: () => nextAction(),
|
||||
}));
|
||||
|
||||
// ---- Hints --------------------------------------------------------------
|
||||
const activeScore = trainer.activeScore;
|
||||
const events = scoreEvents(activeScore);
|
||||
const currentMidi = trainer.currentIdx >= 0 && events[trainer.currentIdx]?.pitch
|
||||
? events[trainer.currentIdx]!.pitch!.midi
|
||||
: null;
|
||||
|
||||
const hintPositions = useMemo(() => {
|
||||
if (!settings.showHint || !currentInstrumentDef.showTuning || !currentTuning || currentMidi === null) return [];
|
||||
if (phrase.fretWindowEnabled) {
|
||||
return positionsWithinWindow(currentMidi, currentTuning, phrase.fretMin, phrase.fretMax);
|
||||
}
|
||||
return getFretboardPositions(currentMidi, currentTuning);
|
||||
}, [settings.showHint, currentInstrumentDef, currentTuning, currentMidi, phrase.fretWindowEnabled, phrase.fretMin, phrase.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 …';
|
||||
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 'Stop preview';
|
||||
return 'Start';
|
||||
}, [trainer.phase]);
|
||||
|
||||
const startAction = useMemo(() => {
|
||||
if (trainer.phase === 'preview') 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>
|
||||
|
||||
<div className="sheet-music-container">
|
||||
{scoreOk ? (
|
||||
<PhraseSheetMusic
|
||||
score={trainer.activeScore}
|
||||
currentIdx={trainer.currentIdx}
|
||||
statuses={trainer.statuses}
|
||||
clef={activeClef}
|
||||
transpose={activeTranspose}
|
||||
width={staffWidth}
|
||||
theme={settings.theme}
|
||||
/>
|
||||
) : (
|
||||
<div className="phrase-error">{materialError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{trainer.error && <div className="error-message">{trainer.error}</div>}
|
||||
{importError && <div className="error-message">{importError}</div>}
|
||||
{rangeWarning && <div className="phrase-warning">{rangeWarning}</div>}
|
||||
|
||||
<div className="feedback-area">
|
||||
<div className="instruction-text phrase-status">{statusText}</div>
|
||||
</div>
|
||||
|
||||
{/* Transport — reuses the single-note button styles/behavior */}
|
||||
<div className="action-row phrase-transport">
|
||||
<button className="skip-button" onClick={startAction} title="Start / Pause (Space)">
|
||||
{trainer.phase === 'playing' || trainer.phase === 'countIn' ? <Pause size={18} /> : <Play size={18} />}
|
||||
{startLabel}
|
||||
</button>
|
||||
{!settings.zenMode && (
|
||||
<>
|
||||
<button
|
||||
className="hint-button"
|
||||
onClick={trainer.previewToggle}
|
||||
title="Preview the phrase (P)"
|
||||
>
|
||||
{trainer.phase === 'preview' ? <Square size={18} /> : <Volume2 size={18} />}
|
||||
{trainer.phase === 'preview' ? 'Stop' : 'Preview'}
|
||||
</button>
|
||||
<button className="hint-button" onClick={trainer.retry} title="Retry from the start (R)">
|
||||
<RotateCcw size={18} />
|
||||
Retry
|
||||
</button>
|
||||
{(phrase.material === 'melody' || fixedMaterial) && (
|
||||
<button className="hint-button" onClick={nextAction} title="New melody / next bars (N)">
|
||||
<SkipForward size={18} />
|
||||
{phrase.material === 'melody' ? 'New' : 'Next'}
|
||||
</button>
|
||||
)}
|
||||
{phrase.pace === 'step' && (
|
||||
<button className="hint-button" onClick={trainer.skip} title="Skip this note (S)">
|
||||
<SkipForward size={18} />
|
||||
Skip
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Same virtual-instrument / hint toggles as single-note mode */}
|
||||
{trainer.phase === 'done' && settings.zenMode && (
|
||||
<>
|
||||
<button className="hint-button" onClick={trainer.retry} title="Retry (R)">
|
||||
<RotateCcw size={18} />
|
||||
Retry
|
||||
</button>
|
||||
{(phrase.material === 'melody' || fixedMaterial) && (
|
||||
<button className="hint-button" onClick={nextAction} title="New / Next (N)">
|
||||
<SkipForward size={18} />
|
||||
{phrase.material === 'melody' ? 'New' : 'Next'}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!settings.zenMode && currentInstrumentDef.showTuning && (
|
||||
<button
|
||||
className={`hint-button ${settings.showFretboard ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showFretboard: !s.showFretboard }))}
|
||||
title={`Toggle Virtual ${currentInstrumentDef.displayName} (Keyboard Shortcut: V)`}
|
||||
>
|
||||
<Guitar size={18} />
|
||||
{currentInstrumentDef.id === 'piano' ? 'Piano' : 'Guitar'}
|
||||
</button>
|
||||
)}
|
||||
{!settings.zenMode && (
|
||||
<button
|
||||
className={`hint-button ${settings.showHint ? 'active' : ''}`}
|
||||
onClick={() => setSettings(s => ({ ...s, showHint: !s.showHint }))}
|
||||
title="Keyboard Shortcut: H"
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
{settings.showHint ? "Hide Hint" : "Show Hint"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Setup panel */}
|
||||
{!settings.zenMode && (
|
||||
<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="library">Exercises</option>
|
||||
<option value="import">Imported file</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{(phrase.material === 'melody' || phrase.material === 'scale') && (
|
||||
<>
|
||||
<label>Key
|
||||
<select value={phrase.keyTonic} onChange={e => setPhrase({ keyTonic: e.target.value })}>
|
||||
{Object.keys(TONICS).filter(t => !['C#', 'Gb', 'D#', 'G#', 'A#'].includes(t)).map(t => (
|
||||
<option key={t} value={t}>{t}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>Mode
|
||||
<select value={phrase.keyMode} onChange={e => setPhrase({ keyMode: e.target.value })}>
|
||||
{(Object.keys(MODE_LABELS) as ModeId[]).map(m => (
|
||||
<option key={m} value={m}>{MODE_LABELS[m]}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{phrase.material === 'melody' && (
|
||||
<>
|
||||
<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: Number(e.target.value) === 3 ? 3 : 4 })}>
|
||||
<option value={4}>4/4</option>
|
||||
<option value={3}>3/4</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Rhythm
|
||||
<select value={phrase.rhythmLevel} onChange={e => setPhrase({ rhythmLevel: Number(e.target.value) === 2 ? 2 : 1 })}>
|
||||
<option value={1}>Simple</option>
|
||||
<option value={2}>Mixed</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 & down</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Meter
|
||||
<select value={phrase.meterNumerator} onChange={e => setPhrase({ meterNumerator: Number(e.target.value) === 3 ? 3 : 4 })}>
|
||||
<option value={4}>4/4</option>
|
||||
<option value={3}>3/4</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>Octave
|
||||
<select value={octaveShift} onChange={e => setOctaveShift(Number(e.target.value))}>
|
||||
<option value={-1}>-1</option>
|
||||
<option value={0}>0</option>
|
||||
<option value={1}>+1</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>
|
||||
)}
|
||||
|
||||
{fretted && (
|
||||
<>
|
||||
<label className="phrase-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={phrase.fretWindowEnabled}
|
||||
onChange={e => setPhrase({ fretWindowEnabled: e.target.checked })}
|
||||
/>
|
||||
Fret window
|
||||
</label>
|
||||
{phrase.fretWindowEnabled && (
|
||||
<>
|
||||
<label>Min fret
|
||||
<input type="number" min={0} max={24} value={phrase.fretMin}
|
||||
onChange={e => setPhrase({ fretMin: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
|
||||
</label>
|
||||
<label>Max fret
|
||||
<input type="number" min={0} max={24} value={phrase.fretMax}
|
||||
onChange={e => setPhrase({ fretMax: Math.max(0, Math.min(24, parseInt(e.target.value) || 0)) })} />
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<label>Pace
|
||||
<select value={phrase.pace} onChange={e => setPhrase({ pace: e.target.value as PhraseSettings['pace'] })}>
|
||||
<option value="step">At your pace</option>
|
||||
<option value="tempo">With tempo</option>
|
||||
</select>
|
||||
</label>
|
||||
{phrase.pace === 'tempo' && (
|
||||
<label>BPM
|
||||
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<button className="control-button small" onClick={() => setPhrase({ bpm: Math.max(30, phrase.bpm - 1) })}>−</button>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={bpmText}
|
||||
onChange={e => { if (/^\d{0,3}$/.test(e.target.value)) setBpmText(e.target.value); }}
|
||||
onBlur={commitBpm}
|
||||
onKeyDown={e => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
|
||||
style={{ width: '46px', textAlign: 'center' }}
|
||||
/>
|
||||
<button className="control-button small" onClick={() => setPhrase({ bpm: Math.min(180, phrase.bpm + 1) })}>+</button>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={30}
|
||||
max={180}
|
||||
step={1}
|
||||
value={phrase.bpm}
|
||||
onChange={e => setPhrase({ bpm: Number(e.target.value) })}
|
||||
aria-label="Phrase tempo"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="phrase-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={phrase.inputMode === 'virtual'}
|
||||
onChange={e => setPhrase({ inputMode: e.target.checked ? 'virtual' : 'mic' })}
|
||||
/>
|
||||
Virtual input only
|
||||
</label>
|
||||
{phrase.pace === 'tempo' && (
|
||||
<label className="phrase-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={phrase.clickSound}
|
||||
onChange={e => setPhrase({ clickSound: e.target.checked })}
|
||||
/>
|
||||
Click sound
|
||||
</label>
|
||||
)}
|
||||
<label className="phrase-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={phrase.autoContinue}
|
||||
onChange={e => setPhrase({ autoContinue: e.target.checked })}
|
||||
/>
|
||||
Auto-continue
|
||||
</label>
|
||||
</div>
|
||||
{phrase.pace === 'tempo' && phrase.clickSound && (
|
||||
<p className="phrase-note">Clicks are short noise bursts, not pitched tones — but speaker bleed can still disturb the microphone. Headphones recommended.</p>
|
||||
)}
|
||||
<p className="phrase-note">
|
||||
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>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{/* 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, phrase.fretWindowEnabled ? phrase.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;
|
||||
@@ -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 (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 } 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';
|
||||
@@ -19,6 +19,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 +50,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' }}>
|
||||
@@ -91,7 +93,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',
|
||||
@@ -213,6 +215,52 @@ export const SettingsModal: React.FC<SettingsModalProps> = ({
|
||||
: "Automatically plays the note audio when a new note appears."}
|
||||
</p>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<hr style={{ borderColor: 'rgba(255,255,255,0.1)', margin: '24px 0' }} />
|
||||
|
||||
{/* Microphone Toggle */}
|
||||
|
||||
@@ -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" />;
|
||||
};
|
||||
|
||||
190
src/components/SingleNoteTrainer.tsx
Normal file
190
src/components/SingleNoteTrainer.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 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 && (
|
||||
<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, 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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
.tuning-readout .cents-value.perfect {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.tuning-readout .cents-value.okay {
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.tuning-readout .cents-value.poor {
|
||||
color: #f87171;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
@@ -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) {
|
||||
if (hasSignal) {
|
||||
if (Math.abs(cents!) < 5) {
|
||||
statusClass = 'perfect'; // Green
|
||||
} else if (Math.abs(cents) < 15) {
|
||||
} else if (Math.abs(cents!) < 15) {
|
||||
statusClass = 'good'; // Green-ish
|
||||
} else if (Math.abs(cents) < 30) {
|
||||
} 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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
27
src/context/SettingsProvider.tsx
Normal file
27
src/context/SettingsProvider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
10
src/context/settings-context.ts
Normal file
10
src/context/settings-context.ts
Normal 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);
|
||||
10
src/context/useSettings.ts
Normal file
10
src/context/useSettings.ts
Normal 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;
|
||||
};
|
||||
427
src/exercises/abcParser.test.ts
Normal file
427
src/exercises/abcParser.test.ts
Normal 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
671
src/exercises/abcParser.ts
Normal 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 };
|
||||
}
|
||||
60
src/exercises/library.test.ts
Normal file
60
src/exercises/library.test.ts
Normal 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
80
src/exercises/library.ts
Normal 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;
|
||||
}
|
||||
10
src/exercises/library/a-minor-arpeggio-study.abc
Normal file
10
src/exercises/library/a-minor-arpeggio-study.abc
Normal 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 |]
|
||||
10
src/exercises/library/bb-major-study.abc
Normal file
10
src/exercises/library/bb-major-study.abc
Normal 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 |]
|
||||
11
src/exercises/library/broken-chord-study.abc
Normal file
11
src/exercises/library/broken-chord-study.abc
Normal 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 |]
|
||||
11
src/exercises/library/c-major-run.abc
Normal file
11
src/exercises/library/c-major-run.abc
Normal 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 |]
|
||||
10
src/exercises/library/descending-study.abc
Normal file
10
src/exercises/library/descending-study.abc
Normal 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 |]
|
||||
10
src/exercises/library/dotted-eighth-study.abc
Normal file
10
src/exercises/library/dotted-eighth-study.abc
Normal 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 |]
|
||||
11
src/exercises/library/dotted-rhythm-study.abc
Normal file
11
src/exercises/library/dotted-rhythm-study.abc
Normal 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 |]
|
||||
10
src/exercises/library/e-minor-pentatonic-run.abc
Normal file
10
src/exercises/library/e-minor-pentatonic-run.abc
Normal 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 |]
|
||||
11
src/exercises/library/e-minor-study.abc
Normal file
11
src/exercises/library/e-minor-study.abc
Normal 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 |]
|
||||
10
src/exercises/library/f-major-study.abc
Normal file
10
src/exercises/library/f-major-study.abc
Normal 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 |]
|
||||
11
src/exercises/library/first-position-study.abc
Normal file
11
src/exercises/library/first-position-study.abc
Normal 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 |]
|
||||
13
src/exercises/library/jingle-bells.abc
Normal file
13
src/exercises/library/jingle-bells.abc
Normal 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 |]
|
||||
13
src/exercises/library/london-bridge.abc
Normal file
13
src/exercises/library/london-bridge.abc
Normal 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 |]
|
||||
12
src/exercises/library/mary-had-a-little-lamb.abc
Normal file
12
src/exercises/library/mary-had-a-little-lamb.abc
Normal 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 |]
|
||||
10
src/exercises/library/mixed-rhythm-study.abc
Normal file
10
src/exercises/library/mixed-rhythm-study.abc
Normal 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 |]
|
||||
15
src/exercises/library/ode-to-joy.abc
Normal file
15
src/exercises/library/ode-to-joy.abc
Normal 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 |]
|
||||
10
src/exercises/library/thirds-study.abc
Normal file
10
src/exercises/library/thirds-study.abc
Normal 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 |]
|
||||
13
src/exercises/library/twinkle.abc
Normal file
13
src/exercises/library/twinkle.abc
Normal 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 |]
|
||||
10
src/exercises/library/waltz-study.abc
Normal file
10
src/exercises/library/waltz-study.abc
Normal 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 |]
|
||||
84
src/game/MatchTracker.test.ts
Normal file
84
src/game/MatchTracker.test.ts
Normal 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
60
src/game/MatchTracker.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
248
src/game/PhraseMatcher.test.ts
Normal file
248
src/game/PhraseMatcher.test.ts
Normal 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
254
src/game/PhraseMatcher.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/game/PhraseTransport.ts
Normal file
76
src/game/PhraseTransport.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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;
|
||||
/** 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 });
|
||||
}
|
||||
|
||||
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: (tick % barTicks === 0) ? i : undefined, 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;
|
||||
}
|
||||
38
src/hooks/rawFrameBus.ts
Normal file
38
src/hooks/rawFrameBus.ts
Normal 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); };
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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
|
||||
@@ -12,7 +14,11 @@ import {
|
||||
getFirstPositionNotes,
|
||||
} from '../music/Tunings';
|
||||
import { INSTRUMENT_DEFINITIONS } from '../music/InstrumentConfigs';
|
||||
import { NOTE_MATCH_THRESHOLD_MS } from '../AppConfig';
|
||||
import {
|
||||
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,12 +27,14 @@ 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];
|
||||
@@ -94,19 +102,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(() => {
|
||||
@@ -129,16 +136,54 @@ export const useGameLogic = (
|
||||
});
|
||||
|
||||
// --- 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;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (shouldAutoPlay && !revealed) {
|
||||
const timer = setTimeout(() => {
|
||||
playNote(targetMidi, 1.0, settings.autoPlayVolume ?? 0.5);
|
||||
autoPlayedForRef.current = targetMidi;
|
||||
playNote(targetMidi, duration, volume);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [targetMidi, settings.gameMode, settings.autoPlaySightReading, revealed, playNote, settings.autoPlayVolume]);
|
||||
}, [
|
||||
targetMidi,
|
||||
revealed,
|
||||
isMicQuiet,
|
||||
settings.gameMode,
|
||||
settings.autoPlaySightReading,
|
||||
settings.referenceNoteDuration,
|
||||
settings.autoPlayVolume,
|
||||
settings.waitForQuiet,
|
||||
playNote,
|
||||
]);
|
||||
|
||||
// --- Init / Reset ---
|
||||
useEffect(() => {
|
||||
@@ -156,7 +201,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 +222,6 @@ export const useGameLogic = (
|
||||
feedbackTimeoutRef.current = setTimeout(() => {
|
||||
setFeedbackMessage("");
|
||||
}, 1500);
|
||||
setMatchStartTime(null);
|
||||
}
|
||||
}
|
||||
}, [targetMidi, settings.rhythm, settings.disableAnimation, restartMetronome, generateNewNote]);
|
||||
@@ -201,25 +244,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) {
|
||||
const matching = pitchData !== null && pitchData.midi === targetMidi;
|
||||
if (tracker.update(matching, Date.now())) {
|
||||
handleMatchSuccess();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setMatchStartTime(null);
|
||||
}
|
||||
}, [pitchData, targetMidi, matchStartTime, handleMatchSuccess]);
|
||||
}, [pitchData, targetMidi, handleMatchSuccess, tracker]);
|
||||
|
||||
return {
|
||||
targetMidi,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { createAudioContext } from '../audio/AudioEngine';
|
||||
|
||||
interface MetronomeOptions {
|
||||
bpm: number;
|
||||
@@ -53,6 +54,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 +67,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 +87,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)
|
||||
|
||||
581
src/hooks/usePhraseTrainer.ts
Normal file
581
src/hooks/usePhraseTrainer.ts
Normal file
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export type PhrasePhase = 'ready' | 'countIn' | 'playing' | 'paused' | 'preview' | '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;
|
||||
}
|
||||
|
||||
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;
|
||||
/** 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;
|
||||
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 [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 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);
|
||||
|
||||
const events = useMemo(() => scoreEvents(activeScore), [activeScore]);
|
||||
|
||||
const setPhaseBoth = useCallback((p: PhrasePhase) => {
|
||||
phaseRef.current = p;
|
||||
setPhase(p);
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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.beat !== undefined);
|
||||
}
|
||||
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;
|
||||
if (p !== 'countIn' && p !== '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 !== '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();
|
||||
// preview: handled by previewToggle
|
||||
}, [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]);
|
||||
|
||||
const previewToggle = useCallback(() => {
|
||||
if (phaseRef.current === 'preview') {
|
||||
audioEngine.cancelGroup(PREVIEW_GROUP);
|
||||
if (previewTimerRef.current) { clearTimeout(previewTimerRef.current); previewTimerRef.current = null; }
|
||||
setPhaseBoth('ready');
|
||||
return;
|
||||
}
|
||||
pause();
|
||||
runTokenRef.current++;
|
||||
const token = runTokenRef.current;
|
||||
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;
|
||||
for (const e of events) {
|
||||
if (!e.pitch) continue;
|
||||
audioEngine.scheduleNote(e.pitch.midi, t0 + (e.startTick / PPQ) * spb, (e.durationTicks / PPQ) * spb, vol, PREVIEW_GROUP);
|
||||
}
|
||||
setPhaseBoth('preview');
|
||||
const totalSec = (events.reduce((a, e) => Math.max(a, e.startTick + e.durationTicks), 0) / PPQ) * spb;
|
||||
previewTimerRef.current = setTimeout(() => {
|
||||
if (runTokenRef.current === token) {
|
||||
setPhaseBoth('ready');
|
||||
}
|
||||
}, 150 + totalSec * 1000 + 200);
|
||||
});
|
||||
}, [events, pause, 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; }
|
||||
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);
|
||||
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,
|
||||
scoreStart: scoreStartRef.current,
|
||||
spb: spbRef.current,
|
||||
error,
|
||||
summary,
|
||||
start,
|
||||
pauseToggle,
|
||||
retry,
|
||||
skip,
|
||||
previewToggle,
|
||||
virtualTap,
|
||||
pause,
|
||||
stop,
|
||||
repeatedNoteBlocked,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
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 {
|
||||
frequency: number;
|
||||
@@ -10,66 +17,164 @@ 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;
|
||||
// Adapt the analysis window to the instrument's lowest note, live
|
||||
useEffect(() => {
|
||||
minFrequencyRef.current = minFrequencyHz;
|
||||
if (minFrequencyHz) {
|
||||
analyzerRef.current?.setLowestFrequency(minFrequencyHz);
|
||||
}
|
||||
}, [minFrequencyHz]);
|
||||
|
||||
useEffect(() => {
|
||||
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 });
|
||||
|
||||
const freq = analyzerRef.current.getPitch();
|
||||
if (freq) {
|
||||
lastPitchAt = now;
|
||||
const midi = frequencyToMidi(freq);
|
||||
const { scientific } = getNoteDetails(midi);
|
||||
|
||||
const cents = getCentDifference(freq, midi);
|
||||
// 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,
|
||||
midi, // raw — matching uses this and must stay unsmoothed
|
||||
note: scientific,
|
||||
cents,
|
||||
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);
|
||||
}
|
||||
|
||||
// Always update level and debug info
|
||||
setAudioLevel(analyzerRef.current.getCurrentLevel());
|
||||
setDebugInfo(analyzerRef.current.getDebugInfo());
|
||||
// Always update level (smoothed)
|
||||
smoothedLevel += (analyzer.getCurrentLevel() - smoothedLevel) * DISPLAY_EMA_FACTOR;
|
||||
setAudioLevel(smoothedLevel);
|
||||
|
||||
animationRef.current = requestAnimationFrame(updatePitch);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
if (!analyzerRef.current) {
|
||||
analyzerRef.current = new PitchAnalyzer();
|
||||
analyzerRef.current.setSensitivity(sensitivity);
|
||||
// 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());
|
||||
}
|
||||
|
||||
analyzerRef.current.start()
|
||||
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);
|
||||
updatePitch();
|
||||
tick();
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err: Error) => {
|
||||
if (cancelled) return;
|
||||
console.error(err);
|
||||
// Provide more specific error messages
|
||||
if (err.name === 'NotAllowedError') {
|
||||
@@ -83,25 +188,20 @@ export function usePitchDetector(active: boolean, sensitivity: number = MIC_DEFA
|
||||
}
|
||||
setIsListening(false);
|
||||
});
|
||||
} else {
|
||||
if (analyzerRef.current) {
|
||||
analyzerRef.current.stop();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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*.
|
||||
|
||||
190
src/music/generators.test.ts
Normal file
190
src/music/generators.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
215
src/music/melodyGenerator.ts
Normal file
215
src/music/melodyGenerator.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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; readonly denominator: 4 };
|
||||
/** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters). */
|
||||
readonly rhythmLevel: 1 | 2;
|
||||
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],
|
||||
];
|
||||
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],
|
||||
];
|
||||
|
||||
function templatesFor(meter: { numerator: 3 | 4 }, level: 1 | 2): number[][] {
|
||||
const base = meter.numerator === 4
|
||||
? (level === 1 ? TEMPLATES_4_L1 : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2])
|
||||
: (level === 1 ? TEMPLATES_3_L1 : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2]);
|
||||
return base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ);
|
||||
}
|
||||
|
||||
/** 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 } };
|
||||
}
|
||||
96
src/music/playableRange.ts
Normal file
96
src/music/playableRange.ts
Normal 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');
|
||||
}
|
||||
130
src/music/scaleDrills.ts
Normal file
130
src/music/scaleDrills.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* 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 interface DrillConfig {
|
||||
readonly keyTonic: string;
|
||||
readonly keyMode: ModeId;
|
||||
readonly direction: DrillDirection;
|
||||
readonly coverage: DrillCoverage;
|
||||
readonly meter: { readonly numerator: 3 | 4; readonly denominator: 4 };
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
// ---- Quarter-note rhythm, final note extended to fill its bar ---------
|
||||
const full = config.meter.numerator * PPQ;
|
||||
if (path.length * PPQ > 8 * full) {
|
||||
return { ok: false, error: 'This drill is longer than 8 bars — reduce the coverage or direction.' };
|
||||
}
|
||||
const barCount = Math.max(1, Math.ceil((path.length * PPQ) / full));
|
||||
const totalTicks = barCount * full;
|
||||
const lastDuration = PPQ + (totalTicks - path.length * PPQ);
|
||||
|
||||
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 : PPQ;
|
||||
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.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
113
src/music/scales.test.ts
Normal 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
183
src/music/scales.ts
Normal 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;
|
||||
}
|
||||
127
src/score/model.test.ts
Normal file
127
src/score/model.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
247
src/score/model.ts
Normal file
247
src/score/model.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
/**
|
||||
* 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; readonly denominator: 4 };
|
||||
readonly key: ScoreKey;
|
||||
readonly measures: ScoreMeasure[];
|
||||
/** Exactly one voice in v1. */
|
||||
readonly voices: ReadonlyArray<{ readonly id: 'melody'; readonly events: ScoreEvent[] }>;
|
||||
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));
|
||||
}
|
||||
@@ -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,6 +58,7 @@
|
||||
/* Auto Dark Mode (System Preference) */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--color-bg: hsl(30, 20%, 10%);
|
||||
--color-surface: hsl(30, 20%, 15%);
|
||||
--color-text-main: hsl(30, 10%, 90%);
|
||||
@@ -65,6 +70,7 @@
|
||||
|
||||
/* Manual Dark Mode Override */
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--color-bg: hsl(30, 20%, 10%);
|
||||
--color-surface: hsl(30, 20%, 15%);
|
||||
--color-text-main: hsl(30, 10%, 90%);
|
||||
|
||||
@@ -10,7 +10,64 @@ export interface RhythmSettings {
|
||||
volume: number;
|
||||
}
|
||||
|
||||
export type PhraseMaterial = 'melody' | 'scale' | '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). */
|
||||
keyTonic: string;
|
||||
keyMode: string; // ModeId
|
||||
bars: number; // 1–8 (melodies)
|
||||
meterNumerator: 3 | 4;
|
||||
rhythmLevel: 1 | 2;
|
||||
scaleCoverage: 'one-octave' | 'two-octave' | 'position';
|
||||
scaleDirection: 'up' | 'down' | 'updown';
|
||||
/** Phrase-local fret window (guitar/bass) — overrides the note set. */
|
||||
fretWindowEnabled: boolean;
|
||||
fretMin: number;
|
||||
fretMax: number;
|
||||
/** Practice range for library/import material (1-based, inclusive). */
|
||||
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';
|
||||
}
|
||||
|
||||
export const DEFAULT_PHRASE_SETTINGS: PhraseSettings = {
|
||||
material: 'melody',
|
||||
libraryId: '',
|
||||
keyTonic: 'C',
|
||||
keyMode: 'major',
|
||||
bars: 2,
|
||||
meterNumerator: 4,
|
||||
rhythmLevel: 1,
|
||||
scaleCoverage: 'one-octave',
|
||||
scaleDirection: 'updown',
|
||||
fretWindowEnabled: false,
|
||||
fretMin: 0,
|
||||
fretMax: 4,
|
||||
startBar: 1,
|
||||
barCount: 2,
|
||||
pace: 'step',
|
||||
bpm: 60,
|
||||
clickSound: false,
|
||||
autoContinue: true,
|
||||
inputMode: 'mic',
|
||||
};
|
||||
|
||||
export function getPhraseSettings(s: AppSettings): PhraseSettings {
|
||||
return { ...DEFAULT_PHRASE_SETTINGS, ...(s.phrase ?? {}) };
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
phrase?: PhraseSettings;
|
||||
difficulty: Difficulty;
|
||||
showHint: boolean;
|
||||
showFretboard: boolean;
|
||||
@@ -20,11 +77,16 @@ export interface AppSettings {
|
||||
instrument: string;
|
||||
rhythm: RhythmSettings;
|
||||
zenMode: boolean;
|
||||
gameMode: 'sight_reading' | 'ear_training';
|
||||
gameMode: 'sight_reading' | 'ear_training' | 'phrase';
|
||||
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;
|
||||
@@ -51,10 +113,13 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
},
|
||||
zenMode: false,
|
||||
gameMode: 'sight_reading',
|
||||
phrase: DEFAULT_PHRASE_SETTINGS,
|
||||
customMinFret: 0,
|
||||
customMaxFret: 12,
|
||||
autoPlaySightReading: false,
|
||||
autoPlayVolume: 0.5,
|
||||
referenceNoteDuration: 1.5,
|
||||
waitForQuiet: false,
|
||||
virtualGuitarVolume: 0.5,
|
||||
virtualGuitarMute: false,
|
||||
micSensitivity: 0.5,
|
||||
|
||||
5
src/types/pitchfinder.d.ts
vendored
5
src/types/pitchfinder.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user