/ proof-of-concept / midiboot
Sign in
proof-of-concept / midiboot
midiboot / src / components / PianoRoll.tsx
527 lines · 20.7 KBBlameHistoryRaw
1import { useEffect, useRef } from 'react';
2import { store } from '../state/store.ts';
3import { player } from '../audio/player.ts';
4import { TempoMap } from '../midi/time.ts';
5import { newId, songEndTick, type Note, type Song } from '../types.ts';
6import { noteName } from '../midi/gm.ts';
7import { trackColor } from './colors.ts';
8import { isBlackKey, KEY_H, KEYS_W, PITCH_MAX, PITCH_MIN, RULER_H, snapValue, VEL_H } from '../pianoroll/geometry.ts';
10// The piano roll: a single canvas holding the ruler, key column, note
11// grid, and velocity lane. It redraws every animation frame from current
12// state, which keeps the playhead and edits in sync without React
13// re-renders. Gestures follow the usual DAW conventions:
14// click empty space set the playhead there (and deselect)
15// drag on empty space marquee select (shift extends the selection)
16// double-click empty space add a note at the snapped position
17// drag a note move it (and the rest of the selection)
18// drag a note's right edge resize
19// right-click a note delete it
20// drag in the ruler move the playhead
21// drag in the velocity lane paint velocities
22// wheel / shift+wheel scroll; ctrl+wheel zooms
23// alt while dragging bypass snap
24// Ctrl+A/C/X/V/D and the arrow keys act on the selection (see App.tsx).
26interface OrigNote { tick: number; duration: number; pitch: number }
28type Drag =
29 | { mode: 'scrub' }
30 | { mode: 'keys'; pitch: number; voice: { noteOff(): void; cut(): void } | null }
31 | { mode: 'velocity' }
32 | { mode: 'pending'; x0: number; y0: number }
33 | { mode: 'marquee'; additive: boolean; x0: number; y0: number; x1: number; y1: number }
34 | { mode: 'move'; startX: number; startY: number; anchorId: string; origs: Map<string, OrigNote>; lastBlipPitch: number }
35 | { mode: 'resize'; startX: number; anchorId: string; origs: Map<string, OrigNote> };
37const MIN_DUR_TICKS = 24;
39export function PianoRoll() {
40 const canvasRef = useRef<HTMLCanvasElement | null>(null);
41 const containerRef = useRef<HTMLDivElement | null>(null);
42 const view = useRef({ pxPerBeat: 96, scrollX: 0, scrollY: (PITCH_MAX - 84) * KEY_H });
43 const drag = useRef<Drag | null>(null);
44 const tmCache = useRef<{ song: Song; tm: TempoMap } | null>(null);
46 useEffect(() => {
47 const canvas = canvasRef.current!;
48 const container = containerRef.current!;
49 let raf = 0;
50 let width = 0;
51 let height = 0;
53 const getTm = (song: Song): TempoMap => {
54 if (!tmCache.current || tmCache.current.song !== song) {
55 tmCache.current = { song, tm: new TempoMap(song.ppq, song.tempo, song.timeSig) };
56 }
57 return tmCache.current.tm;
58 };
60 const resize = () => {
61 const rect = container.getBoundingClientRect();
62 width = Math.max(50, rect.width);
63 height = Math.max(50, rect.height);
64 const dpr = window.devicePixelRatio || 1;
65 canvas.width = Math.round(width * dpr);
66 canvas.height = Math.round(height * dpr);
67 canvas.style.width = `${width}px`;
68 canvas.style.height = `${height}px`;
69 };
70 const ro = new ResizeObserver(resize);
71 ro.observe(container);
72 resize();
74 const pxPerTick = (song: Song) => view.current.pxPerBeat / song.ppq;
75 const tickToX = (song: Song, tick: number) => KEYS_W + tick * pxPerTick(song) - view.current.scrollX;
76 const xToTick = (song: Song, x: number) => (x - KEYS_W + view.current.scrollX) / pxPerTick(song);
77 const pitchToY = (p: number) => RULER_H + (PITCH_MAX - p) * KEY_H - view.current.scrollY;
78 const yToPitch = (y: number) => PITCH_MAX - Math.floor((y + view.current.scrollY - RULER_H) / KEY_H);
80 let lastDrawnPos = -1;
81 const draw = () => {
82 raf = requestAnimationFrame(draw);
83 const ctx = canvas.getContext('2d');
84 if (!ctx) return;
85 const dpr = window.devicePixelRatio || 1;
86 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
87 ctx.fillStyle = '#16181d';
88 ctx.fillRect(0, 0, width, height);
89 const st = store.state;
90 const song = st.rec?.song;
91 if (!song) return;
92 const tm = getTm(song);
93 const rollBottom = height - VEL_H;
94 const selection = new Set(st.selection);
95 const activeIdx = Math.max(0, song.tracks.findIndex((t) => t.id === st.activeTrackId));
96 const activeTrack = song.tracks[activeIdx] as typeof song.tracks[number] | undefined;
98 // playhead position and auto-scroll
99 const posSec = Math.max(0, player.getPositionSec());
100 const playTick = tm.secToTick(posSec);
101 const posChanged = posSec !== lastDrawnPos;
102 lastDrawnPos = posSec;
103 if (player.state !== 'stopped' || posChanged) {
104 const px = tickToX(song, playTick);
105 if (px > width - 40 || px < KEYS_W) {
106 view.current.scrollX = Math.max(0, playTick * pxPerTick(song) - 80);
107 }
108 }
110 // pitch rows
111 for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
112 const y = pitchToY(p);
113 if (y + KEY_H < RULER_H || y > rollBottom) continue;
114 ctx.fillStyle = isBlackKey(p) ? '#1a1d23' : '#20242c';
115 ctx.fillRect(KEYS_W, y, width - KEYS_W, KEY_H - 0.5);
116 }
118 // vertical grid
119 const tick0 = Math.max(0, Math.floor(xToTick(song, KEYS_W)));
120 const tick1 = Math.ceil(xToTick(song, width));
121 const snapTicks = store.snapTicks();
122 if (snapTicks > 0 && snapTicks * pxPerTick(song) > 7) {
123 ctx.strokeStyle = 'rgba(255,255,255,0.05)';
124 ctx.beginPath();
125 for (let t = Math.floor(tick0 / snapTicks) * snapTicks; t <= tick1; t += snapTicks) {
126 const x = tickToX(song, t);
127 ctx.moveTo(x, RULER_H);
128 ctx.lineTo(x, rollBottom);
129 }
130 ctx.stroke();
131 }
132 const lines = tm.gridLines(tick0, tick1 + 1);
133 for (const line of lines) {
134 const x = tickToX(song, line.tick);
135 ctx.strokeStyle = line.isBar ? 'rgba(255,255,255,0.22)' : 'rgba(255,255,255,0.09)';
136 ctx.beginPath();
137 ctx.moveTo(x, RULER_H);
138 ctx.lineTo(x, height);
139 ctx.stroke();
140 }
142 // notes: ghost tracks first, active track on top
143 const drawTrackNotes = (ti: number, ghost: boolean) => {
144 const t = song.tracks[ti];
145 const color = trackColor(ti);
146 for (const n of t.notes) {
147 const x = tickToX(song, n.tick);
148 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
149 if (x + w < KEYS_W || x > width) continue;
150 const y = pitchToY(n.pitch);
151 if (y + KEY_H < RULER_H || y > rollBottom) continue;
152 ctx.globalAlpha = ghost ? 0.22 : 0.45 + 0.55 * (n.velocity / 127);
153 ctx.fillStyle = color;
154 ctx.fillRect(x, y + 1, w, KEY_H - 2);
155 if (!ghost && selection.has(n.id)) {
156 ctx.globalAlpha = 1;
157 ctx.strokeStyle = '#fff';
158 ctx.lineWidth = 1.5;
159 ctx.strokeRect(x + 0.5, y + 1.5, w - 1, KEY_H - 3);
160 ctx.lineWidth = 1;
161 }
162 }
163 ctx.globalAlpha = 1;
164 };
165 song.tracks.forEach((_, i) => { if (i !== activeIdx) drawTrackNotes(i, true); });
166 if (activeTrack) drawTrackNotes(activeIdx, false);
168 // marquee
169 const d = drag.current;
170 if (d && d.mode === 'marquee') {
171 ctx.strokeStyle = 'rgba(120,180,255,0.9)';
172 ctx.fillStyle = 'rgba(120,180,255,0.12)';
173 const rx = Math.min(d.x0, d.x1);
174 const ry = Math.min(d.y0, d.y1);
175 ctx.fillRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0));
176 ctx.strokeRect(rx, ry, Math.abs(d.x1 - d.x0), Math.abs(d.y1 - d.y0));
177 }
179 // velocity lane
180 ctx.fillStyle = '#101216';
181 ctx.fillRect(0, rollBottom, width, VEL_H);
182 ctx.strokeStyle = 'rgba(255,255,255,0.15)';
183 ctx.beginPath();
184 ctx.moveTo(0, rollBottom + 0.5);
185 ctx.lineTo(width, rollBottom + 0.5);
186 ctx.stroke();
187 if (activeTrack) {
188 const color = trackColor(activeIdx);
189 for (const n of activeTrack.notes) {
190 const x = tickToX(song, n.tick);
191 if (x < KEYS_W - 4 || x > width) continue;
192 const bh = (n.velocity / 127) * (VEL_H - 8);
193 ctx.fillStyle = color;
194 ctx.globalAlpha = selection.has(n.id) ? 1 : 0.6;
195 ctx.fillRect(x - 2, height - 2 - bh, 5, bh);
196 }
197 ctx.globalAlpha = 1;
198 }
199 ctx.fillStyle = 'rgba(255,255,255,0.4)';
200 ctx.font = '10px system-ui';
201 ctx.fillText('velocity', 6, rollBottom + 13);
203 // ruler
204 ctx.fillStyle = '#22262e';
205 ctx.fillRect(0, 0, width, RULER_H);
206 for (const line of lines) {
207 if (!line.isBar) continue;
208 const x = tickToX(song, line.tick);
209 ctx.strokeStyle = 'rgba(255,255,255,0.3)';
210 ctx.beginPath();
211 ctx.moveTo(x, RULER_H - 8);
212 ctx.lineTo(x, RULER_H);
213 ctx.stroke();
214 ctx.fillStyle = 'rgba(255,255,255,0.6)';
215 ctx.font = '10px system-ui';
216 ctx.fillText(String(tm.barBeatAt(line.tick).bar), x + 3, RULER_H - 10);
217 }
219 // key column
220 for (let p = PITCH_MIN; p <= PITCH_MAX; p++) {
221 const y = pitchToY(p);
222 if (y + KEY_H < RULER_H || y > rollBottom) continue;
223 const black = isBlackKey(p);
224 ctx.fillStyle = black ? '#2a2d34' : '#e8e8ea';
225 ctx.fillRect(0, Math.max(RULER_H, y), KEYS_W - 2, KEY_H - 0.5);
226 if (p % 12 === 0) {
227 ctx.fillStyle = '#333';
228 ctx.font = '9px system-ui';
229 ctx.fillText(noteName(p), 4, y + KEY_H - 3);
230 }
231 }
232 ctx.fillStyle = '#16181d';
233 ctx.fillRect(KEYS_W - 2, RULER_H, 2, rollBottom - RULER_H);
235 // playhead
236 const px = tickToX(song, playTick);
237 if (px >= KEYS_W) {
238 ctx.strokeStyle = player.state === 'recording' ? '#ff5252' : '#ffd24a';
239 ctx.beginPath();
240 ctx.moveTo(px, 0);
241 ctx.lineTo(px, height);
242 ctx.stroke();
243 }
244 };
245 raf = requestAnimationFrame(draw);
247 // --- interaction ---
249 const hitNote = (song: Song, x: number, y: number): Note | null => {
250 const st = store.state;
251 const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0];
252 if (!track) return null;
253 let hit: Note | null = null;
254 for (const n of track.notes) {
255 const nx = tickToX(song, n.tick);
256 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
257 const ny = pitchToY(n.pitch);
258 if (x >= nx - 1 && x <= nx + w + 1 && y >= ny && y < ny + KEY_H) hit = n;
259 }
260 return hit;
261 };
263 const blip = (instrumentId: string, pitch: number) => {
264 try {
265 const voice = player.previewNoteOn(instrumentId, pitch, 100);
266 setTimeout(() => voice.noteOff(), 180);
267 } catch { /* audio not available */ }
268 };
270 const activeInstrument = (song: Song): string => {
271 const st = store.state;
272 const track = song.tracks.find((t) => t.id === st.activeTrackId) ?? song.tracks[0];
273 return track?.instrumentId ?? 'pluck';
274 };
276 const effSnap = (ev: PointerEvent | MouseEvent): number => (ev.altKey ? 0 : store.snapTicks());
278 const onPointerDown = (ev: PointerEvent) => {
279 const song = store.song();
280 if (!song) return;
281 const rect = canvas.getBoundingClientRect();
282 const x = ev.clientX - rect.left;
283 const y = ev.clientY - rect.top;
284 const rollBottom = height - VEL_H;
285 canvas.setPointerCapture(ev.pointerId);
287 if (y < RULER_H) {
288 const sec = getTm(song).tickToSec(Math.max(0, xToTick(song, Math.max(KEYS_W, x))));
289 if (player.state !== 'stopped') {
290 store.seekTo(sec);
291 drag.current = null;
292 } else {
293 player.seek(sec);
294 drag.current = { mode: 'scrub' };
295 }
296 return;
297 }
298 if (x < KEYS_W && y < rollBottom) {
299 const pitch = yToPitch(y);
300 if (pitch < PITCH_MIN || pitch > PITCH_MAX) return;
301 const voice = player.previewNoteOn(activeInstrument(song), pitch, 100);
302 drag.current = { mode: 'keys', pitch, voice };
303 return;
304 }
305 if (y >= rollBottom) {
306 store.beginEdit();
307 drag.current = { mode: 'velocity' };
308 applyVelocity(x, y);
309 return;
310 }
312 const hit = hitNote(song, x, y);
313 if (ev.button === 2) {
314 if (hit) {
315 store.mutateSong((s) => {
316 for (const t of s.tracks) t.notes = t.notes.filter((n) => n.id !== hit.id);
317 });
318 store.setSelection(store.state.selection.filter((id) => id !== hit.id));
319 }
320 drag.current = null;
321 return;
322 }
323 if (hit) {
324 let sel = store.state.selection;
325 if (!sel.includes(hit.id)) {
326 sel = ev.shiftKey ? [...sel, hit.id] : [hit.id];
327 store.setSelection(sel);
328 }
329 const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
330 const origs = new Map<string, OrigNote>();
331 for (const n of track.notes) if (sel.includes(n.id)) origs.set(n.id, { tick: n.tick, duration: n.duration, pitch: n.pitch });
332 const nx = tickToX(song, hit.tick);
333 const w = Math.max(3, hit.duration * pxPerTick(song) - 1);
334 store.beginEdit();
335 if (x > nx + w - 6 && w > 10) {
336 drag.current = { mode: 'resize', startX: x, anchorId: hit.id, origs };
337 } else {
338 drag.current = { mode: 'move', startX: x, startY: y, anchorId: hit.id, origs, lastBlipPitch: hit.pitch };
339 }
340 return;
341 }
342 if (ev.shiftKey) {
343 drag.current = { mode: 'marquee', additive: true, x0: x, y0: y, x1: x, y1: y };
344 return;
345 }
346 // A click seeks; a drag becomes a marquee. Decided by movement.
347 drag.current = { mode: 'pending', x0: x, y0: y };
348 };
350 const onDblClick = (ev: MouseEvent) => {
351 const song = store.song();
352 if (!song) return;
353 const rect = canvas.getBoundingClientRect();
354 const x = ev.clientX - rect.left;
355 const y = ev.clientY - rect.top;
356 if (y < RULER_H || x < KEYS_W || y >= height - VEL_H) return;
357 if (hitNote(song, x, y)) return;
358 const snap = effSnap(ev);
359 const tick = Math.max(0, snapValue(xToTick(song, x), snap, 'floor'));
360 const pitch = yToPitch(y);
361 if (pitch < PITCH_MIN || pitch > PITCH_MAX) return;
362 const dur = snap > 0 ? snap : Math.round(song.ppq / 4);
363 const id = newId('n');
364 store.mutateSong((s) => {
365 const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0];
366 track?.notes.push({ id, tick, duration: dur, pitch, velocity: 100 });
367 });
368 store.setSelection([id]);
369 blip(activeInstrument(song), pitch);
370 };
372 const applyVelocity = (x: number, y: number) => {
373 const vel = Math.max(1, Math.min(127, Math.round(((height - 2 - y) / (VEL_H - 8)) * 127)));
374 store.editSong((s) => {
375 const track = s.tracks.find((t) => t.id === store.state.activeTrackId) ?? s.tracks[0];
376 if (!track) return;
377 for (const n of track.notes) {
378 const nx = tickToX(s, n.tick);
379 if (Math.abs(nx - x) <= 4) n.velocity = vel;
380 }
381 });
382 };
384 const onPointerMove = (ev: PointerEvent) => {
385 const d = drag.current;
386 const song = store.song();
387 if (!d || !song) return;
388 const rect = canvas.getBoundingClientRect();
389 const x = ev.clientX - rect.left;
390 const y = ev.clientY - rect.top;
391 if (d.mode === 'scrub') {
392 const sec = getTm(song).tickToSec(Math.max(0, xToTick(song, Math.max(KEYS_W, x))));
393 player.seek(sec);
394 } else if (d.mode === 'keys') {
395 const pitch = yToPitch(y);
396 if (pitch !== d.pitch && pitch >= PITCH_MIN && pitch <= PITCH_MAX) {
397 d.voice?.noteOff();
398 d.voice = player.previewNoteOn(activeInstrument(song), pitch, 100);
399 d.pitch = pitch;
400 }
401 } else if (d.mode === 'velocity') {
402 applyVelocity(x, y);
403 } else if (d.mode === 'pending') {
404 if (Math.hypot(x - d.x0, y - d.y0) > 4) {
405 drag.current = { mode: 'marquee', additive: false, x0: d.x0, y0: d.y0, x1: x, y1: y };
406 }
407 } else if (d.mode === 'marquee') {
408 d.x1 = x;
409 d.y1 = y;
410 } else if (d.mode === 'move') {
411 const snap = effSnap(ev);
412 const anchor = d.origs.get(d.anchorId)!;
413 const rawDelta = (x - d.startX) / pxPerTick(song);
414 const delta = snapValue(anchor.tick + rawDelta, snap, 'round') - anchor.tick;
415 const dPitch = -Math.round((y - d.startY) / KEY_H);
416 store.editSong((s) => {
417 for (const t of s.tracks) {
418 for (const n of t.notes) {
419 const o = d.origs.get(n.id);
420 if (!o) continue;
421 n.tick = Math.max(0, o.tick + delta);
422 n.pitch = Math.max(0, Math.min(127, o.pitch + dPitch));
423 }
424 }
425 });
426 const newAnchorPitch = Math.max(0, Math.min(127, anchor.pitch + dPitch));
427 if (newAnchorPitch !== d.lastBlipPitch) {
428 d.lastBlipPitch = newAnchorPitch;
429 blip(activeInstrument(song), newAnchorPitch);
430 }
431 } else if (d.mode === 'resize') {
432 const snap = effSnap(ev);
433 const anchor = d.origs.get(d.anchorId)!;
434 const rawDelta = (x - d.startX) / pxPerTick(song);
435 const newEnd = snapValue(anchor.tick + anchor.duration + rawDelta, snap, 'round');
436 const delta = newEnd - (anchor.tick + anchor.duration);
437 store.editSong((s) => {
438 for (const t of s.tracks) {
439 for (const n of t.notes) {
440 const o = d.origs.get(n.id);
441 if (!o) continue;
442 n.duration = Math.max(MIN_DUR_TICKS, o.duration + delta);
443 }
444 }
445 });
446 }
447 };
449 const onPointerUp = () => {
450 const d = drag.current;
451 const song = store.song();
452 drag.current = null;
453 if (!d || !song) return;
454 if (d.mode === 'keys') {
455 d.voice?.noteOff();
456 } else if (d.mode === 'pending') {
457 const tick = Math.max(0, xToTick(song, Math.max(KEYS_W, d.x0)));
458 store.seekTo(getTm(song).tickToSec(tick));
459 store.setSelection([]);
460 } else if (d.mode === 'marquee') {
461 const track = song.tracks.find((t) => t.id === store.state.activeTrackId) ?? song.tracks[0];
462 if (!track) return;
463 const x0 = Math.min(d.x0, d.x1);
464 const x1 = Math.max(d.x0, d.x1);
465 const y0 = Math.min(d.y0, d.y1);
466 const y1 = Math.max(d.y0, d.y1);
467 const ids: string[] = [];
468 for (const n of track.notes) {
469 const nx = tickToX(song, n.tick);
470 const w = Math.max(3, n.duration * pxPerTick(song) - 1);
471 const ny = pitchToY(n.pitch);
472 if (nx + w >= x0 && nx <= x1 && ny + KEY_H >= y0 && ny <= y1) ids.push(n.id);
473 }
474 store.setSelection(d.additive ? [...new Set([...store.state.selection, ...ids])] : ids);
475 } else if (d.mode === 'move' || d.mode === 'resize' || d.mode === 'velocity') {
476 store.endEdit();
477 }
478 };
480 const onWheel = (ev: WheelEvent) => {
481 ev.preventDefault();
482 const song = store.song();
483 if (!song) return;
484 const rect = canvas.getBoundingClientRect();
485 const x = ev.clientX - rect.left;
486 if (ev.ctrlKey || ev.metaKey) {
487 const tickAtMouse = xToTick(song, x);
488 const factor = Math.exp(-ev.deltaY * 0.0018);
489 view.current.pxPerBeat = Math.max(12, Math.min(700, view.current.pxPerBeat * factor));
490 view.current.scrollX = Math.max(0, tickAtMouse * pxPerTick(song) - (x - KEYS_W));
491 } else if (ev.shiftKey) {
492 view.current.scrollX = Math.max(0, view.current.scrollX + ev.deltaY);
493 } else {
494 const rollH = (PITCH_MAX - PITCH_MIN + 1) * KEY_H;
495 const viewH = Math.max(50, height - VEL_H - RULER_H);
496 view.current.scrollY = Math.max(0, Math.min(rollH - viewH, view.current.scrollY + ev.deltaY));
497 const maxX = Math.max(0, (songEndTick(song) + song.ppq * 64) * pxPerTick(song) - (width - KEYS_W));
498 view.current.scrollX = Math.max(0, Math.min(maxX, view.current.scrollX + ev.deltaX));
499 }
500 };
502 canvas.addEventListener('pointerdown', onPointerDown);
503 canvas.addEventListener('dblclick', onDblClick);
504 canvas.addEventListener('pointermove', onPointerMove);
505 canvas.addEventListener('pointerup', onPointerUp);
506 canvas.addEventListener('pointercancel', onPointerUp);
507 canvas.addEventListener('wheel', onWheel, { passive: false });
508 canvas.addEventListener('contextmenu', (e) => e.preventDefault());
510 return () => {
511 cancelAnimationFrame(raf);
512 ro.disconnect();
513 canvas.removeEventListener('pointerdown', onPointerDown);
514 canvas.removeEventListener('dblclick', onDblClick);
515 canvas.removeEventListener('pointermove', onPointerMove);
516 canvas.removeEventListener('pointerup', onPointerUp);
517 canvas.removeEventListener('pointercancel', onPointerUp);
518 canvas.removeEventListener('wheel', onWheel);
519 };
520 }, []);
522 return (
523 <div className="pianoroll" ref={containerRef}>
524 <canvas ref={canvasRef} />
525 </div>
526 );
↑↓ move↵openescclose