mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
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:
@@ -38,6 +38,8 @@ The **Sheet Music Trainer** is a client-side Single Page Application (SPA) built
|
|||||||
|
|
||||||
### Phrase Mode (sequential practice)
|
### 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).
|
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
|
## Testing
|
||||||
|
|||||||
@@ -330,10 +330,6 @@ function App() {
|
|||||||
|
|
||||||
<LiveNoteStaff
|
<LiveNoteStaff
|
||||||
midi={displayedPitch ? displayedPitch.midi : null}
|
midi={displayedPitch ? displayedPitch.midi : null}
|
||||||
clef={(() => {
|
|
||||||
const { clef } = resolveClefTranspose(currentInstrumentDef, settings.difficulty);
|
|
||||||
return clef === 'grand' ? 'treble' : clef;
|
|
||||||
})()}
|
|
||||||
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
|
transpose={resolveClefTranspose(currentInstrumentDef, settings.difficulty).transpose}
|
||||||
keySignature={settings.keySignature}
|
keySignature={settings.keySignature}
|
||||||
theme={settings.theme}
|
theme={settings.theme}
|
||||||
|
|||||||
@@ -2,26 +2,34 @@
|
|||||||
* Tiny always-visible staff showing the currently DETECTED note (the same
|
* Tiny always-visible staff showing the currently DETECTED note (the same
|
||||||
* display pipeline as the single-note "played note" measure): written pitch
|
* display pipeline as the single-note "played note" measure): written pitch
|
||||||
* (sounding + display transpose), active key signature, theme-aware.
|
* (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 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';
|
import { getNoteInKey } from '../music/NoteUtils';
|
||||||
|
|
||||||
interface LiveNoteStaffProps {
|
interface LiveNoteStaffProps {
|
||||||
/** Sounding midi of the live detected pitch, or null when silent. */
|
/** Sounding midi of the live detected pitch, or null when silent. */
|
||||||
midi: number | null;
|
midi: number | null;
|
||||||
clef: 'treble' | 'bass';
|
|
||||||
/** Display transpose applied once (guitar +12 …), as in SheetMusic. */
|
|
||||||
transpose: number;
|
transpose: number;
|
||||||
keySignature: string;
|
keySignature: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
theme?: 'light' | 'dark' | 'auto';
|
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> = ({
|
export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
||||||
midi,
|
midi,
|
||||||
clef = 'treble',
|
|
||||||
transpose = 0,
|
transpose = 0,
|
||||||
keySignature = 'C',
|
keySignature = 'C',
|
||||||
width = 130,
|
width = 130,
|
||||||
@@ -42,12 +50,7 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
const renderer = new Renderer(container, Renderer.Backends.SVG);
|
const renderer = new Renderer(container, Renderer.Backends.SVG);
|
||||||
// Match the mic button height (64px) so the staff is vertically
|
renderer.resize(width, BOX_H);
|
||||||
// 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);
|
|
||||||
const context = renderer.getContext();
|
const context = renderer.getContext();
|
||||||
|
|
||||||
const resolvedColor =
|
const resolvedColor =
|
||||||
@@ -57,14 +60,30 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
context.setFillStyle(resolvedColor);
|
context.setFillStyle(resolvedColor);
|
||||||
context.setStrokeStyle(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 });
|
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);
|
stave.addClef(clef);
|
||||||
if (keySignature) stave.addKeySignature(keySignature);
|
if (keySignature) stave.addKeySignature(keySignature);
|
||||||
stave.setContext(context).draw();
|
stave.setContext(context).draw();
|
||||||
|
|
||||||
if (midi !== null) {
|
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 spec = getNoteInKey(written, keySignature);
|
||||||
const note = new StaveNote({
|
const note = new StaveNote({
|
||||||
keys: spec.keys,
|
keys: spec.keys,
|
||||||
@@ -73,22 +92,32 @@ export const LiveNoteStaff: React.FC<LiveNoteStaffProps> = ({
|
|||||||
});
|
});
|
||||||
note.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
|
note.setStyle({ fillStyle: resolvedColor, strokeStyle: resolvedColor });
|
||||||
if (spec.accidental) note.addModifier(new Accidental(spec.accidental));
|
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 });
|
const voice = new Voice({ numBeats: 4, beatValue: 4 });
|
||||||
voice.setMode(Voice.Mode.SOFT);
|
voice.setMode(Voice.Mode.SOFT);
|
||||||
voice.addTickable(note);
|
voice.addTickable(note);
|
||||||
new Formatter().joinVoices([voice]).format([voice], width - 40);
|
new Formatter().joinVoices([voice]).format([voice], width - 40);
|
||||||
voice.draw(context, stave);
|
voice.draw(context, stave);
|
||||||
}
|
}
|
||||||
}, [midi, clef, transpose, keySignature, width, theme, schemeVersion]);
|
}, [midi, transpose, keySignature, width, theme, schemeVersion]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="live-note-staff"
|
className="live-note-staff"
|
||||||
style={{ width }}
|
style={{ width, height: BOX_H }}
|
||||||
// (height comes from the SVG)
|
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={midi !== null ? `Currently played: note` : 'No note currently detected'}
|
aria-label={midi !== null ? 'Currently played note' : 'No note currently detected'}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -236,7 +236,9 @@ export const PhraseSheetMusic: React.FC<PhraseSheetMusicProps> = ({
|
|||||||
|
|
||||||
// ---- Staves, voices, formatting, drawing -----------------------------
|
// ---- Staves, voices, formatting, drawing -----------------------------
|
||||||
for (let r = 0; r < rowFragmentList.length; r++) {
|
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[] = [];
|
const rowMeasureNumbers: number[] = [];
|
||||||
for (let b = 0; b < barsPerRow; b++) {
|
for (let b = 0; b < barsPerRow; b++) {
|
||||||
const m = r * barsPerRow + b;
|
const m = r * barsPerRow + b;
|
||||||
|
|||||||
Reference in New Issue
Block a user