mroctopus / app /src /components /PianoRoll.jsx
Ewan
Add Full Song mode with Demucs source separation
56c8033
import { useRef, useEffect } from 'react';
import { COLORS, noteColor, noteGlowColor } from '../utils/colorScheme';
import {
buildNotePositionMap,
noteXPositionFast,
getVisibleNotes,
isBlackKey,
} from '../utils/midiHelpers';
const LOOK_AHEAD_SECONDS = 4;
const KEYBOARD_HEIGHT_RATIO = 0.18; // keyboard takes 18% of canvas height
const MIN_KEYBOARD_HEIGHT = 80;
const MAX_KEYBOARD_HEIGHT = 150;
function drawRoundedRect(ctx, x, y, w, h, r) {
if (h < 0) return;
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r);
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
ctx.lineTo(x, y + r);
ctx.quadraticCurveTo(x, y, x + r, y);
ctx.closePath();
}
function drawFallingNotes(ctx, notes, currentTime, hitLineY, positionMap) {
const pixelsPerSecond = hitLineY / LOOK_AHEAD_SECONDS;
const visibleNotes = getVisibleNotes(notes, currentTime, LOOK_AHEAD_SECONDS, 0.5);
ctx.save();
for (const note of visibleNotes) {
const noteBottom = hitLineY - (note.time - currentTime) * pixelsPerSecond;
const noteTop =
hitLineY - (note.time + note.duration - currentTime) * pixelsPerSecond;
// Clip to note area
if (noteBottom < 0 || noteTop > hitLineY) continue;
const clippedTop = Math.max(noteTop, 0);
const clippedBottom = Math.min(noteBottom, hitLineY);
const height = clippedBottom - clippedTop;
if (height < 1) continue;
const pos = noteXPositionFast(note.midi, positionMap);
if (!pos) continue;
const padding = 1;
const x = pos.x + padding;
const w = pos.width - padding * 2;
// Glow
ctx.shadowColor = noteGlowColor(note.midi, note.instrument);
ctx.shadowBlur = 12;
// Note body
ctx.fillStyle = noteColor(note.midi, note.instrument);
drawRoundedRect(ctx, x, clippedTop, w, height, 4);
ctx.fill();
// Brighter edge at the bottom (hitting edge)
if (noteBottom <= hitLineY && noteBottom >= hitLineY - 3) {
ctx.shadowBlur = 20;
ctx.fillStyle = noteGlowColor(note.midi, note.instrument);
ctx.fillRect(x, hitLineY - 3, w, 3);
}
}
ctx.shadowBlur = 0;
ctx.restore();
}
function drawHitLine(ctx, y, width) {
ctx.save();
ctx.shadowColor = COLORS.hitLine;
ctx.shadowBlur = 8;
ctx.strokeStyle = COLORS.hitLine;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.restore();
}
function drawKeyboard(ctx, keyboardLayout, keyboardY, keyboardHeight, activeNotes) {
const blackKeyHeight = keyboardHeight * 0.62;
// White keys
for (const key of keyboardLayout) {
if (key.isBlack) continue;
const isActive = activeNotes.has(key.midiNumber);
ctx.fillStyle = isActive ? COLORS.whiteKeyActive : COLORS.whiteKey;
ctx.fillRect(key.x, keyboardY, key.width, keyboardHeight);
ctx.strokeStyle = COLORS.keyBorder;
ctx.lineWidth = 1;
ctx.strokeRect(key.x, keyboardY, key.width, keyboardHeight);
if (isActive) {
ctx.save();
ctx.shadowColor = noteGlowColor(key.midiNumber);
ctx.shadowBlur = 15;
ctx.fillStyle = isActive ? COLORS.whiteKeyActive : COLORS.whiteKey;
ctx.fillRect(key.x + 1, keyboardY, key.width - 2, keyboardHeight);
ctx.shadowBlur = 0;
ctx.restore();
}
}
// Black keys (drawn on top)
for (const key of keyboardLayout) {
if (!key.isBlack) continue;
const isActive = activeNotes.has(key.midiNumber);
ctx.fillStyle = isActive ? COLORS.blackKeyActive : COLORS.blackKey;
ctx.fillRect(key.x, keyboardY, key.width, blackKeyHeight);
if (isActive) {
ctx.save();
ctx.shadowColor = noteGlowColor(key.midiNumber);
ctx.shadowBlur = 15;
ctx.fillRect(key.x, keyboardY, key.width, blackKeyHeight);
ctx.shadowBlur = 0;
ctx.restore();
}
// Black key border
ctx.strokeStyle = '#000000';
ctx.lineWidth = 1;
ctx.strokeRect(key.x, keyboardY, key.width, blackKeyHeight);
}
}
function drawChordLabels(ctx, chords, currentTime, hitLineY, width) {
if (!chords || chords.length === 0) return;
const pixelsPerSecond = hitLineY / LOOK_AHEAD_SECONDS;
const CHORD_STRIP_HEIGHT = 28;
ctx.save();
// Find chords visible in the current window
for (const chord of chords) {
const startTime = chord.start_time;
const endTime = chord.end_time;
// Skip if entirely outside visible range
if (endTime < currentTime - 0.5 || startTime > currentTime + LOOK_AHEAD_SECONDS) continue;
// Skip single-note "chords"
if (chord.quality === 'note') continue;
const yBottom = hitLineY - (startTime - currentTime) * pixelsPerSecond;
const yTop = hitLineY - (endTime - currentTime) * pixelsPerSecond;
// Clip to visible area
if (yBottom < 0 || yTop > hitLineY) continue;
const clippedTop = Math.max(yTop, 0);
const clippedBottom = Math.min(yBottom, hitLineY);
// Draw chord label strip on the left side
const stripY = clippedTop;
const stripHeight = Math.max(CHORD_STRIP_HEIGHT, clippedBottom - clippedTop);
// Semi-transparent background pill
ctx.fillStyle = 'rgba(139, 92, 246, 0.15)';
drawRoundedRect(ctx, 8, stripY, 72, Math.min(CHORD_STRIP_HEIGHT, stripHeight), 6);
ctx.fill();
// Chord name text
ctx.font = 'bold 13px -apple-system, BlinkMacSystemFont, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
// Color based on quality
const isMinor = chord.quality?.includes('minor') || chord.quality?.includes('dim');
ctx.fillStyle = isMinor ? 'rgba(167, 139, 250, 0.9)' : 'rgba(255, 255, 255, 0.9)';
const labelY = stripY + Math.min(CHORD_STRIP_HEIGHT, stripHeight) / 2;
ctx.fillText(chord.chord_name, 44, labelY);
// Subtle divider line across the full width at chord boundary
if (yBottom > 0 && yBottom < hitLineY) {
ctx.strokeStyle = 'rgba(139, 92, 246, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, yBottom);
ctx.lineTo(width, yBottom);
ctx.stroke();
}
}
ctx.restore();
}
function drawLoopMarkers(ctx, loopStart, loopEnd, currentTime, hitLineY, width) {
if (loopStart === null || loopEnd === null) return;
const pixelsPerSecond = hitLineY / LOOK_AHEAD_SECONDS;
// Draw loop region boundaries as dashed lines
for (const t of [loopStart, loopEnd]) {
const y = hitLineY - (t - currentTime) * pixelsPerSecond;
if (y < 0 || y > hitLineY) continue;
ctx.save();
ctx.setLineDash([6, 4]);
ctx.strokeStyle = 'rgba(139, 92, 246, 0.6)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
}
// Dim area outside the loop
const loopStartY = hitLineY - (loopStart - currentTime) * pixelsPerSecond;
const loopEndY = hitLineY - (loopEnd - currentTime) * pixelsPerSecond;
ctx.save();
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
// Above loop end (future beyond loop)
if (loopEndY > 0) {
ctx.fillRect(0, 0, width, Math.min(loopEndY, hitLineY));
}
// Below loop start (past before loop)
if (loopStartY < hitLineY) {
ctx.fillRect(0, Math.max(loopStartY, 0), width, hitLineY - Math.max(loopStartY, 0));
}
ctx.restore();
}
export default function PianoRoll({
notes,
currentTimeRef,
activeNotes,
keyboardLayout,
width,
height,
loopStart,
loopEnd,
chords,
}) {
const canvasRef = useRef(null);
const positionMapRef = useRef(null);
// Rebuild position map when layout changes
useEffect(() => {
positionMapRef.current = buildNotePositionMap(keyboardLayout);
}, [keyboardLayout]);
// Main render loop
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
let frameId;
function render() {
const currentTime = currentTimeRef.current;
const keyboardHeight = Math.min(
MAX_KEYBOARD_HEIGHT,
Math.max(MIN_KEYBOARD_HEIGHT, height * KEYBOARD_HEIGHT_RATIO)
);
const hitLineY = height - keyboardHeight;
// Clear
ctx.fillStyle = COLORS.pianoRollBg;
ctx.fillRect(0, 0, width, height);
// Draw subtle grid lines for visual reference
ctx.strokeStyle = '#ffffff08';
ctx.lineWidth = 1;
const pixelsPerSecond = hitLineY / LOOK_AHEAD_SECONDS;
for (let s = 0; s < LOOK_AHEAD_SECONDS; s++) {
const y = hitLineY - s * pixelsPerSecond;
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
// Falling notes
if (positionMapRef.current) {
drawFallingNotes(ctx, notes, currentTime, hitLineY, positionMapRef.current);
}
// Chord labels
drawChordLabels(ctx, chords, currentTime, hitLineY, width);
// Loop markers
drawLoopMarkers(ctx, loopStart, loopEnd, currentTime, hitLineY, width);
// Hit line
drawHitLine(ctx, hitLineY, width);
// Keyboard
drawKeyboard(ctx, keyboardLayout, hitLineY, keyboardHeight, activeNotes);
frameId = requestAnimationFrame(render);
}
render();
return () => cancelAnimationFrame(frameId);
}, [notes, keyboardLayout, activeNotes, width, height, currentTimeRef, loopStart, loopEnd, chords]);
return (
<canvas
ref={canvasRef}
style={{
width: `${width}px`,
height: `${height}px`,
display: 'block',
}}
/>
);
}