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>
511 lines
20 KiB
TypeScript
511 lines
20 KiB
TypeScript
"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 ~5–15 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>
|
||
);
|
||
}
|