"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. User selects an analysis provider * 5. Client sends { bpm, duration, provider, … } to POST /api/analyze * 6. Server returns a CTP document draft * 7. User can review the sections, download the .ctp.json, or submit to DB */ import { useState, useRef, useCallback, useEffect } from "react"; import { detectBPM, type BPMDetectionResult } from "@/lib/analysis/bpm-detect"; import TempoMapEditor from "@/components/TempoMapEditor"; import type { CTPDocument } from "@/lib/ctp/schema"; import type { ProviderInfo } from "@/lib/analysis/providers"; import { RECOMMENDED_OLLAMA_MODELS } from "@/lib/analysis/constants"; const PROVIDER_KEY = "clicktrack_analysis_provider"; const MODEL_KEY = "clicktrack_ollama_model"; // ─── Types ──────────────────────────────────────────────────────────────────── interface SongResult { mbid: string; title: string; artist: string; duration_seconds: number | null; } 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; title: string; artist: string; mbid: string; contributedBy: string; 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); // Provider state const [providers, setProviders] = useState([]); const [ollamaModels, setOllamaModels] = useState([]); const [selectedProvider, setSelectedProvider] = useState(""); const [selectedOllamaModel, setSelectedOllamaModel] = useState(""); const [unavailableNotice, setUnavailableNotice] = useState(""); const [unavailableModelNotice, setUnavailableModelNotice] = useState(""); // Song search const [songQuery, setSongQuery] = useState(""); const [songResults, setSongResults] = useState([]); const [songDropdownOpen, setSongDropdownOpen] = useState(false); const [songHighlightIdx, setSongHighlightIdx] = useState(-1); const [songSearchFailed, setSongSearchFailed] = useState(false); const [selectedSongLabel, setSelectedSongLabel] = useState(""); const searchDebounceRef = useRef | null>(null); const songDropdownRef = useRef(null); const update = (patch: Partial) => setState((prev) => ({ ...prev, ...patch })); // ── Load providers on mount ─────────────────────────────────────────────── useEffect(() => { async function loadProviders() { try { const res = await fetch("/api/analyze/providers"); const data = await res.json() as { providers: ProviderInfo[]; ollamaModels: string[] }; const available = data.providers.filter((p) => p.available); setProviders(available); setOllamaModels(data.ollamaModels); // Restore saved provider preference const savedProvider = localStorage.getItem(PROVIDER_KEY); if (savedProvider) { const found = available.find((p) => p.id === savedProvider); if (found) { setSelectedProvider(found.id); } else { // Saved provider no longer available const unavailable = data.providers.find((p) => p.id === savedProvider); const label = unavailable?.label ?? savedProvider; setUnavailableNotice( `Your previous provider (${label}) is not currently available.` ); setSelectedProvider(available[0]?.id ?? ""); } } else { setSelectedProvider(available[0]?.id ?? ""); } // Restore saved Ollama model if (data.ollamaModels.length > 0) { const savedModel = localStorage.getItem(MODEL_KEY); if (savedModel && data.ollamaModels.includes(savedModel)) { setSelectedOllamaModel(savedModel); } else { if (savedModel) { setUnavailableModelNotice( `Your previous model (${savedModel}) is no longer available.` ); } setSelectedOllamaModel(data.ollamaModels[0]); } } } catch { // Provider fetch failure — app remains functional, generate will fail gracefully } } loadProviders(); }, []); useEffect(() => { function onMouseDown(e: MouseEvent) { if (songDropdownRef.current && !songDropdownRef.current.contains(e.target as Node)) { setSongDropdownOpen(false); } } document.addEventListener("mousedown", onMouseDown); return () => document.removeEventListener("mousedown", onMouseDown); }, []); // ── Song search ─────────────────────────────────────────────────────────── function handleSongQueryChange(value: string) { setSongQuery(value); setSongHighlightIdx(-1); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); if (!value.trim()) { setSongResults([]); setSongDropdownOpen(false); return; } searchDebounceRef.current = setTimeout(async () => { try { const res = await fetch(`/api/songs?q=${encodeURIComponent(value)}&limit=5`); if (!res.ok) throw new Error("Search failed"); const data = await res.json() as { songs: SongResult[] }; setSongResults(data.songs); setSongDropdownOpen(true); setSongSearchFailed(false); } catch { setSongSearchFailed(true); setSongDropdownOpen(false); } }, 350); } function handleSongSelect(song: SongResult) { update({ mbid: song.mbid, title: song.title, artist: song.artist }); const label = `${song.title} — ${song.artist}`; setSelectedSongLabel(label); setSongQuery(label); setSongDropdownOpen(false); setSongResults([]); setSongHighlightIdx(-1); } function handleSongClear() { update({ mbid: "", title: "", artist: "" }); setSelectedSongLabel(""); setSongQuery(""); setSongResults([]); setSongDropdownOpen(false); setSongHighlightIdx(-1); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); } function handleSongKeyDown(e: React.KeyboardEvent) { if (e.key === "ArrowDown") { e.preventDefault(); setSongHighlightIdx((i) => Math.min(i + 1, songResults.length - 1)); setSongDropdownOpen(true); } else if (e.key === "ArrowUp") { e.preventDefault(); setSongHighlightIdx((i) => Math.max(i - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); if (songHighlightIdx >= 0 && songResults[songHighlightIdx]) { handleSongSelect(songResults[songHighlightIdx]); } } else if (e.key === "Escape") { setSongDropdownOpen(false); } } function handleProviderChange(id: string) { setSelectedProvider(id); setUnavailableNotice(""); localStorage.setItem(PROVIDER_KEY, id); } function handleOllamaModelChange(model: string) { setSelectedOllamaModel(model); setUnavailableModelNotice(""); localStorage.setItem(MODEL_KEY, model); } // ── 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; 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" }); } 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 = ""; } // ── Generation ──────────────────────────────────────────────────────────── async function runGenerate(providerId: string, ollamaModel: string) { if (!state.detection) return; const effectiveBpm = state.useHalfTime && state.detection.halfTimeBpm ? state.detection.halfTimeBpm : state.detection.bpm; update({ stage: "generating", ctp: null, warnings: [], errorMsg: "" }); const body: Record = { bpm: effectiveBpm, duration: state.detection.duration, title: state.title || undefined, artist: state.artist || undefined, mbid: state.mbid || undefined, contributed_by: state.contributedBy || undefined, provider: providerId, }; if (providerId === "ollama") { body.ollamaModel = ollamaModel; } const res = await fetch("/api/analyze", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json() as { ctp?: CTPDocument; warnings?: string[]; error?: string }; if (!res.ok) { throw new Error(data.error ?? `Server error ${res.status}`); } update({ ctp: data.ctp ?? null, warnings: data.warnings ?? [], stage: "review" }); } async function handleGenerate() { try { await runGenerate(selectedProvider, selectedOllamaModel); } catch (err) { update({ stage: "error", errorMsg: err instanceof Error ? err.message : String(err), }); } } async function handleRetry() { try { await runGenerate(selectedProvider, selectedOllamaModel); } catch (err) { update({ stage: "error", errorMsg: 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() as { error?: string }; 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); setSongQuery(""); setSongResults([]); setSongDropdownOpen(false); setSelectedSongLabel(""); setSongHighlightIdx(-1); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); } // ─── Render ─────────────────────────────────────────────────────────────── const { stage, file, detection, ctp, warnings, errorMsg, useHalfTime } = state; const isProcessing = stage === "decoding" || stage === "detecting" || stage === "generating" || stage === "saving"; const selectedProviderInfo = providers.find((p) => p.id === selectedProvider); 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…"} {stage === "saving" && "Saving to database…"}

{stage === "generating" && selectedProviderInfo?.id === "ollama" && (

Local AI generation may take 30–90 seconds depending on your hardware.

)} {stage === "generating" && selectedProviderInfo?.id !== "ollama" && (

Analysing song structure — this takes ~5–15 seconds.

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

Generation failed

{errorMsg}

{/* Provider selector in error state */} {providers.length >= 2 && (
)} {selectedProvider === "ollama" && ollamaModels.length > 0 && (
)}
)} {/* ── 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 + provider selector */} {stage === "idle" && ( <> {/* Song search */}
{selectedSongLabel && ( )}
{songSearchFailed ? (

Search unavailable — enter MusicBrainz ID manually:

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" />
) : ( { const val = e.target.value; if (selectedSongLabel) { update({ mbid: "", title: "", artist: "" }); setSelectedSongLabel(""); } handleSongQueryChange(val); }} onKeyDown={handleSongKeyDown} placeholder="Search by title or artist…" autoComplete="off" 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" /> )} {songDropdownOpen && (
{songResults.length === 0 ? (

No songs found — try the search page first to register the song.

) : ( songResults.map((song, i) => ( )) )}
)}
{/* Title + artist manual overrides */}
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" />
{/* Contributed by */}
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" />
{/* Provider selector */} {unavailableNotice && (

{unavailableNotice}

)} {providers.length >= 2 && (
)} {/* Ollama model selector */} {selectedProvider === "ollama" && ollamaModels.length > 0 && (
{unavailableModelNotice && (

{unavailableModelNotice}

)}
)} {/* Context-aware callouts */} {selectedProvider === "algorithmic" && (

Algorithmic mode estimates song structure from BPM and duration. Section labels and bar counts are approximate — review carefully before submitting.

)} {selectedProvider === "ollama" && (

Local AI generation may take 30–90 seconds depending on your hardware.

)} )}
)} {/* ── 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 → )}
)}
)}
); }