Files
clicktrack/components/TempoAnalyzer.tsx
AJ Avezzano 8b9d72bc9d feat: analysis providers, settings UI, song search, WAV duration fix
- 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>
2026-04-03 18:46:17 -04:00

887 lines
35 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. 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 3090 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 ~515 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 3090 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>
);
}