Files
clicktrack/components/TempoAnalyzer.tsx
AJ Avezzano 51f67f0aeb feat: audio upload + AI-assisted tempo map generation
Users can now upload any audio file to generate a CTP tempo map:

BPM detection (lib/analysis/bpm-detect.ts):
- Runs entirely client-side via Web Audio API — audio is never uploaded
- Decodes any browser-supported format (MP3, WAV, AAC, OGG, FLAC, M4A)
- Energy envelope → onset strength → autocorrelation over 55–210 BPM range
- Returns BPM, normalised confidence score, duration, and optional half-time BPM
  for songs where a double-time pulse is detected

AI CTP generation (lib/analysis/ai-ctp.ts):
- Calls Claude (claude-opus-4-6) with adaptive thinking + structured JSON output
- System prompt explains CTP rules and section layout conventions
- Claude uses knowledge of well-known songs to produce accurate section maps;
  falls back to a sensible generic structure for unknown tracks
- Only BPM + duration + optional metadata is sent to the server (no audio data)

API route (app/api/analyze/route.ts):
- POST /api/analyze accepts { bpm, duration, title?, artist?, mbid?, contributed_by? }
- Validates input, calls generateCTPWithAI, runs CTP schema validation
- Returns { ctp, warnings } — warnings are surfaced in the UI rather than 500-ing

UI (components/TempoAnalyzer.tsx, app/(web)/analyze/page.tsx):
- Drag-and-drop or browse file upload
- Shows BPM, confidence, duration after detection
- Half-time toggle when double-time is detected
- Metadata form: title, artist, MusicBrainz ID, contributor name
  (filename parsed into artist/title as a convenience default)
- AI generation with streaming-style progress states
- Sections review via TempoMapEditor
- Download .ctp.json or submit directly to the database

Also: added @anthropic-ai/sdk to package.json, ANTHROPIC_API_KEY to .env.example,
updated next.config.mjs serverComponentsExternalPackages, added Analyze nav link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:43:14 -04:00

511 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<AnalyzerState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const dropRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const update = (patch: Partial<AnalyzerState>) =>
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<HTMLInputElement>) {
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 (
<div className="space-y-8">
{/* ── Drop zone ─────────────────────────────────────────────────── */}
{!file && stage === "idle" && (
<div
ref={dropRef}
onDragOver={(e) => { 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"
}`}
>
<p className="text-4xl mb-4">🎵</p>
<p className="text-lg font-medium text-zinc-200 mb-2">
Drop an audio file here
</p>
<p className="text-sm text-zinc-500 mb-6">
MP3, WAV, AAC, OGG, FLAC, M4A any format your browser supports
</p>
<label className="inline-block cursor-pointer rounded-lg bg-green-700 px-6 py-2.5 text-sm font-semibold text-white hover:bg-green-600 transition-colors">
Browse files
<input
type="file"
accept="audio/*,.mp3,.wav,.aac,.ogg,.flac,.m4a,.aiff"
className="hidden"
onChange={handleFileInput}
/>
</label>
</div>
)}
{/* ── Processing indicator ───────────────────────────────────────── */}
{isProcessing && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 px-6 py-8 text-center">
<div className="mb-3 text-2xl animate-spin inline-block"></div>
<p className="font-medium text-zinc-200">
{stage === "decoding" && "Decoding audio…"}
{stage === "detecting" && "Detecting tempo…"}
{stage === "generating" && "Generating tempo map with AI…"}
{stage === "saving" && "Saving to database…"}
</p>
{stage === "generating" && (
<p className="mt-1 text-sm text-zinc-500">
Claude is analysing the song structure this takes ~515 seconds.
</p>
)}
</div>
)}
{/* ── Error ─────────────────────────────────────────────────────── */}
{stage === "error" && (
<div className="rounded-xl border border-red-800 bg-red-950/30 px-6 py-5">
<p className="text-red-400 font-medium mb-1">Error</p>
<p className="text-sm text-red-300">{errorMsg}</p>
<button
onClick={handleReset}
className="mt-4 text-sm text-zinc-400 hover:text-zinc-200 underline"
>
Try again
</button>
</div>
)}
{/* ── Detection results + metadata form ─────────────────────────── */}
{detection && (stage === "idle" || stage === "review" || stage === "saved") && (
<div className="rounded-xl border border-zinc-800 bg-zinc-900/60 p-6 space-y-5">
{/* File name + detection summary */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs text-zinc-500 mb-0.5">Analysed file</p>
<p className="font-medium text-zinc-200 truncate max-w-sm">{file?.name}</p>
</div>
<button
onClick={handleReset}
className="text-xs text-zinc-600 hover:text-zinc-400 underline shrink-0"
>
Change file
</button>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div className="rounded-lg bg-zinc-800/60 p-4">
<p className="text-2xl font-bold font-mono text-green-400">
{useHalfTime && detection.halfTimeBpm
? detection.halfTimeBpm
: detection.bpm}
</p>
<p className="text-xs text-zinc-500 mt-1">BPM</p>
</div>
<div className="rounded-lg bg-zinc-800/60 p-4">
<p className={`text-2xl font-bold ${confidenceLabel(detection.confidence).color}`}>
{confidenceLabel(detection.confidence).label}
</p>
<p className="text-xs text-zinc-500 mt-1">Confidence</p>
</div>
<div className="rounded-lg bg-zinc-800/60 p-4">
<p className="text-2xl font-bold text-zinc-200">
{formatDuration(detection.duration)}
</p>
<p className="text-xs text-zinc-500 mt-1">Duration</p>
</div>
</div>
{/* Half-time toggle */}
{detection.halfTimeBpm && (
<div className="flex items-center gap-3 rounded-lg border border-amber-800/50 bg-amber-950/20 px-4 py-3">
<span className="text-sm text-amber-300 flex-1">
Detected double-time pulse primary BPM may be 2× the actual feel.
Half-time: <strong>{detection.halfTimeBpm}</strong> BPM
</span>
<button
onClick={() => update({ useHalfTime: !useHalfTime })}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
useHalfTime
? "bg-amber-600 text-white"
: "border border-amber-700 text-amber-400 hover:bg-amber-900/40"
}`}
>
{useHalfTime ? "Using half-time" : "Use half-time"}
</button>
</div>
)}
{/* Confidence warning */}
{detection.confidence < 0.4 && (
<p className="text-sm text-amber-400">
Low confidence the BPM may be inaccurate. Consider a song with a clearer beat, or adjust the detected value manually before generating.
</p>
)}
{/* Metadata form */}
{stage === "idle" && (
<>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="block text-xs text-zinc-500 mb-1">Song title</label>
<input
value={state.title}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Artist</label>
<input
value={state.artist}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">
MusicBrainz ID{" "}
<span className="text-zinc-600">(optional)</span>
</label>
<input
value={state.mbid}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Your name / handle</label>
<input
value={state.contributedBy}
onChange={(e) => 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"
/>
</div>
</div>
<button
onClick={handleGenerate}
className="w-full rounded-lg bg-green-600 py-3 font-semibold text-white hover:bg-green-500 transition-colors"
>
Generate tempo map with AI
</button>
</>
)}
</div>
)}
{/* ── AI-generated CTP review ────────────────────────────────────── */}
{ctp && (stage === "review" || stage === "saved") && (
<div className="space-y-6">
{warnings.length > 0 && (
<div className="rounded-lg border border-amber-800/50 bg-amber-950/20 px-4 py-3">
<p className="text-sm font-medium text-amber-300 mb-1">Validation warnings</p>
<ul className="text-xs text-amber-400 list-disc pl-4 space-y-0.5">
{warnings.map((w, i) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Generated tempo map</h2>
<span className="text-xs text-zinc-600 italic">AI draft verify before using</span>
</div>
<TempoMapEditor ctpDoc={ctp} readOnly />
</div>
{/* Actions */}
{stage === "review" && (
<div className="flex flex-wrap gap-3">
<button
onClick={handleDownload}
className="flex items-center gap-2 rounded-lg border border-zinc-700 px-5 py-2.5 text-sm font-medium text-zinc-300 hover:border-zinc-500 hover:text-zinc-100 transition-colors"
>
Download .ctp.json
</button>
{ctp.metadata.mbid && (
<button
onClick={handleSubmit}
className="flex items-center gap-2 rounded-lg bg-green-700 px-5 py-2.5 text-sm font-semibold text-white hover:bg-green-600 transition-colors"
>
Submit to database
</button>
)}
{!ctp.metadata.mbid && (
<p className="self-center text-xs text-zinc-600">
Add a MusicBrainz ID to submit to the database.
</p>
)}
<button
onClick={() => update({ stage: "idle", ctp: null })}
className="text-sm text-zinc-600 hover:text-zinc-400 underline self-center"
>
Re-generate
</button>
</div>
)}
{stage === "saved" && (
<div className="rounded-lg border border-green-800/50 bg-green-950/20 px-4 py-3 flex items-center gap-3">
<span className="text-green-400"></span>
<div>
<p className="text-sm font-medium text-green-300">Saved to database</p>
{ctp.metadata.mbid && (
<a
href={`/track/${ctp.metadata.mbid}`}
className="text-xs text-green-600 hover:underline"
>
View track page
</a>
)}
</div>
<button
onClick={handleDownload}
className="ml-auto text-xs text-zinc-500 hover:text-zinc-300 underline"
>
Download .ctp.json
</button>
</div>
)}
</div>
)}
</div>
);
}