"use client"; /** * TempoAnalyzer * * Full workflow: * 1. User drops / selects an audio file (MP3, WAV, AAC, OGG, etc.) * 2. Browser decodes the audio and runs BPM detection (Web Audio API) * 3. Optional: user provides song title, artist, MusicBrainz ID * 4. Client sends { bpm, duration, … } to POST /api/analyze * 5. Server calls Claude → returns a CTP document draft * 6. User can review the sections, download the .ctp.json, or submit to DB */ import { useState, useRef, useCallback } from "react"; import { detectBPM, type BPMDetectionResult } from "@/lib/analysis/bpm-detect"; import TempoMapEditor from "@/components/TempoMapEditor"; import type { CTPDocument } from "@/lib/ctp/schema"; // ─── Types ──────────────────────────────────────────────────────────────────── type Stage = | "idle" | "decoding" | "detecting" | "generating" | "review" | "saving" | "saved" | "error"; interface AnalyzerState { stage: Stage; file: File | null; detection: BPMDetectionResult | null; ctp: CTPDocument | null; warnings: string[]; errorMsg: string; // Optional metadata the user may fill in before AI generation title: string; artist: string; mbid: string; contributedBy: string; // Toggle: use halfTimeBpm instead of primary bpm useHalfTime: boolean; } const INITIAL_STATE: AnalyzerState = { stage: "idle", file: null, detection: null, ctp: null, warnings: [], errorMsg: "", title: "", artist: "", mbid: "", contributedBy: "", useHalfTime: false, }; // ─── Helpers ────────────────────────────────────────────────────────────────── function formatDuration(s: number) { const m = Math.floor(s / 60); const sec = Math.round(s % 60); return `${m}:${String(sec).padStart(2, "0")}`; } function confidenceLabel(c: number) { if (c >= 0.7) return { label: "High", color: "text-green-400" }; if (c >= 0.4) return { label: "Medium", color: "text-amber-400" }; return { label: "Low", color: "text-red-400" }; } // ─── Component ──────────────────────────────────────────────────────────────── export default function TempoAnalyzer() { const [state, setState] = useState(INITIAL_STATE); const abortRef = useRef(null); const dropRef = useRef(null); const [isDragging, setIsDragging] = useState(false); const update = (patch: Partial) => setState((prev) => ({ ...prev, ...patch })); // ── File handling ──────────────────────────────────────────────────────── const handleFile = useCallback(async (file: File) => { if (!file.type.startsWith("audio/") && !file.name.match(/\.(mp3|wav|aac|ogg|flac|m4a|aiff)$/i)) { update({ errorMsg: "Please select an audio file (MP3, WAV, AAC, OGG, FLAC, M4A).", stage: "error" }); return; } abortRef.current?.abort(); const abort = new AbortController(); abortRef.current = abort; // Try to pre-fill title/artist from filename: "Artist - Title.mp3" const base = file.name.replace(/\.[^.]+$/, ""); const dashIdx = base.indexOf(" - "); const autoTitle = dashIdx > -1 ? base.slice(dashIdx + 3) : base; const autoArtist = dashIdx > -1 ? base.slice(0, dashIdx) : ""; update({ stage: "decoding", file, detection: null, ctp: null, warnings: [], errorMsg: "", title: autoTitle, artist: autoArtist, }); try { update({ stage: "detecting" }); const detection = await detectBPM(file, abort.signal); update({ detection, stage: "idle" }); // wait for user to confirm/edit metadata } catch (err) { if ((err as Error).name === "AbortError") return; update({ stage: "error", errorMsg: `BPM detection failed: ${err instanceof Error ? err.message : String(err)}`, }); } }, []); function handleDrop(e: React.DragEvent) { e.preventDefault(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) handleFile(file); } function handleFileInput(e: React.ChangeEvent) { const file = e.target.files?.[0]; if (file) handleFile(file); e.target.value = ""; // reset so re-selecting same file works } // ── AI generation ──────────────────────────────────────────────────────── async function handleGenerate() { if (!state.detection) return; const effectiveBpm = state.useHalfTime && state.detection.halfTimeBpm ? state.detection.halfTimeBpm : state.detection.bpm; update({ stage: "generating", ctp: null, warnings: [] }); try { const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ bpm: effectiveBpm, duration: state.detection.duration, title: state.title || undefined, artist: state.artist || undefined, mbid: state.mbid || undefined, contributed_by: state.contributedBy || undefined, }), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error ?? `Server error ${res.status}`); } update({ ctp: data.ctp, warnings: data.warnings ?? [], stage: "review" }); } catch (err) { update({ stage: "error", errorMsg: `Generation failed: ${err instanceof Error ? err.message : String(err)}`, }); } } // ── Submit to DB ───────────────────────────────────────────────────────── async function handleSubmit() { if (!state.ctp) return; update({ stage: "saving" }); try { const res = await fetch("/api/tracks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(state.ctp), }); const data = await res.json(); if (!res.ok) { throw new Error(data.error ?? `Server error ${res.status}`); } update({ stage: "saved" }); } catch (err) { update({ stage: "error", errorMsg: `Save failed: ${err instanceof Error ? err.message : String(err)}`, }); } } // ── Download CTP file ──────────────────────────────────────────────────── function handleDownload() { if (!state.ctp) return; const json = JSON.stringify(state.ctp, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); const safeName = `${state.ctp.metadata.artist} - ${state.ctp.metadata.title}` .replace(/[^\w\s\-]/g, "") .replace(/\s+/g, "_") .slice(0, 80); a.href = url; a.download = `${safeName}.ctp.json`; a.click(); URL.revokeObjectURL(url); } // ── Reset ──────────────────────────────────────────────────────────────── function handleReset() { abortRef.current?.abort(); setState(INITIAL_STATE); } // ─── Render ─────────────────────────────────────────────────────────────── const { stage, file, detection, ctp, warnings, errorMsg, useHalfTime } = state; const isProcessing = stage === "decoding" || stage === "detecting" || stage === "generating" || stage === "saving"; return (
{/* ── Drop zone ─────────────────────────────────────────────────── */} {!file && stage === "idle" && (
{ e.preventDefault(); setIsDragging(true); }} onDragLeave={() => setIsDragging(false)} onDrop={handleDrop} className={`rounded-xl border-2 border-dashed px-8 py-16 text-center transition-colors ${ isDragging ? "border-green-500 bg-green-950/20" : "border-zinc-700 hover:border-zinc-500" }`} >

🎵

Drop an audio file here

MP3, WAV, AAC, OGG, FLAC, M4A — any format your browser supports

)} {/* ── Processing indicator ───────────────────────────────────────── */} {isProcessing && (

{stage === "decoding" && "Decoding audio…"} {stage === "detecting" && "Detecting tempo…"} {stage === "generating" && "Generating tempo map with AI…"} {stage === "saving" && "Saving to database…"}

{stage === "generating" && (

Claude is analysing the song structure — this takes ~5–15 seconds.

)}
)} {/* ── Error ─────────────────────────────────────────────────────── */} {stage === "error" && (

Error

{errorMsg}

)} {/* ── Detection results + metadata form ─────────────────────────── */} {detection && (stage === "idle" || stage === "review" || stage === "saved") && (
{/* File name + detection summary */}

Analysed file

{file?.name}

{useHalfTime && detection.halfTimeBpm ? detection.halfTimeBpm : detection.bpm}

BPM

{confidenceLabel(detection.confidence).label}

Confidence

{formatDuration(detection.duration)}

Duration

{/* Half-time toggle */} {detection.halfTimeBpm && (
Detected double-time pulse — primary BPM may be 2× the actual feel. Half-time: {detection.halfTimeBpm} BPM
)} {/* Confidence warning */} {detection.confidence < 0.4 && (

Low confidence — the BPM may be inaccurate. Consider a song with a clearer beat, or adjust the detected value manually before generating.

)} {/* Metadata form */} {stage === "idle" && ( <>
update({ title: e.target.value })} placeholder="e.g. Bohemian Rhapsody" className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none" />
update({ artist: e.target.value })} placeholder="e.g. Queen" className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none" />
update({ mbid: e.target.value })} placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm font-mono text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none" />
update({ contributedBy: e.target.value })} placeholder="e.g. guitar_pete" className="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 placeholder:text-zinc-600 focus:border-green-500 focus:outline-none" />
)}
)} {/* ── AI-generated CTP review ────────────────────────────────────── */} {ctp && (stage === "review" || stage === "saved") && (
{warnings.length > 0 && (

Validation warnings

    {warnings.map((w, i) =>
  • {w}
  • )}
)}

Generated tempo map

AI draft — verify before using
{/* Actions */} {stage === "review" && (
{ctp.metadata.mbid && ( )} {!ctp.metadata.mbid && (

Add a MusicBrainz ID to submit to the database.

)}
)} {stage === "saved" && (

Saved to database

{ctp.metadata.mbid && ( View track page → )}
)}
)}
); }