Fix stave vertical placement: compensate VexFlow space_above_staff_ln offset in live staff and phrase rows (verified via screenshots)

This commit is contained in:
2026-09-14 02:21:33 +02:00
parent 7d4c71539a
commit 173c4c56bc
4 changed files with 51 additions and 22 deletions

View File

@@ -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

View File

@@ -330,10 +330,6 @@ function App() {
<LiveNoteStaff
midi={displayedPitch ? displayedPitch.midi : null}
clef={(() => {
const { clef } = resolveClefTranspose(currentInstrumentDef, settings.difficulty);
return clef === 'grand' ? 'treble' : clef;
})()}
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
keySignature={settings.keySignature}
theme={settings.theme}

View File

@@ -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<LiveNoteStaffProps> = ({
midi,
clef = 'treble',
transpose = 0,
keySignature = 'C',
width = 130,
@@ -42,12 +50,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
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<LiveNoteStaffProps> = ({
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<LiveNoteStaffProps> = ({
});
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 (
<div
ref={containerRef}
className="live-note-staff"
style={{ width }}
// (height comes from the SVG)
style={{ width, height: BOX_H }}
role="img"
aria-label={midi !== null ? `Currently played: note` : 'No note currently detected'}
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
/>
);
};

View File

@@ -236,7 +236,9 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
// ---- 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;