- Multi-provider AI analysis (Anthropic, OpenAI, Ollama, Algorithmic) - server-only guards on all provider files; client bundle fix - /settings page with provider status, Ollama model picker, preferences - Song search box on /analyze replacing raw MBID input (debounced, keyboard nav) - Auto-register song via MusicBrainz on POST /api/tracks (no more 404) - Fix WAV duration bug: last section songEnd was double-counting elapsed time - Registry sync comment updated for self-hosted HTTPS git servers Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
887 lines
35 KiB
TypeScript
887 lines
35 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. 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<AnalyzerState>(INITIAL_STATE);
|
||
const abortRef = useRef<AbortController | null>(null);
|
||
const dropRef = useRef<HTMLDivElement>(null);
|
||
const [isDragging, setIsDragging] = useState(false);
|
||
|
||
// Provider state
|
||
const [providers, setProviders] = useState<ProviderInfo[]>([]);
|
||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||
const [selectedProvider, setSelectedProvider] = useState<string>("");
|
||
const [selectedOllamaModel, setSelectedOllamaModel] = useState<string>("");
|
||
const [unavailableNotice, setUnavailableNotice] = useState<string>("");
|
||
const [unavailableModelNotice, setUnavailableModelNotice] = useState<string>("");
|
||
|
||
// Song search
|
||
const [songQuery, setSongQuery] = useState("");
|
||
const [songResults, setSongResults] = useState<SongResult[]>([]);
|
||
const [songDropdownOpen, setSongDropdownOpen] = useState(false);
|
||
const [songHighlightIdx, setSongHighlightIdx] = useState(-1);
|
||
const [songSearchFailed, setSongSearchFailed] = useState(false);
|
||
const [selectedSongLabel, setSelectedSongLabel] = useState("");
|
||
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const songDropdownRef = useRef<HTMLDivElement>(null);
|
||
|
||
const update = (patch: Partial<AnalyzerState>) =>
|
||
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<HTMLInputElement>) {
|
||
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<HTMLInputElement>) {
|
||
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<string, unknown> = {
|
||
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 (
|
||
<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…"}
|
||
{stage === "saving" && "Saving to database…"}
|
||
</p>
|
||
{stage === "generating" && selectedProviderInfo?.id === "ollama" && (
|
||
<p className="mt-1 text-sm text-zinc-500">
|
||
Local AI generation may take 30–90 seconds depending on your hardware.
|
||
</p>
|
||
)}
|
||
{stage === "generating" && selectedProviderInfo?.id !== "ollama" && (
|
||
<p className="mt-1 text-sm text-zinc-500">
|
||
Analysing song structure — this takes ~5–15 seconds.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Error panel ────────────────────────────────────────────────── */}
|
||
{stage === "error" && (
|
||
<div className="rounded-xl border border-red-800 bg-red-950/30 px-6 py-5 space-y-4">
|
||
<div>
|
||
<p className="text-red-400 font-medium mb-1">Generation failed</p>
|
||
<p className="text-sm text-red-300">{errorMsg}</p>
|
||
</div>
|
||
|
||
{/* Provider selector in error state */}
|
||
{providers.length >= 2 && (
|
||
<div className="space-y-2">
|
||
<label className="block text-xs text-zinc-500">Analysis provider</label>
|
||
<select
|
||
value={selectedProvider}
|
||
onChange={(e) => handleProviderChange(e.target.value)}
|
||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
|
||
>
|
||
{providers.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{selectedProvider === "ollama" && ollamaModels.length > 0 && (
|
||
<div className="space-y-2">
|
||
<label className="block text-xs text-zinc-500">Ollama model</label>
|
||
<select
|
||
value={selectedOllamaModel}
|
||
onChange={(e) => handleOllamaModelChange(e.target.value)}
|
||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
|
||
>
|
||
{ollamaModels.map((m) => (
|
||
<option key={m} value={m}>
|
||
{RECOMMENDED_OLLAMA_MODELS.includes(m) ? `★ ${m}` : m}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={handleRetry}
|
||
className="rounded-lg bg-red-800/60 px-4 py-2 text-sm font-medium text-red-200 hover:bg-red-700/60 transition-colors"
|
||
>
|
||
Retry
|
||
</button>
|
||
<button
|
||
onClick={handleReset}
|
||
className="text-sm text-zinc-400 hover:text-zinc-200 underline self-center"
|
||
>
|
||
Start over
|
||
</button>
|
||
</div>
|
||
</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 + provider selector */}
|
||
{stage === "idle" && (
|
||
<>
|
||
{/* Song search */}
|
||
<div ref={songDropdownRef} className="relative">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<label className="block text-xs text-zinc-500">Song</label>
|
||
{selectedSongLabel && (
|
||
<button
|
||
type="button"
|
||
onClick={handleSongClear}
|
||
className="text-xs text-zinc-500 hover:text-zinc-300"
|
||
>
|
||
✕ Clear
|
||
</button>
|
||
)}
|
||
</div>
|
||
{songSearchFailed ? (
|
||
<div className="space-y-1">
|
||
<p className="text-xs text-amber-400">Search unavailable — enter MusicBrainz ID manually:</p>
|
||
<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>
|
||
) : (
|
||
<input
|
||
value={songQuery}
|
||
onChange={(e) => {
|
||
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 && (
|
||
<div className="absolute z-10 mt-1 w-full rounded-lg border border-zinc-700 bg-zinc-900 shadow-xl overflow-hidden">
|
||
{songResults.length === 0 ? (
|
||
<p className="px-3 py-2 text-sm text-zinc-500">
|
||
No songs found — try the search page first to register the song.
|
||
</p>
|
||
) : (
|
||
songResults.map((song, i) => (
|
||
<button
|
||
key={song.mbid}
|
||
type="button"
|
||
onMouseDown={(e) => { e.preventDefault(); handleSongSelect(song); }}
|
||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||
i === songHighlightIdx
|
||
? "bg-zinc-700 text-zinc-100"
|
||
: "text-zinc-300 hover:bg-zinc-800"
|
||
}`}
|
||
>
|
||
<span className="font-medium">{song.title}</span>
|
||
<span className="text-zinc-500"> — {song.artist}</span>
|
||
</button>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Title + artist manual overrides */}
|
||
<div className="grid gap-3 sm:grid-cols-2">
|
||
<div>
|
||
<label className="block text-xs text-zinc-500 mb-1">
|
||
Song title <span className="text-zinc-600">(override)</span>
|
||
</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 <span className="text-zinc-600">(override)</span>
|
||
</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>
|
||
|
||
{/* Contributed by */}
|
||
<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>
|
||
|
||
{/* Provider selector */}
|
||
{unavailableNotice && (
|
||
<div className="rounded-lg border border-amber-800/50 bg-amber-950/20 px-4 py-3">
|
||
<p className="text-sm text-amber-300">{unavailableNotice}</p>
|
||
</div>
|
||
)}
|
||
|
||
{providers.length >= 2 && (
|
||
<div className="space-y-2">
|
||
<label className="block text-xs text-zinc-500">Analysis provider</label>
|
||
<select
|
||
value={selectedProvider}
|
||
onChange={(e) => handleProviderChange(e.target.value)}
|
||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
|
||
>
|
||
{providers.map((p) => (
|
||
<option key={p.id} value={p.id}>
|
||
{p.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Ollama model selector */}
|
||
{selectedProvider === "ollama" && ollamaModels.length > 0 && (
|
||
<div className="space-y-2">
|
||
{unavailableModelNotice && (
|
||
<div className="rounded-lg border border-amber-800/50 bg-amber-950/20 px-3 py-2">
|
||
<p className="text-xs text-amber-300">{unavailableModelNotice}</p>
|
||
</div>
|
||
)}
|
||
<label className="block text-xs text-zinc-500">Ollama model</label>
|
||
<select
|
||
value={selectedOllamaModel}
|
||
onChange={(e) => handleOllamaModelChange(e.target.value)}
|
||
className="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm text-zinc-100 focus:border-green-500 focus:outline-none"
|
||
>
|
||
{ollamaModels.map((m) => (
|
||
<option key={m} value={m}>
|
||
{RECOMMENDED_OLLAMA_MODELS.includes(m) ? `★ ${m}` : m}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Context-aware callouts */}
|
||
{selectedProvider === "algorithmic" && (
|
||
<div className="rounded-lg border border-zinc-700 bg-zinc-800/40 px-4 py-3">
|
||
<p className="text-sm text-zinc-400">
|
||
Algorithmic mode estimates song structure from BPM and duration. Section labels and bar counts are approximate — review carefully before submitting.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{selectedProvider === "ollama" && (
|
||
<div className="rounded-lg border border-zinc-700 bg-zinc-800/40 px-4 py-3">
|
||
<p className="text-sm text-zinc-400">
|
||
Local AI generation may take 30–90 seconds depending on your hardware.
|
||
</p>
|
||
</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 →
|
||
</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>
|
||
);
|
||
}
|