mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
Phrase mode: ABC parser (subagent), library content, renderer, PhraseTrainer component
This commit is contained in:
344
src/components/PhraseSheetMusic.tsx
Normal file
344
src/components/PhraseSheetMusic.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
/**
|
||||
* 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 } 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 ? 200 : 105;
|
||||
const height = rowCount * rowHeight + 14;
|
||||
|
||||
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');
|
||||
const currentColor = resolve('--color-primary', '#e63946');
|
||||
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!;
|
||||
const stateKey = `${p.step}${p.octave}`;
|
||||
const expected = measureState[stateKey] ?? sigAlter[p.step];
|
||||
const glyph = p.alter !== expected ? accidentalGlyph(p.alter) : undefined;
|
||||
measureState[stateKey] = p.alter;
|
||||
note = new StaveNote({
|
||||
keys: [keyString(p.step, p.alter, p.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++) {
|
||||
const y = r * rowHeight + 10;
|
||||
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 + 110, 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;
|
||||
Reference in New Issue
Block a user