1/**
2 * The take as an editable timeline: the recording's waveform in a strip at
3 * the top, one lane per voice below, a marker per hit whose height is its
4 * strength. Times are exactly as performed; the only edits are the ones a
5 * wrong classification needs. Click selects and auditions; dragging a
6 * marker to another lane reassigns it; double-clicking an empty spot in a
7 * lane adds a hit there.
8 */
9import type { Clip } from '../audio/recorder.ts';
11export interface Lane { id: string; name: string; color: string; }
12export interface TLEvent { id: string; t: number; voiceId: string; gain: number; }
14const BG = '#16181d';
15const WAVE = '#44546a';
16const STRIP_H = 44;
17const LANE_H = 42;
18const AXIS_H = 18;
19const GUTTER = 92;
21export class Timeline {
22 private canvas: HTMLCanvasElement;
23 private lanes: Lane[] = [];
24 private events: TLEvent[] = [];
25 private clip: Clip | null = null;
26 private duration = 1;
27 private playT: number | null = null;
28 private drag: { id: string; moved: boolean } | null = null;
29 selected: string | null = null;
30 onSelect: ((id: string | null) => void) | null = null;
31 onReassign: ((id: string, voiceId: string) => void) | null = null;
32 onAdd: ((t: number, voiceId: string) => void) | null = null;
34 constructor(canvas: HTMLCanvasElement) {
35 this.canvas = canvas;
36 canvas.addEventListener('pointerdown', (e) => this.pointerDown(e));
37 canvas.addEventListener('pointermove', (e) => this.pointerMove(e));
38 canvas.addEventListener('pointerup', (e) => this.pointerUp(e));
39 canvas.addEventListener('dblclick', (e) => this.dblClick(e));
40 new ResizeObserver(() => this.draw()).observe(canvas);
41 }
43 setData(lanes: Lane[], events: TLEvent[], clip: Clip | null): void {
44 this.lanes = lanes;
45 this.events = events;
46 this.clip = clip;
47 const lastT = events.reduce((a, e) => Math.max(a, e.t), 0);
48 this.duration = Math.max(clip ? clip.samples.length / clip.sr : 0, lastT + 0.5, 1);
49 this.canvas.style.height = `${STRIP_H + lanes.length * LANE_H + AXIS_H}px`;
50 if (this.selected && !events.some((e) => e.id === this.selected)) this.selected = null;
51 this.draw();
52 }
54 playhead(t: number | null): void {
55 this.playT = t;
56 this.draw();
57 }
59 private xOf(t: number, w: number): number {
60 return GUTTER + (t / this.duration) * (w - GUTTER - 8);
61 }
62 private tOf(x: number, w: number): number {
63 return ((x - GUTTER) / (w - GUTTER - 8)) * this.duration;
64 }
65 private laneOf(y: number): number {
66 return Math.floor((y - STRIP_H) / LANE_H);
67 }
69 private hitAt(x: number, y: number): TLEvent | null {
70 const w = this.canvas.clientWidth;
71 const li = this.laneOf(y);
72 let best: TLEvent | null = null, bestDx = 7;
73 for (const ev of this.events) {
74 if (this.lanes.findIndex((l) => l.id === ev.voiceId) !== li) continue;
75 const dx = Math.abs(this.xOf(ev.t, w) - x);
76 if (dx < bestDx) { best = ev; bestDx = dx; }
77 }
78 return best;
79 }
81 private pointerDown(e: PointerEvent): void {
82 const rect = this.canvas.getBoundingClientRect();
83 const ev = this.hitAt(e.clientX - rect.left, e.clientY - rect.top);
84 if (ev) {
85 this.drag = { id: ev.id, moved: false };
86 this.canvas.setPointerCapture(e.pointerId);
87 if (this.selected !== ev.id) {
88 this.selected = ev.id;
89 this.onSelect?.(ev.id);
90 } else this.onSelect?.(ev.id); // re-click re-auditions
91 } else {
92 this.selected = null;
93 this.onSelect?.(null);
94 }
95 this.draw();
96 }
98 private pointerMove(e: PointerEvent): void {
99 if (!this.drag) return;
100 const rect = this.canvas.getBoundingClientRect();
101 const li = Math.max(0, Math.min(this.lanes.length - 1, this.laneOf(e.clientY - rect.top)));
102 const ev = this.events.find((v) => v.id === this.drag!.id);
103 if (ev && this.lanes[li] && ev.voiceId !== this.lanes[li].id) {
104 ev.voiceId = this.lanes[li].id;
105 this.drag.moved = true;
106 this.draw();
107 }
108 }
110 private pointerUp(e: PointerEvent): void {
111 if (this.drag?.moved) {
112 const ev = this.events.find((v) => v.id === this.drag!.id);
113 if (ev) this.onReassign?.(ev.id, ev.voiceId);
114 }
115 this.drag = null;
116 if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId);
117 }
119 private dblClick(e: PointerEvent | MouseEvent): void {
120 const rect = this.canvas.getBoundingClientRect();
121 const x = e.clientX - rect.left, y = e.clientY - rect.top;
122 if (this.hitAt(x, y)) return;
123 const li = this.laneOf(y);
124 if (li < 0 || li >= this.lanes.length || x < GUTTER) return;
125 const t = Math.max(0, this.tOf(x, this.canvas.clientWidth));
126 this.onAdd?.(t, this.lanes[li].id);
127 }
129 draw(): void {
130 const c = this.canvas;
131 const dpr = devicePixelRatio || 1;
132 const w = c.clientWidth, h = c.clientHeight;
133 if (w === 0 || h === 0) return;
134 if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) {
135 c.width = Math.round(w * dpr);
136 c.height = Math.round(h * dpr);
137 }
138 const g = c.getContext('2d')!;
139 g.setTransform(dpr, 0, 0, dpr, 0, 0);
140 g.fillStyle = BG;
141 g.fillRect(0, 0, w, h);
143 // Waveform strip.
144 if (this.clip) {
145 const { samples, sr } = this.clip;
146 const mid = STRIP_H / 2;
147 g.strokeStyle = WAVE;
148 g.lineWidth = 1;
149 g.beginPath();
150 for (let px = GUTTER; px < w - 8; px++) {
151 const t0 = this.tOf(px, w), t1 = this.tOf(px + 1, w);
152 const i0 = Math.max(0, Math.floor(t0 * sr)), i1 = Math.min(samples.length, Math.floor(t1 * sr));
153 let lo = 0, hi = 0;
154 for (let i = i0; i < i1; i++) {
155 if (samples[i] < lo) lo = samples[i];
156 if (samples[i] > hi) hi = samples[i];
157 }
158 g.moveTo(px + 0.5, mid - hi * mid * 0.9 - 0.5);
159 g.lineTo(px + 0.5, mid - lo * mid * 0.9 + 0.5);
160 }
161 g.stroke();
162 }
164 // Lanes.
165 for (let i = 0; i < this.lanes.length; i++) {
166 const y0 = STRIP_H + i * LANE_H;
167 g.fillStyle = i % 2 ? 'rgba(255,255,255,0.028)' : 'rgba(255,255,255,0.055)';
168 g.fillRect(0, y0, w, LANE_H);
169 g.fillStyle = this.lanes[i].color;
170 g.fillRect(0, y0, 3, LANE_H);
171 g.font = '12px system-ui, sans-serif';
172 g.fillStyle = 'rgba(255,255,255,0.85)';
173 g.fillText(this.lanes[i].name.slice(0, 12), 10, y0 + LANE_H / 2 + 4);
174 }
176 // Time axis.
177 const axisY = STRIP_H + this.lanes.length * LANE_H;
178 g.font = '10px system-ui, sans-serif';
179 g.fillStyle = 'rgba(255,255,255,0.5)';
180 const step = this.duration > 8 ? 2 : this.duration > 3 ? 1 : 0.5;
181 for (let t = 0; t <= this.duration; t += step) {
182 const x = this.xOf(t, w);
183 g.fillRect(x, axisY, 1, 4);
184 g.fillText(`${t.toFixed(step < 1 ? 1 : 0)}s`, x + 2, axisY + 13);
185 }
187 // Events.
188 for (const ev of this.events) {
189 const li = this.lanes.findIndex((l) => l.id === ev.voiceId);
190 if (li < 0) continue;
191 const x = this.xOf(ev.t, w);
192 const y0 = STRIP_H + li * LANE_H;
193 const hBar = 6 + ev.gain * (LANE_H - 12);
194 const sel = ev.id === this.selected;
195 g.fillStyle = this.lanes[li].color;
196 g.globalAlpha = sel ? 1 : 0.85;
197 g.fillRect(x - (sel ? 2.5 : 1.5), y0 + LANE_H - 3 - hBar, sel ? 5 : 3, hBar);
198 if (sel) {
199 g.strokeStyle = '#fff';
200 g.lineWidth = 1;
201 g.strokeRect(x - 4.5, y0 + 2.5, 9, LANE_H - 5);
202 }
203 g.globalAlpha = 1;
204 }
206 // Playhead.
207 if (this.playT !== null) {
208 const x = this.xOf(this.playT, w);
209 g.strokeStyle = 'rgba(255,255,255,0.8)';
210 g.lineWidth = 1;
211 g.beginPath();
212 g.moveTo(x, 0);
213 g.lineTo(x, axisY);
214 g.stroke();
215 }
216 }
217}