"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import Waveform, { type Peaks } from "@/components/Waveform"; import type { Hook, TrimEntry } from "@/lib/trim"; import type { NoteMap } from "@/lib/notes"; import NoteField from "@/components/NoteField"; // The hook trimmer. // // EVERYTHING HERE IS IN STEM SECONDS, which is what makes a trim widenable. // The catalogue window is drawn as `cand` -- the reference -- and the selection // can leave it in either direction. hook-22's window opens at 97.54 when // "Scorpion!" starts at 97.10; an editor that could only cut further in could // never fix that, and the last one couldn't, so it was fixed by hand. // // Which shape gets written depends on where the selection ends up, and that is // a real decision rather than a formatting one -- see toEntry() in lib/trim.ts. type Props = { setId: string; label: string; hooks: Hook[]; initialTrims: TrimEntry[]; hookNotes: NoteMap; }; /** One second of air either side, so both edges are reachable immediately. */ const PAD = 1; export default function TrimBench({ setId, label, hooks, initialTrims, hookNotes }: Props) { const [trims, setTrims] = useState(initialTrims); const [idx, setIdx] = useState(0); const [peaks, setPeaks] = useState(null); const [view, setView] = useState({ from: 0, to: 1 }); const [sel, setSel] = useState({ from: 0, to: 1 }); const [playhead, setPlayhead] = useState(null); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); const [saved, setSaved] = useState(false); const audio = useRef(null); const raf = useRef(null); const playFrom = useRef(0); const hook = hooks[idx]; const entryFor = useCallback( (h: Hook) => trims.find((t) => t.file === h.file), [trims], ); // Load a hook: the window it currently has, and a view one second wider. useEffect(() => { if (!hook) return; const e = trims.find((t) => t.file === hook.file); const win = !e ? { from: hook.srcStart, to: hook.srcEnd } : e.source === "stem" ? { from: e.start, to: e.end } : { from: hook.srcStart + e.start, to: hook.srcStart + e.end }; setSel(win); setView({ from: Math.max(0, win.from - PAD), to: win.to + PAD }); setSaved(false); // trims is intentionally not a dependency: re-running on every save would // throw away an in-progress drag. // eslint-disable-next-line react-hooks/exhaustive-deps }, [idx, hook?.file]); // Peaks follow the view. useEffect(() => { let alive = true; const q = new URLSearchParams({ from: String(view.from), to: String(view.to), n: "1200", }); fetch(`/api/trim/${setId}/peaks?${q}`, { cache: "no-store" }) .then((r) => (r.ok ? r.json() : null)) .then((j) => { if (alive && j) setPeaks(j as Peaks); }) .catch(() => {}); return () => { alive = false; }; }, [setId, view.from, view.to]); const widen = useCallback((side: "start" | "end") => { setView((v) => (side === "start" ? { ...v, from: Math.max(0, v.from - 1) } : { ...v, to: v.to + 1 })); }, []); const stop = useCallback(() => { audio.current?.pause(); if (raf.current !== null) cancelAnimationFrame(raf.current); raf.current = null; setPlayhead(null); }, []); const play = useCallback( (from: number, to: number) => { stop(); const el = new Audio(`/api/trim/${setId}/audio?from=${from}&to=${to}`); audio.current = el; playFrom.current = from; const tick = () => { if (!audio.current) return; setPlayhead(playFrom.current + audio.current.currentTime); raf.current = requestAnimationFrame(tick); }; el.addEventListener("ended", () => { setPlayhead(null); if (raf.current !== null) cancelAnimationFrame(raf.current); raf.current = null; }); void el.play().then(tick).catch(() => setError("could not play the stem window")); }, [setId, stop], ); useEffect(() => stop, [stop]); const save = async (next: TrimEntry[]) => { setSaving(true); setError(null); try { const res = await fetch(`/api/trim/${setId}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ trims: next }), cache: "no-store", }); const j = (await res.json()) as { error?: string; trims?: TrimEntry[] }; if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setTrims(j.trims ?? next); setSaved(true); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setSaving(false); } }; if (!hook) { return

no hooks in this catalogue

; } // The same rule as toEntry(), so the UI can say which shape it is about to // write BEFORE it writes it -- re-cutting from the stem loses the reverb, and // that should never be a surprise. const inside = sel.from >= hook.srcStart - 0.005 && sel.to <= hook.srcEnd + 0.005; const unchanged = Math.abs(sel.from - hook.srcStart) < 0.005 && Math.abs(sel.to - hook.srcEnd) < 0.005; const commit = () => { const rest = trims.filter((t) => t.file !== hook.file); if (unchanged) return save(rest); const entry: TrimEntry = inside ? { id: hook.id, file: hook.file, start: +(sel.from - hook.srcStart).toFixed(4), end: +(sel.to - hook.srcStart).toFixed(4), } : { id: hook.id, file: hook.file, source: "stem", start: +sel.from.toFixed(4), end: +sel.to.toFixed(4), }; return save([...rest, entry].sort((a, b) => a.file.localeCompare(b.file))); }; return (
{hook.id} “{hook.text}” catalogue {hook.srcStart.toFixed(2)}–{hook.srcEnd.toFixed(2)}s {label}
setSel(s)} onReachEdge={widen} />
{sel.from.toFixed(3)} – {sel.to.toFixed(3)}s ({(sel.to - sel.from).toFixed(3)}s)

{unchanged ? "the catalogue window, unchanged — saving removes any trim for this hook" : inside ? "inside the catalogue window → a clip-relative trim, keeping the reverb baked into hooks-verb" : "outside the catalogue window → re-cut from the stem, which RECOVERS the syllable but LOSES the reverb"}

{/* A hook's note is about the LINE, not about one song's use of it -- the catalogue is shared -- so it lives in the trim-set store. */} {error &&

{error}

} {saved && !error &&

saved

}
{hooks.map((h, i) => { const e = entryFor(h); return ( ); })}
); }