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