loop station

This commit is contained in:
vadimwit
2026-03-20 01:03:43 +00:00
parent 8cd9b01941
commit f583979b3e
9 changed files with 2600 additions and 25 deletions
+508
View File
@@ -0,0 +1,508 @@
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 (
<div className="relative mx-4 mb-3 rounded overflow-hidden bg-surface border border-border"
style={{ height: `${H_MASTER}px` }}>
<canvas ref={canvasRef} className="w-full h-full block" />
{!masterLen && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-[10px] text-gray-700">
record first loop to set master length
</span>
</div>
)}
{masterLen && (
<div
ref={playheadRef}
className="absolute top-0 bottom-0 w-px bg-white/50 pointer-events-none"
style={{ left: '0%' }}
/>
)}
</div>
)
}
// ── 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 (
<div className={`flex items-stretch border rounded-lg mb-1.5 overflow-hidden transition-colors ${borderClass}`}>
{/* Left: tap button (state dot + track number) */}
<button
onClick={() => isClickable && onSlotClick(slotIdx)}
disabled={!isClickable}
title={
slot.status === 'empty' ? 'Tap to record' :
slot.status === 'recording' ? 'Tap to stop' :
slot.status === 'playing' ? 'Tap to mute' :
slot.status === 'muted' ? 'Tap to unmute' : ''
}
className="flex flex-col items-center justify-center gap-1 px-3 bg-surface border-r border-border shrink-0 hover:bg-white/5 transition-colors disabled:cursor-default"
style={{ width: '44px' }}
>
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
<span className="text-[10px] text-gray-600 font-mono">{slotIdx + 1}</span>
</button>
{/* Canvas: waveform / recording / empty */}
<div
className="relative flex-1 cursor-pointer"
style={{ height: `${H_TRACK}px` }}
onClick={() => isClickable && onSlotClick(slotIdx)}
>
<canvas ref={canvasRef} className="w-full h-full block" />
{slot.status === 'empty' && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="text-xs text-gray-700">tap to record</span>
</div>
)}
{slot.status === 'trimming' && (
<div className="absolute inset-0 flex items-center justify-center bg-amber-950/20 pointer-events-none">
<span className="text-xs text-amber-500/60">trimming </span>
</div>
)}
{/* Moving playhead */}
{isActive && (
<div
ref={playheadRef}
className="absolute top-0 bottom-0 w-px bg-white/40 pointer-events-none"
style={{ left: '-2px' }}
/>
)}
</div>
{/* Right: controls */}
<div className="flex items-center gap-1 px-2 bg-surface border-l border-border shrink-0">
{showVol && (
<input
type="range"
min={0} max={1} step={0.01}
value={slot.volume}
onChange={e => onVolumeChange(slotIdx, parseFloat(e.target.value))}
className="w-14 accent-purple-500"
title="Volume"
/>
)}
<button
onClick={() => setShowVol(v => !v)}
className={`w-7 h-7 flex items-center justify-center rounded text-base transition-colors ${
showVol ? 'text-accent' : 'text-gray-600 hover:text-gray-300'
}`}
title="Volume"
>
{slot.status === 'muted' ? '🔇' : '🔊'}
</button>
{isActive && slot.originalBuffer && (
<button
onClick={() => onRetrim(slotIdx)}
className="w-7 h-7 flex items-center justify-center rounded text-gray-600 hover:text-amber-400 transition-colors text-sm"
title="Re-trim loop"
>
</button>
)}
<button
onClick={() => onDelete(slotIdx)}
className="w-7 h-7 flex items-center justify-center rounded text-gray-700 hover:text-red-400 transition-colors text-sm"
title="Clear track"
>
</button>
</div>
</div>
)
}
// ── 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 (
<div className="mb-3 bg-panel border border-border rounded-xl overflow-hidden">
{/* ── Header ──────────────────────────────────────────────────────────── */}
<div className="flex items-center justify-between px-4 py-2 text-sm text-gray-400">
<button
onClick={() => setOpen(v => !v)}
className="flex items-center gap-2 hover:text-gray-200 transition-colors text-left"
>
<span className={`w-2 h-2 rounded-full shrink-0 ${dotClass}`} />
<span>LOOP STATION</span>
{masterLabel && (
<span className="text-[11px] text-gray-600 font-mono">{masterLabel}</span>
)}
{recordingCount > 0 && (
<span className="text-[11px] text-red-400"> rec</span>
)}
{playingCount > 0 && recordingCount === 0 && (
<span className="text-[11px] text-accent">
{playingCount} loop{playingCount !== 1 ? 's' : ''}
</span>
)}
</button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-surface border border-border rounded-lg px-2 py-1">
<span className="text-[10px] text-gray-600 uppercase tracking-wider">BPM</span>
<input
type="number"
min={40} max={300} step={1}
value={gridBpm}
onChange={e => 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' }}
/>
</div>
<button
onClick={() => setOpen(v => !v)}
className="w-6 h-6 flex items-center justify-center rounded text-gray-500 hover:text-gray-300 hover:bg-white/5 transition-all"
title={open ? 'Collapse' : 'Expand'}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"
stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
{open
? <polyline points="2,8 6,4 10,8" />
: <polyline points="2,4 6,8 10,4" />
}
</svg>
</button>
</div>
</div>
{/* ── Body ────────────────────────────────────────────────────────────── */}
{open && (
<div className="border-t border-border pt-3">
{/* Master timeline */}
<MasterTimeline
masterStartRef={masterStartRef}
masterLenRef={masterLenRef}
audioCtxRef={audioCtxRef}
bpm={gridBpmNum}
masterLen={masterLen}
/>
{/* Track rows */}
<div className="px-4">
{slots.map((slot, i) => (
<TrackRow
key={i}
slot={slot}
slotIdx={i}
bpm={gridBpmNum}
audioCtxRef={audioCtxRef}
masterStartRef={masterStartRef}
masterLenRef={masterLenRef}
onSlotClick={onSlotClick}
onRetrim={onRetrim}
onDelete={onDelete}
onVolumeChange={onVolumeChange}
/>
))}
</div>
{/* Add track */}
<div className="px-4 pb-3">
<button
onClick={onAddSlot}
className="w-full py-1.5 rounded-lg border border-dashed border-border text-gray-700 hover:border-accent/40 hover:text-accent/60 transition-colors text-xs flex items-center justify-center gap-2"
>
<span className="text-base leading-none">+</span>
<span>Add Track</span>
</button>
</div>
{/* LoopTrimmer — shown below tracks when trimming */}
{trimmingIdx !== -1 && (
<LoopTrimmer
slot={slots[trimmingIdx]}
slotIdx={trimmingIdx}
bpm={gridBpmNum}
audioCtxRef={audioCtxRef}
onCommit={onCommitTrim}
onCancel={onCancelRecord}
/>
)}
</div>
)}
</div>
)
}