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

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