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:
25
.env.example
25
.env.example
@@ -10,16 +10,33 @@ POSTGRES_PASSWORD=clicktrack
|
|||||||
# ── Redis ────────────────────────────────────────────────────────────────────
|
# ── Redis ────────────────────────────────────────────────────────────────────
|
||||||
REDIS_URL=redis://localhost:6379
|
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_REPO=
|
||||||
REGISTRY_BRANCH=main
|
REGISTRY_BRANCH=main
|
||||||
REGISTRY_SYNC_INTERVAL=3600
|
REGISTRY_SYNC_INTERVAL=3600
|
||||||
|
|
||||||
# ── AI Tempo Analysis ────────────────────────────────────────────────────────
|
# ── AI Tempo Analysis ────────────────────────────────────────────────────────
|
||||||
# Required for the /analyze feature (AI tempo map generation).
|
# BPM detection is client-side and works without any of these keys.
|
||||||
# Get a key at https://console.anthropic.com
|
|
||||||
# BPM detection is client-side and works without this key.
|
# --- Cloud AI: Anthropic (existing) ---
|
||||||
|
# Required to enable Anthropic provider. Get a key at https://console.anthropic.com
|
||||||
ANTHROPIC_API_KEY=
|
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 ──────────────────────────────────────────────────────────────────────
|
# ── App ──────────────────────────────────────────────────────────────────────
|
||||||
NEXT_PUBLIC_APP_NAME=ClickTrack
|
NEXT_PUBLIC_APP_NAME=ClickTrack
|
||||||
|
|||||||
123
app/(web)/settings/page.tsx
Normal file
123
app/(web)/settings/page.tsx
Normal file
@@ -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<ProviderInfo[]>([]);
|
||||||
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [defaultProvider, setDefaultProvider] = useState<string>("");
|
||||||
|
const [selectedOllamaModel, setSelectedOllamaModel] = useState<string>("");
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
<p className="text-zinc-500">Loading…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10 max-w-2xl">
|
||||||
|
<h1 className="text-2xl font-bold">Settings</h1>
|
||||||
|
|
||||||
|
{/* Analysis Providers */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Analysis Providers</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{providers.map((provider) => (
|
||||||
|
<ProviderStatus
|
||||||
|
key={provider.id}
|
||||||
|
provider={provider}
|
||||||
|
selectedOllamaModel={selectedOllamaModel}
|
||||||
|
isDefault={defaultProvider === provider.id}
|
||||||
|
onSetDefault={handleSetDefault}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Ollama Models */}
|
||||||
|
{ollamaAvailable && (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Ollama Models</h2>
|
||||||
|
<OllamaModelPicker
|
||||||
|
models={ollamaModels}
|
||||||
|
value={selectedOllamaModel}
|
||||||
|
onChange={handleModelChange}
|
||||||
|
onRefresh={() => fetchProviders(true)}
|
||||||
|
refreshing={refreshing}
|
||||||
|
/>
|
||||||
|
{ollamaProvider?.ollamaBaseUrl && (
|
||||||
|
<p className="text-xs text-zinc-600">
|
||||||
|
Base URL (operator-configured):{" "}
|
||||||
|
<code className="text-zinc-500">{ollamaProvider.ollamaBaseUrl}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preferences */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">Preferences</h2>
|
||||||
|
<PreferencesPanel />
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
app/api/analyze/providers/route.ts
Normal file
20
app/api/analyze/providers/route.ts
Normal file
@@ -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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
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";
|
import { validateCTP } from "@/lib/ctp/validate";
|
||||||
|
|
||||||
// ─── Request schema ───────────────────────────────────────────────────────────
|
// ─── Request schema ───────────────────────────────────────────────────────────
|
||||||
@@ -12,16 +12,18 @@ const AnalyzeRequestSchema = z.object({
|
|||||||
artist: z.string().min(1).max(256).optional(),
|
artist: z.string().min(1).max(256).optional(),
|
||||||
mbid: z.string().uuid().optional().nullable(),
|
mbid: z.string().uuid().optional().nullable(),
|
||||||
contributed_by: z.string().min(1).max(64).optional(),
|
contributed_by: z.string().min(1).max(64).optional(),
|
||||||
|
provider: z.string().optional(),
|
||||||
|
ollamaModel: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/analyze
|
* POST /api/analyze
|
||||||
*
|
*
|
||||||
* Accepts BPM detection results from the browser and uses Claude to generate
|
* Accepts BPM detection results from the browser and uses the selected provider
|
||||||
* a draft CTP document for human review.
|
* to generate a draft CTP document for human review.
|
||||||
*
|
*
|
||||||
* Body (JSON):
|
* Body (JSON):
|
||||||
* { bpm, duration, title?, artist?, mbid?, contributed_by? }
|
* { bpm, duration, title?, artist?, mbid?, contributed_by?, provider?, ollamaModel? }
|
||||||
*
|
*
|
||||||
* Returns:
|
* Returns:
|
||||||
* { ctp: CTPDocument, warnings: string[] }
|
* { 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(
|
return NextResponse.json(
|
||||||
{ error: "ANTHROPIC_API_KEY is not configured on this server" },
|
{ error: "ollamaModel is required when using the Ollama provider" },
|
||||||
{ status: 503 }
|
{ 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;
|
let ctpDoc;
|
||||||
try {
|
try {
|
||||||
ctpDoc = await generateCTPWithAI({
|
ctpDoc = await provider.generateCTP(input);
|
||||||
bpm,
|
|
||||||
duration,
|
|
||||||
title,
|
|
||||||
artist,
|
|
||||||
mbid: mbid ?? null,
|
|
||||||
contributedBy: contributed_by ?? "anonymous",
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[analyze] AI generation failed:", err);
|
console.error(`[analyze] Provider '${provider.label}' failed:`, err);
|
||||||
return NextResponse.json(
|
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 validation = validateCTP(ctpDoc);
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
|
||||||
if (!validation.success) {
|
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}`));
|
warnings.push(...validation.errors.issues.map((i) => `${i.path.join(".")}: ${i.message}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
134
app/api/analyze/test/route.ts
Normal file
134
app/api/analyze/test/route.ts
Normal file
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { z } from "zod";
|
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 { validateCTP } from "@/lib/ctp/validate";
|
||||||
|
import { lookupRecording, formatArtistCredit, mbDurationToSeconds } from "@/lib/musicbrainz/client";
|
||||||
|
|
||||||
// ─── GET /api/tracks?mbid=<uuid> ─────────────────────────────────────────────
|
// ─── GET /api/tracks?mbid=<uuid> ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -55,19 +56,33 @@ export async function POST(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the song exists
|
// Ensure the song exists — auto-register it if not
|
||||||
const { rowCount } = await query("SELECT 1 FROM songs WHERE mbid = $1", [
|
const existing = await getSongByMbid(doc.metadata.mbid);
|
||||||
doc.metadata.mbid,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!rowCount || rowCount === 0) {
|
if (!existing) {
|
||||||
return NextResponse.json(
|
try {
|
||||||
{
|
const rec = await lookupRecording(doc.metadata.mbid);
|
||||||
error: "Song not found. Search for the song first to register it.",
|
await upsertSong({
|
||||||
mbid: doc.metadata.mbid,
|
mbid: doc.metadata.mbid,
|
||||||
},
|
title: rec.title,
|
||||||
{ status: 404 }
|
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({
|
const map = await insertTempoMap({
|
||||||
|
|||||||
@@ -30,7 +30,10 @@ export default function RootLayout({
|
|||||||
<a href="/analyze" className="hover:text-zinc-100 transition-colors">
|
<a href="/analyze" className="hover:text-zinc-100 transition-colors">
|
||||||
Analyze
|
Analyze
|
||||||
</a>
|
</a>
|
||||||
<
|
<a href="/settings" className="hover:text-zinc-100 transition-colors">
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
href="https://github.com/your-org/clicktrack"
|
href="https://github.com/your-org/clicktrack"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
|||||||
@@ -7,18 +7,31 @@
|
|||||||
* 1. User drops / selects an audio file (MP3, WAV, AAC, OGG, etc.)
|
* 1. User drops / selects an audio file (MP3, WAV, AAC, OGG, etc.)
|
||||||
* 2. Browser decodes the audio and runs BPM detection (Web Audio API)
|
* 2. Browser decodes the audio and runs BPM detection (Web Audio API)
|
||||||
* 3. Optional: user provides song title, artist, MusicBrainz ID
|
* 3. Optional: user provides song title, artist, MusicBrainz ID
|
||||||
* 4. Client sends { bpm, duration, … } to POST /api/analyze
|
* 4. User selects an analysis provider
|
||||||
* 5. Server calls Claude → returns a CTP document draft
|
* 5. Client sends { bpm, duration, provider, … } to POST /api/analyze
|
||||||
* 6. User can review the sections, download the .ctp.json, or submit to DB
|
* 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 { detectBPM, type BPMDetectionResult } from "@/lib/analysis/bpm-detect";
|
||||||
import TempoMapEditor from "@/components/TempoMapEditor";
|
import TempoMapEditor from "@/components/TempoMapEditor";
|
||||||
import type { CTPDocument } from "@/lib/ctp/schema";
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface SongResult {
|
||||||
|
mbid: string;
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
duration_seconds: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
type Stage =
|
type Stage =
|
||||||
| "idle"
|
| "idle"
|
||||||
| "decoding"
|
| "decoding"
|
||||||
@@ -36,12 +49,10 @@ interface AnalyzerState {
|
|||||||
ctp: CTPDocument | null;
|
ctp: CTPDocument | null;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
errorMsg: string;
|
errorMsg: string;
|
||||||
// Optional metadata the user may fill in before AI generation
|
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
mbid: string;
|
mbid: string;
|
||||||
contributedBy: string;
|
contributedBy: string;
|
||||||
// Toggle: use halfTimeBpm instead of primary bpm
|
|
||||||
useHalfTime: boolean;
|
useHalfTime: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,10 +92,167 @@ export default function TempoAnalyzer() {
|
|||||||
const dropRef = useRef<HTMLDivElement>(null);
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
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>) =>
|
const update = (patch: Partial<AnalyzerState>) =>
|
||||||
setState((prev) => ({ ...prev, ...patch }));
|
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) => {
|
const handleFile = useCallback(async (file: File) => {
|
||||||
if (!file.type.startsWith("audio/") && !file.name.match(/\.(mp3|wav|aac|ogg|flac|m4a|aiff)$/i)) {
|
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();
|
const abort = new AbortController();
|
||||||
abortRef.current = abort;
|
abortRef.current = abort;
|
||||||
|
|
||||||
// Try to pre-fill title/artist from filename: "Artist - Title.mp3"
|
|
||||||
const base = file.name.replace(/\.[^.]+$/, "");
|
const base = file.name.replace(/\.[^.]+$/, "");
|
||||||
const dashIdx = base.indexOf(" - ");
|
const dashIdx = base.indexOf(" - ");
|
||||||
const autoTitle = dashIdx > -1 ? base.slice(dashIdx + 3) : base;
|
const autoTitle = dashIdx > -1 ? base.slice(dashIdx + 3) : base;
|
||||||
@@ -116,7 +283,7 @@ export default function TempoAnalyzer() {
|
|||||||
try {
|
try {
|
||||||
update({ stage: "detecting" });
|
update({ stage: "detecting" });
|
||||||
const detection = await detectBPM(file, abort.signal);
|
const detection = await detectBPM(file, abort.signal);
|
||||||
update({ detection, stage: "idle" }); // wait for user to confirm/edit metadata
|
update({ detection, stage: "idle" });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if ((err as Error).name === "AbortError") return;
|
if ((err as Error).name === "AbortError") return;
|
||||||
update({
|
update({
|
||||||
@@ -136,12 +303,12 @@ export default function TempoAnalyzer() {
|
|||||||
function handleFileInput(e: React.ChangeEvent<HTMLInputElement>) {
|
function handleFileInput(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) handleFile(file);
|
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;
|
if (!state.detection) return;
|
||||||
|
|
||||||
const effectiveBpm =
|
const effectiveBpm =
|
||||||
@@ -149,38 +316,60 @@ export default function TempoAnalyzer() {
|
|||||||
? state.detection.halfTimeBpm
|
? state.detection.halfTimeBpm
|
||||||
: state.detection.bpm;
|
: 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 {
|
try {
|
||||||
const res = await fetch("/api/analyze", {
|
await runGenerate(selectedProvider, selectedOllamaModel);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
bpm: effectiveBpm,
|
|
||||||
duration: state.detection.duration,
|
|
||||||
title: state.title || undefined,
|
|
||||||
artist: state.artist || undefined,
|
|
||||||
mbid: state.mbid || undefined,
|
|
||||||
contributed_by: state.contributedBy || undefined,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(data.error ?? `Server error ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
update({ ctp: data.ctp, warnings: data.warnings ?? [], stage: "review" });
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update({
|
update({
|
||||||
stage: "error",
|
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() {
|
async function handleSubmit() {
|
||||||
if (!state.ctp) return;
|
if (!state.ctp) return;
|
||||||
@@ -193,7 +382,7 @@ export default function TempoAnalyzer() {
|
|||||||
body: JSON.stringify(state.ctp),
|
body: JSON.stringify(state.ctp),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json() as { error?: string };
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(data.error ?? `Server error ${res.status}`);
|
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() {
|
function handleDownload() {
|
||||||
if (!state.ctp) return;
|
if (!state.ctp) return;
|
||||||
@@ -225,17 +414,26 @@ export default function TempoAnalyzer() {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Reset ────────────────────────────────────────────────────────────────
|
// ── Reset ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleReset() {
|
function handleReset() {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
setState(INITIAL_STATE);
|
setState(INITIAL_STATE);
|
||||||
|
setSongQuery("");
|
||||||
|
setSongResults([]);
|
||||||
|
setSongDropdownOpen(false);
|
||||||
|
setSelectedSongLabel("");
|
||||||
|
setSongHighlightIdx(-1);
|
||||||
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Render ───────────────────────────────────────────────────────────────
|
// ─── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const { stage, file, detection, ctp, warnings, errorMsg, useHalfTime } = state;
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -279,28 +477,79 @@ export default function TempoAnalyzer() {
|
|||||||
<p className="font-medium text-zinc-200">
|
<p className="font-medium text-zinc-200">
|
||||||
{stage === "decoding" && "Decoding audio…"}
|
{stage === "decoding" && "Decoding audio…"}
|
||||||
{stage === "detecting" && "Detecting tempo…"}
|
{stage === "detecting" && "Detecting tempo…"}
|
||||||
{stage === "generating" && "Generating tempo map with AI…"}
|
{stage === "generating" && "Generating tempo map…"}
|
||||||
{stage === "saving" && "Saving to database…"}
|
{stage === "saving" && "Saving to database…"}
|
||||||
</p>
|
</p>
|
||||||
{stage === "generating" && (
|
{stage === "generating" && selectedProviderInfo?.id === "ollama" && (
|
||||||
<p className="mt-1 text-sm text-zinc-500">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Error ─────────────────────────────────────────────────────── */}
|
{/* ── Error panel ────────────────────────────────────────────────── */}
|
||||||
{stage === "error" && (
|
{stage === "error" && (
|
||||||
<div className="rounded-xl border border-red-800 bg-red-950/30 px-6 py-5">
|
<div className="rounded-xl border border-red-800 bg-red-950/30 px-6 py-5 space-y-4">
|
||||||
<p className="text-red-400 font-medium mb-1">Error</p>
|
<div>
|
||||||
<p className="text-sm text-red-300">{errorMsg}</p>
|
<p className="text-red-400 font-medium mb-1">Generation failed</p>
|
||||||
<button
|
<p className="text-sm text-red-300">{errorMsg}</p>
|
||||||
onClick={handleReset}
|
</div>
|
||||||
className="mt-4 text-sm text-zinc-400 hover:text-zinc-200 underline"
|
|
||||||
>
|
{/* Provider selector in error state */}
|
||||||
Try again
|
{providers.length >= 2 && (
|
||||||
</button>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -371,12 +620,83 @@ export default function TempoAnalyzer() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Metadata form */}
|
{/* Metadata form + provider selector */}
|
||||||
{stage === "idle" && (
|
{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 className="grid gap-3 sm:grid-cols-2">
|
||||||
<div>
|
<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
|
<input
|
||||||
value={state.title}
|
value={state.title}
|
||||||
onChange={(e) => update({ title: e.target.value })}
|
onChange={(e) => update({ title: e.target.value })}
|
||||||
@@ -385,7 +705,9 @@ export default function TempoAnalyzer() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<input
|
||||||
value={state.artist}
|
value={state.artist}
|
||||||
onChange={(e) => update({ artist: e.target.value })}
|
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"
|
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>
|
||||||
<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>
|
</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
|
<button
|
||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
className="w-full rounded-lg bg-green-600 py-3 font-semibold text-white hover:bg-green-500 transition-colors"
|
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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,179 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* AI-assisted CTP document generation
|
* Re-exports from the Anthropic provider for backwards compatibility.
|
||||||
*
|
* @deprecated Import directly from @/lib/analysis/providers/anthropic instead.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
export type { AnalysisInput } from "@/lib/analysis/providers";
|
||||||
|
export { anthropicProvider as default } from "@/lib/analysis/providers/anthropic";
|
||||||
|
|
||||||
import Anthropic from "@anthropic-ai/sdk";
|
// Legacy named export for any remaining callers
|
||||||
import type { CTPDocument } from "@/lib/ctp/schema";
|
import { anthropicProvider } from "@/lib/analysis/providers/anthropic";
|
||||||
|
import type { AnalysisInput } from "@/lib/analysis/providers";
|
||||||
|
|
||||||
const client = new Anthropic();
|
export async function generateCTPWithAI(input: AnalysisInput & { contributedBy?: string }) {
|
||||||
|
return anthropicProvider.generateCTP({
|
||||||
// ─── Input / output types ─────────────────────────────────────────────────────
|
...input,
|
||||||
|
contributed_by: input.contributed_by ?? input.contributedBy ?? "anonymous",
|
||||||
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<CTPDocument> {
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
3
lib/analysis/constants.ts
Normal file
3
lib/analysis/constants.ts
Normal file
@@ -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'];
|
||||||
29
lib/analysis/providers.ts
Normal file
29
lib/analysis/providers.ts
Normal file
@@ -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<CTPDocument>;
|
||||||
|
}
|
||||||
131
lib/analysis/providers/algorithmic.ts
Normal file
131
lib/analysis/providers/algorithmic.ts
Normal file
@@ -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<CTPDocument> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
181
lib/analysis/providers/anthropic.ts
Normal file
181
lib/analysis/providers/anthropic.ts
Normal file
@@ -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<ReturnType<Anthropic["messages"]["create"]>>,
|
||||||
|
{ 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<CTPDocument> {
|
||||||
|
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<typeof client.messages.create>[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<typeof client.messages.create>[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;
|
||||||
|
},
|
||||||
|
};
|
||||||
157
lib/analysis/providers/ollama.ts
Normal file
157
lib/analysis/providers/ollama.ts
Normal file
@@ -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<string[]> {
|
||||||
|
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<string> {
|
||||||
|
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<CTPDocument> {
|
||||||
|
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)}`
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
108
lib/analysis/providers/openai.ts
Normal file
108
lib/analysis/providers/openai.ts
Normal file
@@ -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<CTPDocument> {
|
||||||
|
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;
|
||||||
|
},
|
||||||
|
};
|
||||||
78
lib/analysis/providers/registry.ts
Normal file
78
lib/analysis/providers/registry.ts
Normal file
@@ -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<ProviderInfo[]> {
|
||||||
|
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<AnalysisProvider[]> {
|
||||||
|
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<AnalysisProvider> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -149,7 +149,10 @@ function calculateBeats(doc: CTPDocument): Beat[] {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Last section: generate beats until we exceed duration_seconds
|
// 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
|
// Estimate bars remaining
|
||||||
const approxBarsRemaining = Math.ceil(
|
const approxBarsRemaining = Math.ceil(
|
||||||
(doc.metadata.duration_seconds / 60) * section.bpm / numerator + 2
|
(doc.metadata.duration_seconds / 60) * section.bpm / numerator + 2
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ export interface SongRow {
|
|||||||
updated_at: Date;
|
updated_at: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSongByMbid(mbid: string): Promise<SongRow | null> {
|
||||||
|
const { rows } = await query<SongRow>("SELECT * FROM songs WHERE mbid = $1", [mbid]);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchSongs(q: string, limit = 20): Promise<SongRow[]> {
|
export async function searchSongs(q: string, limit = 20): Promise<SongRow[]> {
|
||||||
const { rows } = await query<SongRow>(
|
const { rows } = await query<SongRow>(
|
||||||
`SELECT * FROM songs
|
`SELECT * FROM songs
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Git Registry Sync
|
* Git Registry Sync
|
||||||
*
|
*
|
||||||
* Pulls CTP files from a remote GitHub repository (the "community registry")
|
* Pulls CTP files from a remote git repository (the "community registry")
|
||||||
* and upserts them into the local database.
|
* 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:
|
* The registry repo is expected to contain CTP JSON files at:
|
||||||
* <repo-root>/<artist-initial>/<artist-slug>/<recording-mbid>.ctp.json
|
* <repo-root>/<artist-initial>/<artist-slug>/<recording-mbid>.ctp.json
|
||||||
*
|
*
|
||||||
* Configuration:
|
* 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)
|
* REGISTRY_BRANCH — branch to pull from (default: main)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user