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