length and meter are the DJ's to choose (30–300 s, 4/4 3/4 6/8 …), steered by what the listener says; remixes and track-born stations hand the source audio to the renderer as a reference
shots / shots (push) Canceled after 0s

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6
This commit is contained in:
teahubxyzandClaude Fable 5.1 committed 2026-10-21 01:29:18 +00:00
1 parent 31805089f0
commit e89bb9c538
1 file changed
+16 -5
+16 -5
View File
@@ -613,10 +613,11 @@ async function venice(path, body, { raw = false } = {}) {
const DJ_SCHEMA = { const DJ_SCHEMA = {
type: "object", additionalProperties: false, type: "object", additionalProperties: false,
required: ["title", "tags", "lyrics", "bpm", "key", "energy", "station", "line"], required: ["title", "tags", "lyrics", "bpm", "key", "energy", "seconds", "meter", "station", "line"],
properties: { properties: {
title: { type: "string" }, tags: { type: "string" }, lyrics: { type: "string" }, title: { type: "string" }, tags: { type: "string" }, lyrics: { type: "string" },
bpm: { type: "integer" }, key: { type: "string" }, energy: { type: "number" }, bpm: { type: "integer" }, key: { type: "string" }, energy: { type: "number" },
seconds: { type: "integer" }, meter: { type: "string" },
station: { type: "string" }, line: { type: "string" }, station: { type: "string" }, line: { type: "string" },
}, },
}; };
@@ -628,7 +629,7 @@ function remixText() {
return `\nTHIS IS A REMIX REQUEST. Rework this exact track rather than writing a new one:\n- title: ${s.title}\n- tags: ${s.tags}\n- bpm ${s.bpm}, key ${s.key}, energy ${s.energy}\n- lyrics:\n${s.lyrics || "(instrumental)"}\nKeep its bones (hook, mood, most of the lyrics) and apply the listener's changes: ${likes.length ? "more " + likes.join(", ") + "; " : ""}${avoids.length ? "less or no " + avoids.join(", ") + "; " : ""}${words.length ? "in their words: " + words.join(" / ") : ""}${!likes.length && !avoids.length && !words.length ? "a fresh take on the same song (new arrangement, same heart)" : ""}. Title it as a remix of the original (e.g. "${s.title} (remix)" or a named version).\n`; return `\nTHIS IS A REMIX REQUEST. Rework this exact track rather than writing a new one:\n- title: ${s.title}\n- tags: ${s.tags}\n- bpm ${s.bpm}, key ${s.key}, energy ${s.energy}\n- lyrics:\n${s.lyrics || "(instrumental)"}\nKeep its bones (hook, mood, most of the lyrics) and apply the listener's changes: ${likes.length ? "more " + likes.join(", ") + "; " : ""}${avoids.length ? "less or no " + avoids.join(", ") + "; " : ""}${words.length ? "in their words: " + words.join(" / ") : ""}${!likes.length && !avoids.length && !words.length ? "a fresh take on the same song (new arrangement, same heart)" : ""}. Title it as a remix of the original (e.g. "${s.title} (remix)" or a named version).\n`;
} }
function djPrompt() { function djPrompt() {
const prev = state.history.slice(-4).map((t, i) => `- ${t.title} [${t.tags}] ${t.bpm}bpm ${t.key} energy ${t.energy}${t.reaction ? " ← listener: " + t.reaction : ""}`).join("\n"); const prev = state.history.slice(-4).map((t, i) => `- ${t.title} [${t.tags}] ${t.bpm}bpm ${t.key} ${t.meter || "4/4"} energy ${t.energy} ${t.seconds || 120}s${t.reaction ? " ← listener: " + t.reaction : ""}`).join("\n");
return `You are the DJ of a one-listener radio station. The listener typed a seed and the station grows from it.${remixText()} return `You are the DJ of a one-listener radio station. The listener typed a seed and the station grows from it.${remixText()}
Seed: "${state.seed}" Seed: "${state.seed}"
${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"} ${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"}
@@ -645,6 +646,8 @@ Return JSON:
- bpm: integer 60-180 that fits the tags - bpm: integer 60-180 that fits the tags
- key: like "A minor" or "F# major" - key: like "A minor" or "F# major"
- energy: 0 to 1 - energy: 0 to 1
- seconds: how long the track runs, an integer 30-300. Default around 120; go shorter for sketches, jingles and interludes, longer for builds and journeys; if the listener said shorter or longer, move by at least 30 and keep moving that way until they say otherwise. Lyrics should fit the length (about 8 lines per 30 s).
- meter: the time signature, one of "4/4", "3/4", "6/8", "5/4", "7/8"; 4/4 unless the music calls for another
- station: a two or three word station name grown from the seed (keep it stable once chosen) - station: a two or three word station name grown from the seed (keep it stable once chosen)
- line: one line, under 90 characters, the DJ says about this track to the listener (no quotes, no emoji)`; - line: one line, under 90 characters, the DJ says about this track to the listener (no quotes, no emoji)`;
} }
@@ -658,9 +661,12 @@ async function askDJ() {
let text = String(d.choices?.[0]?.message?.content || ""); let text = String(d.choices?.[0]?.message?.content || "");
const m = text.match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't answer in JSON."); const m = text.match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't answer in JSON.");
const spec = JSON.parse(m[0]); const spec = JSON.parse(m[0]);
if (state.refFor && state.refFor.left > 0 && state.refFor.blob) { spec.refAudio = await asDataUrl(state.refFor.blob); if (--state.refFor.left <= 0) state.refFor = null; }
spec.tags = String(spec.tags || "").slice(0, 500); if (spec.tags.length < 10) spec.tags += ", instrumental, warm, lo-fi"; spec.tags = String(spec.tags || "").slice(0, 500); if (spec.tags.length < 10) spec.tags += ", instrumental, warm, lo-fi";
spec.lyrics = String(spec.lyrics || "").slice(0, 4000); spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100));
spec.seconds = Math.min(300, Math.max(30, Math.round((parseInt(spec.seconds) || 120) / 10) * 10));
spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
spec.seed = state.seed; spec.made = Date.now(); spec.look = state.look || "rings"; spec.palette = state.palette || "duo"; spec.seed = state.seed; spec.made = Date.now(); spec.look = state.look || "rings"; spec.palette = state.palette || "duo";
if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; } if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; }
@@ -674,7 +680,8 @@ async function renderTrack(spec, onStatus) {
if (!state.key) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice) if (!state.key) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice)
const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000); const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000);
try { try {
const body = { prompt: spec.tags, lyrics: spec.lyrics || "", duration: 120, bpm: spec.bpm, key: spec.key, const body = { prompt: spec.tags, lyrics: spec.lyrics || "", duration: spec.seconds || 120, bpm: spec.bpm, key: spec.key, timeSignature: spec.meter || "4/4",
reference: spec.refAudio || undefined,
seed: spec.rseed ?? (hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) >>> 1), quality: spec.quality || "turbo" }; seed: spec.rseed ?? (hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) >>> 1), quality: spec.quality || "turbo" };
let res; let res;
try { res = await venice("/render", body, { raw: true }); } try { res = await venice("/render", body, { raw: true }); }
@@ -684,13 +691,14 @@ async function renderTrack(spec, onStatus) {
const cost = parseFloat(res.headers.get("x-cost") || ""), seed = parseInt(res.headers.get("x-seed") || ""); const cost = parseFloat(res.headers.get("x-cost") || ""), seed = parseInt(res.headers.get("x-seed") || "");
spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15"; spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15";
if (!isNaN(seed)) spec.rseed = seed; if (!isNaN(seed)) spec.rseed = seed;
spec.cost = isNaN(cost) ? 0.06 : cost; spec.cost = isNaN(cost) ? 0.0005 * (spec.seconds || 120) : cost;
delete spec.refAudio; // the reference did its job; don't keep megabytes on the sheet
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost)); state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return blob; return blob;
} }
} finally { clearInterval(tick); } } finally { clearInterval(tick); }
} }
const body = { model: "ace-step-15", prompt: spec.tags, duration_seconds: 120, ...(spec.lyrics ? { lyrics_prompt: spec.lyrics } : {}) }; const body = { model: "ace-step-15", prompt: spec.tags, duration_seconds: Math.max(60, Math.min(210, spec.seconds || 120)), ...(spec.lyrics ? { lyrics_prompt: spec.lyrics } : {}) };
const jobs = [await venice("/audio/queue", body)]; const jobs = [await venice("/audio/queue", body)];
const started = Date.now(); const started = Date.now();
let hedged = false; let hedged = false;
@@ -829,6 +837,7 @@ async function openStation(seed, { id, name = "", taste, history, played, first,
} }
} }
function resumeStation(s) { openStation(s.seed, s); } function resumeStation(s) { openStation(s.seed, s); }
const asDataUrl = (blob) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => no(r.error); r.readAsDataURL(blob); });
/* a station grown from one track: the track plays first (no re-render), its tags are what the station likes, the DJ /* a station grown from one track: the track plays first (no re-render), its tags are what the station likes, the DJ
continues from it. The track is copied into the new station so the original stays where it was. */ continues from it. The track is copied into the new station so the original stays where it was. */
async function stationFrom(t) { async function stationFrom(t) {
@@ -841,6 +850,7 @@ async function stationFrom(t) {
state.stationId = null; // so openStation treats it as a change of station state.stationId = null; // so openStation treats it as a change of station
await openStation(seed, { id, name: `${s.title} radio`, taste: { like: [s.tags], avoid: [], notes: [`this station started from "${s.title}": stay close to it, then grow`] }, await openStation(seed, { id, name: `${s.title} radio`, taste: { like: [s.tags], avoid: [], notes: [`this station started from "${s.title}": stay close to it, then grow`] },
history: [copy], played: 0, look: s.look, palette: s.palette }); history: [copy], played: 0, look: s.look, palette: s.palette });
state.refFor = { blob: t.blob, left: 2 }; // the next two tracks are rendered with the source as reference
toast(`New station from “${s.title}”.`, 3000); toast(`New station from “${s.title}”.`, 3000);
} }
$("#fromTrack").addEventListener("click", async () => { $("#fromTrack").addEventListener("click", async () => {
@@ -1173,6 +1183,7 @@ $("#remix").addEventListener("click", async () => {
state.remixOf = cur; toast("Remixing — it goes next."); state.remixOf = cur; toast("Remixing — it goes next.");
try { try {
const spec = await askDJ(); spec.remixOf = cur.id; const spec = await askDJ(); spec.remixOf = cur.id;
if (state.current && state.current.blob) spec.refAudio = await asDataUrl(state.current.blob); // the renderer hears the original
const job = makeJob(spec); const job = makeJob(spec);
if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; // the one already cooking plays after the remix if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; // the one already cooking plays after the remix
state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · REMIX"; state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · REMIX";