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>
This commit is contained in:
@@ -7,18 +7,31 @@
|
||||
* 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
|
||||
* 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 } from "react";
|
||||
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"
|
||||
@@ -36,12 +49,10 @@ interface AnalyzerState {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -81,10 +92,167 @@ export default function TempoAnalyzer() {
|
||||
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 }));
|
||||
|
||||
// ── File handling ────────────────────────────────────────────────────────
|
||||
// ── 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)) {
|
||||
@@ -96,7 +264,6 @@ export default function TempoAnalyzer() {
|
||||
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;
|
||||
@@ -116,7 +283,7 @@ export default function TempoAnalyzer() {
|
||||
try {
|
||||
update({ stage: "detecting" });
|
||||
const detection = await detectBPM(file, abort.signal);
|
||||
update({ detection, stage: "idle" }); // wait for user to confirm/edit metadata
|
||||
update({ detection, stage: "idle" });
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
update({
|
||||
@@ -136,12 +303,12 @@ export default function TempoAnalyzer() {
|
||||
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
|
||||
e.target.value = "";
|
||||
}
|
||||
|
||||
// ── AI generation ────────────────────────────────────────────────────────
|
||||
// ── Generation ────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleGenerate() {
|
||||
async function runGenerate(providerId: string, ollamaModel: string) {
|
||||
if (!state.detection) return;
|
||||
|
||||
const effectiveBpm =
|
||||
@@ -149,38 +316,60 @@ export default function TempoAnalyzer() {
|
||||
? state.detection.halfTimeBpm
|
||||
: state.detection.bpm;
|
||||
|
||||
update({ stage: "generating", ctp: null, warnings: [] });
|
||||
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 {
|
||||
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" });
|
||||
await runGenerate(selectedProvider, selectedOllamaModel);
|
||||
} catch (err) {
|
||||
update({
|
||||
stage: "error",
|
||||
errorMsg: `Generation failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
errorMsg: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Submit to DB ─────────────────────────────────────────────────────────
|
||||
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;
|
||||
@@ -193,7 +382,7 @@ export default function TempoAnalyzer() {
|
||||
body: JSON.stringify(state.ctp),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const data = await res.json() as { error?: string };
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error ?? `Server error ${res.status}`);
|
||||
}
|
||||
@@ -207,7 +396,7 @@ export default function TempoAnalyzer() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Download CTP file ────────────────────────────────────────────────────
|
||||
// ── Download CTP file ─────────────────────────────────────────────────────
|
||||
|
||||
function handleDownload() {
|
||||
if (!state.ctp) return;
|
||||
@@ -225,17 +414,26 @@ export default function TempoAnalyzer() {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
// ── Reset ────────────────────────────────────────────────────────────────
|
||||
// ── 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 isProcessing =
|
||||
stage === "decoding" || stage === "detecting" || stage === "generating" || stage === "saving";
|
||||
|
||||
const selectedProviderInfo = providers.find((p) => p.id === selectedProvider);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -279,28 +477,79 @@ export default function TempoAnalyzer() {
|
||||
<p className="font-medium text-zinc-200">
|
||||
{stage === "decoding" && "Decoding audio…"}
|
||||
{stage === "detecting" && "Detecting tempo…"}
|
||||
{stage === "generating" && "Generating tempo map with AI…"}
|
||||
{stage === "generating" && "Generating tempo map…"}
|
||||
{stage === "saving" && "Saving to database…"}
|
||||
</p>
|
||||
{stage === "generating" && (
|
||||
{stage === "generating" && selectedProviderInfo?.id === "ollama" && (
|
||||
<p className="mt-1 text-sm text-zinc-500">
|
||||
Claude is analysing the song structure — this takes ~5–15 seconds.
|
||||
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 ─────────────────────────────────────────────────────── */}
|
||||
{/* ── Error panel ────────────────────────────────────────────────── */}
|
||||
{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 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>
|
||||
)}
|
||||
|
||||
@@ -371,12 +620,83 @@ export default function TempoAnalyzer() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Metadata form */}
|
||||
{/* 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</label>
|
||||
<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 })}
|
||||
@@ -385,7 +705,9 @@ export default function TempoAnalyzer() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Artist</label>
|
||||
<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 })}
|
||||
@@ -393,34 +715,88 @@ export default function TempoAnalyzer() {
|
||||
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>
|
||||
|
||||
{/* 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 with AI →
|
||||
Generate tempo map →
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
63
components/settings/OllamaModelPicker.tsx
Normal file
63
components/settings/OllamaModelPicker.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { RECOMMENDED_OLLAMA_MODELS } from "@/lib/analysis/constants";
|
||||
|
||||
interface OllamaModelPickerProps {
|
||||
models: string[];
|
||||
value: string;
|
||||
onChange: (model: string) => void;
|
||||
onRefresh: () => void;
|
||||
refreshing: boolean;
|
||||
}
|
||||
|
||||
export default function OllamaModelPicker({
|
||||
models,
|
||||
value,
|
||||
onChange,
|
||||
onRefresh,
|
||||
refreshing,
|
||||
}: OllamaModelPickerProps) {
|
||||
if (models.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No models found. Pull a model with{" "}
|
||||
<code className="text-zinc-400">ollama pull qwen2.5:7b</code> and refresh.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-col gap-2">
|
||||
{models.map((model) => {
|
||||
const isRecommended = RECOMMENDED_OLLAMA_MODELS.includes(model);
|
||||
return (
|
||||
<label key={model} className="flex items-center gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="radio"
|
||||
name="ollama-model"
|
||||
value={model}
|
||||
checked={value === model}
|
||||
onChange={() => onChange(model)}
|
||||
className="accent-green-500"
|
||||
/>
|
||||
<span className="text-sm text-zinc-300 group-hover:text-zinc-100 transition-colors">
|
||||
{model}
|
||||
</span>
|
||||
{isRecommended && (
|
||||
<span className="text-xs text-amber-400">★ recommended</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 underline disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? "Refreshing…" : "Refresh model list"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
components/settings/PreferencesPanel.tsx
Normal file
79
components/settings/PreferencesPanel.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
const PROVIDER_KEY = "clicktrack_analysis_provider";
|
||||
const MODEL_KEY = "clicktrack_ollama_model";
|
||||
|
||||
export default function PreferencesPanel() {
|
||||
const [rememberProvider, setRememberProvider] = useState(true);
|
||||
const [rememberModel, setRememberModel] = useState(true);
|
||||
const [cleared, setCleared] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Reflect current state: if the keys exist, persistence is active
|
||||
const hasProvider = localStorage.getItem(PROVIDER_KEY) !== null;
|
||||
const hasModel = localStorage.getItem(MODEL_KEY) !== null;
|
||||
setRememberProvider(hasProvider);
|
||||
setRememberModel(hasModel);
|
||||
}, []);
|
||||
|
||||
function handleToggleProvider(on: boolean) {
|
||||
setRememberProvider(on);
|
||||
if (!on) {
|
||||
localStorage.removeItem(PROVIDER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleModel(on: boolean) {
|
||||
setRememberModel(on);
|
||||
if (!on) {
|
||||
localStorage.removeItem(MODEL_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
localStorage.removeItem(PROVIDER_KEY);
|
||||
localStorage.removeItem(MODEL_KEY);
|
||||
setRememberProvider(false);
|
||||
setRememberModel(false);
|
||||
setCleared(true);
|
||||
setTimeout(() => setCleared(false), 3000);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberProvider}
|
||||
onChange={(e) => handleToggleProvider(e.target.checked)}
|
||||
className="accent-green-500 w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-zinc-300">Remember my last provider selection</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberModel}
|
||||
onChange={(e) => handleToggleModel(e.target.checked)}
|
||||
className="accent-green-500 w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-zinc-300">Remember my last Ollama model</span>
|
||||
</label>
|
||||
|
||||
<div className="pt-1">
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="rounded-lg border border-zinc-700 px-4 py-2 text-sm text-zinc-400 hover:border-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
Clear saved preferences
|
||||
</button>
|
||||
{cleared && (
|
||||
<span className="ml-3 text-sm text-green-400">Preferences cleared.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
components/settings/ProviderStatus.tsx
Normal file
114
components/settings/ProviderStatus.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { ProviderInfo } from "@/lib/analysis/providers";
|
||||
|
||||
interface ProviderStatusProps {
|
||||
provider: ProviderInfo;
|
||||
selectedOllamaModel?: string;
|
||||
isDefault: boolean;
|
||||
onSetDefault: (id: string) => void;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<ProviderInfo["type"], string> = {
|
||||
"cloud-ai": "Cloud AI",
|
||||
"local-ai": "Local AI",
|
||||
"algorithmic": "No AI",
|
||||
};
|
||||
|
||||
export default function ProviderStatus({
|
||||
provider,
|
||||
selectedOllamaModel,
|
||||
isDefault,
|
||||
onSetDefault,
|
||||
}: ProviderStatusProps) {
|
||||
const [testStatus, setTestStatus] = useState<"idle" | "testing" | "ok" | "error">("idle");
|
||||
const [testError, setTestError] = useState("");
|
||||
|
||||
async function handleTest() {
|
||||
setTestStatus("testing");
|
||||
setTestError("");
|
||||
try {
|
||||
const res = await fetch("/api/analyze/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: provider.id,
|
||||
ollamaModel: provider.id === "ollama" ? selectedOllamaModel : undefined,
|
||||
}),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; error?: string };
|
||||
if (data.ok) {
|
||||
setTestStatus("ok");
|
||||
} else {
|
||||
setTestStatus("error");
|
||||
setTestError(data.error ?? "Unknown error");
|
||||
}
|
||||
} catch (err) {
|
||||
setTestStatus("error");
|
||||
setTestError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/60 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-zinc-200">{provider.label}</span>
|
||||
<span className="rounded px-1.5 py-0.5 text-xs bg-zinc-800 text-zinc-400">
|
||||
{TYPE_LABELS[provider.type]}
|
||||
</span>
|
||||
{provider.available ? (
|
||||
<span className="rounded px-1.5 py-0.5 text-xs bg-green-900/50 text-green-400">
|
||||
Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded px-1.5 py-0.5 text-xs bg-zinc-800 text-zinc-500">
|
||||
Unavailable
|
||||
</span>
|
||||
)}
|
||||
{isDefault && (
|
||||
<span className="rounded px-1.5 py-0.5 text-xs bg-blue-900/50 text-blue-400">
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!provider.available && provider.unavailableReason && (
|
||||
<p className="mt-1 text-xs text-zinc-600">{provider.unavailableReason}</p>
|
||||
)}
|
||||
{provider.id === "ollama" && provider.available && provider.ollamaBaseUrl && (
|
||||
<p className="mt-1 text-xs text-zinc-600 font-mono">{provider.ollamaBaseUrl}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{provider.available && (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{!isDefault && (
|
||||
<button
|
||||
onClick={() => onSetDefault(provider.id)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-300 underline"
|
||||
>
|
||||
Set as default
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={testStatus === "testing"}
|
||||
className="rounded px-3 py-1 text-xs font-medium border border-zinc-700 text-zinc-300 hover:border-zinc-500 hover:text-zinc-100 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{testStatus === "testing" ? "Testing…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{testStatus === "ok" && (
|
||||
<p className="mt-2 text-xs text-green-400">✓ Working</p>
|
||||
)}
|
||||
{testStatus === "error" && (
|
||||
<p className="mt-2 text-xs text-red-400">✗ {testError}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user