/** * The take as an editable timeline: the recording's waveform in a strip at * the top, one lane per voice below, a marker per hit whose height is its * strength. Times are exactly as performed; the only edits are the ones a * wrong classification needs. Click selects and auditions; dragging a * marker to another lane reassigns it; double-clicking an empty spot in a * lane adds a hit there. */ import type { Clip } from '../audio/recorder.ts'; export interface Lane { id: string; name: string; color: string; } export interface TLEvent { id: string; t: number; voiceId: string; gain: number; } const BG = '#16181d'; const WAVE = '#44546a'; const STRIP_H = 44; const LANE_H = 42; const AXIS_H = 18; const GUTTER = 92; export class Timeline { private canvas: HTMLCanvasElement; private lanes: Lane[] = []; private events: TLEvent[] = []; private clip: Clip | null = null; private duration = 1; private playT: number | null = null; private drag: { id: string; moved: boolean } | null = null; selected: string | null = null; onSelect: ((id: string | null) => void) | null = null; onReassign: ((id: string, voiceId: string) => void) | null = null; onAdd: ((t: number, voiceId: string) => void) | null = null; constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; canvas.addEventListener('pointerdown', (e) => this.pointerDown(e)); canvas.addEventListener('pointermove', (e) => this.pointerMove(e)); canvas.addEventListener('pointerup', (e) => this.pointerUp(e)); canvas.addEventListener('dblclick', (e) => this.dblClick(e)); new ResizeObserver(() => this.draw()).observe(canvas); } setData(lanes: Lane[], events: TLEvent[], clip: Clip | null): void { this.lanes = lanes; this.events = events; this.clip = clip; const lastT = events.reduce((a, e) => Math.max(a, e.t), 0); this.duration = Math.max(clip ? clip.samples.length / clip.sr : 0, lastT + 0.5, 1); this.canvas.style.height = `${STRIP_H + lanes.length * LANE_H + AXIS_H}px`; if (this.selected && !events.some((e) => e.id === this.selected)) this.selected = null; this.draw(); } playhead(t: number | null): void { this.playT = t; this.draw(); } private xOf(t: number, w: number): number { return GUTTER + (t / this.duration) * (w - GUTTER - 8); } private tOf(x: number, w: number): number { return ((x - GUTTER) / (w - GUTTER - 8)) * this.duration; } private laneOf(y: number): number { return Math.floor((y - STRIP_H) / LANE_H); } private hitAt(x: number, y: number): TLEvent | null { const w = this.canvas.clientWidth; const li = this.laneOf(y); let best: TLEvent | null = null, bestDx = 7; for (const ev of this.events) { if (this.lanes.findIndex((l) => l.id === ev.voiceId) !== li) continue; const dx = Math.abs(this.xOf(ev.t, w) - x); if (dx < bestDx) { best = ev; bestDx = dx; } } return best; } private pointerDown(e: PointerEvent): void { const rect = this.canvas.getBoundingClientRect(); const ev = this.hitAt(e.clientX - rect.left, e.clientY - rect.top); if (ev) { this.drag = { id: ev.id, moved: false }; this.canvas.setPointerCapture(e.pointerId); if (this.selected !== ev.id) { this.selected = ev.id; this.onSelect?.(ev.id); } else this.onSelect?.(ev.id); // re-click re-auditions } else { this.selected = null; this.onSelect?.(null); } this.draw(); } private pointerMove(e: PointerEvent): void { if (!this.drag) return; const rect = this.canvas.getBoundingClientRect(); const li = Math.max(0, Math.min(this.lanes.length - 1, this.laneOf(e.clientY - rect.top))); const ev = this.events.find((v) => v.id === this.drag!.id); if (ev && this.lanes[li] && ev.voiceId !== this.lanes[li].id) { ev.voiceId = this.lanes[li].id; this.drag.moved = true; this.draw(); } } private pointerUp(e: PointerEvent): void { if (this.drag?.moved) { const ev = this.events.find((v) => v.id === this.drag!.id); if (ev) this.onReassign?.(ev.id, ev.voiceId); } this.drag = null; if (this.canvas.hasPointerCapture(e.pointerId)) this.canvas.releasePointerCapture(e.pointerId); } private dblClick(e: PointerEvent | MouseEvent): void { const rect = this.canvas.getBoundingClientRect(); const x = e.clientX - rect.left, y = e.clientY - rect.top; if (this.hitAt(x, y)) return; const li = this.laneOf(y); if (li < 0 || li >= this.lanes.length || x < GUTTER) return; const t = Math.max(0, this.tOf(x, this.canvas.clientWidth)); this.onAdd?.(t, this.lanes[li].id); } draw(): void { const c = this.canvas; const dpr = devicePixelRatio || 1; const w = c.clientWidth, h = c.clientHeight; if (w === 0 || h === 0) return; if (c.width !== Math.round(w * dpr) || c.height !== Math.round(h * dpr)) { c.width = Math.round(w * dpr); c.height = Math.round(h * dpr); } const g = c.getContext('2d')!; g.setTransform(dpr, 0, 0, dpr, 0, 0); g.fillStyle = BG; g.fillRect(0, 0, w, h); // Waveform strip. if (this.clip) { const { samples, sr } = this.clip; const mid = STRIP_H / 2; g.strokeStyle = WAVE; g.lineWidth = 1; g.beginPath(); for (let px = GUTTER; px < w - 8; px++) { const t0 = this.tOf(px, w), t1 = this.tOf(px + 1, w); const i0 = Math.max(0, Math.floor(t0 * sr)), i1 = Math.min(samples.length, Math.floor(t1 * sr)); let lo = 0, hi = 0; for (let i = i0; i < i1; i++) { if (samples[i] < lo) lo = samples[i]; if (samples[i] > hi) hi = samples[i]; } g.moveTo(px + 0.5, mid - hi * mid * 0.9 - 0.5); g.lineTo(px + 0.5, mid - lo * mid * 0.9 + 0.5); } g.stroke(); } // Lanes. for (let i = 0; i < this.lanes.length; i++) { const y0 = STRIP_H + i * LANE_H; g.fillStyle = i % 2 ? 'rgba(255,255,255,0.028)' : 'rgba(255,255,255,0.055)'; g.fillRect(0, y0, w, LANE_H); g.fillStyle = this.lanes[i].color; g.fillRect(0, y0, 3, LANE_H); g.font = '12px system-ui, sans-serif'; g.fillStyle = 'rgba(255,255,255,0.85)'; g.fillText(this.lanes[i].name.slice(0, 12), 10, y0 + LANE_H / 2 + 4); } // Time axis. const axisY = STRIP_H + this.lanes.length * LANE_H; g.font = '10px system-ui, sans-serif'; g.fillStyle = 'rgba(255,255,255,0.5)'; const step = this.duration > 8 ? 2 : this.duration > 3 ? 1 : 0.5; for (let t = 0; t <= this.duration; t += step) { const x = this.xOf(t, w); g.fillRect(x, axisY, 1, 4); g.fillText(`${t.toFixed(step < 1 ? 1 : 0)}s`, x + 2, axisY + 13); } // Events. for (const ev of this.events) { const li = this.lanes.findIndex((l) => l.id === ev.voiceId); if (li < 0) continue; const x = this.xOf(ev.t, w); const y0 = STRIP_H + li * LANE_H; const hBar = 6 + ev.gain * (LANE_H - 12); const sel = ev.id === this.selected; g.fillStyle = this.lanes[li].color; g.globalAlpha = sel ? 1 : 0.85; g.fillRect(x - (sel ? 2.5 : 1.5), y0 + LANE_H - 3 - hBar, sel ? 5 : 3, hBar); if (sel) { g.strokeStyle = '#fff'; g.lineWidth = 1; g.strokeRect(x - 4.5, y0 + 2.5, 9, LANE_H - 5); } g.globalAlpha = 1; } // Playhead. if (this.playT !== null) { const x = this.xOf(this.playT, w); g.strokeStyle = 'rgba(255,255,255,0.8)'; g.lineWidth = 1; g.beginPath(); g.moveTo(x, 0); g.lineTo(x, axisY); g.stroke(); } } }