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 ──────────────────────────────────────────────────────────── */}
{/* ── Body ────────────────────────────────────────────────────────────── */}
{open && (
{/* Master timeline */}
{/* Track rows */}
{slots.map((slot, i) => (
))}
{/* Add track */}
{/* LoopTrimmer — shown below tracks when trimming */}
{trimmingIdx !== -1 && (
)}
)}
)
}