import { useEffect, useRef } from 'react'; import { store } from '../state/store.ts'; import { player } from '../audio/player.ts'; import { TempoMap } from '../midi/time.ts'; import { newId, songEndTick, type Note, type Song } from '../types.ts'; import { noteName } from '../midi/gm.ts'; import { trackColor } from './colors.ts'; import { isBlackKey, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VEL_H } from '../pianoroll/geometry.ts'; // The piano roll: a single canvas holding the ruler, key column, note // grid, and velocity lane. It redraws every animation frame from current // state, which keeps the playhead and edits in sync without React // re-renders. Gestures follow the usual DAW conventions: // click empty space set the playhead there (and deselect) // drag on empty space marquee select (shift extends the selection) // double-click empty space add a note; double-click a note deletes it // drag a note move it (alt+drag moves a copy) // drag a note's edges resize from either end // right-click a note delete it // click in the ruler move the playhead // drag in the ruler make a time selection (loop, range edits) // drag in the velocity lane paint velocities // wheel / shift+wheel scroll; ctrl+wheel and alt+wheel zoom // shift while dragging bypass snap (free positioning) // Ctrl+A/C/X/V/D/E and the arrow keys act on the selection (see App.tsx). interface OrigNote { tick: number; duration: number; pitch: number } type Drag = | { mode: 'ruler-pending'; x0: number } | { mode: 'range'; anchorTick: number } | { mode: 'keys'; pitch: number; voice: { noteOff(): void; cut(): void } | null } | { mode: 'velocity' } | { mode: 'pending'; x0: number; y0: number } | { mode: 'marquee'; additive: boolean; x0: number; y0: number; x1: number; y1: number } | { mode: 'move'; startX: number; startY: number; anchorId: string; origs: Map; lastBlipPitch: number } | { mode: 'resize'; startX: number; anchorId: string; origs: Map } | { mode: 'resize-left'; startX: number; anchorId: string; origs: Map }; const MIN_DUR_TICKS = 24; export function PianoRoll() { const canvasRef = useRef(null); const containerRef = useRef(null); const view = useRef({ pxPerBeat: 96, scrollX: 0, scrollY: (PITCH_MAX - 84) * 12, keyH: 12 }); const drag = useRef(null); const tmCache = useRef<{ song: Song; tm: TempoMap } | null>(null); useEffect(() => { const canvas = canvasRef.current!; const container = containerRef.current!; let raf = 0; let width = 0; let height = 0; const getTm = (song: Song): TempoMap => { if (!tmCache.current || tmCache.current.song !== song) { tmCache.current = { song, tm: new TempoMap(song.ppq, song.tempo, song.timeSig) }; } return tmCache.current.tm; }; const resize = () => { const rect = container.getBoundingClientRect(); width = Math.max(50, rect.width); height = Math.max(50, rect.height); const dpr = window.devicePixelRatio || 1; canvas.width = Math.round(width * dpr); canvas.height = Math.round(height * dpr); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; }; const ro = new ResizeObserver(resize); ro.observe(container); resize(); const pxPerTick = (song: Song) => view.current.pxPerBeat / song.ppq; const tickToX = (song: Song, tick: number) => KEYS_W + tick * pxPerTick(song) - view.current.scrollX; const xToTick = (song: Song, x: number) => (x - KEYS_W + view.current.scrollX) / pxPerTick(song); const pitchToY = (p: number) => RULER_H + (PITCH_MAX - p) * view.current.keyH - view.current.scrollY; const yToPitch = (y: number) => PITCH_MAX - Math.floor((y + view.current.scrollY - RULER_H) / view.current.keyH); let lastDrawnPos = -1; const draw = () => { raf = requestAnimationFrame(draw); const ctx = canvas.getContext('2d'); if (!ctx) return; const dpr = window.devicePixelRatio || 1; ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.fillStyle = '#16181d'; ctx.fillRect(0, 0, width, height); const st = store.state; const song = st.rec?.song; if (!song) return; const tm = getTm(song); const rollBottom = height - VEL_H; const selection = new Set(st.selection); const activeIdx = Math.max(0, song.tracks.findIndex((t) => t.id === st.activeTrackId)); const activeTrack = song.tracks[activeIdx] as typeof song.tracks[number] | undefined; // playhead position and auto-scroll const posSec = Math.max(0, player.getPositionSec()); const playTick = tm.secToTick(posSec); const posChanged = posSec !== lastDrawnPos; lastDrawnPos = posSec; if (player.state !== 'stopped' || posChanged) { const px = tickToX(song, playTick); if (px > width - 40 || px < KEYS_W) { view.current.scrollX = Math.max(0, playTick * pxPerTick(song) - 80); } } // pitch rows for (let p = PITCH_MIN; p <= PITCH_MAX; p++) { const y = pitchToY(p); if (y + view.current.keyH < RULER_H || y > rollBottom) continue; ctx.fillStyle = isBlackKey(p) ? '#1a1d23' : '#20242c'; ctx.fillRect(KEYS_W, y, width - KEYS_W, view.current.keyH - 0.5); } // vertical grid const tick0 = Math.max(0, Math.floor(xToTick(song, KEYS_W))); const tick1 = Math.ceil(xToTick(song, width)); const snapTicks = store.snapTicks(); if (snapTicks > 0 && snapTicks * pxPerTick(song) > 7) { ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.beginPath(); for (let t = Math.floor(tick0 / snapTicks) * snapTicks; t <= tick1; t += snapTicks) { const x = tickToX(song, t); ctx.moveTo(x, RULER_H); ctx.lineTo(x, rollBottom); } ctx.stroke(); } const lines = tm.gridLines(tick0, tick1 + 1); for (const line of lines) { const x = tickToX(song, line.tick); ctx.strokeStyle = line.isBar ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.09)'; ctx.beginPath(); ctx.moveTo(x, RULER_H); ctx.lineTo(x, height); ctx.stroke(); } // time selection band const range = st.rangeTicks; if (range) { const rx0 = tickToX(song, range[0]); const rx1 = tickToX(song, range[1]); ctx.fillStyle = st.loopEnabled ? 'rgba(76, 217, 100, 0.07)' : 'rgba(255, 210, 74, 0.07)'; ctx.fillRect(rx0, RULER_H, rx1 - rx0, rollBottom - RULER_H); } // notes: ghost tracks first, active track on top const drawTrackNotes = (ti: number, ghost: boolean) => { const t = song.tracks[ti]; const color = trackColor(ti); for (const n of t.notes) { const x = tickToX(song, n.tick); const w = Math.max(3, n.duration * pxPerTick(song) - 1); if (x + w < KEYS_W || x > width) continue; const y = pitchToY(n.pitch); if (y + view.current.keyH < RULER_H || y > rollBottom) continue; ctx.globalAlpha = ghost ? 0.22 : 0.45 + 0.55 * (n.velocity / 127); ctx.fillStyle = color; ctx.fillRect(x, y + 1, w, view.current.keyH - 2); if (!ghost && selection.has(n.id)) { ctx.globalAlpha = 1; ctx.strokeStyle = '#fff'; ctx.lineWidth = 1.5; ctx.strokeRect(x + 0.5, y + 1.5, w - 1, view.current.keyH - 3); ctx.lineWidth = 1; } } ctx.globalAlpha = 1; }; song.tracks.forEach((_, i) => { if (i !== activeIdx) drawTrackNotes(i, true); }); if (activeTrack) drawTrackNotes(activeIdx, false); // marquee const d = drag.current; if (d && d.mode === 'marquee') { ctx.strokeStyle = 'rgba(120,180,255,0.9)'; ctx.fillStyle = 'rgba(120,180,255,0.12)'; const rx = Math.min(d.x0, d.x1); const ry = Math.min(d.y0, d.y1); ctx.fillRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0)); ctx.strokeRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0)); } // velocity lane ctx.fillStyle = '#101216'; ctx.fillRect(0, rollBottom, width, VEL_H); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.beginPath(); ctx.moveTo(0, rollBottom + 0.5); ctx.lineTo(width, rollBottom + 0.5); ctx.stroke(); if (activeTrack) { const color = trackColor(activeIdx); for (const n of activeTrack.notes) { const x = tickToX(song, n.tick); if (x < KEYS_W - 4 || x > width) continue; const bh = (n.velocity / 127) * (VEL_H - 8); ctx.fillStyle = color; ctx.globalAlpha = selection.has(n.id) ? 1 : 0.6; ctx.fillRect(x - 2, height - 2 - bh, 5, bh); } ctx.globalAlpha = 1; } ctx.fillStyle = 'rgba(255,255,255,0.4)'; ctx.font = '10px system-ui'; ctx.fillText('velocity', 6, rollBottom + 13); // ruler ctx.fillStyle = '#22262e'; ctx.fillRect(0, 0, width, RULER_H); if (range) { ctx.fillStyle = st.loopEnabled ? 'rgba(76, 217, 100, 0.35)' : 'rgba(255, 210, 74, 0.3)'; ctx.fillRect(tickToX(song, range[0]), 0, (range[1] - range[0]) * pxPerTick(song), RULER_H - 1); } for (const line of lines) { if (!line.isBar) continue; const x = tickToX(song, line.tick); ctx.strokeStyle = 'rgba(255,255,255,0.3)'; ctx.beginPath(); ctx.moveTo(x, RULER_H - 8); ctx.lineTo(x, RULER_H); ctx.stroke(); ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '10px system-ui'; ctx.fillText(String(tm.barBeatAt(line.tick).bar), x + 3, RULER_H - 10); } // key column for (let p = PITCH_MIN; p <= PITCH_MAX; p++) { const y = pitchToY(p); if (y + view.current.keyH < RULER_H || y > rollBottom) continue; const black = isBlackKey(p); ctx.fillStyle = black ? '#2a2d34' : '#e8e8ea'; ctx.fillRect(0, Math.max(RULER_H, y), KEYS_W - 2, view.current.keyH - 0.5); if (p % 12 === 0) { ctx.fillStyle = '#333'; ctx.font = '9px system-ui'; ctx.fillText(noteName(p), 4, y + view.current.keyH - 3); } } ctx.fillStyle = '#16181d'; ctx.fillRect(KEYS_W - 2, RULER_H, 2, rollBottom - RULER_H); // playhead const px = tickToX(song, playTick); if (px >= KEYS_W) { ctx.strokeStyle = player.state === 'recording' ? '#ff5252' : '#ffd24a'; ctx.beginPath(); ctx.moveTo(px, 0); ctx.lineTo(px, height); ctx.stroke(); } }; raf = requestAnimationFrame(draw); // --- interaction --- const hitNote = (song: Song, x: number, y: number): Note | null => { const st = store.state; const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0]; if (!track) return null; let hit: Note | null = null; for (const n of track.notes) { const nx = tickToX(song, n.tick); const w = Math.max(3, n.duration * pxPerTick(song) - 1); const ny = pitchToY(n.pitch); if (x >= nx - 1 && x <= nx + w + 1 && y >= ny && y < ny + view.current.keyH) hit = n; } return hit; }; const blip = (instrumentId: string, pitch: number) => { try { const voice = player.previewNoteOn(instrumentId, pitch, 100); setTimeout(() => voice.noteOff(), 180); } catch { /* audio not available */ } }; const activeInstrument = (song: Song): string => { const st = store.state; const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0]; return track?.instrumentId ?? 'pluck'; }; const effSnap = (ev: PointerEvent | MouseEvent): number => (ev.altKey ? 0 : store.snapTicks()); const effSnapDrag = (ev: PointerEvent): number => (ev.shiftKey ? 0 : store.snapTicks()); const onPointerDown = (ev: PointerEvent) => { const song = store.song(); if (!song) return; const rect = canvas.getBoundingClientRect(); const x = ev.clientX - rect.left; const y = ev.clientY - rect.top; const rollBottom = height - VEL_H; canvas.setPointerCapture(ev.pointerId); if (y < RULER_H) { drag.current = { mode: 'ruler-pending', x0: x }; return; } if (x < KEYS_W && y < rollBottom) { const pitch = yToPitch(y); if (pitch < PITCH_MIN || pitch > PITCH_MAX) return; const voice = player.previewNoteOn(activeInstrument(song), pitch, 100); drag.current = { mode: 'keys', pitch, voice }; return; } if (y >= rollBottom) { store.beginEdit(); drag.current = { mode: 'velocity' }; applyVelocity(x, y); return; } const hit = hitNote(song, x, y); if (ev.button === 2) { if (hit) { store.mutateSong((s) => { for (const t of s.tracks) t.notes = t.notes.filter((n) => n.id !== hit.id); }); store.setSelection(store.state.selection.filter((id) => id !== hit.id)); } drag.current = null; return; } if (hit) { let sel = store.state.selection; if (!sel.includes(hit.id)) { sel = ev.shiftKey ? [...sel, hit.id] : [hit.id]; store.setSelection(sel); } const nx = tickToX(song, hit.tick); const w = Math.max(3, hit.duration * pxPerTick(song) - 1); const nearRight = x > nx + w - 6 && w > 10; const nearLeft = x < nx + 5 && w > 10 && !nearRight; store.beginEdit(); let ids = sel; let anchorId = hit.id; if (ev.altKey && !nearRight && !nearLeft) { // alt+drag: duplicate the selection and move the copies const map = new Map(); store.editSong((s) => { for (const t of s.tracks) { const copies = t.notes .filter((n) => ids.includes(n.id)) .map((n) => { const id = newId('n'); map.set(n.id, id); return { ...n, id }; }); t.notes.push(...copies); } }); ids = [...map.values()]; anchorId = map.get(hit.id)!; store.setSelection(ids); } const cur = store.song()!; const origs = new Map(); for (const t of cur.tracks) for (const n of t.notes) if (ids.includes(n.id)) origs.set(n.id, { tick: n.tick, duration: n.duration, pitch: n.pitch }); if (nearRight) drag.current = { mode: 'resize', startX: x, anchorId, origs }; else if (nearLeft) drag.current = { mode: 'resize-left', startX: x, anchorId, origs }; else drag.current = { mode: 'move', startX: x, startY: y, anchorId, origs, lastBlipPitch: hit.pitch }; return; } if (ev.shiftKey) { drag.current = { mode: 'marquee', additive: true, x0: x, y0: y, x1: x, y1: y }; return; } // A click seeks; a drag becomes a marquee. Decided by movement. drag.current = { mode: 'pending', x0: x, y0: y }; }; const onDblClick = (ev: MouseEvent) => { const song = store.song(); if (!song) return; const rect = canvas.getBoundingClientRect(); const x = ev.clientX - rect.left; const y = ev.clientY - rect.top; if (y < RULER_H || x < KEYS_W || y >= height - VEL_H) return; const hitn = hitNote(song, x, y); if (hitn) { store.mutateSong((s) => { for (const t of s.tracks) t.notes = t.notes.filter((n) => n.id !== hitn.id); }); store.setSelection(store.state.selection.filter((id) => id !== hitn.id)); return; } const snap = effSnap(ev); const tick = Math.max(0, snapValue(xToTick(song, x), snap, 'floor')); const pitch = yToPitch(y); if (pitch < PITCH_MIN || pitch > PITCH_MAX) return; const dur = snap > 0 ? snap : Math.round(song.ppq / 4); const id = newId('n'); store.mutateSong((s) => { const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0]; track?.notes.push({ id, tick, duration: dur, pitch, velocity: 100 }); }); store.setSelection([id]); blip(activeInstrument(song), pitch); }; const applyVelocity = (x: number, y: number) => { const vel = Math.max(1, Math.min(127, Math.round(((height - 2 - y) / (VEL_H - 8)) * 127))); store.editSong((s) => { const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0]; if (!track) return; for (const n of track.notes) { const nx = tickToX(s, n.tick); if (Math.abs(nx - x) <= 4) n.velocity = vel; } }); }; const onPointerMove = (ev: PointerEvent) => { const d = drag.current; const song = store.song(); if (!d || !song) return; const rect = canvas.getBoundingClientRect(); const x = ev.clientX - rect.left; const y = ev.clientY - rect.top; if (d.mode === 'ruler-pending') { if (Math.abs(x - d.x0) > 4) { const grid = effSnapDrag(ev); const anchor = Math.max(0, snapValue(xToTick(song, Math.max(KEYS_W, d.x0)), grid, 'round')); drag.current = { mode: 'range', anchorTick: anchor }; } } else if (d.mode === 'range') { const grid = effSnapDrag(ev); const t = Math.max(0, snapValue(xToTick(song, Math.max(KEYS_W, x)), grid, 'round')); const a = d.anchorTick; store.setRange(t === a ? null : [Math.min(a, t), Math.max(a, t)]); } else if (d.mode === 'keys') { const pitch = yToPitch(y); if (pitch !== d.pitch && pitch >= PITCH_MIN && pitch <= PITCH_MAX) { d.voice?.noteOff(); d.voice = player.previewNoteOn(activeInstrument(song), pitch, 100); d.pitch = pitch; } } else if (d.mode === 'velocity') { applyVelocity(x, y); } else if (d.mode === 'pending') { if (Math.hypot(x - d.x0, y - d.y0) > 4) { drag.current = { mode: 'marquee', additive: false, x0: d.x0, y0: d.y0, x1: x, y1: y }; } } else if (d.mode === 'marquee') { d.x1 = x; d.y1 = y; } else if (d.mode === 'move') { const snap = effSnapDrag(ev); const anchor = d.origs.get(d.anchorId)!; const rawDelta = (x - d.startX) / pxPerTick(song); const delta = snapValue(anchor.tick + rawDelta, snap, 'round') - anchor.tick; const dPitch = -Math.round((y - d.startY) / view.current.keyH); store.editSong((s) => { for (const t of s.tracks) { for (const n of t.notes) { const o = d.origs.get(n.id); if (!o) continue; n.tick = Math.max(0, o.tick + delta); n.pitch = Math.max(0, Math.min(127, o.pitch + dPitch)); } } }); const newAnchorPitch = Math.max(0, Math.min(127, anchor.pitch + dPitch)); if (newAnchorPitch !== d.lastBlipPitch) { d.lastBlipPitch = newAnchorPitch; blip(activeInstrument(song), newAnchorPitch); } } else if (d.mode === 'resize') { const snap = effSnapDrag(ev); const anchor = d.origs.get(d.anchorId)!; const rawDelta = (x - d.startX) / pxPerTick(song); const newEnd = snapValue(anchor.tick + anchor.duration + rawDelta, snap, 'round'); const delta = newEnd - (anchor.tick + anchor.duration); store.editSong((s) => { for (const t of s.tracks) { for (const n of t.notes) { const o = d.origs.get(n.id); if (!o) continue; n.duration = Math.max(MIN_DUR_TICKS, o.duration + delta); } } }); } else if (d.mode === 'resize-left') { const snap = effSnapDrag(ev); const anchor = d.origs.get(d.anchorId)!; const rawDelta = (x - d.startX) / pxPerTick(song); const newStart = snapValue(anchor.tick + rawDelta, snap, 'round'); const delta = newStart - anchor.tick; store.editSong((s) => { for (const t of s.tracks) { for (const n of t.notes) { const o = d.origs.get(n.id); if (!o) continue; const tick = Math.max(0, Math.min(o.tick + o.duration - MIN_DUR_TICKS, o.tick + delta)); n.tick = tick; n.duration = o.duration - (tick - o.tick); } } }); } }; const onPointerUp = () => { const d = drag.current; const song = store.song(); drag.current = null; if (!d || !song) return; if (d.mode === 'keys') { d.voice?.noteOff(); } else if (d.mode === 'pending') { const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0))); store.seekTo(getTm(song).tickToSec(tick)); store.setSelection([]); } else if (d.mode === 'ruler-pending') { const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0))); store.seekTo(getTm(song).tickToSec(tick)); store.setRange(null); } else if (d.mode === 'marquee') { const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0]; if (!track) return; const x0 = Math.min(d.x0, d.x1); const x1 = Math.max(d.x0, d.x1); const y0 = Math.min(d.y0, d.y1); const y1 = Math.max(d.y0, d.y1); const ids: string[] = []; for (const n of track.notes) { const nx = tickToX(song, n.tick); const w = Math.max(3, n.duration * pxPerTick(song) - 1); const ny = pitchToY(n.pitch); if (nx + w >= x0 && nx <= x1 && ny + view.current.keyH >= y0 && ny <= y1) ids.push(n.id); } store.setSelection(d.additive ? [...new Set([...store.state.selection, ...ids])] : ids); } else if (d.mode === 'move' || d.mode === 'resize' || d.mode === 'resize-left' || d.mode === 'velocity') { store.endEdit(); } }; const onWheel = (ev: WheelEvent) => { ev.preventDefault(); const song = store.song(); if (!song) return; const rect = canvas.getBoundingClientRect(); const x = ev.clientX - rect.left; if (ev.ctrlKey || ev.metaKey) { const tickAtMouse = xToTick(song, x); const factor = Math.exp(-ev.deltaY * 0.0018); view.current.pxPerBeat = Math.max(12, Math.min(700, view.current.pxPerBeat * factor)); view.current.scrollX = Math.max(0, tickAtMouse * pxPerTick(song) - (x - KEYS_W)); } else if (ev.altKey) { const y = ev.clientY - rect.top; const pitchF = (y + view.current.scrollY - RULER_H) / view.current.keyH; const factor = Math.exp(-ev.deltaY * 0.0018); view.current.keyH = Math.max(6, Math.min(26, view.current.keyH * factor)); view.current.scrollY = Math.max(0, pitchF * view.current.keyH - (y - RULER_H)); } else if (ev.shiftKey) { view.current.scrollX = Math.max(0, view.current.scrollX + ev.deltaY); } else { const rollH = (PITCH_MAX - PITCH_MIN + 1) * view.current.keyH; const viewH = Math.max(50, height - VEL_H - RULER_H); view.current.scrollY = Math.max(0, Math.min(rollH - viewH, view.current.scrollY + ev.deltaY)); const maxX = Math.max(0, (songEndTick(song) + song.ppq * 64) * pxPerTick(song) - (width - KEYS_W)); view.current.scrollX = Math.max(0, Math.min(maxX, view.current.scrollX + ev.deltaX)); } }; canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('dblclick', onDblClick); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointercancel', onPointerUp); canvas.addEventListener('wheel', onWheel, { passive: false }); canvas.addEventListener('contextmenu', (e) => e.preventDefault()); return () => { cancelAnimationFrame(raf); ro.disconnect(); canvas.removeEventListener('pointerdown', onPointerDown); canvas.removeEventListener('dblclick', onDblClick); canvas.removeEventListener('pointermove', onPointerMove); canvas.removeEventListener('pointerup', onPointerUp); canvas.removeEventListener('pointercancel', onPointerUp); canvas.removeEventListener('wheel', onWheel); }; }, []); return (
); }