/** * 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 { const map: Record = {}; 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 = ({ score, currentIdx, statuses, clef, transpose, width, theme = 'auto', }) => { const containerRef = useRef(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 = {}; 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 (
); }; export default PhraseSheetMusic;