import { useState, useRef, useEffect } from 'react' import LoopTrimmer from './LoopTrimmer' const H_TRACK = 56 // track canvas height px const H_MASTER = 26 // master timeline height px // ── Canvas draw helpers ─────────────────────────────────────────────────────── function drawGrid(canvas, totalSec, bpm) { const rect = canvas.getBoundingClientRect() if (!rect.width || !rect.height) return const dpr = window.devicePixelRatio ?? 1 canvas.width = rect.width * dpr canvas.height = rect.height * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) const W = rect.width, H = rect.height ctx.fillStyle = '#0f0f0f' ctx.fillRect(0, 0, W, H) if (!bpm || !totalSec) return const beatSec = 60 / bpm const barSec = beatSec * 4 // Beat lines ctx.strokeStyle = 'rgba(255,255,255,0.06)' ctx.lineWidth = 1 for (let t = beatSec; t < totalSec; t += beatSec) { if ((t % barSec) < beatSec * 0.4) continue const x = (t / totalSec) * W ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() } // Bar lines + numbers for (let t = 0; t <= totalSec; t += barSec) { ctx.strokeStyle = 'rgba(168,85,247,0.45)' ctx.lineWidth = 1.5 const x = (t / totalSec) * W ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() const n = Math.round(t / barSec) if (n > 0) { ctx.fillStyle = 'rgba(168,85,247,0.55)' ctx.font = '9px monospace' ctx.textAlign = 'left' ctx.fillText(String(n), x + 3, H - 2) } } } function drawWaveform(canvas, waveform, muted) { const rect = canvas.getBoundingClientRect() if (!rect.width || !rect.height) return const dpr = window.devicePixelRatio ?? 1 canvas.width = rect.width * dpr canvas.height = rect.height * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) const W = rect.width, H = rect.height ctx.fillStyle = '#0f0f0f' ctx.fillRect(0, 0, W, H) const N = waveform.length const mid = H / 2 for (let i = 0; i < N; i++) { const x = (i / N) * W const barW = Math.max(1, W / N - 0.3) ctx.fillStyle = muted ? '#3b1f55' : '#a855f7' const h = waveform[i] * mid * 0.85 ctx.fillRect(x, mid - h, barW, h * 2) } } function drawRecording(canvas, duration, bpm) { const rect = canvas.getBoundingClientRect() if (!rect.width || !rect.height) return const dpr = window.devicePixelRatio ?? 1 canvas.width = rect.width * dpr canvas.height = rect.height * dpr const ctx = canvas.getContext('2d') ctx.scale(dpr, dpr) const W = rect.width, H = rect.height ctx.fillStyle = '#0f0f0f' ctx.fillRect(0, 0, W, H) const beatSec = bpm ? 60 / bpm : null const barSec = beatSec ? beatSec * 4 : null const viewDur = barSec ? Math.max(barSec * 4, Math.ceil(duration / barSec + 1) * barSec) : Math.max(8, duration * 1.4) // Grid if (beatSec) { ctx.strokeStyle = 'rgba(255,255,255,0.06)' ctx.lineWidth = 1 for (let t = beatSec; t < viewDur; t += beatSec) { if (barSec && (t % barSec) < beatSec * 0.4) continue const x = (t / viewDur) * W ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() } if (barSec) { for (let t = barSec; t <= viewDur; t += barSec) { ctx.strokeStyle = 'rgba(239,68,68,0.3)' ctx.lineWidth = 1.5 const x = (t / viewDur) * W ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke() const n = Math.round(t / barSec) ctx.fillStyle = 'rgba(239,68,68,0.45)' ctx.font = '9px monospace' ctx.textAlign = 'left' ctx.fillText(String(n), x + 2, H - 2) } } } // Growing fill + cursor const fillX = (duration / viewDur) * W ctx.fillStyle = 'rgba(239,68,68,0.18)' ctx.fillRect(0, 0, fillX, H) ctx.strokeStyle = 'rgba(239,68,68,0.85)' ctx.lineWidth = 1.5 ctx.beginPath(); ctx.moveTo(fillX, 0); ctx.lineTo(fillX, H); ctx.stroke() // Counter label const bars = barSec ? Math.floor(duration / barSec) + 1 : null const label = bars !== null ? `● REC BAR ${bars} ${duration.toFixed(1)}s — tap to stop` : `● REC ${duration.toFixed(1)}s — tap to stop` ctx.fillStyle = 'rgba(239,68,68,0.9)' ctx.font = 'bold 11px monospace' ctx.textAlign = 'center' ctx.textBaseline = 'middle' ctx.fillText(label, W / 2, H / 2) } // ── Master Timeline ─────────────────────────────────────────────────────────── function MasterTimeline({ masterStartRef, masterLenRef, audioCtxRef, bpm, masterLen }) { const canvasRef = useRef(null) const playheadRef = useRef(null) const rafRef = useRef(null) useEffect(() => { const canvas = canvasRef.current if (!canvas) return const id = requestAnimationFrame(() => drawGrid(canvas, masterLen, bpm)) return () => cancelAnimationFrame(id) }, [masterLen, bpm]) useEffect(() => { if (!masterLen) { cancelAnimationFrame(rafRef.current); return } function tick() { const ac = audioCtxRef.current const t0 = masterStartRef.current const len = masterLenRef.current if (ac && t0 !== null && len && playheadRef.current) { const pos = ((ac.currentTime - t0) % len) / len playheadRef.current.style.left = `${pos * 100}%` } rafRef.current = requestAnimationFrame(tick) } rafRef.current = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafRef.current) }, [masterLen, audioCtxRef, masterStartRef, masterLenRef]) return (
{!masterLen && (
record first loop to set master length
)} {masterLen && (
)}
) } // ── Track Row ───────────────────────────────────────────────────────────────── function TrackRow({ slot, slotIdx, bpm, audioCtxRef, masterStartRef, masterLenRef, onSlotClick, onRetrim, onDelete, onVolumeChange }) { const [showVol, setShowVol] = useState(false) const canvasRef = useRef(null) const playheadRef = useRef(null) const rafRef = useRef(null) const DOT_CLASS = { empty: 'bg-gray-700', recording: 'bg-red-500 animate-pulse', trimming: 'bg-amber-500', playing: 'bg-accent', muted: 'bg-gray-500', } const BORDER_CLASS = { empty: 'border-border', recording: 'border-red-800', trimming: 'border-amber-800/60', playing: 'border-accent/40', muted: 'border-border', } const dotClass = DOT_CLASS[slot.status] ?? 'bg-gray-700' const borderClass = BORDER_CLASS[slot.status] ?? 'border-border' // Draw waveform when data arrives or mute state changes useEffect(() => { if (!slot.waveform) return if (slot.status === 'recording' || slot.status === 'trimming') return const canvas = canvasRef.current if (!canvas) return const id = requestAnimationFrame(() => drawWaveform(canvas, slot.waveform, slot.status === 'muted') ) return () => cancelAnimationFrame(id) }, [slot.waveform, slot.status]) // Draw recording progress on each duration tick useEffect(() => { if (slot.status !== 'recording') return const canvas = canvasRef.current if (!canvas) return drawRecording(canvas, slot.recordingDuration, bpm) }, [slot.recordingDuration, slot.status, bpm]) // Playhead animation useEffect(() => { const active = slot.status === 'playing' || slot.status === 'muted' if (!active) { cancelAnimationFrame(rafRef.current) if (playheadRef.current) playheadRef.current.style.left = '-2px' return } function tick() { const ac = audioCtxRef.current const t0 = masterStartRef.current const len = masterLenRef.current if (ac && t0 !== null && len && playheadRef.current) { const pos = ((ac.currentTime - t0) % len) / len playheadRef.current.style.left = `${pos * 100}%` } rafRef.current = requestAnimationFrame(tick) } rafRef.current = requestAnimationFrame(tick) return () => cancelAnimationFrame(rafRef.current) }, [slot.status, audioCtxRef, masterStartRef, masterLenRef]) const isClickable = slot.status !== 'trimming' const isActive = slot.status === 'playing' || slot.status === 'muted' return (
{/* Left: tap button (state dot + track number) */} {/* Canvas: waveform / recording / empty */}
isClickable && onSlotClick(slotIdx)} > {slot.status === 'empty' && (
tap to record
)} {slot.status === 'trimming' && (
trimming ↓
)} {/* Moving playhead */} {isActive && (
)}
{/* Right: controls */}
{showVol && ( onVolumeChange(slotIdx, parseFloat(e.target.value))} className="w-14 accent-purple-500" title="Volume" /> )} {isActive && slot.originalBuffer && ( )}
) } // ── Main ────────────────────────────────────────────────────────────────────── export default function LoopStation({ slots, bpm, masterLen, audioCtxRef, masterStartRef, masterLenRef, onSlotClick, onCommitTrim, onCancelRecord, onRetrim, onDelete, onVolumeChange, onAddSlot, }) { const [open, setOpen] = useState(false) const [gridBpm, setGridBpm] = useState(bpm ?? '') // Pre-fill BPM when detection arrives useEffect(() => { if (bpm && !gridBpm) setGridBpm(bpm) }, [bpm]) // eslint-disable-line react-hooks/exhaustive-deps const gridBpmNum = parseFloat(gridBpm) || null const trimmingIdx = slots.findIndex(s => s.status === 'trimming') const recordingCount = slots.filter(s => s.status === 'recording').length const playingCount = slots.filter(s => s.status === 'playing').length const dotClass = recordingCount > 0 ? 'bg-red-500 animate-pulse' : playingCount > 0 ? 'bg-accent' : 'bg-gray-700' const masterLabel = (() => { if (!masterLen) return null if (gridBpmNum) { const bars = Math.round(masterLen / ((60 / gridBpmNum) * 4)) return `${bars} bar${bars !== 1 ? 's' : ''} · ${masterLen.toFixed(2)}s` } return masterLen.toFixed(2) + 's' })() return (
{/* ── Header ──────────────────────────────────────────────────────────── */}
BPM setGridBpm(e.target.value)} placeholder={bpm ? String(Math.round(bpm)) : '—'} className="w-10 bg-transparent text-xs text-gray-300 text-center focus:outline-none focus:text-white" style={{ MozAppearance: 'textfield' }} />
{/* ── Body ────────────────────────────────────────────────────────────── */} {open && (
{/* Master timeline */} {/* Track rows */}
{slots.map((slot, i) => ( ))}
{/* Add track */}
{/* LoopTrimmer — shown below tracks when trimming */} {trimmingIdx !== -1 && ( )}
)}
) }