diff --git a/index.html b/index.html index f6957a9..67b55ce 100644 --- a/index.html +++ b/index.html @@ -613,10 +613,11 @@ async function venice(path, body, { raw = false } = {}) { const DJ_SCHEMA = { 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: { title: { type: "string" }, tags: { type: "string" }, lyrics: { type: "string" }, bpm: { type: "integer" }, key: { type: "string" }, energy: { type: "number" }, + seconds: { type: "integer" }, meter: { 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`; } 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()} Seed: "${state.seed}" ${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 - key: like "A minor" or "F# major" - 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) - 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 || ""); const m = text.match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't answer in JSON."); 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.lyrics = String(spec.lyrics || "").slice(0, 4000); 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.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; } @@ -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) const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000); 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" }; let res; 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") || ""); spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15"; 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)); return blob; } } 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 started = Date.now(); let hedged = false; @@ -829,6 +837,7 @@ async function openStation(seed, { id, name = "", taste, history, played, first, } } 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 continues from it. The track is copied into the new station so the original stays where it was. */ async function stationFrom(t) { @@ -841,6 +850,7 @@ async function stationFrom(t) { 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`] }, 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); } $("#fromTrack").addEventListener("click", async () => { @@ -1173,6 +1183,7 @@ $("#remix").addEventListener("click", async () => { state.remixOf = cur; toast("Remixing — it goes next."); try { 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); 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";