mroctopus / app /src /components /SheetMusic.jsx
Ewan
Smooth progress line movement on sheet music
57d692f
import { useEffect, useRef, useMemo, useCallback } from 'react';
import { Renderer, Stave, StaveNote, Voice, Formatter, Dot, StaveConnector } from 'vexflow';
import { midiToMeasures } from '../utils/midiToNotation';
const MEASURES_PER_LINE = 4;
const STAVE_WIDTH = 250;
const TREBLE_Y_OFFSET = 0;
const BASS_Y_OFFSET = 80;
const LINE_HEIGHT = 200;
const LEFT_MARGIN = 60;
const TOP_MARGIN = 70;
const ACTIVE_COLOR = '#8b5cf6';
const DEFAULT_COLOR = '#000000';
function createStaveNote(noteData, clef) {
const note = new StaveNote({
keys: noteData.keys,
duration: noteData.duration,
clef: clef === 'treble' ? 'treble' : 'bass',
});
for (let d = 0; d < noteData.dots; d++) {
Dot.buildAndAttach([note]);
}
return note;
}
export default function SheetMusic({ midiObject, fileName, currentTimeRef, isPlaying }) {
const containerRef = useRef(null);
const scrollRef = useRef(null);
const progressRef = useRef(null);
const noteRefsRef = useRef([]); // { svgEl, absoluteBeat, isRest }
const animFrameRef = useRef(null);
const measureLayoutRef = useRef([]); // { x, y, width, lineIdx, measureIdx }
const { measures, timeSignature, bpm } = useMemo(() => {
if (!midiObject) return { measures: [], timeSignature: [4, 4], bpm: 120 };
return midiToMeasures(midiObject);
}, [midiObject]);
const beatsPerMeasure = timeSignature[0];
const secondsPerBeat = 60 / bpm;
const render = useCallback(() => {
const container = containerRef.current;
if (!container || measures.length === 0) return;
container.innerHTML = '';
noteRefsRef.current = [];
measureLayoutRef.current = [];
const numLines = Math.ceil(measures.length / MEASURES_PER_LINE);
const totalWidth = LEFT_MARGIN + STAVE_WIDTH * MEASURES_PER_LINE + 40;
const totalHeight = TOP_MARGIN + numLines * LINE_HEIGHT + 40;
const renderer = new Renderer(container, Renderer.Backends.SVG);
renderer.resize(totalWidth, totalHeight);
const context = renderer.getContext();
context.setFont('Arial', 10);
// Draw title
const title = (fileName || 'Untitled').replace(/\.[^.]+$/, '');
const svgEl = container.querySelector('svg');
if (svgEl) {
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', totalWidth / 2);
text.setAttribute('y', 30);
text.setAttribute('text-anchor', 'middle');
text.setAttribute('font-family', 'Inter, Arial, sans-serif');
text.setAttribute('font-size', '20');
text.setAttribute('font-weight', '700');
text.setAttribute('fill', '#1a1a2e');
text.textContent = title;
svgEl.appendChild(text);
}
for (let lineIdx = 0; lineIdx < numLines; lineIdx++) {
const startMeasure = lineIdx * MEASURES_PER_LINE;
const endMeasure = Math.min(startMeasure + MEASURES_PER_LINE, measures.length);
const lineY = TOP_MARGIN + lineIdx * LINE_HEIGHT;
for (let m = startMeasure; m < endMeasure; m++) {
const measureIdx = m - startMeasure;
const x = LEFT_MARGIN + measureIdx * STAVE_WIDTH;
const isFirst = measureIdx === 0;
const measure = measures[m];
// Store measure layout for progress line positioning
measureLayoutRef.current.push({
globalIdx: m,
x,
y: lineY,
width: STAVE_WIDTH,
lineIdx,
measureStart: measure.measureStart, // in beats
});
// Create treble stave
const trebleStave = new Stave(x, lineY + TREBLE_Y_OFFSET, STAVE_WIDTH);
if (isFirst) {
trebleStave.addClef('treble');
if (lineIdx === 0) {
trebleStave.addTimeSignature(`${timeSignature[0]}/${timeSignature[1]}`);
}
}
trebleStave.setContext(context).draw();
// Create bass stave
const bassStave = new Stave(x, lineY + BASS_Y_OFFSET, STAVE_WIDTH);
if (isFirst) {
bassStave.addClef('bass');
if (lineIdx === 0) {
bassStave.addTimeSignature(`${timeSignature[0]}/${timeSignature[1]}`);
}
}
bassStave.setContext(context).draw();
// Draw brace and line connector for first measure of each line
if (isFirst) {
const brace = new StaveConnector(trebleStave, bassStave);
brace.setType('brace');
brace.setContext(context).draw();
const lineConn = new StaveConnector(trebleStave, bassStave);
lineConn.setType('singleLeft');
lineConn.setContext(context).draw();
}
// Right barline connector
const rightConn = new StaveConnector(trebleStave, bassStave);
rightConn.setType('singleRight');
rightConn.setContext(context).draw();
// Create and draw treble voice, collecting note refs
try {
const trebleNotes = measure.treble.map((n, ni) => {
const sn = createStaveNote(n, 'treble');
sn._beatData = { absoluteBeat: measure.measureStart + (n.beatOffset || 0), isRest: n.isRest };
return sn;
});
const trebleVoice = new Voice({
num_beats: timeSignature[0],
beat_value: timeSignature[1],
}).setStrict(false);
trebleVoice.addTickables(trebleNotes);
new Formatter().joinVoices([trebleVoice]).format([trebleVoice], STAVE_WIDTH - 50);
trebleVoice.draw(context, trebleStave);
// Collect SVG element references
for (const sn of trebleNotes) {
const el = sn.getSVGElement?.();
if (el && !sn._beatData.isRest) {
noteRefsRef.current.push({
el,
absoluteBeat: sn._beatData.absoluteBeat,
});
}
}
} catch (e) {
// Skip measures that fail to render
}
// Create and draw bass voice
try {
const bassNotes = measure.bass.map((n, ni) => {
const sn = createStaveNote(n, 'bass');
sn._beatData = { absoluteBeat: measure.measureStart + (n.beatOffset || 0), isRest: n.isRest };
return sn;
});
const bassVoice = new Voice({
num_beats: timeSignature[0],
beat_value: timeSignature[1],
}).setStrict(false);
bassVoice.addTickables(bassNotes);
new Formatter().joinVoices([bassVoice]).format([bassVoice], STAVE_WIDTH - 50);
bassVoice.draw(context, bassStave);
for (const sn of bassNotes) {
const el = sn.getSVGElement?.();
if (el && !sn._beatData.isRest) {
noteRefsRef.current.push({
el,
absoluteBeat: sn._beatData.absoluteBeat,
});
}
}
} catch (e) {
// Skip measures that fail to render
}
}
}
}, [measures, timeSignature, fileName]);
useEffect(() => {
render();
}, [render]);
// Animation loop: update progress line position and note colors
useEffect(() => {
const progressEl = progressRef.current;
const scrollEl = scrollRef.current;
if (!progressEl || !scrollEl) return;
let lastColoredBeat = -1;
let lastLineIdx = -1;
const tick = () => {
const currentTime = currentTimeRef?.current ?? 0;
const currentBeat = currentTime / secondsPerBeat;
const layout = measureLayoutRef.current;
if (layout.length === 0) {
progressEl.style.display = 'none';
animFrameRef.current = requestAnimationFrame(tick);
return;
}
// Find which measure we're in
let mLayout = null;
for (let i = layout.length - 1; i >= 0; i--) {
if (currentBeat >= layout[i].measureStart - 0.01) {
mLayout = layout[i];
break;
}
}
if (!mLayout) {
mLayout = layout[0];
}
// Calculate x position within measure
const beatInMeasure = currentBeat - mLayout.measureStart;
const progress = Math.max(0, Math.min(1, beatInMeasure / beatsPerMeasure));
// Note area starts ~50px from stave left (after clef/time sig) and ends ~10px before right
const noteAreaStart = mLayout.x + 50;
const noteAreaEnd = mLayout.x + mLayout.width - 10;
const noteAreaWidth = noteAreaEnd - noteAreaStart;
const lineX = noteAreaStart + progress * noteAreaWidth;
const lineY = mLayout.y - 5;
const lineHeight = BASS_Y_OFFSET + 80; // span treble + bass staves
// Enable smooth transition within the same staff line, disable on line jumps
if (mLayout.lineIdx !== lastLineIdx) {
progressEl.classList.remove('smooth');
} else {
progressEl.classList.add('smooth');
}
lastLineIdx = mLayout.lineIdx;
progressEl.style.display = 'block';
progressEl.style.left = `${lineX + 20}px`; // +20 for container padding
progressEl.style.top = `${lineY + 20}px`;
progressEl.style.height = `${lineHeight}px`;
// Auto-scroll to keep progress line visible
const lineScreenY = lineY + 20 - scrollEl.scrollTop;
const viewHeight = scrollEl.clientHeight;
if (lineScreenY < 50 || lineScreenY > viewHeight - 100) {
scrollEl.scrollTo({
top: lineY - 80,
behavior: 'smooth',
});
}
// Color notes based on playback position
// Only update when we've crossed a meaningful threshold
if (Math.abs(currentBeat - lastColoredBeat) > 0.05) {
lastColoredBeat = currentBeat;
for (const noteRef of noteRefsRef.current) {
const isPast = noteRef.absoluteBeat <= currentBeat + 0.1;
const color = isPast ? ACTIVE_COLOR : DEFAULT_COLOR;
if (noteRef._lastColor !== color) {
noteRef._lastColor = color;
// Color all child paths and text within the note group
const paths = noteRef.el.querySelectorAll('path, text, circle, ellipse, line, rect');
for (const p of paths) {
p.setAttribute('fill', color);
p.setAttribute('stroke', color);
}
}
}
}
animFrameRef.current = requestAnimationFrame(tick);
};
animFrameRef.current = requestAnimationFrame(tick);
return () => {
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, [currentTimeRef, secondsPerBeat, beatsPerMeasure]);
// Reset note colors when not playing and at time 0
useEffect(() => {
if (!isPlaying && currentTimeRef?.current < 0.1) {
for (const noteRef of noteRefsRef.current) {
noteRef._lastColor = DEFAULT_COLOR;
const paths = noteRef.el.querySelectorAll('path, text, circle, ellipse, line, rect');
for (const p of paths) {
p.setAttribute('fill', DEFAULT_COLOR);
p.setAttribute('stroke', DEFAULT_COLOR);
}
}
}
}, [isPlaying, currentTimeRef]);
const handleDownloadPDF = useCallback(() => {
const container = containerRef.current;
if (!container) return;
const svgEl = container.querySelector('svg');
if (!svgEl) return;
// Clone SVG and set white background
const clone = svgEl.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
// Add white background rect
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
bg.setAttribute('width', '100%');
bg.setAttribute('height', '100%');
bg.setAttribute('fill', 'white');
clone.insertBefore(bg, clone.firstChild);
const svgData = new XMLSerializer().serializeToString(clone);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = 2;
canvas.width = img.width * scale;
canvas.height = img.height * scale;
const ctx = canvas.getContext('2d');
ctx.scale(scale, scale);
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, img.width, img.height);
ctx.drawImage(img, 0, 0);
canvas.toBlob((blob) => {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
const name = (fileName || 'sheet-music').replace(/\.[^.]+$/, '');
a.download = `${name} - Sheet Music.png`;
a.click();
URL.revokeObjectURL(a.href);
}, 'image/png');
URL.revokeObjectURL(url);
};
img.src = url;
}, [fileName]);
if (!midiObject || measures.length === 0) {
return (
<div className="sheet-music-empty">
<p>No sheet music to display. Transcribe a song first.</p>
</div>
);
}
return (
<div className="sheet-music-wrapper">
<div className="sheet-music-toolbar">
<span className="sheet-music-info">
{measures.length} measures | {timeSignature[0]}/{timeSignature[1]} | {Math.round(bpm)} BPM
</span>
<button className="btn btn-download" onClick={handleDownloadPDF}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
Download PNG
</button>
</div>
<div className="sheet-music-scroll" ref={scrollRef}>
<div className="sheet-music-container" ref={containerRef} />
<div className="sheet-music-progress-line" ref={progressRef} />
</div>
</div>
);
}