Spaces:
Sleeping
Sleeping
| 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', | |
| }} | |
| /> | |
| ); | |
| } | |