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:
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user