diff --git a/flow-state/.env.example b/flow-state/.env.example new file mode 100644 index 0000000..d9ca802 --- /dev/null +++ b/flow-state/.env.example @@ -0,0 +1,5 @@ +# Vercel AI Gateway — copy to .env.local and fill VITE_AI_API_KEY +# Get a token at https://vercel.com/dashboard/ai-gateway +VITE_AI_GATEWAY_URL=https://ai-gateway.vercel.sh/v1 +VITE_AI_MODEL=openai/gpt-4o-mini +VITE_AI_API_KEY= diff --git a/flow-state/.gitignore b/flow-state/.gitignore index 042ff31..1c0d0b5 100644 --- a/flow-state/.gitignore +++ b/flow-state/.gitignore @@ -2,6 +2,7 @@ node_modules dist .vite out +.env.local # Scratch probes: one-off harnesses for debugging decode/encode/mux issues. # They are throwaway by nature and were committed once by accident. diff --git a/flow-state/index.html b/flow-state/index.html index 029b189..7b9a9b6 100644 --- a/flow-state/index.html +++ b/flow-state/index.html @@ -59,6 +59,8 @@ + + diff --git a/flow-state/package-lock.json b/flow-state/package-lock.json index 324f2e2..5c258ef 100644 --- a/flow-state/package-lock.json +++ b/flow-state/package-lock.json @@ -9,14 +9,80 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@ai-sdk/openai": "^3.0.112", + "ai": "^6.0.282", "mp4-muxer": "^5.2.2", - "three": "^0.181.1" + "three": "^0.181.1", + "zod": "^4.6.4" }, "devDependencies": { "@playwright/test": "^1.62.1", "vite": "^7.2.2" } }, + "node_modules/@ai-sdk/gateway": { + "version": "3.0.193", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-3.0.193.tgz", + "integrity": "sha512-T2vq1EKPhbFF2Sk2MUFL/RmCg/2mp1LGbCZZSx5vh0M6Sr1EAj3wRr1EnpcwCV3/Imj2q56pT89XLiUFnB+ZgQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.16", + "@ai-sdk/provider-utils": "4.0.51", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "3.0.112", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-3.0.112.tgz", + "integrity": "sha512-0ho3a9CJ+3KCcrPdBQx8fX7/gyUxFHsfs1M6++SpunYk8+pgGOPMskr0GVOOxY+94jsJbuGxksKfMuSYhYHcxw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.16", + "@ai-sdk/provider-utils": "4.0.51" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.16.tgz", + "integrity": "sha512-9Av6kg0t/IN/dcYAAmEJ4B9OPhcEJqwSR+GfHEA8olRkindXpUHadc3p3cyvgFUQFTVm1G5thtsmgZ9yVb2w3A==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "4.0.51", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-4.0.51.tgz", + "integrity": "sha512-ukLTs9x1Xm6lxSIwbJIxYQxbZHmVeIczNntARZrBcOa6pjdBhqeZnATNnJMHa7M9dZ8Ji5NJbpKpvz7o5XyBtg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "3.0.16", + "@standard-schema/spec": "^1.1.0", + "eventsource-parser": "^3.0.8", + "undici": "^6.28.0" + }, + "engines": { + "node": ">=18.17" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", @@ -476,6 +542,15 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@playwright/test": { "version": "1.62.1", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", @@ -842,6 +917,12 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@types/dom-webcodecs": { "version": "0.1.18", "resolved": "https://registry.npmjs.org/@types/dom-webcodecs/-/dom-webcodecs-0.1.18.tgz", @@ -861,6 +942,33 @@ "integrity": "sha512-ggMz8nOygG7d/stpH40WVaNvBwuyYLnrg5Mbyf6bmsj/8+gb6Ei4ZZ9/4PNpcPNTT8th9Q8sM8wYmWGjMWLX/A==", "license": "MIT" }, + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" + } + }, + "node_modules/ai": { + "version": "6.0.282", + "resolved": "https://registry.npmjs.org/ai/-/ai-6.0.282.tgz", + "integrity": "sha512-fdzEzSppVTYlgyW/Gp6tMAFIU88yv7k86x77m5vAJo7hXV31hDJCsAkq/aIpE3tP3k4LjrrLcUANPHcBZ0YWsw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/gateway": "3.0.193", + "@ai-sdk/provider": "3.0.16", + "@ai-sdk/provider-utils": "4.0.51", + "@opentelemetry/api": "^1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -903,6 +1011,15 @@ "@esbuild/win32-x64": "0.28.1" } }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -936,6 +1053,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/mp4-muxer": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/mp4-muxer/-/mp4-muxer-5.2.2.tgz", @@ -1141,6 +1264,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/undici": { + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", @@ -1215,6 +1347,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.4.tgz", + "integrity": "sha512-AXSD6hvGdvRjajG/l1cC+d6IrhH+sjmPKtYeQdJIK8MFJl3LyClzS+o/YsVC+zQZPupAaeH5skwwm8YqYH7BqA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/flow-state/package.json b/flow-state/package.json index 897625e..20ab3c8 100644 --- a/flow-state/package.json +++ b/flow-state/package.json @@ -20,7 +20,10 @@ "vite": "^7.2.2" }, "dependencies": { + "@ai-sdk/openai": "^3.0.112", + "ai": "^6.0.282", "mp4-muxer": "^5.2.2", - "three": "^0.181.1" + "three": "^0.181.1", + "zod": "^4.6.4" } } diff --git a/flow-state/src/llm/budget.js b/flow-state/src/llm/budget.js new file mode 100644 index 0000000..c945a72 --- /dev/null +++ b/flow-state/src/llm/budget.js @@ -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; diff --git a/flow-state/src/llm/config.js b/flow-state/src/llm/config.js new file mode 100644 index 0000000..3b06153 --- /dev/null +++ b/flow-state/src/llm/config.js @@ -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; diff --git a/flow-state/src/llm/llmTweak.js b/flow-state/src/llm/llmTweak.js new file mode 100644 index 0000000..ffc8402 --- /dev/null +++ b/flow-state/src/llm/llmTweak.js @@ -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; + } +} diff --git a/flow-state/src/llm/spec.js b/flow-state/src/llm/spec.js new file mode 100644 index 0000000..f1e9794 --- /dev/null +++ b/flow-state/src/llm/spec.js @@ -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 }.` }, + ]; +} diff --git a/flow-state/src/main.js b/flow-state/src/main.js index 87f5f6c..18612f8 100644 --- a/flow-state/src/main.js +++ b/flow-state/src/main.js @@ -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 = ` +
+localStorage (flow-state:llm). Never committed. Leave API key empty to disable.VITE_AI_GATEWAY_URL / VITE_AI_MODEL / VITE_AI_API_KEY in .env.local. See .env.example.