mroctopus / app /src /components /DrumSheet.jsx
Ewan
Add drum transcription with madmom onset detection + spectral classification
eed42a3
import { useEffect, useRef, useCallback } from 'react';
import { COLORS } from '../utils/colorScheme';
const LOOK_AHEAD = 4; // seconds ahead to show
// Lane display order (top to bottom)
const LANE_CONFIG = [
{ id: 'crash', label: 'Crash', color: COLORS.drumCrash, shape: 'diamond' },
{ id: 'ride', label: 'Ride', color: COLORS.drumRide, shape: 'diamond' },
{ id: 'hihat', label: 'HH', color: COLORS.drumHihat, shape: 'x' },
{ id: 'tom_high', label: 'Tom H', color: COLORS.drumTomHigh, shape: 'circle' },
{ id: 'snare', label: 'Snare', color: COLORS.drumSnare, shape: 'circle' },
{ id: 'tom_low', label: 'Tom L', color: COLORS.drumTomLow, shape: 'circle' },
{ id: 'kick', label: 'Kick', color: COLORS.drumKick, shape: 'circle' },
];
function drawDiamond(ctx, x, y, size) {
ctx.beginPath();
ctx.moveTo(x, y - size);
ctx.lineTo(x + size, y);
ctx.lineTo(x, y + size);
ctx.lineTo(x - size, y);
ctx.closePath();
ctx.fill();
}
function drawX(ctx, x, y, size) {
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(x - size, y - size);
ctx.lineTo(x + size, y + size);
ctx.moveTo(x + size, y - size);
ctx.lineTo(x - size, y + size);
ctx.stroke();
}
export default function DrumSheet({
tabData,
currentTimeRef,
width,
height,
}) {
const canvasRef = useRef(null);
const animRef = useRef(null);
const draw = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas || !tabData) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== width * dpr || canvas.height !== height * dpr) {
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr);
}
const currentTime = currentTimeRef?.current ?? 0;
const numLanes = LANE_CONFIG.length;
// Layout
const topMargin = 50;
const bottomMargin = 30;
const leftMargin = 55;
const rightMargin = 20;
const laneAreaHeight = height - topMargin - bottomMargin;
const laneSpacing = laneAreaHeight / (numLanes - 1);
const playableWidth = width - leftMargin - rightMargin;
// Progress line at 20% from left
const progressX = leftMargin + playableWidth * 0.2;
const pixelsPerSecond = (playableWidth * 0.8) / LOOK_AHEAD;
// Clear
ctx.fillStyle = COLORS.tabBg;
ctx.fillRect(0, 0, width, height);
// Draw lane labels
ctx.font = '12px Inter, monospace';
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
for (let i = 0; i < numLanes; i++) {
const y = topMargin + i * laneSpacing;
ctx.fillStyle = LANE_CONFIG[i].color;
ctx.fillText(LANE_CONFIG[i].label, leftMargin - 10, y);
}
// Draw lane lines (subtle)
ctx.strokeStyle = COLORS.tabString;
ctx.lineWidth = 0.5;
for (let i = 0; i < numLanes; i++) {
const y = topMargin + i * laneSpacing;
ctx.beginPath();
ctx.moveTo(leftMargin, y);
ctx.lineTo(width - rightMargin, y);
ctx.stroke();
}
// Build lane index lookup
const laneIndex = {};
LANE_CONFIG.forEach((lane, i) => { laneIndex[lane.id] = i; });
// Draw drum hits
const events = tabData.events || [];
const hitSize = 7;
for (const event of events) {
const x = progressX + (event.time - currentTime) * pixelsPerSecond;
if (x < leftMargin - 20 || x > width + 20) continue;
const laneIdx = laneIndex[event.lane];
if (laneIdx === undefined) continue;
const lane = LANE_CONFIG[laneIdx];
const y = topMargin + laneIdx * laneSpacing;
const isPast = event.time <= currentTime;
const isActive = isPast && event.time + 0.1 > currentTime;
// Glow for active hits
if (isActive) {
ctx.shadowColor = lane.color;
ctx.shadowBlur = 12;
}
// Set color: brighter for future, dimmer for past
const alpha = isPast ? 0.4 : 1.0;
ctx.fillStyle = lane.color;
ctx.strokeStyle = lane.color;
ctx.globalAlpha = alpha;
// Draw shape based on instrument type
if (lane.shape === 'diamond') {
drawDiamond(ctx, x, y, hitSize);
} else if (lane.shape === 'x') {
drawX(ctx, x, y, hitSize * 0.7);
} else {
// Circle (default for kick, snare, toms)
ctx.beginPath();
ctx.arc(x, y, hitSize * 0.8, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1.0;
ctx.shadowBlur = 0;
}
// Progress line
ctx.strokeStyle = COLORS.tabProgressLine;
ctx.lineWidth = 2;
ctx.shadowColor = 'rgba(139, 92, 246, 0.5)';
ctx.shadowBlur = 8;
ctx.beginPath();
ctx.moveTo(progressX, topMargin - 10);
ctx.lineTo(progressX, topMargin + (numLanes - 1) * laneSpacing + 10);
ctx.stroke();
ctx.shadowBlur = 0;
// "DRUMS" clef label
ctx.fillStyle = COLORS.textMuted;
ctx.font = 'bold 11px Inter, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const clefY = topMargin + laneAreaHeight / 2;
ctx.fillText('D', 15, clefY - 24);
ctx.fillText('R', 15, clefY - 12);
ctx.fillText('U', 15, clefY);
ctx.fillText('M', 15, clefY + 12);
ctx.fillText('S', 15, clefY + 24);
animRef.current = requestAnimationFrame(draw);
}, [tabData, currentTimeRef, width, height]);
useEffect(() => {
animRef.current = requestAnimationFrame(draw);
return () => {
if (animRef.current) cancelAnimationFrame(animRef.current);
};
}, [draw]);
if (!tabData) {
return (
<div className="tab-empty">
<p>No drum data available.</p>
</div>
);
}
return <canvas ref={canvasRef} style={{ display: 'block', width: '100%', height: '100%' }} />;
}