From 8b9d72bc9debf042134a444ab0d98b39aa8b3098 Mon Sep 17 00:00:00 2001 From: AJ Avezzano Date: Fri, 3 Apr 2026 18:46:17 -0400 Subject: [PATCH] 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 --- .env.example | 25 +- app/(web)/settings/page.tsx | 123 +++++ app/api/analyze/providers/route.ts | 20 + app/api/analyze/route.ts | 73 ++- app/api/analyze/test/route.ts | 134 ++++++ app/api/tracks/route.ts | 39 +- app/layout.tsx | 5 +- components/TempoAnalyzer.tsx | 530 ++++++++++++++++++---- components/settings/OllamaModelPicker.tsx | 63 +++ components/settings/PreferencesPanel.tsx | 79 ++++ components/settings/ProviderStatus.tsx | 114 +++++ lib/analysis/ai-ctp.ts | 184 +------- lib/analysis/constants.ts | 3 + lib/analysis/providers.ts | 29 ++ lib/analysis/providers/algorithmic.ts | 131 ++++++ lib/analysis/providers/anthropic.ts | 181 ++++++++ lib/analysis/providers/ollama.ts | 157 +++++++ lib/analysis/providers/openai.ts | 108 +++++ lib/analysis/providers/registry.ts | 78 ++++ lib/ctp/render.ts | 5 +- lib/db/client.ts | 5 + lib/registry/sync.ts | 10 +- 22 files changed, 1803 insertions(+), 293 deletions(-) create mode 100644 app/(web)/settings/page.tsx create mode 100644 app/api/analyze/providers/route.ts create mode 100644 app/api/analyze/test/route.ts create mode 100644 components/settings/OllamaModelPicker.tsx create mode 100644 components/settings/PreferencesPanel.tsx create mode 100644 components/settings/ProviderStatus.tsx create mode 100644 lib/analysis/constants.ts create mode 100644 lib/analysis/providers.ts create mode 100644 lib/analysis/providers/algorithmic.ts create mode 100644 lib/analysis/providers/anthropic.ts create mode 100644 lib/analysis/providers/ollama.ts create mode 100644 lib/analysis/providers/openai.ts create mode 100644 lib/analysis/providers/registry.ts diff --git a/.env.example b/.env.example index 3bddbd4..d12ee05 100644 --- a/.env.example +++ b/.env.example @@ -10,16 +10,33 @@ POSTGRES_PASSWORD=clicktrack # ── Redis ──────────────────────────────────────────────────────────────────── REDIS_URL=redis://localhost:6379 -# ── Community registry ─────────────────────────────────────────────────────── +# ── Community registry (optional) ──────────────────────────────────────────── +# HTTPS URL of a git repository containing .ctp.json tempo map files. +# Compatible with any self-hosted git server (Gitea, Forgejo, GitLab, etc.). +# To authenticate, embed a personal access token in the URL: +# REGISTRY_REPO=https://user:token@git.yourdomain.com/org/clicktrack-registry REGISTRY_REPO= REGISTRY_BRANCH=main REGISTRY_SYNC_INTERVAL=3600 # ── AI Tempo Analysis ──────────────────────────────────────────────────────── -# Required for the /analyze feature (AI tempo map generation). -# Get a key at https://console.anthropic.com -# BPM detection is client-side and works without this key. +# BPM detection is client-side and works without any of these keys. + +# --- Cloud AI: Anthropic (existing) --- +# Required to enable Anthropic provider. Get a key at https://console.anthropic.com ANTHROPIC_API_KEY= +ANTHROPIC_MODEL=claude-opus-4-6 # optional model override + +# --- Cloud AI: OpenAI-compatible --- +OPENAI_API_KEY= # required to enable OpenAI provider +OPENAI_BASE_URL=https://api.openai.com/v1 # override for Groq, Together, Fireworks, etc. +OPENAI_MODEL=gpt-4o # optional model override + +# --- Local AI: Ollama --- +OLLAMA_BASE_URL=http://localhost:11434 # required to enable Ollama provider +# Model is selected by the user in the UI — no OLLAMA_MODEL env var needed + +# Algorithmic provider is always available — no config needed. # ── App ────────────────────────────────────────────────────────────────────── NEXT_PUBLIC_APP_NAME=ClickTrack diff --git a/app/(web)/settings/page.tsx b/app/(web)/settings/page.tsx new file mode 100644 index 0000000..32bb81e --- /dev/null +++ b/app/(web)/settings/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import type { ProviderInfo } from "@/lib/analysis/providers"; +import ProviderStatus from "@/components/settings/ProviderStatus"; +import OllamaModelPicker from "@/components/settings/OllamaModelPicker"; +import PreferencesPanel from "@/components/settings/PreferencesPanel"; + +const PROVIDER_KEY = "clicktrack_analysis_provider"; +const MODEL_KEY = "clicktrack_ollama_model"; + +interface ProvidersResponse { + providers: ProviderInfo[]; + ollamaModels: string[]; +} + +export default function SettingsPage() { + const [providers, setProviders] = useState([]); + const [ollamaModels, setOllamaModels] = useState([]); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [defaultProvider, setDefaultProvider] = useState(""); + const [selectedOllamaModel, setSelectedOllamaModel] = useState(""); + + const fetchProviders = useCallback(async (isRefresh = false) => { + if (isRefresh) setRefreshing(true); + try { + const res = await fetch("/api/analyze/providers"); + const data = await res.json() as ProvidersResponse; + setProviders(data.providers); + setOllamaModels(data.ollamaModels); + + // Initialise model selection + if (data.ollamaModels.length > 0) { + const saved = localStorage.getItem(MODEL_KEY); + if (saved && data.ollamaModels.includes(saved)) { + setSelectedOllamaModel(saved); + } else { + setSelectedOllamaModel(data.ollamaModels[0]); + } + } + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + const saved = localStorage.getItem(PROVIDER_KEY); + if (saved) setDefaultProvider(saved); + fetchProviders(); + }, [fetchProviders]); + + function handleSetDefault(id: string) { + setDefaultProvider(id); + localStorage.setItem(PROVIDER_KEY, id); + } + + function handleModelChange(model: string) { + setSelectedOllamaModel(model); + localStorage.setItem(MODEL_KEY, model); + } + + const ollamaProvider = providers.find((p) => p.id === "ollama"); + const ollamaAvailable = ollamaProvider?.available === true; + + if (loading) { + return ( +
+

Settings

+

Loading…

+
+ ); + } + + return ( +
+

Settings

+ + {/* Analysis Providers */} +
+

Analysis Providers

+
+ {providers.map((provider) => ( + + ))} +
+
+ + {/* Ollama Models */} + {ollamaAvailable && ( +
+

Ollama Models

+ fetchProviders(true)} + refreshing={refreshing} + /> + {ollamaProvider?.ollamaBaseUrl && ( +

+ Base URL (operator-configured):{" "} + {ollamaProvider.ollamaBaseUrl} +

+ )} +
+ )} + + {/* Preferences */} +
+

Preferences

+ +
+
+ ); +} diff --git a/app/api/analyze/providers/route.ts b/app/api/analyze/providers/route.ts new file mode 100644 index 0000000..d1140fd --- /dev/null +++ b/app/api/analyze/providers/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import { getProviderInfoList, getOllamaModels } from "@/lib/analysis/providers/registry"; + +/** + * GET /api/analyze/providers + * + * Returns all providers (available and unavailable) and the list of + * locally available Ollama models (empty array if Ollama is unreachable). + */ +export async function GET() { + const [providers, ollamaModels] = await Promise.all([ + getProviderInfoList(), + getOllamaModels(), + ]); + + return NextResponse.json( + { providers, ollamaModels }, + { headers: { "Cache-Control": "no-store" } } + ); +} diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts index 01c16a9..e3747b8 100644 --- a/app/api/analyze/route.ts +++ b/app/api/analyze/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { generateCTPWithAI } from "@/lib/analysis/ai-ctp"; +import { getProvider, getAvailableProviders } from "@/lib/analysis/providers/registry"; import { validateCTP } from "@/lib/ctp/validate"; // ─── Request schema ─────────────────────────────────────────────────────────── @@ -12,16 +12,18 @@ const AnalyzeRequestSchema = z.object({ artist: z.string().min(1).max(256).optional(), mbid: z.string().uuid().optional().nullable(), contributed_by: z.string().min(1).max(64).optional(), + provider: z.string().optional(), + ollamaModel: z.string().optional(), }); /** * POST /api/analyze * - * Accepts BPM detection results from the browser and uses Claude to generate - * a draft CTP document for human review. + * Accepts BPM detection results from the browser and uses the selected provider + * to generate a draft CTP document for human review. * * Body (JSON): - * { bpm, duration, title?, artist?, mbid?, contributed_by? } + * { bpm, duration, title?, artist?, mbid?, contributed_by?, provider?, ollamaModel? } * * Returns: * { ctp: CTPDocument, warnings: string[] } @@ -42,40 +44,67 @@ export async function POST(req: NextRequest) { ); } - const { bpm, duration, title, artist, mbid, contributed_by } = parsed.data; + const { bpm, duration, title, artist, mbid, contributed_by, provider: providerId, ollamaModel } = + parsed.data; - if (!process.env.ANTHROPIC_API_KEY) { + // Validate Ollama-specific requirement + if (providerId === "ollama" && (!ollamaModel || ollamaModel.trim() === "")) { return NextResponse.json( - { error: "ANTHROPIC_API_KEY is not configured on this server" }, - { status: 503 } + { error: "ollamaModel is required when using the Ollama provider" }, + { status: 400 } ); } + // Resolve provider + let provider; + if (providerId) { + try { + provider = await getProvider(providerId); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : String(err) }, + { status: 400 } + ); + } + } else { + const available = await getAvailableProviders(); + provider = available[0]; + if (!provider) { + return NextResponse.json( + { error: "No analysis providers are currently available" }, + { status: 503 } + ); + } + } + + const input = { + bpm, + duration, + title, + artist, + mbid: mbid ?? null, + contributed_by: contributed_by ?? "anonymous", + ollamaModel, + }; + let ctpDoc; try { - ctpDoc = await generateCTPWithAI({ - bpm, - duration, - title, - artist, - mbid: mbid ?? null, - contributedBy: contributed_by ?? "anonymous", - }); + ctpDoc = await provider.generateCTP(input); } catch (err) { - console.error("[analyze] AI generation failed:", err); + console.error(`[analyze] Provider '${provider.label}' failed:`, err); return NextResponse.json( - { error: "Failed to generate CTP document", detail: String(err) }, - { status: 500 } + { + error: `Provider '${provider.label}' failed: ${err instanceof Error ? err.message : String(err)}`, + }, + { status: 502 } ); } - // Validate the AI output against the CTP schema + // Validate the output against the CTP schema const validation = validateCTP(ctpDoc); const warnings: string[] = []; if (!validation.success) { - // Rather than 500-ing, return the draft with validation warnings so the user - // can still see and manually correct it. warnings.push(...validation.errors.issues.map((i) => `${i.path.join(".")}: ${i.message}`)); } diff --git a/app/api/analyze/test/route.ts b/app/api/analyze/test/route.ts new file mode 100644 index 0000000..d6ffc8f --- /dev/null +++ b/app/api/analyze/test/route.ts @@ -0,0 +1,134 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const TestRequestSchema = z.object({ + provider: z.string(), + ollamaModel: z.string().optional(), +}); + +/** + * POST /api/analyze/test + * + * Runs a lightweight probe for a provider to confirm it is working. + * Always returns 200 — use the `ok` field to check success/failure. + * + * Body: { provider: string, ollamaModel?: string } + * Response: { ok: true } | { ok: false, error: string } + */ +export async function POST(req: NextRequest) { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ ok: false, error: "Invalid JSON body" }); + } + + const parsed = TestRequestSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ ok: false, error: "Missing required field: provider" }); + } + + const { provider, ollamaModel } = parsed.data; + + try { + switch (provider) { + case "algorithmic": { + return NextResponse.json({ ok: true }); + } + + case "anthropic": { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + return NextResponse.json({ ok: false, error: "ANTHROPIC_API_KEY not set" }); + } + // Minimal 1-token probe + const response = await fetch("https://api.anthropic.com/v1/messages", { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify({ + model: process.env.ANTHROPIC_MODEL ?? "claude-opus-4-6", + max_tokens: 10, + messages: [{ role: "user", content: "Reply with the word OK" }], + }), + signal: AbortSignal.timeout(15000), + }); + if (response.ok) { + return NextResponse.json({ ok: true }); + } + const text = await response.text().catch(() => ""); + return NextResponse.json({ + ok: false, + error: `Anthropic API returned ${response.status}: ${text.slice(0, 200)}`, + }); + } + + case "openai": { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + return NextResponse.json({ ok: false, error: "OPENAI_API_KEY not set" }); + } + const baseUrl = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; + const model = process.env.OPENAI_MODEL ?? "gpt-4o"; + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + max_tokens: 10, + messages: [{ role: "user", content: "Reply with the word OK" }], + }), + signal: AbortSignal.timeout(15000), + }); + if (response.ok) { + return NextResponse.json({ ok: true }); + } + const text = await response.text().catch(() => ""); + return NextResponse.json({ + ok: false, + error: `OpenAI API returned ${response.status}: ${text.slice(0, 200)}`, + }); + } + + case "ollama": { + const baseUrl = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434"; + if (!ollamaModel) { + return NextResponse.json({ ok: false, error: "ollamaModel is required for Ollama test" }); + } + const response = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: ollamaModel, + max_tokens: 10, + messages: [{ role: "user", content: "Reply with the word OK" }], + stream: false, + }), + signal: AbortSignal.timeout(30000), + }); + if (response.ok) { + return NextResponse.json({ ok: true }); + } + const text = await response.text().catch(() => ""); + return NextResponse.json({ + ok: false, + error: `Ollama returned ${response.status}: ${text.slice(0, 200)}`, + }); + } + + default: + return NextResponse.json({ ok: false, error: `Unknown provider: ${provider}` }); + } + } catch (err) { + return NextResponse.json({ + ok: false, + error: err instanceof Error ? err.message : String(err), + }); + } +} diff --git a/app/api/tracks/route.ts b/app/api/tracks/route.ts index dd3b92c..80f46e4 100644 --- a/app/api/tracks/route.ts +++ b/app/api/tracks/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { getTempoMapsForSong, insertTempoMap, query } from "@/lib/db/client"; +import { getTempoMapsForSong, getSongByMbid, insertTempoMap, upsertSong } from "@/lib/db/client"; import { validateCTP } from "@/lib/ctp/validate"; +import { lookupRecording, formatArtistCredit, mbDurationToSeconds } from "@/lib/musicbrainz/client"; // ─── GET /api/tracks?mbid= ───────────────────────────────────────────── @@ -55,19 +56,33 @@ export async function POST(req: NextRequest) { ); } - // Ensure the song exists - const { rowCount } = await query("SELECT 1 FROM songs WHERE mbid = $1", [ - doc.metadata.mbid, - ]); + // Ensure the song exists — auto-register it if not + const existing = await getSongByMbid(doc.metadata.mbid); - if (!rowCount || rowCount === 0) { - return NextResponse.json( - { - error: "Song not found. Search for the song first to register it.", + if (!existing) { + try { + const rec = await lookupRecording(doc.metadata.mbid); + await upsertSong({ mbid: doc.metadata.mbid, - }, - { status: 404 } - ); + title: rec.title, + artist: formatArtistCredit(rec["artist-credit"]), + duration_seconds: mbDurationToSeconds(rec.length), + acousticbrainz_bpm: null, + acousticbrainz_time_sig_num: null, + source: "musicbrainz", + }); + } catch { + // MusicBrainz unreachable — fall back to CTP metadata + await upsertSong({ + mbid: doc.metadata.mbid, + title: doc.metadata.title, + artist: doc.metadata.artist, + duration_seconds: doc.metadata.duration_seconds, + acousticbrainz_bpm: null, + acousticbrainz_time_sig_num: null, + source: "manual", + }); + } } const map = await insertTempoMap({ diff --git a/app/layout.tsx b/app/layout.tsx index b369853..85169bf 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -30,7 +30,10 @@ export default function RootLayout({ Analyze - < + + Settings + + (null); const [isDragging, setIsDragging] = useState(false); + // Provider state + const [providers, setProviders] = useState([]); + const [ollamaModels, setOllamaModels] = useState([]); + const [selectedProvider, setSelectedProvider] = useState(""); + const [selectedOllamaModel, setSelectedOllamaModel] = useState(""); + const [unavailableNotice, setUnavailableNotice] = useState(""); + const [unavailableModelNotice, setUnavailableModelNotice] = useState(""); + + // Song search + const [songQuery, setSongQuery] = useState(""); + const [songResults, setSongResults] = useState([]); + const [songDropdownOpen, setSongDropdownOpen] = useState(false); + const [songHighlightIdx, setSongHighlightIdx] = useState(-1); + const [songSearchFailed, setSongSearchFailed] = useState(false); + const [selectedSongLabel, setSelectedSongLabel] = useState(""); + const searchDebounceRef = useRef | null>(null); + const songDropdownRef = useRef(null); + const update = (patch: Partial) => 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) { + 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) { 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 = { + 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 (
@@ -279,28 +477,79 @@ export default function TempoAnalyzer() {

{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…"}

- {stage === "generating" && ( + {stage === "generating" && selectedProviderInfo?.id === "ollama" && (

- Claude is analysing the song structure — this takes ~5–15 seconds. + Local AI generation may take 30–90 seconds depending on your hardware. +

+ )} + {stage === "generating" && selectedProviderInfo?.id !== "ollama" && ( +

+ Analysing song structure — this takes ~5–15 seconds.

)}
)} - {/* ── Error ─────────────────────────────────────────────────────── */} + {/* ── Error panel ────────────────────────────────────────────────── */} {stage === "error" && ( -
-

Error

-

{errorMsg}

- +
+
+

Generation failed

+

{errorMsg}

+
+ + {/* Provider selector in error state */} + {providers.length >= 2 && ( +
+ + +
+ )} + + {selectedProvider === "ollama" && ollamaModels.length > 0 && ( +
+ + +
+ )} + +
+ + +
)} @@ -371,12 +620,83 @@ export default function TempoAnalyzer() {

)} - {/* Metadata form */} + {/* Metadata form + provider selector */} {stage === "idle" && ( <> + {/* Song search */} +
+
+ + {selectedSongLabel && ( + + )} +
+ {songSearchFailed ? ( +
+

Search unavailable — enter MusicBrainz ID manually:

+ 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" + /> +
+ ) : ( + { + 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 && ( +
+ {songResults.length === 0 ? ( +

+ No songs found — try the search page first to register the song. +

+ ) : ( + songResults.map((song, i) => ( + + )) + )} +
+ )} +
+ + {/* Title + artist manual overrides */}
- + update({ title: e.target.value })} @@ -385,7 +705,9 @@ export default function TempoAnalyzer() { />
- + 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" />
-
- - 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" - /> -
-
- - 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" - /> -
+ {/* Contributed by */} +
+ + 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" + /> +
+ + {/* Provider selector */} + {unavailableNotice && ( +
+

{unavailableNotice}

+
+ )} + + {providers.length >= 2 && ( +
+ + +
+ )} + + {/* Ollama model selector */} + {selectedProvider === "ollama" && ollamaModels.length > 0 && ( +
+ {unavailableModelNotice && ( +
+

{unavailableModelNotice}

+
+ )} + + +
+ )} + + {/* Context-aware callouts */} + {selectedProvider === "algorithmic" && ( +
+

+ Algorithmic mode estimates song structure from BPM and duration. Section labels and bar counts are approximate — review carefully before submitting. +

+
+ )} + + {selectedProvider === "ollama" && ( +
+

+ Local AI generation may take 30–90 seconds depending on your hardware. +

+
+ )} + )} diff --git a/components/settings/OllamaModelPicker.tsx b/components/settings/OllamaModelPicker.tsx new file mode 100644 index 0000000..b3ee5aa --- /dev/null +++ b/components/settings/OllamaModelPicker.tsx @@ -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 ( +

+ No models found. Pull a model with{" "} + ollama pull qwen2.5:7b and refresh. +

+ ); + } + + return ( +
+
+ {models.map((model) => { + const isRecommended = RECOMMENDED_OLLAMA_MODELS.includes(model); + return ( + + ); + })} +
+ +
+ ); +} diff --git a/components/settings/PreferencesPanel.tsx b/components/settings/PreferencesPanel.tsx new file mode 100644 index 0000000..c353521 --- /dev/null +++ b/components/settings/PreferencesPanel.tsx @@ -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 ( +
+ + + + +
+ + {cleared && ( + Preferences cleared. + )} +
+
+ ); +} diff --git a/components/settings/ProviderStatus.tsx b/components/settings/ProviderStatus.tsx new file mode 100644 index 0000000..f4d973c --- /dev/null +++ b/components/settings/ProviderStatus.tsx @@ -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 = { + "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 ( +
+
+
+
+ {provider.label} + + {TYPE_LABELS[provider.type]} + + {provider.available ? ( + + Available + + ) : ( + + Unavailable + + )} + {isDefault && ( + + Default + + )} +
+ {!provider.available && provider.unavailableReason && ( +

{provider.unavailableReason}

+ )} + {provider.id === "ollama" && provider.available && provider.ollamaBaseUrl && ( +

{provider.ollamaBaseUrl}

+ )} +
+ + {provider.available && ( +
+ {!isDefault && ( + + )} + +
+ )} +
+ + {testStatus === "ok" && ( +

✓ Working

+ )} + {testStatus === "error" && ( +

✗ {testError}

+ )} +
+ ); +} diff --git a/lib/analysis/ai-ctp.ts b/lib/analysis/ai-ctp.ts index 9a8b85f..0966b30 100644 --- a/lib/analysis/ai-ctp.ts +++ b/lib/analysis/ai-ctp.ts @@ -1,179 +1,17 @@ /** - * AI-assisted CTP document generation - * - * Takes the results of BPM detection (and optional song metadata) and uses - * Claude to produce a plausible, well-structured CTP document. - * - * Claude is asked to: - * - Divide the song into typical sections (Intro, Verse, Chorus, Bridge…) - * - Assign realistic start bars for each section - * - Note any tempo changes it would expect for the song/genre - * - Return a fully valid CTP 1.0 JSON document - * - * The caller should treat the result as a *draft* — the generated sections - * are educated guesses and should be verified against the recording. + * Re-exports from the Anthropic provider for backwards compatibility. + * @deprecated Import directly from @/lib/analysis/providers/anthropic instead. */ +export type { AnalysisInput } from "@/lib/analysis/providers"; +export { anthropicProvider as default } from "@/lib/analysis/providers/anthropic"; -import Anthropic from "@anthropic-ai/sdk"; -import type { CTPDocument } from "@/lib/ctp/schema"; +// Legacy named export for any remaining callers +import { anthropicProvider } from "@/lib/analysis/providers/anthropic"; +import type { AnalysisInput } from "@/lib/analysis/providers"; -const client = new Anthropic(); - -// ─── Input / output types ───────────────────────────────────────────────────── - -export interface AnalysisInput { - bpm: number; - duration: number; // seconds - title?: string; - artist?: string; - mbid?: string | null; - contributedBy?: string; -} - -// ─── JSON Schema for structured output ─────────────────────────────────────── -// Must be strict (no additionalProperties, all required fields present). - -const CTP_SCHEMA = { - type: "object", - additionalProperties: false, - required: ["version", "metadata", "count_in", "sections"], - properties: { - version: { type: "string", enum: ["1.0"] }, - metadata: { - type: "object", - additionalProperties: false, - required: [ - "title", "artist", "mbid", "duration_seconds", - "contributed_by", "verified", "created_at", - ], - properties: { - title: { type: "string" }, - artist: { type: "string" }, - mbid: { type: ["string", "null"] }, - duration_seconds: { type: "number" }, - contributed_by: { type: "string" }, - verified: { type: "boolean" }, - created_at: { type: "string" }, - }, - }, - count_in: { - type: "object", - additionalProperties: false, - required: ["enabled", "bars", "use_first_section_tempo"], - properties: { - enabled: { type: "boolean" }, - bars: { type: "integer", minimum: 1, maximum: 8 }, - use_first_section_tempo: { type: "boolean" }, - }, - }, - sections: { - type: "array", - minItems: 1, - items: { - type: "object", - additionalProperties: false, - required: ["label", "start_bar", "time_signature", "transition"], - // bpm is required for step, bpm_start/bpm_end for ramp — handled via oneOf - // but we keep this schema simple (strict mode) and validate downstream with Zod. - properties: { - label: { type: "string" }, - start_bar: { type: "integer", minimum: 1 }, - bpm: { type: "number" }, - bpm_start: { type: "number" }, - bpm_end: { type: "number" }, - transition: { type: "string", enum: ["step", "ramp"] }, - time_signature: { - type: "object", - additionalProperties: false, - required: ["numerator", "denominator"], - properties: { - numerator: { type: "integer", minimum: 1, maximum: 32 }, - denominator: { type: "integer", enum: [1, 2, 4, 8, 16, 32] }, - }, - }, - }, - }, - }, - }, -}; - -// ─── System prompt ──────────────────────────────────────────────────────────── - -const SYSTEM_PROMPT = `\ -You are an expert music producer and session musician assisting cover bands with click tracks. - -You will receive automated BPM detection results for a song and must generate a CTP (Click Track Protocol) document describing the song's full tempo map. - -CTP rules: -- "version" must be "1.0" -- sections[0].start_bar must be 1 -- sections must be sorted by start_bar ascending, with no gaps -- Step sections have a single "bpm" field; ramp sections have "bpm_start" and "bpm_end" (no "bpm" field) -- All BPM values must be between 20 and 400 -- time_signature.denominator must be a power of 2 (1, 2, 4, 8, 16, or 32) -- metadata.verified must be false (this is AI-generated, not human-verified) -- metadata.created_at must be an ISO 8601 datetime string - -Guidelines for section layout: -- Use typical pop/rock section names: Intro, Verse, Pre-Chorus, Chorus, Bridge, Outro -- Estimate bar counts based on song duration and BPM (bars = duration_seconds × BPM / 60 / beats_per_bar) -- Most songs are 4/4; note any unusual meters if you know the song -- If you know the song has a tempo change (ritardando, double-time feel, key change with tempo shift), model it with a ramp or step section -- If unsure about sections, use a single constant-tempo section covering the whole song -- Use the detected BPM as the primary tempo — do not invent a different BPM unless the song is well-known to have a different tempo - -The output is a draft for human review. Add reasonable section structure based on the song's typical arrangement.`; - -// ─── Main function ──────────────────────────────────────────────────────────── - -export async function generateCTPWithAI(input: AnalysisInput): Promise { - const { bpm, duration, title, artist, mbid, contributedBy } = input; - - const approxBars = Math.round((duration * bpm) / 60 / 4); // assuming 4/4 - - const userMessage = `\ -Generate a CTP document for the following song: - -Title: ${title ?? "Unknown Title"} -Artist: ${artist ?? "Unknown Artist"} -MusicBrainz ID: ${mbid ?? "unknown"} -Detected BPM: ${bpm} -Duration: ${duration.toFixed(1)} seconds (~${approxBars} bars at 4/4) -Contributed by: ${contributedBy ?? "anonymous"} - -Create a plausible section layout for this song. If this is a well-known song, use your knowledge of its actual arrangement. If not, use a sensible generic structure.`; - - const response = await client.messages.create({ - model: "claude-opus-4-6", - max_tokens: 2048, - thinking: { type: "adaptive" }, - system: SYSTEM_PROMPT, - messages: [{ role: "user", content: userMessage }], - output_config: { - format: { - type: "json_schema", - schema: CTP_SCHEMA, - }, - }, +export async function generateCTPWithAI(input: AnalysisInput & { contributedBy?: string }) { + return anthropicProvider.generateCTP({ + ...input, + contributed_by: input.contributed_by ?? input.contributedBy ?? "anonymous", }); - - const textBlock = response.content.find((b) => b.type === "text"); - if (!textBlock || textBlock.type !== "text") { - throw new Error("Claude did not return a text block"); - } - - let parsed: unknown; - try { - parsed = JSON.parse(textBlock.text); - } catch { - throw new Error(`Claude returned invalid JSON: ${textBlock.text.slice(0, 200)}`); - } - - // Stamp the current timestamp if Claude left a placeholder - const doc = parsed as CTPDocument; - if (!doc.metadata.created_at || doc.metadata.created_at.includes("placeholder")) { - doc.metadata.created_at = new Date().toISOString(); - } - - return doc; } diff --git a/lib/analysis/constants.ts b/lib/analysis/constants.ts new file mode 100644 index 0000000..fea9b3e --- /dev/null +++ b/lib/analysis/constants.ts @@ -0,0 +1,3 @@ +// Safe to import from client components — no SDK dependencies. + +export const RECOMMENDED_OLLAMA_MODELS = ['qwen2.5:7b', 'llama3.1:8b', 'mistral:7b']; diff --git a/lib/analysis/providers.ts b/lib/analysis/providers.ts new file mode 100644 index 0000000..7ec9da5 --- /dev/null +++ b/lib/analysis/providers.ts @@ -0,0 +1,29 @@ +import type { CTPDocument } from '@/lib/ctp/schema'; + +export interface AnalysisInput { + bpm: number; + duration: number; // seconds + title?: string; + artist?: string; + mbid?: string | null; + contributed_by: string; + ollamaModel?: string; // required when provider id is "ollama" +} + +export interface ProviderInfo { + id: string; + label: string; + type: 'cloud-ai' | 'local-ai' | 'algorithmic'; + available: boolean; + unavailableReason?: string; // present only when available === false + ollamaBaseUrl?: string; // present only for the ollama provider +} + +export interface AnalysisProvider { + id: string; + label: string; + type: 'cloud-ai' | 'local-ai' | 'algorithmic'; + /** Returns true if this provider is configured and reachable. Must not throw. */ + isAvailable(): Promise<{ available: boolean; reason?: string }>; + generateCTP(input: AnalysisInput): Promise; +} diff --git a/lib/analysis/providers/algorithmic.ts b/lib/analysis/providers/algorithmic.ts new file mode 100644 index 0000000..0512a37 --- /dev/null +++ b/lib/analysis/providers/algorithmic.ts @@ -0,0 +1,131 @@ +import type { CTPDocument } from "@/lib/ctp/schema"; +import type { AnalysisInput, AnalysisProvider } from "@/lib/analysis/providers"; + +// Section templates keyed by duration bucket +interface SectionTemplate { + labels: string[]; + weights: number[]; +} + +function getTemplate(duration: number): SectionTemplate { + if (duration < 120) { + return { + labels: ["Intro", "Verse / Chorus", "Outro"], + weights: [0.12, 0.76, 0.12], + }; + } + if (duration < 240) { + return { + labels: ["Intro", "Verse", "Chorus", "Verse + Chorus", "Outro"], + weights: [0.08, 0.22, 0.20, 0.38, 0.12], + }; + } + if (duration < 360) { + return { + labels: ["Intro", "Verse", "Chorus", "Verse + Chorus", "Bridge", "Outro"], + weights: [0.07, 0.20, 0.18, 0.33, 0.10, 0.12], + }; + } + return { + labels: ["Intro", "Verse", "Chorus", "Verse + Chorus", "Instrumental", "Bridge", "Outro"], + weights: [0.06, 0.18, 0.16, 0.30, 0.10, 0.10, 0.10], + }; +} + +function buildFallback(input: AnalysisInput): CTPDocument { + return { + version: "1.0", + metadata: { + title: input.title ?? "Unknown Title", + artist: input.artist ?? "Unknown Artist", + mbid: input.mbid ?? null, + duration_seconds: input.duration, + contributed_by: input.contributed_by, + verified: false, + created_at: new Date().toISOString(), + }, + count_in: { + enabled: true, + bars: 2, + use_first_section_tempo: true, + }, + sections: [ + { + label: "Song", + start_bar: 1, + bpm: input.bpm, + time_signature: { numerator: 4, denominator: 4 }, + transition: "step", + }, + ], + }; +} + +export const algorithmicProvider: AnalysisProvider = { + id: "algorithmic", + label: "Algorithmic (no AI)", + type: "algorithmic", + + async isAvailable() { + return { available: true }; + }, + + async generateCTP(input: AnalysisInput): Promise { + try { + const { bpm, duration, title } = input; + + const totalBars = Math.floor((duration * bpm) / 240); + const template = getTemplate(duration); + const { labels, weights } = template; + + // Allocate bars per section + const rawBars = weights.map((w) => Math.round(totalBars * w)); + + // Adjust last section so total is exact + const allocatedSum = rawBars.reduce((a, b) => a + b, 0); + const diff = totalBars - allocatedSum; + rawBars[rawBars.length - 1] = Math.max(1, rawBars[rawBars.length - 1] + diff); + + // Determine time signature + const lowerTitle = title?.toLowerCase() ?? ""; + const numerator = lowerTitle.includes("waltz") || lowerTitle.includes("3/4") ? 3 : 4; + const timeSignature = { numerator, denominator: 4 as const }; + + // Build sections with cumulative start_bar + let currentBar = 1; + const sections = labels.map((label, i) => { + const start_bar = currentBar; + currentBar += rawBars[i]; + return { + label, + start_bar, + bpm, + time_signature: timeSignature, + transition: "step" as const, + }; + }); + + return { + version: "1.0", + metadata: { + title: input.title ?? "Unknown Title", + artist: input.artist ?? "Unknown Artist", + mbid: input.mbid ?? null, + duration_seconds: duration, + contributed_by: input.contributed_by, + verified: false, + created_at: new Date().toISOString(), + }, + count_in: { + enabled: true, + bars: 2, + use_first_section_tempo: true, + }, + sections, + }; + } catch { + // Algorithmic provider must never surface an error + return buildFallback(input); + } + }, +}; diff --git a/lib/analysis/providers/anthropic.ts b/lib/analysis/providers/anthropic.ts new file mode 100644 index 0000000..5f0ecdb --- /dev/null +++ b/lib/analysis/providers/anthropic.ts @@ -0,0 +1,181 @@ +import 'server-only'; + +import Anthropic from "@anthropic-ai/sdk"; +import type { CTPDocument } from "@/lib/ctp/schema"; +import type { AnalysisInput, AnalysisProvider } from "@/lib/analysis/providers"; + +// Extract the non-streaming Message type from the SDK without relying on internal paths +type AnthropicMessage = Extract< + Awaited>, + { content: unknown[] } +>; + +const client = new Anthropic(); + +// ─── JSON Schema for structured output ─────────────────────────────────────── + +export const CTP_SCHEMA = { + type: "object", + additionalProperties: false, + required: ["version", "metadata", "count_in", "sections"], + properties: { + version: { type: "string", enum: ["1.0"] }, + metadata: { + type: "object", + additionalProperties: false, + required: [ + "title", "artist", "mbid", "duration_seconds", + "contributed_by", "verified", "created_at", + ], + properties: { + title: { type: "string" }, + artist: { type: "string" }, + mbid: { type: ["string", "null"] }, + duration_seconds: { type: "number" }, + contributed_by: { type: "string" }, + verified: { type: "boolean" }, + created_at: { type: "string" }, + }, + }, + count_in: { + type: "object", + additionalProperties: false, + required: ["enabled", "bars", "use_first_section_tempo"], + properties: { + enabled: { type: "boolean" }, + bars: { type: "integer", minimum: 1, maximum: 8 }, + use_first_section_tempo: { type: "boolean" }, + }, + }, + sections: { + type: "array", + minItems: 1, + items: { + type: "object", + additionalProperties: false, + required: ["label", "start_bar", "time_signature", "transition"], + properties: { + label: { type: "string" }, + start_bar: { type: "integer", minimum: 1 }, + bpm: { type: "number" }, + bpm_start: { type: "number" }, + bpm_end: { type: "number" }, + transition: { type: "string", enum: ["step", "ramp"] }, + time_signature: { + type: "object", + additionalProperties: false, + required: ["numerator", "denominator"], + properties: { + numerator: { type: "integer", minimum: 1, maximum: 32 }, + denominator: { type: "integer", enum: [1, 2, 4, 8, 16, 32] }, + }, + }, + }, + }, + }, + }, +}; + +// ─── System prompt ──────────────────────────────────────────────────────────── + +export const SYSTEM_PROMPT = `\ +You are an expert music producer and session musician assisting cover bands with click tracks. + +You will receive automated BPM detection results for a song and must generate a CTP (Click Track Protocol) document describing the song's full tempo map. + +CTP rules: +- "version" must be "1.0" +- sections[0].start_bar must be 1 +- sections must be sorted by start_bar ascending, with no gaps +- Step sections have a single "bpm" field; ramp sections have "bpm_start" and "bpm_end" (no "bpm" field) +- All BPM values must be between 20 and 400 +- time_signature.denominator must be a power of 2 (1, 2, 4, 8, 16, or 32) +- metadata.verified must be false (this is AI-generated, not human-verified) +- metadata.created_at must be an ISO 8601 datetime string + +Guidelines for section layout: +- Use typical pop/rock section names: Intro, Verse, Pre-Chorus, Chorus, Bridge, Outro +- Estimate bar counts based on song duration and BPM (bars = duration_seconds × BPM / 60 / beats_per_bar) +- Most songs are 4/4; note any unusual meters if you know the song +- If you know the song has a tempo change (ritardando, double-time feel, key change with tempo shift), model it with a ramp or step section +- If unsure about sections, use a single constant-tempo section covering the whole song +- Use the detected BPM as the primary tempo — do not invent a different BPM unless the song is well-known to have a different tempo + +The output is a draft for human review. Add reasonable section structure based on the song's typical arrangement.`; + +// ─── Provider implementation ────────────────────────────────────────────────── + +export const anthropicProvider: AnalysisProvider = { + id: "anthropic", + label: "Claude (Anthropic)", + type: "cloud-ai", + + async isAvailable() { + if (process.env.ANTHROPIC_API_KEY) { + return { available: true }; + } + return { available: false, reason: "ANTHROPIC_API_KEY not set" }; + }, + + async generateCTP(input: AnalysisInput): Promise { + const { bpm, duration, title, artist, mbid, contributed_by } = input; + const model = process.env.ANTHROPIC_MODEL ?? "claude-opus-4-6"; + const approxBars = Math.round((duration * bpm) / 60 / 4); + + const userMessage = `\ +Generate a CTP document for the following song: + +Title: ${title ?? "Unknown Title"} +Artist: ${artist ?? "Unknown Artist"} +MusicBrainz ID: ${mbid ?? "unknown"} +Detected BPM: ${bpm} +Duration: ${duration.toFixed(1)} seconds (~${approxBars} bars at 4/4) +Contributed by: ${contributed_by} + +Create a plausible section layout for this song. If this is a well-known song, use your knowledge of its actual arrangement. If not, use a sensible generic structure.`; + + // thinking and output_config are not yet in the SDK type definitions; + // cast through the base param type to avoid type errors. + type ExtendedParams = Parameters[0] & { + thinking?: { type: string }; + output_config?: { format: { type: string; schema: unknown } }; + }; + + const params: ExtendedParams = { + model, + max_tokens: 2048, + thinking: { type: "adaptive" }, + system: SYSTEM_PROMPT, + messages: [{ role: "user", content: userMessage }], + output_config: { + format: { + type: "json_schema", + schema: CTP_SCHEMA, + }, + }, + }; + + const response = (await client.messages.create( + params as Parameters[0] + )) as AnthropicMessage; + + const textBlock = response.content.find((b) => b.type === "text"); + if (!textBlock || textBlock.type !== "text") { + throw new Error("Claude did not return a text block"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(textBlock.text); + } catch { + throw new Error(`Claude returned invalid JSON: ${textBlock.text.slice(0, 200)}`); + } + + const doc = parsed as CTPDocument; + if (!doc.metadata.created_at || doc.metadata.created_at.includes("placeholder")) { + doc.metadata.created_at = new Date().toISOString(); + } + + return doc; + }, +}; diff --git a/lib/analysis/providers/ollama.ts b/lib/analysis/providers/ollama.ts new file mode 100644 index 0000000..d350485 --- /dev/null +++ b/lib/analysis/providers/ollama.ts @@ -0,0 +1,157 @@ +import 'server-only'; + +import type { CTPDocument } from "@/lib/ctp/schema"; +import type { AnalysisInput, AnalysisProvider } from "@/lib/analysis/providers"; +import { SYSTEM_PROMPT } from "./anthropic"; + +function getBaseUrl(): string { + return process.env.OLLAMA_BASE_URL ?? "http://localhost:11434"; +} + +// ─── Model list ─────────────────────────────────────────────────────────────── + +interface OllamaTagsResponse { + models?: Array<{ name: string }>; +} + +export async function getOllamaModels(): Promise { + try { + const url = `${getBaseUrl()}/api/tags`; + const response = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!response.ok) return []; + const json = await response.json() as OllamaTagsResponse; + return (json.models ?? []).map((m) => m.name); + } catch { + return []; + } +} + +// ─── Chat completions helper ────────────────────────────────────────────────── + +async function callOllama(model: string, userMessage: string): Promise { + const baseUrl = getBaseUrl(); + const response = await fetch(`${baseUrl}/v1/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { + role: "user", + content: + userMessage + + "\n\nRespond with valid JSON only. Do not add any explanation or markdown. Your entire response must be a single valid JSON object matching the schema.", + }, + ], + stream: false, + }), + signal: AbortSignal.timeout(120000), // 2-minute timeout for slow local models + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Ollama API error ${response.status}: ${text.slice(0, 200)}`); + } + + const json = await response.json() as { + choices?: Array<{ message?: { content?: string } }>; + }; + + const content = json.choices?.[0]?.message?.content; + if (!content) { + throw new Error("Ollama did not return a message content"); + } + + return content; +} + +// ─── Provider implementation ────────────────────────────────────────────────── + +export const ollamaProvider: AnalysisProvider = { + id: "ollama", + label: "Ollama", + type: "local-ai", + + async isAvailable() { + const url = getBaseUrl(); + try { + const response = await fetch(`${url}/api/tags`, { + signal: AbortSignal.timeout(5000), + }); + if (response.ok) { + return { available: true }; + } + return { available: false, reason: `Ollama not reachable at ${url}` }; + } catch { + return { available: false, reason: `Ollama not reachable at ${url}` }; + } + }, + + async generateCTP(input: AnalysisInput): Promise { + const { ollamaModel, bpm, duration, title, artist, mbid, contributed_by } = input; + + if (!ollamaModel) { + throw new Error("ollamaModel is required for Ollama provider"); + } + + const approxBars = Math.round((duration * bpm) / 60 / 4); + + const userMessage = `\ +Generate a CTP document for the following song: + +Title: ${title ?? "Unknown Title"} +Artist: ${artist ?? "Unknown Artist"} +MusicBrainz ID: ${mbid ?? "unknown"} +Detected BPM: ${bpm} +Duration: ${duration.toFixed(1)} seconds (~${approxBars} bars at 4/4) +Contributed by: ${contributed_by} + +Create a plausible section layout for this song. If this is a well-known song, use your knowledge of its actual arrangement. If not, use a sensible generic structure.`; + + // Attempt parse with one retry on failure + let content: string; + try { + content = await callOllama(ollamaModel, userMessage); + } catch (err) { + throw new Error( + `Ollama request failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + + const tryParse = (raw: string): CTPDocument | null => { + // Strip markdown code fences if present + const stripped = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim(); + try { + const doc = JSON.parse(stripped) as CTPDocument; + if (!doc.metadata.created_at || doc.metadata.created_at.includes("placeholder")) { + doc.metadata.created_at = new Date().toISOString(); + } + return doc; + } catch { + return null; + } + }; + + const firstAttempt = tryParse(content); + if (firstAttempt) return firstAttempt; + + // Retry once + let retryContent: string; + try { + retryContent = await callOllama(ollamaModel, userMessage); + } catch (err) { + throw new Error( + `Ollama retry request failed: ${err instanceof Error ? err.message : String(err)}` + ); + } + + const secondAttempt = tryParse(retryContent); + if (secondAttempt) return secondAttempt; + + throw new Error( + `Ollama (${ollamaModel}) returned a response that could not be parsed as a valid CTP document. ` + + `Response preview: ${content.slice(0, 200)}` + ); + }, +}; diff --git a/lib/analysis/providers/openai.ts b/lib/analysis/providers/openai.ts new file mode 100644 index 0000000..4b120b2 --- /dev/null +++ b/lib/analysis/providers/openai.ts @@ -0,0 +1,108 @@ +import 'server-only'; + +import type { CTPDocument } from "@/lib/ctp/schema"; +import type { AnalysisInput, AnalysisProvider } from "@/lib/analysis/providers"; +import { CTP_SCHEMA, SYSTEM_PROMPT } from "./anthropic"; + +function buildLabel(): string { + const model = process.env.OPENAI_MODEL ?? "GPT-4o"; + const baseUrl = process.env.OPENAI_BASE_URL ?? ""; + if (baseUrl && !baseUrl.includes("api.openai.com")) { + try { + const host = new URL(baseUrl).hostname; + return `${model} (${host})`; + } catch { + // fall through + } + } + return `${model} (OpenAI)`; +} + +export const openaiProvider: AnalysisProvider = { + id: "openai", + label: buildLabel(), + type: "cloud-ai", + + async isAvailable() { + if (process.env.OPENAI_API_KEY) { + return { available: true }; + } + return { available: false, reason: "OPENAI_API_KEY not set" }; + }, + + async generateCTP(input: AnalysisInput): Promise { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Error("OPENAI_API_KEY not set"); + } + + const baseUrl = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; + const model = process.env.OPENAI_MODEL ?? "gpt-4o"; + const { bpm, duration, title, artist, mbid, contributed_by } = input; + const approxBars = Math.round((duration * bpm) / 60 / 4); + + const userMessage = `\ +Generate a CTP document for the following song: + +Title: ${title ?? "Unknown Title"} +Artist: ${artist ?? "Unknown Artist"} +MusicBrainz ID: ${mbid ?? "unknown"} +Detected BPM: ${bpm} +Duration: ${duration.toFixed(1)} seconds (~${approxBars} bars at 4/4) +Contributed by: ${contributed_by} + +Create a plausible section layout for this song. If this is a well-known song, use your knowledge of its actual arrangement. If not, use a sensible generic structure.`; + + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userMessage }, + ], + response_format: { + type: "json_schema", + json_schema: { + strict: true, + name: "CTPDocument", + schema: CTP_SCHEMA, + }, + }, + max_tokens: 2048, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`OpenAI API error ${response.status}: ${text.slice(0, 200)}`); + } + + const json = await response.json() as { + choices?: Array<{ message?: { content?: string } }>; + }; + + const content = json.choices?.[0]?.message?.content; + if (!content) { + throw new Error("OpenAI did not return a message content"); + } + + let parsed: unknown; + try { + parsed = JSON.parse(content); + } catch { + throw new Error(`OpenAI returned invalid JSON: ${content.slice(0, 200)}`); + } + + const doc = parsed as CTPDocument; + if (!doc.metadata.created_at || doc.metadata.created_at.includes("placeholder")) { + doc.metadata.created_at = new Date().toISOString(); + } + + return doc; + }, +}; diff --git a/lib/analysis/providers/registry.ts b/lib/analysis/providers/registry.ts new file mode 100644 index 0000000..454ee81 --- /dev/null +++ b/lib/analysis/providers/registry.ts @@ -0,0 +1,78 @@ +import 'server-only'; + +import type { AnalysisProvider, ProviderInfo } from "@/lib/analysis/providers"; +import { anthropicProvider } from "./anthropic"; +import { openaiProvider } from "./openai"; +import { ollamaProvider, getOllamaModels } from "./ollama"; +import { algorithmicProvider } from "./algorithmic"; +import { RECOMMENDED_OLLAMA_MODELS } from "@/lib/analysis/constants"; + +export { getOllamaModels, RECOMMENDED_OLLAMA_MODELS }; + +// Registration order determines the default when the user has no saved preference. +const ALL_PROVIDERS: AnalysisProvider[] = [ + anthropicProvider, + openaiProvider, + ollamaProvider, + algorithmicProvider, +]; + +/** + * Returns every provider with its current availability status. + * Runs all isAvailable() checks in parallel. + */ +export async function getProviderInfoList(): Promise { + const results = await Promise.all( + ALL_PROVIDERS.map(async (p) => { + const availability = await p.isAvailable(); + const info: ProviderInfo = { + id: p.id, + label: p.label, + type: p.type, + available: availability.available, + }; + if (!availability.available && availability.reason) { + info.unavailableReason = availability.reason; + } + if (p.id === "ollama") { + info.ollamaBaseUrl = process.env.OLLAMA_BASE_URL ?? "http://localhost:11434"; + } + return info; + }) + ); + return results; +} + +/** + * Returns only providers where available === true. + * The algorithmic provider is always included. + */ +export async function getAvailableProviders(): Promise { + const checks = await Promise.all( + ALL_PROVIDERS.map(async (p) => { + const availability = await p.isAvailable(); + return { provider: p, available: availability.available }; + }) + ); + return checks.filter((c) => c.available).map((c) => c.provider); +} + +/** + * Looks up a provider by id. Throws with a descriptive message if not found + * or if isAvailable() returns false. + */ +export async function getProvider(id: string): Promise { + const provider = ALL_PROVIDERS.find((p) => p.id === id); + if (!provider) { + throw new Error( + `Unknown provider '${id}'. Available providers: ${ALL_PROVIDERS.map((p) => p.id).join(", ")}` + ); + } + const availability = await provider.isAvailable(); + if (!availability.available) { + throw new Error( + `Provider '${id}' is not available: ${availability.reason ?? "unknown reason"}` + ); + } + return provider; +} diff --git a/lib/ctp/render.ts b/lib/ctp/render.ts index a17a0b8..00c5a76 100644 --- a/lib/ctp/render.ts +++ b/lib/ctp/render.ts @@ -149,7 +149,10 @@ function calculateBeats(doc: CTPDocument): Beat[] { } } else { // Last section: generate beats until we exceed duration_seconds - const songEnd = cursor + doc.metadata.duration_seconds; + const countInSeconds = doc.count_in.enabled + ? (doc.count_in.bars * firstNumerator * 60) / firstBpm + : 0; + const songEnd = countInSeconds + doc.metadata.duration_seconds; // Estimate bars remaining const approxBarsRemaining = Math.ceil( (doc.metadata.duration_seconds / 60) * section.bpm / numerator + 2 diff --git a/lib/db/client.ts b/lib/db/client.ts index dd5693d..a5bbb88 100644 --- a/lib/db/client.ts +++ b/lib/db/client.ts @@ -68,6 +68,11 @@ export interface SongRow { updated_at: Date; } +export async function getSongByMbid(mbid: string): Promise { + const { rows } = await query("SELECT * FROM songs WHERE mbid = $1", [mbid]); + return rows[0] ?? null; +} + export async function searchSongs(q: string, limit = 20): Promise { const { rows } = await query( `SELECT * FROM songs diff --git a/lib/registry/sync.ts b/lib/registry/sync.ts index 8ce8bde..762c88b 100644 --- a/lib/registry/sync.ts +++ b/lib/registry/sync.ts @@ -1,14 +1,18 @@ /** * Git Registry Sync * - * Pulls CTP files from a remote GitHub repository (the "community registry") - * and upserts them into the local database. + * Pulls CTP files from a remote git repository (the "community registry") + * served over HTTPS. Compatible with any self-hosted git server + * (Gitea, Forgejo, GitLab CE, Gogs, etc.) or any public git host. * * The registry repo is expected to contain CTP JSON files at: * ///.ctp.json * * Configuration: - * REGISTRY_REPO — GitHub repo URL, e.g. https://github.com/org/clicktrack-registry + * REGISTRY_REPO — HTTPS URL of the registry repo, + * e.g. https://git.yourdomain.com/org/clicktrack-registry + * To authenticate, embed credentials in the URL: + * https://user:token@git.yourdomain.com/org/clicktrack-registry * REGISTRY_BRANCH — branch to pull from (default: main) */