Files
sheetmusictrainer/src/components/PhraseSheetMusic.tsx

347 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 ? 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!;
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 + 26;
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;