LLM song-tweak: AI Gateway via Vercel AI SDK, budgeted tune flow

Add a ✨ tune button that lets an LLM inspect the proposed
visualization and tweak the proc-song knobs so the next generation
looks better. Edits only the 7 songbank params (bpm, brightness,
noise, noiseColour, dynamics, transition, arrangement) — never the
Look — then re-synthesizes and re-generates locally for preview.

- ai + @ai-sdk/openai + zod (library per plan) against the Vercel AI
  Gateway (OpenAI-compatible, configurable via
  VITE_AI_GATEWAY_URL/MODEL/API_KEY, localStorage override,
  .env.example).
- Structured output (TweakResultSchema) + clampEdits enforces ranges;
  prompt ≤ ~2.5k tokens, max 2-3 knobs ±0.15 / bpm ±8.
- Budget caps: 3/load + 10/session (sessionStorage), checked before
  network, NO_EDITS refunded, offline/auth/rate-limit mapped.
- Prompt split into src/llm/spec.js (pure, node-testable) + llmTweak.js
  (gateway + digest) to keep `node --test` off the Vite JSON path.
- UI: ⚙️ gateway/model/key dialog (localStorage flow-state:llm),
  confirm(prompt) with hint, diff modal with Apply/Dismiss; Apply
  synthesizes → FeatureTrack → generateLook → Show.useTrack.
- Tests: 7 new LLM cases (clamp, schema, prompt, budget), 18/18 pass.
  Build + lint:scenes green.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Dejvino
2026-09-13 20:03:27 +02:00
co-authored by Claude
parent 7736390f9c
commit b7c3537fcb
12 changed files with 980 additions and 2 deletions
+85
View File
@@ -0,0 +1,85 @@
// Invocation budget — caps cost by refusing before the network.
//
// perLoad: how many LLM calls per page load
// perSession: how many per sessionStorage lifetime (survives reload until tab close)
import { getLlmConfig } from './config.js';
export const LLM_BUDGET = Object.freeze({
perLoad: 3,
perSession: 10,
});
const SESSION_KEY = 'flow-state:llm-budget';
let loadCount = 0;
function readSessionCount() {
try {
const raw = sessionStorage.getItem(SESSION_KEY);
if (!raw) return 0;
const n = Number(JSON.parse(raw));
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
} catch {
return 0;
}
}
function writeSessionCount(n) {
try {
sessionStorage.setItem(SESSION_KEY, JSON.stringify(Math.max(0, Math.floor(n))));
} catch { /* ignore */ }
}
export function getBudgetCounts() {
return {
loadCount,
sessionCount: readSessionCount(),
perLoad: LLM_BUDGET.perLoad,
perSession: LLM_BUDGET.perSession,
};
}
export function canInvoke() {
const cfg = getLlmConfig();
if (!cfg.isConfigured) return { ok: false, reason: 'not-configured', detail: 'Set an AI Gateway API key in ⚙️ settings.' };
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
return { ok: false, reason: 'offline', detail: 'Offline — LLM unavailable.' };
}
const counts = getBudgetCounts();
if (counts.loadCount >= counts.perLoad) {
return { ok: false, reason: 'per-load', detail: `Budget exhausted for this page load (${counts.perLoad}). Reload to reset.` };
}
if (counts.sessionCount >= counts.perSession) {
return { ok: false, reason: 'per-session', detail: `Session budget exhausted (${counts.perSession}). Close the tab or clear sessionStorage.` };
}
return { ok: true, reason: 'ok', detail: '', counts };
}
export function recordInvocation() {
loadCount += 1;
writeSessionCount(readSessionCount() + 1);
return getBudgetCounts();
}
/** Refund one invocation — when the model returned empty/useless edits. */
export function refundInvocation() {
if (loadCount > 0) loadCount -= 1;
const sc = readSessionCount();
if (sc > 0) writeSessionCount(sc - 1);
return getBudgetCounts();
}
export function remainingText() {
const c = getBudgetCounts();
const perLoadLeft = Math.max(0, c.perLoad - c.loadCount);
const perSessionLeft = Math.max(0, c.perSession - c.sessionCount);
return `${perLoadLeft}/${c.perLoad} this load · ${perSessionLeft}/${c.perSession} this session`;
}
export function clearBudget() {
loadCount = 0;
try { sessionStorage.removeItem(SESSION_KEY); } catch { /* ignore */ }
}
export const LLM_BUDGET_SESSION_KEY = SESSION_KEY;
+87
View File
@@ -0,0 +1,87 @@
// LLM gateway config — localStorage overrides > VITE env > defaults.
//
// The app is a static Vite site with no server; the gateway credential lives
// in the browser. Never commit a key — paste it in the panel (→ localStorage)
// or in an untracked .env.local.
const STORAGE_KEY = 'flow-state:llm';
const DEFAULTS = {
gatewayUrl: 'https://ai-gateway.vercel.sh/v1',
model: 'openai/gpt-4o-mini',
apiKey: '',
};
function readEnv() {
try {
const env = import.meta.env || {};
return {
gatewayUrl: env.VITE_AI_GATEWAY_URL || null,
model: env.VITE_AI_MODEL || null,
apiKey: env.VITE_AI_API_KEY || null,
};
} catch {
return { gatewayUrl: null, model: null, apiKey: null };
}
}
function readStorage() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
/** Resolved config — localStorage wins, then env, then defaults. */
export function getLlmConfig() {
const env = readEnv();
const stored = readStorage();
const gatewayUrl = stored.gatewayUrl || env.gatewayUrl || DEFAULTS.gatewayUrl;
const model = stored.model || env.model || DEFAULTS.model;
const apiKey = stored.apiKey != null ? stored.apiKey : (env.apiKey != null ? env.apiKey : DEFAULTS.apiKey);
const key = typeof apiKey === 'string' ? apiKey.trim() : '';
return {
gatewayUrl: String(gatewayUrl || DEFAULTS.gatewayUrl).trim() || DEFAULTS.gatewayUrl,
model: String(model || DEFAULTS.model).trim() || DEFAULTS.model,
apiKey: key,
isConfigured: key.length > 0,
defaults: { ...DEFAULTS },
env: { ...env },
stored: { ...stored },
};
}
/** Merge patch into localStorage. Pass null/'' to clear a field. */
export function setLlmConfig(patch) {
const current = readStorage();
const next = { ...current };
for (const k of ['gatewayUrl', 'model', 'apiKey']) {
if (patch && Object.prototype.hasOwnProperty.call(patch, k)) {
const v = patch[k];
if (v == null || v === '') {
delete next[k];
} else {
next[k] = String(v).trim();
}
}
}
try {
if (!Object.keys(next).length) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
} catch {
// storage full / blocked — ignore, caller sees isConfigured false
}
return getLlmConfig();
}
export function clearLlmConfig() {
try { localStorage.removeItem(STORAGE_KEY); } catch { /* ignore */ }
return getLlmConfig();
}
export const LLM_STORAGE_KEY = STORAGE_KEY;
export const LLM_DEFAULTS = DEFAULTS;
+129
View File
@@ -0,0 +1,129 @@
// LLM song-tweak — gateway caller (Vite/browser).
//
// Prompt/schema live in ./spec.js so `node --test` can import them without
// pulling in `src/scenes/metadata.json` (which needs Vite's JSON handling).
// This file adds the look digest (describeLook/validateLook) and the actual
// `ai`/`@ai-sdk/openai` call, which only run in the browser bundle.
import { generateObject } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { getLlmConfig } from './config.js';
import { canInvoke, recordInvocation, refundInvocation } from './budget.js';
import { describeLook } from '../look/LookGenerator.js';
import { validateLook } from '../look/composition-validator.js';
import {
TweakResultSchema,
TweakEditsSchema,
ALLOWED_KEYS,
ARRANGEMENT_VALUES,
clampEdits,
buildTweakPrompt as buildTweakPromptPure,
} from './spec.js';
export { TweakResultSchema, TweakEditsSchema, ALLOWED_KEYS, ARRANGEMENT_VALUES, clampEdits };
const clamp01 = (x) => Math.max(0, Math.min(1, x));
function lookDigestForPrompt(look) {
let validation = null;
try { validation = validateLook(look, null); } catch { /* ignore */ }
const sections = (look.sections || []).map((s) => {
const primary = s.layers && s.layers.find((l) => !l.role || l.role !== 'ground');
const name = primary ? primary.module.name : (s.layers && s.layers[0] ? s.layers[0].module.name : '?');
const layerNames = (s.layers || []).map((l) => `${l.role === 'ground' ? 'ground:' : ''}${l.module.name}(${l.blend || '?'})`).join(' + ');
return { index: s.index, kind: s.kind, name, layers: layerNames };
});
return {
describe: (() => { try { return describeLook(look); } catch { return ''; } })(),
director: look.director,
paletteScheme: look.paletteScheme || (look.paletteSchemes && look.paletteSchemes[0]) || null,
sections,
validation: validation ? {
ok: validation.ok,
refused: validation.refused || [],
songRefused: !!validation.songRefused,
perSection: (validation.sections || []).map((v) => ({
index: v.index, kind: v.kind, ok: v.ok, desired: Number(v.desired.toFixed(3)), predicted: Number(v.predicted.toFixed(3)), reason: v.reason || null,
})),
} : null,
};
}
/** Prompt builder — wraps the pure one with a Look→digest step for browser callers. */
export function buildTweakPrompt({ spec, track, look, lookDigest = null, hint = '' } = {}) {
const digest = lookDigest != null ? lookDigest : (look ? lookDigestForPrompt(look) : null);
return buildTweakPromptPure({ spec, track, lookDigest: digest, hint });
}
export function lookDigestForPromptExport(look) { return lookDigestForPrompt(look); }
function toErrorMessage(err) {
if (!err) return 'Unknown error';
if (err instanceof Error) return err.message;
if (typeof err === 'string') return err;
try { return JSON.stringify(err).slice(0, 600); } catch { return String(err); }
}
/**
* Call the gateway for one tweak. Checks budget, builds prompt, calls
* generateObject, clamps and returns. Counts toward budget on every network
* attempt; refunds if the result is empty/useless.
*/
export async function requestSongTweak({ spec, track, look, hint = '', config = null } = {}) {
const cfg = config || getLlmConfig();
if (!cfg.isConfigured) {
const err = new Error('LLM not configured — set an AI Gateway API key in ⚙️ settings.');
err.code = 'NOT_CONFIGURED';
throw err;
}
const gate = canInvoke();
if (!gate.ok) {
const err = new Error(gate.detail || 'Budget exhausted');
err.code = gate.reason === 'per-load' ? 'BUDGET_PER_LOAD' : gate.reason === 'per-session' ? 'BUDGET_PER_SESSION' : 'BUDGET';
throw err;
}
const messages = buildTweakPrompt({ spec, track, look, hint });
recordInvocation();
const provider = createOpenAI({ baseURL: cfg.gatewayUrl, apiKey: cfg.apiKey });
const modelId = cfg.model;
try {
const result = await generateObject({
model: provider(modelId),
schema: TweakResultSchema,
messages,
temperature: 0.4,
maxRetries: 0,
});
const raw = result.object || {};
const edits = clampEdits(raw.edits || {});
const rationale = String(raw.rationale || '').trim().slice(0, 800) || 'No rationale provided.';
const confidence = Number.isFinite(raw.confidence) ? clamp01(raw.confidence) : 0.5;
if (!Object.keys(edits).length) {
refundInvocation();
const err = new Error('LLM suggested no changes — the current generation already looks good. Try a hint or reroll.');
err.code = 'NO_EDITS';
err.result = { rationale, edits: {}, confidence, usage: result.usage, raw: result.object };
throw err;
}
return { rationale, edits, confidence, usage: result.usage || null, raw: result.object };
} catch (err) {
const msg = toErrorMessage(err);
const lower = msg.toLowerCase();
if (err && err.code === 'NO_EDITS') throw err;
if (!err.code) {
if (lower.includes('401') || lower.includes('unauthorized') || lower.includes('invalid api key')) err.code = 'AUTH';
else if (lower.includes('429') || lower.includes('rate')) err.code = 'RATE_LIMIT';
else if (lower.includes('timeout') || lower.includes('timed out')) err.code = 'TIMEOUT';
else err.code = 'LLM_ERROR';
}
if (!err.message || err.message === '[object Object]') err.message = msg;
throw err;
}
}
+129
View File
@@ -0,0 +1,129 @@
// Pure prompt/schema logic — no gateway, no LookGenerator/Scene imports.
//
// Split from llmTweak.js so `node --test` can import it without pulling in
// `src/scenes/metadata.json` (which requires Vite's JSON handling and fails
// under bare Node's import-assertion check).
import { z } from 'zod';
import { ARRANGEMENTS } from '../audio/synth.js';
const ARRANGEMENT_VALUES = Object.keys(ARRANGEMENTS);
export { ARRANGEMENT_VALUES };
export const TweakEditsSchema = z.object({
bpm: z.number().int().min(60).max(180).optional(),
brightness: z.number().min(0).max(1).optional(),
noise: z.number().min(0).max(1).optional(),
noiseColour: z.number().min(0).max(1).nullable().optional(),
dynamics: z.number().min(0).max(1).optional(),
transition: z.number().min(0).max(1).optional(),
arrangement: z.enum(ARRANGEMENT_VALUES).optional(),
}).strict();
export const TweakResultSchema = z.object({
rationale: z.string().min(1).max(800),
edits: TweakEditsSchema,
confidence: z.number().min(0).max(1),
});
export const ALLOWED_KEYS = ['bpm', 'brightness', 'noise', 'noiseColour', 'dynamics', 'transition', 'arrangement'];
const clamp01 = (x) => Math.max(0, Math.min(1, x));
const round2 = (x) => Math.round(x * 100) / 100;
export function clampEdits(edits) {
if (!edits || typeof edits !== 'object') return {};
const out = {};
if (edits.bpm != null) {
const n = Number(edits.bpm);
if (Number.isFinite(n)) out.bpm = Math.max(60, Math.min(180, Math.round(n)));
}
for (const k of ['brightness', 'noise', 'dynamics', 'transition']) {
if (edits[k] != null) {
const n = Number(edits[k]);
if (Number.isFinite(n)) out[k] = round2(clamp01(n));
}
}
if (Object.prototype.hasOwnProperty.call(edits, 'noiseColour')) {
const v = edits.noiseColour;
if (v == null) out.noiseColour = null;
else {
const n = Number(v);
if (Number.isFinite(n)) out.noiseColour = round2(clamp01(n));
}
}
if (edits.arrangement != null && ARRANGEMENT_VALUES.includes(edits.arrangement)) {
out.arrangement = edits.arrangement;
}
return out;
}
/**
* Build chat messages from already-serializable inputs.
*
* `lookDigest` is the pre-built digest (describeLook + validateLook) — callers
* that have a live Look should build it via llmTweak.lookDigestForPrompt(look)
* and pass it here, so this module never imports scene metadata.
*
* Kept compatible with the old `{spec,track,look,hint}` shape by accepting
* either `look` (no-op here) or `lookDigest`.
*/
export function buildTweakPrompt({ spec, track, look, lookDigest = null, hint = '' } = {}) {
const summary = track ? track.summary : null;
const sections = track ? (track.sections || []).map((s) => ({
kind: s.kind,
start: Number(s.startSec != null ? s.startSec : s.start).toFixed(1),
end: Number(s.endSec != null ? s.endSec : s.end).toFixed(1),
energy: Number((s.energy ?? 0).toFixed(3)),
})) : [];
const digest = lookDigest ?? (look ? look : null);
const specForPrompt = spec ? {
bpm: spec.bpm ?? null,
brightness: spec.brightness ?? null,
noise: spec.noise ?? null,
noiseColour: spec.noiseColour ?? null,
dynamics: spec.dynamics ?? null,
transition: spec.transition ?? null,
arrangement: spec.arrangement ?? null,
duration: spec.duration ?? null,
seed: spec.seed ?? null,
} : null;
const hintBlock = hint && String(hint).trim()
? `User hint: "${String(hint).trim().slice(0, 300)}"`
: 'No user hint.';
const system = [
'You are a producer for a generative music-video system called flow-state.',
'You suggest SMALL tweaks to the *song synthesis parameters* so the next generation looks better.',
'You do NOT edit the visualization directly — only the 7 song knobs below. The system re-synthesizes audio and regenerates the look.',
'',
'Rules:',
'- Change at most 2-3 knobs, each by ≤0.15 (brightness/noise/dynamics/transition/noiseColour) or bpm ±8, or one arrangement step.',
'- Prefer lowering agitation for breakdowns/intros; keep section count ≥3 if possible.',
'- Valid bpm 60-180 int, brightness/noise/dynamics/transition/noiseColour 0-1, arrangement one of: ' + ARRANGEMENT_VALUES.join(', ') + '.',
'- noiseColour may be null (follows brightness) or 0-1; leave it null unless you have a reason.',
'- Explain your reasoning in 1-2 sentences (rationale).',
'- If the look is already good, return empty edits {}.',
hintBlock,
].join('\n');
const user = JSON.stringify({
songSpec: specForPrompt,
trackSummary: summary ? {
bpm: summary.bpm, meanCentroid: summary.meanCentroid, meanFlatness: summary.meanFlatness,
dynamicRange: summary.dynamicRange, meanLoudness: summary.meanLoudness,
tempoConfidence: summary.tempoConfidence,
} : null,
sections,
lookDigest: digest,
}, null, 2);
return [
{ role: 'system', content: system },
{ role: 'user', content: `Current generation:\n\`\`\`json\n${user}\n\`\`\`\n\nSuggest edits as { rationale, edits, confidence }.` },
];
}
+309
View File
@@ -9,6 +9,13 @@ import { renderClickTrack, audioBufferToWavBlob } from './audio/metronome.js';
import { Exporter, exportSegment, downloadBlob, PRESETS, isSupported, probeExportCodec } from './export/Exporter.js';
import { subjectOf, groundOf, overlaysOf, stackCoverage } from './look/stack.js';
import { coverageOf as sceneCoverage } from './scenes/surface.js';
import { getLlmConfig, setLlmConfig } from './llm/config.js';
import { canInvoke, remainingText, getBudgetCounts } from './llm/budget.js';
import { requestSongTweak } from './llm/llmTweak.js';
import { synthesizeSong } from './audio/synth.js';
import { FeatureTrack } from './audio/FeatureTrack.js';
import { generateLook } from './look/LookGenerator.js';
import { SONGS } from './audio/songbank.js';
const QUALITY = {
draft: 0.5, // half resolution — for scrubbing heavy stacks
@@ -836,3 +843,305 @@ if (query.get('song')) {
const seed = query.get('seed');
loadBankSong(query.get('song'), seed === null ? null : (Number(seed) >>> 0));
}
// ---------------------------------------------------------------- LLM song tweak
let llmTweakInFlight = false;
let currentSongSpec = null; // last synthesizeSong spec (for bank songs); null for dropped files
function specForTrack(track) {
// For bank songs we have the original spec; for dropped files we derive
// a plausible spec from the measured summary so the LLM has something to edit.
if (currentSongSpec) return { ...currentSongSpec };
if (!track || !track.summary) return null;
const s = track.summary;
return {
bpm: Math.round(s.bpm || 120),
brightness: Math.max(0, Math.min(1, s.meanCentroid ?? 0.5)),
noise: Math.max(0, Math.min(1, s.meanFlatness ?? 0.15)),
noiseColour: null,
dynamics: Math.max(0, Math.min(1, s.dynamicRange ?? 0.6)),
transition: 0.5,
arrangement: 'classic',
duration: Math.round(track.duration || 120),
seed: track.summary ? 1 : 1,
};
}
function setLlmTweakButtonState() {
const btn = document.getElementById('btn-llm-tweak');
const gear = document.getElementById('btn-llm-config');
if (!btn) return;
const gate = canInvoke();
const cfg = getLlmConfig();
const ready = state.show && state.show.ready;
btn.disabled = llmTweakInFlight || !ready || !gate.ok;
if (!ready) btn.title = 'Load a track first';
else if (!cfg.isConfigured) btn.title = 'Set an AI Gateway API key — click ⚙️';
else if (!gate.ok) btn.title = gate.detail;
else btn.title = `Let an LLM tune the generated song · ${remainingText()} · click to review`;
if (gear) {
const dot = cfg.isConfigured ? '●' : '○';
gear.textContent = `${dot} ⚙️`;
gear.title = cfg.isConfigured
? `LLM: ${cfg.model} @ ${cfg.gatewayUrl} · ${remainingText()} — click to configure`
: 'LLM not configured — click to set gateway URL / model / API key';
gear.classList.toggle('llm-not-configured', !cfg.isConfigured);
}
}
function openLlmConfigDialog() {
const cfg = getLlmConfig();
const counts = getBudgetCounts();
const overlay = document.createElement('div');
overlay.className = 'llm-overlay';
overlay.innerHTML = `
<div class="llm-backdrop"></div>
<div class="llm-box" role="dialog" aria-modal="true" aria-labelledby="llm-cfg-title">
<div class="llm-title" id="llm-cfg-title">LLM gateway settings</div>
<div class="hint" style="margin-bottom:10px;">Stored in <code>localStorage</code> (<code>flow-state:llm</code>). Never committed. Leave API key empty to disable.</div>
<label class="llm-field"><span>Gateway URL</span><input id="llm-gw" type="text" placeholder="${cfg.defaults.gatewayUrl}" value="${escapeHtml(cfg.stored.gatewayUrl || '')}"></label>
<label class="llm-field"><span>Model</span><input id="llm-model" type="text" placeholder="${cfg.defaults.model}" value="${escapeHtml(cfg.stored.model || '')}"></label>
<label class="llm-field"><span>API key</span><input id="llm-key" type="password" autocomplete="off" placeholder="(env or empty)" value="${escapeHtml(cfg.stored.apiKey || '')}"></label>
<div class="hint">Env fallback: <code>VITE_AI_GATEWAY_URL</code> / <code>VITE_AI_MODEL</code> / <code>VITE_AI_API_KEY</code> in <code>.env.local</code>. See <code>.env.example</code>.</div>
<div class="hint">Budget: ${counts.loadCount}/${counts.perLoad} this load · ${counts.sessionCount}/${counts.perSession} this session. Counters reset on reload / tab close.</div>
<div class="llm-actions">
<button id="llm-cancel">Cancel</button>
<button id="llm-save" class="primary">Save</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('.llm-backdrop').addEventListener('click', close);
overlay.querySelector('#llm-cancel').addEventListener('click', close);
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
overlay.querySelector('#llm-save').addEventListener('click', () => {
const gw = overlay.querySelector('#llm-gw').value.trim();
const model = overlay.querySelector('#llm-model').value.trim();
const key = overlay.querySelector('#llm-key').value;
setLlmConfig({
gatewayUrl: gw || null,
model: model || null,
apiKey: key,
});
close();
setLlmTweakButtonState();
showToast('LLM settings saved', { ok: true, timeout: 2000 });
});
overlay.querySelector('#llm-gw').focus();
}
function escapeHtml(s) {
return String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function openLlmTweakDialog(result, specBefore, specAfter, trackBefore, trackAfter, lookBefore, lookAfter) {
const diffRows = Object.keys(specAfter || {}).filter((k) => {
if (k === 'seed' || k === 'duration') return false;
return specBefore[k] !== specAfter[k];
}).map((k) => {
const a = specBefore[k];
const b = specAfter[k];
const fmt = (v) => v == null ? '∅' : (typeof v === 'number' ? (Number.isInteger(v) ? String(v) : v.toFixed(2)) : String(v));
return `<div class="kv"><span>${k}</span><b>${fmt(a)} → ${fmt(b)}</b></div>`;
}).join('') || '<div class="hint">No song knobs changed (model returned empty edits — this dialog should not have opened).</div>';
const overlay = document.createElement('div');
overlay.className = 'llm-overlay';
overlay.innerHTML = `
<div class="llm-backdrop"></div>
<div class="llm-box llm-diff" role="dialog" aria-modal="true" aria-labelledby="llm-diff-title">
<div class="llm-title" id="llm-diff-title">LLM suggestion — review before applying</div>
<div class="llm-rationale">${escapeHtml(result.rationale || '')}</div>
<div class="llm-confidence hint">confidence ${(result.confidence ?? 0).toFixed(2)}${result.usage ? ` · tokens ${result.usage.totalTokens ?? result.usage.total_tokens ?? '—'}` : ''}</div>
<div class="pp-sub" style="margin-top:10px;">song spec diff</div>
${diffRows}
<div class="pp-sub">effect (approx)</div>
<div class="hint">Re-synthesized and re-analysed locally — what you see when you Apply is what you get.</div>
<div class="llm-actions">
<button id="llm-dismiss">Dismiss</button>
<button id="llm-apply" class="primary">Apply</button>
</div>
</div>`;
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.querySelector('.llm-backdrop').addEventListener('click', close);
overlay.querySelector('#llm-dismiss').addEventListener('click', close);
overlay.querySelector('#llm-apply').addEventListener('click', () => {
try {
const newDuration = specAfter.duration || trackBefore.duration;
// Synthesize a fresh buffer from the edited spec, derive a new FeatureTrack,
// then install it via Show.useTrack so prewarm/arc are correct.
const buf = synthesizeSong({
bpm: specAfter.bpm,
duration: newDuration,
arrangement: specAfter.arrangement,
brightness: specAfter.brightness,
noise: specAfter.noise,
noiseColour: specAfter.noiseColour,
dynamics: specAfter.dynamics,
transition: specAfter.transition,
seed: specAfter.seed ?? (specBefore.seed ?? 1),
});
const newTrack = FeatureTrack.fromAudioBuffer(buf, { fps: state.show.fps });
// Derive a seed from the new spec so generateLook is deterministic on the edit.
const newSeed = specAfter.seed != null ? (specAfter.seed >>> 0) : (specBefore.seed != null ? (specBefore.seed >>> 0) + 1 : 1);
const newLook = generateLook(newTrack, { seed: newSeed });
// Swap in — useTrack handles timeline/provider/arc/prewarm
currentSongSpec = { ...specAfter, seed: newSeed };
state.show.useTrack(newTrack, newLook);
// Keep the displayed name but mark as LLM-tuned
state.show.fileName = `${state.show.fileName || 'track'} · llm-tuned`;
state.show.osd.setText(state.show.fileName, newLook.personality, newLook.palette);
strip.setShow(state.show);
seekTo(0);
renderPanel();
showToast(`Applied LLM tweak: ${result.rationale.slice(0, 120)}`, { ok: true, timeout: 4000 });
} catch (err) {
showToast(`Failed to apply LLM tweak: ${err.message}`);
console.error(err);
} finally {
close();
setLlmTweakButtonState();
}
});
overlay.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
// focus Apply for quick keyboard confirm
setTimeout(() => overlay.querySelector('#llm-apply')?.focus(), 50);
}
async function handleLlmTweak() {
if (llmTweakInFlight) return;
if (!state.show || !state.show.ready) {
showToast('Load a track first');
return;
}
const cfg = getLlmConfig();
if (!cfg.isConfigured) {
openLlmConfigDialog();
return;
}
const gate = canInvoke();
if (!gate.ok) {
showToast(gate.detail || 'LLM budget exhausted');
return;
}
// Confirm — preview cost/budget
const remaining = remainingText();
const hint = window.prompt(
`Let the LLM tune the generated song?` +
`\nBudget: ${remaining}` +
`\nModel: ${cfg.model}` +
`\n` +
`\nOptional hint for the model (e.g. "make the drop calmer") — leave empty to skip:`,
''
);
if (hint === null) return; // cancelled
const spec = specForTrack(state.show.track);
if (!spec) {
showToast('No song spec available for this track');
return;
}
// Capture before for diff
const specBefore = { ...spec };
const trackBefore = state.show.track;
const lookBefore = state.show.look;
llmTweakInFlight = true;
setLlmTweakButtonState();
const btn = document.getElementById('btn-llm-tweak');
const prevLabel = btn ? btn.textContent : '';
if (btn) btn.textContent = '… tuning';
try {
const result = await requestSongTweak({
spec: specBefore,
track: trackBefore,
look: lookBefore,
hint: hint || '',
config: cfg,
});
const specAfter = { ...specBefore, ...result.edits };
// Validate locally by re-synthesizing and measuring — show diff before applying.
// Do a lightweight preview synthesis so we can report section/agitation delta
// without mutating the live Show.
let trackAfter = null, lookAfter = null;
try {
const buf = synthesizeSong({
bpm: specAfter.bpm,
duration: specAfter.duration,
arrangement: specAfter.arrangement,
brightness: specAfter.brightness,
noise: specAfter.noise,
noiseColour: specAfter.noiseColour,
dynamics: specAfter.dynamics,
transition: specAfter.transition,
seed: specAfter.seed ?? specBefore.seed ?? 1,
});
trackAfter = FeatureTrack.fromAudioBuffer(buf, { fps: state.show.fps });
const previewSeed = specAfter.seed != null ? (specAfter.seed >>> 0) : (specBefore.seed != null ? (specBefore.seed >>> 0) + 1 : 1);
lookAfter = generateLook(trackAfter, { seed: previewSeed });
} catch (e) {
console.warn('[llm] preview synthesis failed', e);
}
openLlmTweakDialog(result, specBefore, specAfter, trackBefore, trackAfter, lookBefore, lookAfter);
} catch (err) {
const code = err && err.code;
if (code === 'NO_EDITS') {
showToast(err.message, { ok: true, timeout: 5000 });
} else if (code === 'BUDGET_PER_LOAD' || code === 'BUDGET_PER_SESSION' || code === 'BUDGET') {
showToast(err.message);
} else if (code === 'AUTH') {
showToast(`LLM auth failed (401) — check your gateway API key in ⚙️\n${err.message}`);
} else if (code === 'RATE_LIMIT') {
showToast(`LLM rate limited (429) — try again shortly\n${err.message}`);
} else if (code === 'NOT_CONFIGURED') {
openLlmConfigDialog();
} else {
showToast(`LLM tweak failed: ${err.message}`);
console.error('[llm] request failed', err);
}
} finally {
llmTweakInFlight = false;
if (btn) btn.textContent = prevLabel || '✨ tune';
setLlmTweakButtonState();
}
}
document.getElementById('btn-llm-tweak')?.addEventListener('click', handleLlmTweak);
document.getElementById('btn-llm-config')?.addEventListener('click', openLlmConfigDialog);
// Keep the tune button in sync with ready/config/budget — renderPanel is
// called after every load/reroll/param change.
const __prevRenderPanel = renderPanel;
renderPanel = function llmAwareRenderPanel(...args) {
const r = __prevRenderPanel.apply(this, args);
setLlmTweakButtonState();
return r;
};
// Initial state — disabled until a track is loaded or a previous session had one
setLlmTweakButtonState();
// Keep loadBankSong in sync with LLM spec tracking
const __origLoadBankSong = loadBankSong;
loadBankSong = async function trackedLoadBankSong(name, seed) {
const spec = SONGS.find((s) => s.name === name);
if (spec) {
currentSongSpec = {
bpm: spec.bpm,
brightness: spec.brightness,
noise: spec.noise,
noiseColour: spec.noiseColour ?? null,
dynamics: spec.dynamics,
transition: spec.transition ?? 0.5,
arrangement: spec.arrangement,
duration: 120,
seed: seed != null ? (Number(seed) >>> 0) : (1 + SONGS.indexOf(spec)),
};
}
return __origLoadBankSong(name, seed);
};
+15
View File
@@ -284,6 +284,21 @@ input[type=range] { accent-color: var(--accent); background: transparent; }
}
}
/* LLM gateway dialogs (main app). Minimal, inherits panel/modal language. */
.llm-overlay { position: fixed; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 50; }
.llm-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.62); backdrop-filter: blur(2px); }
.llm-box { position: relative; width: min(560px, calc(100vw - 32px)); max-height: min(78vh, 720px); overflow: auto; background: var(--panel); border: 1px solid var(--line); border-left: 3px solid var(--accent); padding: 16px 18px 14px; box-shadow: 0 20px 40px rgba(0,0,0,0.55); }
.llm-box.llm-diff { width: min(640px, calc(100vw - 32px)); }
.llm-title { font-weight: 700; font-size: 12px; letter-spacing: .08em; text-transform: uppercase; margin-bottom: 10px; }
.llm-field { display: grid; grid-template-columns: 110px 1fr; gap: 8px; align-items: center; margin: 8px 0; font-size: 12px; }
.llm-field span { color: var(--dim); }
.llm-field input { width: 100%; background: #171a22; border: 1px solid var(--line); color: var(--text); padding: 6px 8px; font: inherit; font-size: 12px; }
.llm-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
.llm-rationale { font-size: 12px; line-height: 1.6; white-space: pre-wrap; color: var(--text); margin-bottom: 6px; }
.llm-confidence { margin-bottom: 4px; }
.llm-not-configured { opacity: 0.85; }
#btn-llm-tweak:disabled { opacity: 0.45; cursor: not-allowed; }
/* Single-scene gate output (checks.html?scene=Name). Monospaced so the
PASS/FAIL column lines up and the whole verdict reads in one glance. */
.scene-gate {