diff --git a/TECHNICAL_REALIZATION.md b/TECHNICAL_REALIZATION.md index 53363af..fb31fe2 100644 --- a/TECHNICAL_REALIZATION.md +++ b/TECHNICAL_REALIZATION.md @@ -38,6 +38,8 @@ The **Sheet Music Trainer** is a client-side Single Page Application (SPA) built ### 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 diff --git a/src/App.tsx b/src/App.tsx index b4a7d52..56589bb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -330,10 +330,6 @@ function App() { { - const { clef } = resolveClefTranspose(currentInstrumentDef, settings.difficulty); - return clef === 'grand' ? 'treble' : clef; - })()} transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose} keySignature={settings.keySignature} theme={settings.theme} diff --git a/src/components/LiveNoteStaff.tsx b/src/components/LiveNoteStaff.tsx index 6d9187b..58ff041 100644 --- a/src/components/LiveNoteStaff.tsx +++ b/src/components/LiveNoteStaff.tsx @@ -2,26 +2,34 @@ * 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. - * Renders only when the detected midi changes — not per frame. + * + * 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 } from 'vexflow'; +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; - clef: 'treble' | 'bass'; - /** Display transpose applied once (guitar +12 …), as in SheetMusic. */ 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 = ({ midi, - clef = 'treble', transpose = 0, keySignature = 'C', width = 130, @@ -42,12 +50,7 @@ export const LiveNoteStaff: React.FC = ({ container.innerHTML = ''; const renderer = new Renderer(container, Renderer.Backends.SVG); - // Match the mic button height (64px) so the staff is vertically - // aligned with it. The stave is centered in the box; extreme ledger - // lines (very high/low notes) intentionally OVERFLOW the box - // (CSS overflow: visible) and may overlap neighboring elements — - // preferred over reserving large empty headroom. - renderer.resize(width, 64); + renderer.resize(width, BOX_H); const context = renderer.getContext(); const resolvedColor = @@ -57,14 +60,30 @@ export const LiveNoteStaff: React.FC = ({ context.setFillStyle(resolvedColor); context.setStrokeStyle(resolvedColor); - const stave = new Stave(0, 13, width - 2); + 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) { - const written = midi + transpose; + // 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, @@ -73,22 +92,32 @@ export const LiveNoteStaff: React.FC = ({ }); 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, clef, transpose, keySignature, width, theme, schemeVersion]); + }, [midi, transpose, keySignature, width, theme, schemeVersion]); return (
); }; diff --git a/src/components/PhraseSheetMusic.tsx b/src/components/PhraseSheetMusic.tsx index b4669ce..ff1e67e 100644 --- a/src/components/PhraseSheetMusic.tsx +++ b/src/components/PhraseSheetMusic.tsx @@ -236,7 +236,9 @@ export const PhraseSheetMusic: React.FC = ({ // ---- Staves, voices, formatting, drawing ----------------------------- for (let r = 0; r < rowFragmentList.length; r++) { - const y = r * rowHeight + 26; + // 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;