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
+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);
};