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:
AJ Avezzano
2026-04-03 18:46:17 -04:00
parent 51f67f0aeb
commit 8b9d72bc9d
22 changed files with 1803 additions and 293 deletions

View File

@@ -1,179 +1,17 @@
/**
* AI-assisted CTP document generation
*
* Takes the results of BPM detection (and optional song metadata) and uses
* Claude to produce a plausible, well-structured CTP document.
*
* Claude is asked to:
* - Divide the song into typical sections (Intro, Verse, Chorus, Bridge…)
* - Assign realistic start bars for each section
* - Note any tempo changes it would expect for the song/genre
* - Return a fully valid CTP 1.0 JSON document
*
* The caller should treat the result as a *draft* — the generated sections
* are educated guesses and should be verified against the recording.
* Re-exports from the Anthropic provider for backwards compatibility.
* @deprecated Import directly from @/lib/analysis/providers/anthropic instead.
*/
export type { AnalysisInput } from "@/lib/analysis/providers";
export { anthropicProvider as default } from "@/lib/analysis/providers/anthropic";
import Anthropic from "@anthropic-ai/sdk";
import type { CTPDocument } from "@/lib/ctp/schema";
// Legacy named export for any remaining callers
import { anthropicProvider } from "@/lib/analysis/providers/anthropic";
import type { AnalysisInput } from "@/lib/analysis/providers";
const client = new Anthropic();
// ─── Input / output types ─────────────────────────────────────────────────────
export interface AnalysisInput {
bpm: number;
duration: number; // seconds
title?: string;
artist?: string;
mbid?: string | null;
contributedBy?: string;
}
// ─── JSON Schema for structured output ───────────────────────────────────────
// Must be strict (no additionalProperties, all required fields present).
const CTP_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["version", "metadata", "count_in", "sections"],
properties: {
version: { type: "string", enum: ["1.0"] },
metadata: {
type: "object",
additionalProperties: false,
required: [
"title", "artist", "mbid", "duration_seconds",
"contributed_by", "verified", "created_at",
],
properties: {
title: { type: "string" },
artist: { type: "string" },
mbid: { type: ["string", "null"] },
duration_seconds: { type: "number" },
contributed_by: { type: "string" },
verified: { type: "boolean" },
created_at: { type: "string" },
},
},
count_in: {
type: "object",
additionalProperties: false,
required: ["enabled", "bars", "use_first_section_tempo"],
properties: {
enabled: { type: "boolean" },
bars: { type: "integer", minimum: 1, maximum: 8 },
use_first_section_tempo: { type: "boolean" },
},
},
sections: {
type: "array",
minItems: 1,
items: {
type: "object",
additionalProperties: false,
required: ["label", "start_bar", "time_signature", "transition"],
// bpm is required for step, bpm_start/bpm_end for ramp — handled via oneOf
// but we keep this schema simple (strict mode) and validate downstream with Zod.
properties: {
label: { type: "string" },
start_bar: { type: "integer", minimum: 1 },
bpm: { type: "number" },
bpm_start: { type: "number" },
bpm_end: { type: "number" },
transition: { type: "string", enum: ["step", "ramp"] },
time_signature: {
type: "object",
additionalProperties: false,
required: ["numerator", "denominator"],
properties: {
numerator: { type: "integer", minimum: 1, maximum: 32 },
denominator: { type: "integer", enum: [1, 2, 4, 8, 16, 32] },
},
},
},
},
},
},
};
// ─── System prompt ────────────────────────────────────────────────────────────
const SYSTEM_PROMPT = `\
You are an expert music producer and session musician assisting cover bands with click tracks.
You will receive automated BPM detection results for a song and must generate a CTP (Click Track Protocol) document describing the song's full tempo map.
CTP rules:
- "version" must be "1.0"
- sections[0].start_bar must be 1
- sections must be sorted by start_bar ascending, with no gaps
- Step sections have a single "bpm" field; ramp sections have "bpm_start" and "bpm_end" (no "bpm" field)
- All BPM values must be between 20 and 400
- time_signature.denominator must be a power of 2 (1, 2, 4, 8, 16, or 32)
- metadata.verified must be false (this is AI-generated, not human-verified)
- metadata.created_at must be an ISO 8601 datetime string
Guidelines for section layout:
- Use typical pop/rock section names: Intro, Verse, Pre-Chorus, Chorus, Bridge, Outro
- Estimate bar counts based on song duration and BPM (bars = duration_seconds × BPM / 60 / beats_per_bar)
- Most songs are 4/4; note any unusual meters if you know the song
- If you know the song has a tempo change (ritardando, double-time feel, key change with tempo shift), model it with a ramp or step section
- If unsure about sections, use a single constant-tempo section covering the whole song
- Use the detected BPM as the primary tempo — do not invent a different BPM unless the song is well-known to have a different tempo
The output is a draft for human review. Add reasonable section structure based on the song's typical arrangement.`;
// ─── Main function ────────────────────────────────────────────────────────────
export async function generateCTPWithAI(input: AnalysisInput): Promise<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,
},
},
export async function generateCTPWithAI(input: AnalysisInput & { contributedBy?: string }) {
return anthropicProvider.generateCTP({
...input,
contributed_by: input.contributed_by ?? input.contributedBy ?? "anonymous",
});
const textBlock = response.content.find((b) => b.type === "text");
if (!textBlock || textBlock.type !== "text") {
throw new Error("Claude did not return a text block");
}
let parsed: unknown;
try {
parsed = JSON.parse(textBlock.text);
} catch {
throw new Error(`Claude returned invalid JSON: ${textBlock.text.slice(0, 200)}`);
}
// Stamp the current timestamp if Claude left a placeholder
const doc = parsed as CTPDocument;
if (!doc.metadata.created_at || doc.metadata.created_at.includes("placeholder")) {
doc.metadata.created_at = new Date().toISOString();
}
return doc;
}

View 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
View 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>;
}

View 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);
}
},
};

View 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;
},
};

View 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)}`
);
},
};

View 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;
},
};

View 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;
}

View File

@@ -149,7 +149,10 @@ function calculateBeats(doc: CTPDocument): Beat[] {
}
} else {
// Last section: generate beats until we exceed duration_seconds
const songEnd = cursor + doc.metadata.duration_seconds;
const countInSeconds = doc.count_in.enabled
? (doc.count_in.bars * firstNumerator * 60) / firstBpm
: 0;
const songEnd = countInSeconds + doc.metadata.duration_seconds;
// Estimate bars remaining
const approxBarsRemaining = Math.ceil(
(doc.metadata.duration_seconds / 60) * section.bpm / numerator + 2

View File

@@ -68,6 +68,11 @@ export interface SongRow {
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[]> {
const { rows } = await query<SongRow>(
`SELECT * FROM songs

View File

@@ -1,14 +1,18 @@
/**
* Git Registry Sync
*
* Pulls CTP files from a remote GitHub repository (the "community registry")
* and upserts them into the local database.
* Pulls CTP files from a remote git repository (the "community registry")
* served over HTTPS. Compatible with any self-hosted git server
* (Gitea, Forgejo, GitLab CE, Gogs, etc.) or any public git host.
*
* The registry repo is expected to contain CTP JSON files at:
* <repo-root>/<artist-initial>/<artist-slug>/<recording-mbid>.ctp.json
*
* Configuration:
* REGISTRY_REPO — GitHub repo URL, e.g. https://github.com/org/clicktrack-registry
* REGISTRY_REPO — HTTPS URL of the registry repo,
* e.g. https://git.yourdomain.com/org/clicktrack-registry
* To authenticate, embed credentials in the URL:
* https://user:token@git.yourdomain.com/org/clicktrack-registry
* REGISTRY_BRANCH — branch to pull from (default: main)
*/