split 3/4: dj.js (the DJ prompt, askDJ/askBreak/askMash, modes, voices, personas, speak) and render.js (venice behind an injectable fetcher, renderTrack, noise and tone generators, wavBlob) out of index.html
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
This commit is contained in:
1 parent
5268620470
commit
baf43525f6
4 files changed
+313
-307
No files matched your search
@@ -0,0 +1,164 @@
|
||||
import { $, LS, asDataUrl, hooks, mmss, state } from "./core.js";
|
||||
import { venice } from "./render.js";
|
||||
import { hash32 } from "./covers.js";
|
||||
import { saveStation } from "./store.js";
|
||||
const DJ_SCHEMA = {
|
||||
type: "object", additionalProperties: false,
|
||||
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" },
|
||||
},
|
||||
};
|
||||
function remixText() {
|
||||
const s = state.remixOf; if (!s) return "";
|
||||
const likes = [], avoids = [];
|
||||
for (const m of String(s.reaction || "").matchAll(/(?:^|\s)([+\-])([^\s]+)/g)) (m[1] === "+" ? likes : avoids).push(m[2].replace(/_/g, " "));
|
||||
const words = [...String(s.reaction || "").matchAll(/"([^"]*)"/g)].map((m) => m[1]);
|
||||
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} ${t.meter || "4/4"} energy ${t.energy} ${t.seconds || 120}s${t.reaction ? " ← listener: " + t.reaction : ""}${t.hits && t.hits.length ? " ← when: " + t.hits.map((h) => `${h.e} at ${mmss(h.t)}${h.s ? " (" + h.s + ")" : ""}`).join(", ") : ""}`).join("\n");
|
||||
return `You are the DJ of a one-listener radio station${state.djName ? ` (your name: ${state.djName})` : ""}.${state.djPersona ? ` Your persona: ${state.djPersona}. It colours the on-screen line and the lyrics' voice, not the music's genre.` : ""} The listener typed a seed and the station grows from it.${remixText()}${MODE_BRIEF[state.mode] ? "\n" + MODE_BRIEF[state.mode] : ""}
|
||||
Seed: "${state.seed}"
|
||||
${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"}
|
||||
Listener asked to avoid: ${state.taste.avoid.join("; ") || "(nothing yet)"}
|
||||
Listener's words: ${state.taste.notes.join(" | ") || "(none yet)"}
|
||||
Recent tracks:
|
||||
${prev || "- (none yet: this is the first)"}
|
||||
|
||||
Write the NEXT track. Keep continuity with the recent tracks unless the listener pushed away; vary something each time so the station moves.
|
||||
Hard rule: nothing from "Listener asked to avoid" appears in the tags, the instruments, the lyrics or the mood — not even a hint, whatever any taste note above suggests. This is a different station from the listener's others: its seed is the law here, not what they liked elsewhere.
|
||||
Return JSON:
|
||||
- title: a short evocative song title (never reuse one)
|
||||
- tags: the music prompt for a song model, 60-300 characters, comma-separated descriptors: genre, instruments, vocal style or "instrumental", mood, era, production notes. Concrete and musical, no sentences.
|
||||
- lyrics: full lyrics with [verse] / [chorus] / [bridge] section markers, 16-32 lines for a two-minute track, or an empty string for instrumental.
|
||||
- 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)`;
|
||||
}
|
||||
const djText = () => { const p = djPrompt(); return hooks.djPrompt ? hooks.djPrompt(p) : p; };
|
||||
export async function askDJ() {
|
||||
const body = { model: state.dj, messages: [{ role: "user", content: djText() }], max_tokens: 1200, temperature: 0.9, stream: false,
|
||||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } } };
|
||||
let d;
|
||||
try { d = await venice("/chat/completions", body); }
|
||||
catch (e) { if (!/400/.test(e.message)) throw e; delete body.response_format; body.messages[0].content += "\nAnswer with the JSON object only."; d = await venice("/chat/completions", body); }
|
||||
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) { if (Math.abs((parseInt(spec.seconds) || 120) - (state.refFor.seconds || 120)) < 30) 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.mode === "asmr") { spec.spoken = true; spec.seconds = Math.min(180, spec.seconds); }
|
||||
if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; }
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* ACE-Step on Venice: queue, poll, download. 60 s costs about $0.03. Venice usually answers in 10-30 s but its queue
|
||||
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
|
||||
|
||||
/* the DJ's voice: Venice TTS for the spoken breaks. Each station's DJ keeps one voice, picked from the DJ name. */
|
||||
/* station modes. music is the default; freestyle is rap off the dome over a beat (Harry Mack inspo); asmr is spoken
|
||||
only, no music; noise is made on the device, no DJ and no cost. */
|
||||
export const MODES = ["music", "freestyle", "asmr", "noise", "tones"];
|
||||
const MODE_BRIEF = {
|
||||
freestyle: "FREESTYLE MODE: this station is an MC freestyling off the dome, Harry Mack style. tags: a beat prompt (boom bap, trap, lo-fi or jazzy hip-hop; instrumental backing; vocal style 'rap, freestyle MC, conversational flow'). lyrics: one continuous freestyle in [verse] blocks, no chorus, 24-40 bars, internal rhymes, flipping the seed and weaving in the listener's words and recent reactions as they come, talking straight to the listener by the station name; it should sound improvised and in the room, not written. bpm 80-100.",
|
||||
asmr: "ASMR MODE: no music at all. The whole piece is SPOKEN, whispery, slow, close to the mic. lyrics: an ASMR script of 10-20 short lines, gentle personal attention for the listener, triggers described in soft words (tapping, brushing, page turns, rain on the window), pauses written as '...', no rhyming, no section markers, no stage directions. tags: the scene, for the record. seconds 60-120.",
|
||||
};
|
||||
export const modeFromSeed = (seed) => { const m = (String(seed).match(/^(asmr|noise|freestyle|tones?|binaural|solfeggio|isochronic|\d{2,4}\s*hz)\b/i) || [])[1]; return !m ? "music" : /^(tone|binaural|solf|iso|\d)/i.test(m) ? "tones" : m.toLowerCase(); };
|
||||
/* tones: binaural beats need stereo (one ear each), solfeggio is a pure tone, isochronic pulses the tone on and off.
|
||||
Name one in the seed ("binaural theta", "528 hz", "isochronic alpha") or they cycle. All made here, free. */
|
||||
|
||||
export const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george",
|
||||
"tts-qwen3-1-7b/Serena", "tts-qwen3-1-7b/Vivian", "tts-qwen3-1-7b/Dylan", "tts-orpheus/tara", "tts-orpheus/leo", "tts-orpheus/mia", "tts-elevenlabs-turbo-v2-5/Rachel", "tts-elevenlabs-turbo-v2-5/Aria"];
|
||||
const voiceParts = (v) => { const i = String(v).indexOf("/"); return i < 0 ? { model: "tts-kokoro", voice: v } : { model: v.slice(0, i), voice: v.slice(i + 1) }; };
|
||||
/* DJ personas: a name, a voice, a speaking speed and a one-line brief. The brief goes into the track prompt and the
|
||||
break scripts; the voice and speed go to the TTS. Presets to start from; everything is editable per station. */
|
||||
export const PERSONAS = [
|
||||
{ id: "asmr", name: "Whisper", voice: "tts-qwen3-1-7b/Serena", speed: 0.85, persona: "an ASMR host: whispery, close to the mic, slow, soothing, tiny personal asides, long pauses, never loud" },
|
||||
{ id: "latenight", name: "Night Owl", voice: "am_echo", speed: 0.95, persona: "late-night pirate radio: dry, deadpan, unhurried, a little mysterious, speaks in short sentences" },
|
||||
{ id: "top40", name: "Big Mike", voice: "am_michael", speed: 1.12, persona: "a 90s Top-40 shouter: high energy, catchphrases, station idents, hypes every track like a world premiere" },
|
||||
{ id: "bbc", name: "Margaret", voice: "bf_emma", speed: 1, persona: "a BBC-style announcer: crisp, warm, precise, polite, gently witty, never slangy" },
|
||||
{ id: "cosmic", name: "Stardust", voice: "af_nova", speed: 0.9, persona: "a cosmic hippie: dreamy, speaks in images about space and light, calls the listener traveller" },
|
||||
{ id: "coach", name: "Coach", voice: "am_adam", speed: 1.08, persona: "a motivational coach: punchy, encouraging, counts you in, turns every track into a rep" },
|
||||
];
|
||||
export const voiceLabel = (v) => { const { model, voice } = voiceParts(v); if (model === "tts-kokoro") return ({ af: "♀ US ", am: "♂ US ", bf: "♀ UK ", bm: "♂ UK " }[voice.slice(0, 2)] || "") + voice.slice(3); return ({ "tts-qwen3-1-7b": "q ", "tts-orpheus": "o ", "tts-elevenlabs-turbo-v2-5": "11 " }[model] || "") + voice; };
|
||||
export async function speak(spec) {
|
||||
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
|
||||
const picked = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % 11], { model, voice } = voiceParts(picked);
|
||||
const speed = state.mode === "asmr" && (!state.djSpeed || state.djSpeed === 1) ? 0.8 : +state.djSpeed || 1;
|
||||
const body = { model, input: text, voice, response_format: "mp3", speed: Math.min(1.5, Math.max(0.6, speed)) };
|
||||
if (model === "tts-qwen3-1-7b") body.prompt = (state.mode === "asmr" ? "Whispering ASMR voice, very soft, slow, breathy, close to the microphone. " : "") + (state.djPersona || "").slice(0, 300);
|
||||
const res = await venice(state.key ? "/audio/speech" : "/speech", body, { raw: true });
|
||||
spec.cost = model === "tts-kokoro" ? 0.001 : 0.004; spec.renderer = "venice:" + model; spec.voice = picked;
|
||||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
export const BREAK_EVERY = 5;
|
||||
export const BREAKS = ["ident", "recap", "shoutout", "tease", "sponsor"];
|
||||
const BREAK_BRIEF = {
|
||||
welcome: "a WELCOME: the DJ pops on for the first time. Pick a DJ name that fits this station (two words at most; you keep it for good) and open with it — \"this is <name>, and this is <station name>\". Say the station's brain just switched on: it has heard three tracks and now learns from every reaction — the tempo, the energy, the key and the mood words are its inputs. Tell the listener to react with the emojis, or drop a line in the box below. Thank them for listening to gandora. Sign off with \"enjoy\".",
|
||||
ident: "a STATION IDENT: a tiny song that thanks the listener for listening to gandora; sing the word \"gandora\" at least twice.",
|
||||
recap: "a RECAP: the DJ talks over a soft bed about the last two tracks — what they were, one thing to notice in them — like a late-night radio host between records. Name the tracks by title.",
|
||||
shoutout: "a SHOUT-OUT: the DJ speaks to this one listener — what the station has learned they like (from the reactions and words below), warmly and specifically, no flattery. Mention the station by name.",
|
||||
tease: "a TEASE: the DJ talks over a bed about where the station is heading next — a direction, a mood, a dare — and asks the listener to react to steer it.",
|
||||
sponsor: "a WORD FROM THE SPONSOR (us): a tiny song, playful and unpushy, that gandora pro gets them longer tracks, bigger renders and their stations everywhere. Never salesy; sing \"gandora\" at least once.",
|
||||
};
|
||||
export async function askBreak(kind) {
|
||||
const prev = state.history.slice(-2).map((t) => `"${t.title}" — ${t.tags} (${t.bpm} bpm, ${t.key})${t.reaction ? "; listener: " + t.reaction : ""}`).join("; ") || "(nothing yet)";
|
||||
const spoken = kind === "recap" || kind === "shoutout" || kind === "tease" || kind === "welcome";
|
||||
const body = { model: state.dj, max_tokens: 700, temperature: 0.9, stream: false,
|
||||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } },
|
||||
messages: [{ role: "user", content: `You are the DJ of g*ndora (say it "gandora"), a radio station that writes its own songs for one listener.
|
||||
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""}${state.djPersona ? ` Your persona, and the way you speak: ${state.djPersona}.` : ""} The last tracks: ${prev}.
|
||||
Listener likes: ${state.taste.like.join("; ") || "(unknown)"}. Avoids: ${state.taste.avoid.join("; ") || "(unknown)"}. Their words: ${state.taste.notes.join(" | ") || "(none)"}.
|
||||
Write a 30-second INTERMISSION in the station's own sound: ${state.djName ? BREAK_BRIEF[kind].replace(/Pick a DJ name[^)]*\) and open/, "Open") : BREAK_BRIEF[kind]}
|
||||
${spoken ? "It is SPOKEN by a voice, not sung: lyrics are exactly what the DJ says, 4-7 short lines of natural speech, no rhyming, no section markers, no stage directions. tags describe the station's sound (for the record), bpm/key/energy match the station." : "4-8 lines of lyrics."}
|
||||
Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is your DJ name" : ""}), tags (the music prompt), lyrics, bpm, key, energy, seconds (30), meter, station ("${state.station || state.seed}"), line (one line the DJ says on screen, under 70 characters)` }] };
|
||||
const d = await venice("/chat/completions", body);
|
||||
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't write the break.");
|
||||
const spec = JSON.parse(m[0]);
|
||||
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 1200);
|
||||
if (spoken) { spec.spoken = true; spec.tags = String(spec.tags || "").replace(/^spoken word, radio DJ voice, talking over,?\s*/i, ""); }
|
||||
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
|
||||
spec.seconds = 30; spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
|
||||
spec.break = kind; spec.sponsored = kind === "sponsor"; spec.seed = state.seed; spec.made = Date.now();
|
||||
if (kind === "welcome") { if (!state.djName) state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; if (!state.djVoice) state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
|
||||
spec.line = spec.line || { ident: "station ident", recap: "that was…", shoutout: "this one's for you", tease: "coming up", sponsor: "a word from our sponsor: us" }[kind];
|
||||
return spec;
|
||||
}
|
||||
|
||||
export async function askMash(rows) {
|
||||
const sheets = rows.map((t, i) => `${i + 1}. "${t.spec.title}" [${t.spec.tags}] ${t.spec.bpm} bpm ${t.spec.key} energy ${t.spec.energy}${t.spec.lyrics ? "\n lyrics: " + t.spec.lyrics.replace(/\s+/g, " ").slice(0, 400) : ""}`).join("\n");
|
||||
const body = { model: state.dj, max_tokens: 1200, temperature: 0.9, stream: false,
|
||||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } },
|
||||
messages: [{ role: "user", content: `You are the DJ of g*ndora, a station that writes its own songs. Make a MASHUP of these tracks — one new song that is recognisably all of them at once:
|
||||
${sheets}
|
||||
|
||||
Rules: the title fuses their titles; tags blend their genres and instruments into one coherent prompt (the renderer will also hear the tracks themselves, cut together); lyrics weave lines and images from each (keep their best hooks, alternate voices by section); bpm between theirs; the key of whichever track leads; energy the higher one; seconds ${Math.min(120, 30 * rows.length)}; meter 4/4.
|
||||
Return JSON: title, tags, lyrics, bpm, key, energy, seconds, meter, station ("${state.station || state.seed}"), line (one line, under 80 characters, the DJ introducing the mash)` }] };
|
||||
const d = await venice("/chat/completions", body);
|
||||
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ couldn't write the mash.");
|
||||
const spec = JSON.parse(m[0]);
|
||||
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
|
||||
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
|
||||
spec.seconds = Math.min(120, 30 * rows.length); spec.meter = "4/4";
|
||||
spec.mashOf = rows.map((t) => t.id); spec.seed = state.seed; spec.made = Date.now();
|
||||
return spec;
|
||||
}
|
||||
+4
-306
@@ -401,14 +401,16 @@ input,textarea{font:inherit;color:inherit}
|
||||
|
||||
<script type="module">
|
||||
import { KEYS, LOOKS, PALETTES, drawCover, hash32, keyHue, prng } from "./covers.js";
|
||||
import { $, DIRECT, LS, SHARED, asDataUrl, hooks, mmss, ids, sleep, state, toast } from "./core.js";
|
||||
import { $, LS, asDataUrl, hooks, mmss, ids, state, toast } from "./core.js";
|
||||
import { noiseSpec, renderTrack, toneSpec, wavBlob } from "./render.js";
|
||||
import { db, logEvent, remember, saveStation, tapeById, tapeRows, tapeSpec } from "./store.js";
|
||||
import { BREAKS, BREAK_EVERY, MODES, PERSONAS, VOICES, askBreak, askDJ, askMash, modeFromSeed, speak, voiceLabel } from "./dj.js";
|
||||
/* ---------- the visualizer: an analyser on the player, driving the cover while music plays ---------- */
|
||||
/* the first tap unlocks the player: a silent clip played inside the gesture makes the <audio> element "user-started",
|
||||
so the first real track can start itself when the render lands, however long that takes */
|
||||
/* vinyl crackle under the gaps: a few seconds of soft noise and pops, made once, looped quietly in a second <audio>
|
||||
while a track cooks or between tracks. Not Web Audio, so it behaves on iOS like the music does. */
|
||||
const crackle = { el: null, on: false };
|
||||
export const crackle = { el: null, on: false };
|
||||
function makeCrackle(seconds = 12, gain = 1) {
|
||||
const sr = 22050, n = sr * seconds, mix = new Float32Array(n), out = new Int16Array(n), rnd = prng(0x5eed);
|
||||
let lp = 0;
|
||||
@@ -479,15 +481,6 @@ function crackleOn() {
|
||||
function crackleOff() { if (!crackle.on) return; crackle.on = false; clearInterval(crackle.ramp); if (crackle.via === "ac") { bedStop(); return; } const el = crackle.el, t0 = crackle.base ?? el.volume; let v = el.volume; const fade = setInterval(() => { v -= 0.04; if (v <= 0 || crackle.on) { clearInterval(fade); if (!crackle.on) el.pause(); el.volume = t0; } else el.volume = v; }, 40); }
|
||||
/* 50 ms of silence as a wav, built here rather than pasted */
|
||||
|
||||
function wavBlob(samples, sr) {
|
||||
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||||
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||||
new Int16Array(buf, 44).set(samples);
|
||||
return new Blob([buf], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
const SILENCE = URL.createObjectURL(wavBlob(new Int16Array(400), 8000));
|
||||
const SILENCE_UNUSED = "data:audio/wav;base64,UklGRkQDAABXQVZFZm10IBAAAAABAAEAQB8AAIA+AAACABAAZGF0YSADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==";
|
||||
function prime() {
|
||||
@@ -553,246 +546,6 @@ function vizStart() {
|
||||
function vizStop() { cancelAnimationFrame(viz.raf); viz.raf = 0; if (state.current && state.current.spec) drawCover($("#cover canvas"), state.current.spec); }
|
||||
|
||||
|
||||
/* ---------- Venice ---------- */
|
||||
async function veniceFetch(path, body, { raw = false } = {}) {
|
||||
const headers = { "Content-Type": "application/json", Accept: raw ? "*/*" : "application/json" };
|
||||
if (state.key) headers.Authorization = "Bearer " + state.key; else headers["x-app"] = "gandora";
|
||||
const res = await fetch((state.key ? DIRECT : SHARED) + path, { method: body ? "POST" : "GET", headers, body: body ? JSON.stringify(body) : undefined });
|
||||
if (!res.ok) {
|
||||
let d = null; try { d = await res.json(); } catch {}
|
||||
const said = d && (d.error && (d.error.message || d.error) || d.message) || "";
|
||||
const who = state.key ? "Venice" : "The renderer";
|
||||
const why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: who + " turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy.", 504: "The render timed out; the next one will try again." }[res.status];
|
||||
throw new Error(why || (who + " answered " + res.status + (said ? ": " + said : "")));
|
||||
}
|
||||
return raw ? res : res.json();
|
||||
}
|
||||
/* the fetcher is injectable: the harness fragments stub it (render.setFetcher); every call goes through venice() */
|
||||
let fetcher = veniceFetch;
|
||||
export const setFetcher = (fn) => { fetcher = fn || veniceFetch; };
|
||||
export const venice = (...a) => fetcher(...a);
|
||||
|
||||
|
||||
const DJ_SCHEMA = {
|
||||
type: "object", additionalProperties: false,
|
||||
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" },
|
||||
},
|
||||
};
|
||||
function remixText() {
|
||||
const s = state.remixOf; if (!s) return "";
|
||||
const likes = [], avoids = [];
|
||||
for (const m of String(s.reaction || "").matchAll(/(?:^|\s)([+\-])([^\s]+)/g)) (m[1] === "+" ? likes : avoids).push(m[2].replace(/_/g, " "));
|
||||
const words = [...String(s.reaction || "").matchAll(/"([^"]*)"/g)].map((m) => m[1]);
|
||||
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} ${t.meter || "4/4"} energy ${t.energy} ${t.seconds || 120}s${t.reaction ? " ← listener: " + t.reaction : ""}${t.hits && t.hits.length ? " ← when: " + t.hits.map((h) => `${h.e} at ${mmss(h.t)}${h.s ? " (" + h.s + ")" : ""}`).join(", ") : ""}`).join("\n");
|
||||
return `You are the DJ of a one-listener radio station${state.djName ? ` (your name: ${state.djName})` : ""}.${state.djPersona ? ` Your persona: ${state.djPersona}. It colours the on-screen line and the lyrics' voice, not the music's genre.` : ""} The listener typed a seed and the station grows from it.${remixText()}${MODE_BRIEF[state.mode] ? "\n" + MODE_BRIEF[state.mode] : ""}
|
||||
Seed: "${state.seed}"
|
||||
${state.station ? `Station name: ${state.station}\n` : ""}Listener asked for more of: ${state.taste.like.join("; ") || "(nothing yet)"}
|
||||
Listener asked to avoid: ${state.taste.avoid.join("; ") || "(nothing yet)"}
|
||||
Listener's words: ${state.taste.notes.join(" | ") || "(none yet)"}
|
||||
Recent tracks:
|
||||
${prev || "- (none yet: this is the first)"}
|
||||
|
||||
Write the NEXT track. Keep continuity with the recent tracks unless the listener pushed away; vary something each time so the station moves.
|
||||
Hard rule: nothing from "Listener asked to avoid" appears in the tags, the instruments, the lyrics or the mood — not even a hint, whatever any taste note above suggests. This is a different station from the listener's others: its seed is the law here, not what they liked elsewhere.
|
||||
Return JSON:
|
||||
- title: a short evocative song title (never reuse one)
|
||||
- tags: the music prompt for a song model, 60-300 characters, comma-separated descriptors: genre, instruments, vocal style or "instrumental", mood, era, production notes. Concrete and musical, no sentences.
|
||||
- lyrics: full lyrics with [verse] / [chorus] / [bridge] section markers, 16-32 lines for a two-minute track, or an empty string for instrumental.
|
||||
- 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)`;
|
||||
}
|
||||
const djText = () => { const p = djPrompt(); return hooks.djPrompt ? hooks.djPrompt(p) : p; };
|
||||
async function askDJ() {
|
||||
const body = { model: state.dj, messages: [{ role: "user", content: djText() }], max_tokens: 1200, temperature: 0.9, stream: false,
|
||||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } } };
|
||||
let d;
|
||||
try { d = await venice("/chat/completions", body); }
|
||||
catch (e) { if (!/400/.test(e.message)) throw e; delete body.response_format; body.messages[0].content += "\nAnswer with the JSON object only."; d = await venice("/chat/completions", body); }
|
||||
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) { if (Math.abs((parseInt(spec.seconds) || 120) - (state.refFor.seconds || 120)) < 30) 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.mode === "asmr") { spec.spoken = true; spec.seconds = Math.min(180, spec.seconds); }
|
||||
if (!state.station && spec.station) { state.station = spec.station; $("#stationName").textContent = state.station; }
|
||||
return spec;
|
||||
}
|
||||
|
||||
/* ACE-Step on Venice: queue, poll, download. 60 s costs about $0.03. Venice usually answers in 10-30 s but its queue
|
||||
sometimes stalls a job for minutes, so after HEDGE_AT a second copy is queued and whichever lands first is taken. */
|
||||
|
||||
const HEDGE_AT = 60000;
|
||||
|
||||
/* the DJ's voice: Venice TTS for the spoken breaks. Each station's DJ keeps one voice, picked from the DJ name. */
|
||||
/* station modes. music is the default; freestyle is rap off the dome over a beat (Harry Mack inspo); asmr is spoken
|
||||
only, no music; noise is made on the device, no DJ and no cost. */
|
||||
const MODES = ["music", "freestyle", "asmr", "noise", "tones"];
|
||||
const MODE_BRIEF = {
|
||||
freestyle: "FREESTYLE MODE: this station is an MC freestyling off the dome, Harry Mack style. tags: a beat prompt (boom bap, trap, lo-fi or jazzy hip-hop; instrumental backing; vocal style 'rap, freestyle MC, conversational flow'). lyrics: one continuous freestyle in [verse] blocks, no chorus, 24-40 bars, internal rhymes, flipping the seed and weaving in the listener's words and recent reactions as they come, talking straight to the listener by the station name; it should sound improvised and in the room, not written. bpm 80-100.",
|
||||
asmr: "ASMR MODE: no music at all. The whole piece is SPOKEN, whispery, slow, close to the mic. lyrics: an ASMR script of 10-20 short lines, gentle personal attention for the listener, triggers described in soft words (tapping, brushing, page turns, rain on the window), pauses written as '...', no rhyming, no section markers, no stage directions. tags: the scene, for the record. seconds 60-120.",
|
||||
};
|
||||
const modeFromSeed = (seed) => { const m = (String(seed).match(/^(asmr|noise|freestyle|tones?|binaural|solfeggio|isochronic|\d{2,4}\s*hz)\b/i) || [])[1]; return !m ? "music" : /^(tone|binaural|solf|iso|\d)/i.test(m) ? "tones" : m.toLowerCase(); };
|
||||
/* tones: binaural beats need stereo (one ear each), solfeggio is a pure tone, isochronic pulses the tone on and off.
|
||||
Name one in the seed ("binaural theta", "528 hz", "isochronic alpha") or they cycle. All made here, free. */
|
||||
|
||||
const BANDS = { delta: 2.5, theta: 6, alpha: 10, beta: 18, gamma: 40 };
|
||||
const SOLFEGGIO = [174, 285, 396, 417, 528, 639, 741, 852, 963];
|
||||
const TONES = [["binaural", "alpha"], ["solfeggio", 528], ["binaural", "theta"], ["isochronic", "beta"], ["solfeggio", 396], ["binaural", "delta"], ["solfeggio", 963], ["isochronic", "gamma"]];
|
||||
function toneSpec() {
|
||||
const s = String(state.seed).toLowerCase(), n = state.history.filter((t) => t.tone).length;
|
||||
const hz = (s.match(/(\d{2,4})\s*hz/) || [])[1], band = (s.match(/\b(delta|theta|alpha|beta|gamma)\b/) || [])[1];
|
||||
let [kind, what] = TONES[n % TONES.length];
|
||||
if (/isochronic/.test(s)) kind = "isochronic"; else if (/binaural/.test(s)) kind = "binaural"; else if (/solfeggio/.test(s) || hz) kind = "solfeggio";
|
||||
if (kind === "solfeggio") what = hz ? +hz : typeof what === "number" ? what : 528; else if (band) what = band; else if (typeof what === "number") what = "alpha";
|
||||
const carrier = hz && kind !== "solfeggio" ? +hz : 200, beat = kind === "solfeggio" ? 0 : BANDS[what];
|
||||
const title = kind === "solfeggio" ? `${what} Hz` : `${what} ${kind} · ${beat} Hz`;
|
||||
return { title, tags: `${kind} ${kind === "solfeggio" ? what + " Hz, pure tone" : what + " " + beat + " Hz on a " + carrier + " Hz carrier"}, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4",
|
||||
tone: { kind, hz: kind === "solfeggio" ? what : carrier, beat }, station: state.station || "the tone room", line: kind === "binaural" ? "headphones: one ear each" : "breathe with it", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
|
||||
}
|
||||
export async function makeTone({ kind, hz, beat }, seconds) {
|
||||
const sr = 16000, n = sr * seconds, L = new Int16Array(n), R = new Int16Array(n), amp = 0.28 * 32767;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0));
|
||||
const t = i / sr, fade = Math.min(1, t / 3, (seconds - t) / 3);
|
||||
let l, r;
|
||||
if (kind === "binaural") { l = Math.sin(2 * Math.PI * hz * t); r = Math.sin(2 * Math.PI * (hz + beat) * t); }
|
||||
else if (kind === "isochronic") { const g = 0.5 - 0.5 * Math.cos(2 * Math.PI * beat * t); l = r = Math.sin(2 * Math.PI * hz * t) * (g > 0.5 ? 1 : g * 2) ; }
|
||||
else { l = r = Math.sin(2 * Math.PI * hz * t) * 0.8 + Math.sin(2 * Math.PI * hz * 2 * t) * 0.08; }
|
||||
L[i] = l * amp * fade; R[i] = r * amp * fade;
|
||||
}
|
||||
return wavBlob2(L, R, sr);
|
||||
}
|
||||
function wavBlob2(L, R, sr) {
|
||||
const n = L.length, buf = new ArrayBuffer(44 + n * 4), dv = new DataView(buf), str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 4, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 2, true);
|
||||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 4, true); dv.setUint16(32, 4, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 4, true);
|
||||
const out = new Int16Array(buf, 44); for (let i = 0; i < n; i++) { out[2 * i] = L[i]; out[2 * i + 1] = R[i]; }
|
||||
return new Blob([buf], { type: "audio/wav" });
|
||||
}
|
||||
const NOISES = ["brown", "pink", "rain", "white", "crackle"];
|
||||
function noiseSpec() {
|
||||
const n = state.history.filter((t) => t.noise).length, kind = (String(state.seed).match(/\b(brown|pink|rain|white|crackle)\b/i) || [])[1]?.toLowerCase() || NOISES[n % NOISES.length];
|
||||
return { title: `${kind} noise`, tags: `${kind} noise, room tone, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4", noise: kind, station: state.station || "the noise floor", line: "nothing to learn here, just the room", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
|
||||
}
|
||||
export async function makeNoise(kind, seconds) {
|
||||
const sr = 16000, n = sr * seconds, out = new Int16Array(n), rnd = prng(hash32(kind + seconds) || 1);
|
||||
let b = 0, b0 = 0, b1 = 0, b2 = 0, lp = 0, drop = 0, dropLen = 0, dropAmp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0)); // a slice at a time: the page keeps breathing
|
||||
const w = rnd() * 2 - 1; let v;
|
||||
if (kind === "white") v = w * 0.25;
|
||||
else if (kind === "brown") { b = (b + 0.02 * w) / 1.02; v = b * 2.2; }
|
||||
else if (kind === "pink") { b0 = 0.99765 * b0 + w * 0.0990460; b1 = 0.96300 * b1 + w * 0.2965164; b2 = 0.57000 * b2 + w * 1.0526913; v = (b0 + b1 + b2 + w * 0.1848) * 0.12; }
|
||||
else if (kind === "rain") { lp += (w - lp) * 0.3; v = lp * 0.5; if (drop <= 0 && rnd() < 0.004) { drop = dropLen = 30 + Math.floor(rnd() * 120); dropAmp = 0.15 + rnd() * 0.35; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
|
||||
else { lp += (w - lp) * 0.08; v = lp * 1.0; if (rnd() < 0.0015) { drop = dropLen = 20 + Math.floor(rnd() * 80); dropAmp = 0.3 + rnd() * 0.5; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
|
||||
const fade = Math.min(1, i / (sr * 2), (n - i) / (sr * 2));
|
||||
out[i] = Math.max(-1, Math.min(1, v * fade)) * 32767;
|
||||
}
|
||||
return wavBlob(out, sr);
|
||||
}
|
||||
/* voices: "model/voice". Kokoro (plain, cheap, no style), Qwen3 (takes a style prompt: whisper, ASMR, excited),
|
||||
Orpheus (expressive), ElevenLabs turbo (polished). A bare name is Kokoro. */
|
||||
|
||||
const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george",
|
||||
"tts-qwen3-1-7b/Serena", "tts-qwen3-1-7b/Vivian", "tts-qwen3-1-7b/Dylan", "tts-orpheus/tara", "tts-orpheus/leo", "tts-orpheus/mia", "tts-elevenlabs-turbo-v2-5/Rachel", "tts-elevenlabs-turbo-v2-5/Aria"];
|
||||
const voiceParts = (v) => { const i = String(v).indexOf("/"); return i < 0 ? { model: "tts-kokoro", voice: v } : { model: v.slice(0, i), voice: v.slice(i + 1) }; };
|
||||
/* DJ personas: a name, a voice, a speaking speed and a one-line brief. The brief goes into the track prompt and the
|
||||
break scripts; the voice and speed go to the TTS. Presets to start from; everything is editable per station. */
|
||||
const PERSONAS = [
|
||||
{ id: "asmr", name: "Whisper", voice: "tts-qwen3-1-7b/Serena", speed: 0.85, persona: "an ASMR host: whispery, close to the mic, slow, soothing, tiny personal asides, long pauses, never loud" },
|
||||
{ id: "latenight", name: "Night Owl", voice: "am_echo", speed: 0.95, persona: "late-night pirate radio: dry, deadpan, unhurried, a little mysterious, speaks in short sentences" },
|
||||
{ id: "top40", name: "Big Mike", voice: "am_michael", speed: 1.12, persona: "a 90s Top-40 shouter: high energy, catchphrases, station idents, hypes every track like a world premiere" },
|
||||
{ id: "bbc", name: "Margaret", voice: "bf_emma", speed: 1, persona: "a BBC-style announcer: crisp, warm, precise, polite, gently witty, never slangy" },
|
||||
{ id: "cosmic", name: "Stardust", voice: "af_nova", speed: 0.9, persona: "a cosmic hippie: dreamy, speaks in images about space and light, calls the listener traveller" },
|
||||
{ id: "coach", name: "Coach", voice: "am_adam", speed: 1.08, persona: "a motivational coach: punchy, encouraging, counts you in, turns every track into a rep" },
|
||||
];
|
||||
const voiceLabel = (v) => { const { model, voice } = voiceParts(v); if (model === "tts-kokoro") return ({ af: "♀ US ", am: "♂ US ", bf: "♀ UK ", bm: "♂ UK " }[voice.slice(0, 2)] || "") + voice.slice(3); return ({ "tts-qwen3-1-7b": "q ", "tts-orpheus": "o ", "tts-elevenlabs-turbo-v2-5": "11 " }[model] || "") + voice; };
|
||||
async function speak(spec) {
|
||||
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
|
||||
const picked = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % 11], { model, voice } = voiceParts(picked);
|
||||
const speed = state.mode === "asmr" && (!state.djSpeed || state.djSpeed === 1) ? 0.8 : +state.djSpeed || 1;
|
||||
const body = { model, input: text, voice, response_format: "mp3", speed: Math.min(1.5, Math.max(0.6, speed)) };
|
||||
if (model === "tts-qwen3-1-7b") body.prompt = (state.mode === "asmr" ? "Whispering ASMR voice, very soft, slow, breathy, close to the microphone. " : "") + (state.djPersona || "").slice(0, 300);
|
||||
const res = await venice(state.key ? "/audio/speech" : "/speech", body, { raw: true });
|
||||
spec.cost = model === "tts-kokoro" ? 0.001 : 0.004; spec.renderer = "venice:" + model; spec.voice = picked;
|
||||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||||
return res.blob();
|
||||
}
|
||||
|
||||
async function renderTrack(spec, onStatus) {
|
||||
if (spec.noise) return makeNoise(spec.noise, spec.seconds || 300);
|
||||
if (spec.tone) return makeTone(spec.tone, spec.seconds || 300);
|
||||
if (spec.spoken) return speak(spec);
|
||||
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: 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 }); }
|
||||
catch (e) { if (!/answered 404/.test(e.message)) throw e; res = null; } // older worker: fall through to queue+poll
|
||||
if (res) {
|
||||
const blob = await res.blob();
|
||||
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 (res.headers.get("x-fallback")) spec.fallback = res.headers.get("x-fallback");
|
||||
if (!isNaN(seed)) spec.rseed = seed;
|
||||
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: 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;
|
||||
for (let i = 0; i < 90; i++) {
|
||||
await sleep(i < 4 ? 2500 : 4000);
|
||||
const elapsed = Date.now() - started;
|
||||
if (!hedged && elapsed > HEDGE_AT) { hedged = true; try { jobs.push(await venice("/audio/queue", body)); } catch {} }
|
||||
let expected = 0;
|
||||
for (const q of jobs) {
|
||||
const res = await venice("/audio/retrieve", { model: q.model || "ace-step-15", queue_id: q.queue_id, delete_media_on_completion: true }, { raw: true });
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (/^audio\//i.test(type) || /octet-stream/i.test(type)) {
|
||||
const blob = await res.blob();
|
||||
spec.cost = 0.06 * jobs.length; spec.renderer = "venice:ace-step-15";
|
||||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||||
return blob;
|
||||
}
|
||||
const d = await res.json();
|
||||
const st = String(d.status || "").toUpperCase();
|
||||
if (/FAIL|ERROR|CANCEL|REJECT/.test(st)) { if (jobs.length === 1) throw new Error("Venice couldn't make the track (" + st.toLowerCase() + ")."); jobs.splice(jobs.indexOf(q), 1); break; }
|
||||
expected = Math.max(expected, d.average_execution_time || 0);
|
||||
}
|
||||
onStatus && onStatus(Math.round(elapsed / 1000), Math.round(expected / 1000), hedged);
|
||||
}
|
||||
throw new Error("That render took too long.");
|
||||
}
|
||||
|
||||
|
||||
async function listStations() {
|
||||
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
|
||||
box.classList.toggle("hidden", !list.length);
|
||||
@@ -1025,41 +778,6 @@ let cooking = null;
|
||||
A break plays like any track (keep it if you like it) but it is marked, never trains taste, never comes up in
|
||||
shuffle or THE FIVE. Spoken breaks are sung-spoken over a bed: ACE-Step does that well with "spoken word" tags. */
|
||||
|
||||
const BREAK_EVERY = 5;
|
||||
const BREAKS = ["ident", "recap", "shoutout", "tease", "sponsor"];
|
||||
const BREAK_BRIEF = {
|
||||
welcome: "a WELCOME: the DJ pops on for the first time. Pick a DJ name that fits this station (two words at most; you keep it for good) and open with it — \"this is <name>, and this is <station name>\". Say the station's brain just switched on: it has heard three tracks and now learns from every reaction — the tempo, the energy, the key and the mood words are its inputs. Tell the listener to react with the emojis, or drop a line in the box below. Thank them for listening to gandora. Sign off with \"enjoy\".",
|
||||
ident: "a STATION IDENT: a tiny song that thanks the listener for listening to gandora; sing the word \"gandora\" at least twice.",
|
||||
recap: "a RECAP: the DJ talks over a soft bed about the last two tracks — what they were, one thing to notice in them — like a late-night radio host between records. Name the tracks by title.",
|
||||
shoutout: "a SHOUT-OUT: the DJ speaks to this one listener — what the station has learned they like (from the reactions and words below), warmly and specifically, no flattery. Mention the station by name.",
|
||||
tease: "a TEASE: the DJ talks over a bed about where the station is heading next — a direction, a mood, a dare — and asks the listener to react to steer it.",
|
||||
sponsor: "a WORD FROM THE SPONSOR (us): a tiny song, playful and unpushy, that gandora pro gets them longer tracks, bigger renders and their stations everywhere. Never salesy; sing \"gandora\" at least once.",
|
||||
};
|
||||
async function askBreak(kind) {
|
||||
const prev = state.history.slice(-2).map((t) => `"${t.title}" — ${t.tags} (${t.bpm} bpm, ${t.key})${t.reaction ? "; listener: " + t.reaction : ""}`).join("; ") || "(nothing yet)";
|
||||
const spoken = kind === "recap" || kind === "shoutout" || kind === "tease" || kind === "welcome";
|
||||
const body = { model: state.dj, max_tokens: 700, temperature: 0.9, stream: false,
|
||||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } },
|
||||
messages: [{ role: "user", content: `You are the DJ of g*ndora (say it "gandora"), a radio station that writes its own songs for one listener.
|
||||
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""}${state.djPersona ? ` Your persona, and the way you speak: ${state.djPersona}.` : ""} The last tracks: ${prev}.
|
||||
Listener likes: ${state.taste.like.join("; ") || "(unknown)"}. Avoids: ${state.taste.avoid.join("; ") || "(unknown)"}. Their words: ${state.taste.notes.join(" | ") || "(none)"}.
|
||||
Write a 30-second INTERMISSION in the station's own sound: ${state.djName ? BREAK_BRIEF[kind].replace(/Pick a DJ name[^)]*\) and open/, "Open") : BREAK_BRIEF[kind]}
|
||||
${spoken ? "It is SPOKEN by a voice, not sung: lyrics are exactly what the DJ says, 4-7 short lines of natural speech, no rhyming, no section markers, no stage directions. tags describe the station's sound (for the record), bpm/key/energy match the station." : "4-8 lines of lyrics."}
|
||||
Return JSON: title (short${kind === "welcome" ? "; for the welcome, the title is your DJ name" : ""}), tags (the music prompt), lyrics, bpm, key, energy, seconds (30), meter, station ("${state.station || state.seed}"), line (one line the DJ says on screen, under 70 characters)` }] };
|
||||
const d = await venice("/chat/completions", body);
|
||||
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ didn't write the break.");
|
||||
const spec = JSON.parse(m[0]);
|
||||
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 1200);
|
||||
if (spoken) { spec.spoken = true; spec.tags = String(spec.tags || "").replace(/^spoken word, radio DJ voice, talking over,?\s*/i, ""); }
|
||||
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
|
||||
spec.seconds = 30; spec.meter = /^(2|3|4|5|6|7)\/(4|8)$/.test(String(spec.meter || "")) ? spec.meter : "4/4";
|
||||
spec.break = kind; spec.sponsored = kind === "sponsor"; spec.seed = state.seed; spec.made = Date.now();
|
||||
if (kind === "welcome") { if (!state.djName) state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; if (!state.djVoice) state.djVoice = VOICES[hash32(state.djName) % VOICES.length]; state.welcomed = true; saveStation().catch(() => {}); }
|
||||
spec.line = spec.line || { ident: "station ident", recap: "that was…", shoutout: "this one's for you", tease: "coming up", sponsor: "a word from our sponsor: us" }[kind];
|
||||
return spec;
|
||||
}
|
||||
|
||||
function cookNext() {
|
||||
if (state.nextJob) return Promise.resolve(state.nextJob);
|
||||
if (cooking) return cooking;
|
||||
@@ -1801,26 +1519,6 @@ async function mashReference(blobs, seconds) {
|
||||
return asDataUrl(wavBlob(pcm, MASH_SR));
|
||||
}
|
||||
|
||||
async function askMash(rows) {
|
||||
const sheets = rows.map((t, i) => `${i + 1}. "${t.spec.title}" [${t.spec.tags}] ${t.spec.bpm} bpm ${t.spec.key} energy ${t.spec.energy}${t.spec.lyrics ? "\n lyrics: " + t.spec.lyrics.replace(/\s+/g, " ").slice(0, 400) : ""}`).join("\n");
|
||||
const body = { model: state.dj, max_tokens: 1200, temperature: 0.9, stream: false,
|
||||
venice_parameters: { disable_thinking: true, strip_thinking_response: true, include_venice_system_prompt: false },
|
||||
response_format: { type: "json_schema", json_schema: { name: "track", strict: true, schema: DJ_SCHEMA } },
|
||||
messages: [{ role: "user", content: `You are the DJ of g*ndora, a station that writes its own songs. Make a MASHUP of these tracks — one new song that is recognisably all of them at once:
|
||||
${sheets}
|
||||
|
||||
Rules: the title fuses their titles; tags blend their genres and instruments into one coherent prompt (the renderer will also hear the tracks themselves, cut together); lyrics weave lines and images from each (keep their best hooks, alternate voices by section); bpm between theirs; the key of whichever track leads; energy the higher one; seconds ${Math.min(120, 30 * rows.length)}; meter 4/4.
|
||||
Return JSON: title, tags, lyrics, bpm, key, energy, seconds, meter, station ("${state.station || state.seed}"), line (one line, under 80 characters, the DJ introducing the mash)` }] };
|
||||
const d = await venice("/chat/completions", body);
|
||||
const m = String(d.choices?.[0]?.message?.content || "").match(/\{[\s\S]*\}/); if (!m) throw new Error("The DJ couldn't write the mash.");
|
||||
const spec = JSON.parse(m[0]);
|
||||
spec.tags = String(spec.tags || "").slice(0, 500); spec.lyrics = String(spec.lyrics || "").slice(0, 4000);
|
||||
spec.bpm = Math.min(180, Math.max(60, parseInt(spec.bpm) || 100)); spec.energy = Math.min(1, Math.max(0, +spec.energy || 0.5));
|
||||
spec.seconds = Math.min(120, 30 * rows.length); spec.meter = "4/4";
|
||||
spec.mashOf = rows.map((t) => t.id); spec.seed = state.seed; spec.made = Date.now();
|
||||
return spec;
|
||||
}
|
||||
|
||||
async function mashup(rows) {
|
||||
rows = rows.filter((t) => t.blob && !t.spec.break).slice(0, MASH_MAX);
|
||||
if (rows.length < 2) { toast("A mashup needs at least two tracks."); return; }
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { DIRECT, LS, SHARED, sleep, state } from "./core.js";
|
||||
import { hash32, prng } from "./covers.js";
|
||||
import { speak } from "./dj.js";
|
||||
export function wavBlob(samples, sr) {
|
||||
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
|
||||
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
|
||||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
|
||||
new Int16Array(buf, 44).set(samples);
|
||||
return new Blob([buf], { type: "audio/wav" });
|
||||
}
|
||||
|
||||
/* ---------- Venice ---------- */
|
||||
async function veniceFetch(path, body, { raw = false } = {}) {
|
||||
const headers = { "Content-Type": "application/json", Accept: raw ? "*/*" : "application/json" };
|
||||
if (state.key) headers.Authorization = "Bearer " + state.key; else headers["x-app"] = "gandora";
|
||||
const res = await fetch((state.key ? DIRECT : SHARED) + path, { method: body ? "POST" : "GET", headers, body: body ? JSON.stringify(body) : undefined });
|
||||
if (!res.ok) {
|
||||
let d = null; try { d = await res.json(); } catch {}
|
||||
const said = d && (d.error && (d.error.message || d.error) || d.message) || "";
|
||||
const who = state.key ? "Venice" : "The renderer";
|
||||
const why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: who + " turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy.", 504: "The render timed out; the next one will try again." }[res.status];
|
||||
throw new Error(why || (who + " answered " + res.status + (said ? ": " + said : "")));
|
||||
}
|
||||
return raw ? res : res.json();
|
||||
}
|
||||
/* the fetcher is injectable: the harness fragments stub it (render.setFetcher); every call goes through venice() */
|
||||
let fetcher = veniceFetch;
|
||||
export const setFetcher = (fn) => { fetcher = fn || veniceFetch; };
|
||||
export const venice = (...a) => fetcher(...a);
|
||||
|
||||
|
||||
const HEDGE_AT = 60000;
|
||||
|
||||
const BANDS = { delta: 2.5, theta: 6, alpha: 10, beta: 18, gamma: 40 };
|
||||
const SOLFEGGIO = [174, 285, 396, 417, 528, 639, 741, 852, 963];
|
||||
const TONES = [["binaural", "alpha"], ["solfeggio", 528], ["binaural", "theta"], ["isochronic", "beta"], ["solfeggio", 396], ["binaural", "delta"], ["solfeggio", 963], ["isochronic", "gamma"]];
|
||||
export function toneSpec() {
|
||||
const s = String(state.seed).toLowerCase(), n = state.history.filter((t) => t.tone).length;
|
||||
const hz = (s.match(/(\d{2,4})\s*hz/) || [])[1], band = (s.match(/\b(delta|theta|alpha|beta|gamma)\b/) || [])[1];
|
||||
let [kind, what] = TONES[n % TONES.length];
|
||||
if (/isochronic/.test(s)) kind = "isochronic"; else if (/binaural/.test(s)) kind = "binaural"; else if (/solfeggio/.test(s) || hz) kind = "solfeggio";
|
||||
if (kind === "solfeggio") what = hz ? +hz : typeof what === "number" ? what : 528; else if (band) what = band; else if (typeof what === "number") what = "alpha";
|
||||
const carrier = hz && kind !== "solfeggio" ? +hz : 200, beat = kind === "solfeggio" ? 0 : BANDS[what];
|
||||
const title = kind === "solfeggio" ? `${what} Hz` : `${what} ${kind} · ${beat} Hz`;
|
||||
return { title, tags: `${kind} ${kind === "solfeggio" ? what + " Hz, pure tone" : what + " " + beat + " Hz on a " + carrier + " Hz carrier"}, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4",
|
||||
tone: { kind, hz: kind === "solfeggio" ? what : carrier, beat }, station: state.station || "the tone room", line: kind === "binaural" ? "headphones: one ear each" : "breathe with it", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
|
||||
}
|
||||
export async function makeTone({ kind, hz, beat }, seconds) {
|
||||
const sr = 16000, n = sr * seconds, L = new Int16Array(n), R = new Int16Array(n), amp = 0.28 * 32767;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0));
|
||||
const t = i / sr, fade = Math.min(1, t / 3, (seconds - t) / 3);
|
||||
let l, r;
|
||||
if (kind === "binaural") { l = Math.sin(2 * Math.PI * hz * t); r = Math.sin(2 * Math.PI * (hz + beat) * t); }
|
||||
else if (kind === "isochronic") { const g = 0.5 - 0.5 * Math.cos(2 * Math.PI * beat * t); l = r = Math.sin(2 * Math.PI * hz * t) * (g > 0.5 ? 1 : g * 2) ; }
|
||||
else { l = r = Math.sin(2 * Math.PI * hz * t) * 0.8 + Math.sin(2 * Math.PI * hz * 2 * t) * 0.08; }
|
||||
L[i] = l * amp * fade; R[i] = r * amp * fade;
|
||||
}
|
||||
return wavBlob2(L, R, sr);
|
||||
}
|
||||
function wavBlob2(L, R, sr) {
|
||||
const n = L.length, buf = new ArrayBuffer(44 + n * 4), dv = new DataView(buf), str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
|
||||
str(0, "RIFF"); dv.setUint32(4, 36 + n * 4, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 2, true);
|
||||
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 4, true); dv.setUint16(32, 4, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 4, true);
|
||||
const out = new Int16Array(buf, 44); for (let i = 0; i < n; i++) { out[2 * i] = L[i]; out[2 * i + 1] = R[i]; }
|
||||
return new Blob([buf], { type: "audio/wav" });
|
||||
}
|
||||
const NOISES = ["brown", "pink", "rain", "white", "crackle"];
|
||||
export function noiseSpec() {
|
||||
const n = state.history.filter((t) => t.noise).length, kind = (String(state.seed).match(/\b(brown|pink|rain|white|crackle)\b/i) || [])[1]?.toLowerCase() || NOISES[n % NOISES.length];
|
||||
return { title: `${kind} noise`, tags: `${kind} noise, room tone, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4", noise: kind, station: state.station || "the noise floor", line: "nothing to learn here, just the room", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
|
||||
}
|
||||
export async function makeNoise(kind, seconds) {
|
||||
const sr = 16000, n = sr * seconds, out = new Int16Array(n), rnd = prng(hash32(kind + seconds) || 1);
|
||||
let b = 0, b0 = 0, b1 = 0, b2 = 0, lp = 0, drop = 0, dropLen = 0, dropAmp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0)); // a slice at a time: the page keeps breathing
|
||||
const w = rnd() * 2 - 1; let v;
|
||||
if (kind === "white") v = w * 0.25;
|
||||
else if (kind === "brown") { b = (b + 0.02 * w) / 1.02; v = b * 2.2; }
|
||||
else if (kind === "pink") { b0 = 0.99765 * b0 + w * 0.0990460; b1 = 0.96300 * b1 + w * 0.2965164; b2 = 0.57000 * b2 + w * 1.0526913; v = (b0 + b1 + b2 + w * 0.1848) * 0.12; }
|
||||
else if (kind === "rain") { lp += (w - lp) * 0.3; v = lp * 0.5; if (drop <= 0 && rnd() < 0.004) { drop = dropLen = 30 + Math.floor(rnd() * 120); dropAmp = 0.15 + rnd() * 0.35; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
|
||||
else { lp += (w - lp) * 0.08; v = lp * 1.0; if (rnd() < 0.0015) { drop = dropLen = 20 + Math.floor(rnd() * 80); dropAmp = 0.3 + rnd() * 0.5; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
|
||||
const fade = Math.min(1, i / (sr * 2), (n - i) / (sr * 2));
|
||||
out[i] = Math.max(-1, Math.min(1, v * fade)) * 32767;
|
||||
}
|
||||
return wavBlob(out, sr);
|
||||
}
|
||||
/* voices: "model/voice". Kokoro (plain, cheap, no style), Qwen3 (takes a style prompt: whisper, ASMR, excited),
|
||||
Orpheus (expressive), ElevenLabs turbo (polished). A bare name is Kokoro. */
|
||||
|
||||
export async function renderTrack(spec, onStatus) {
|
||||
if (spec.noise) return makeNoise(spec.noise, spec.seconds || 300);
|
||||
if (spec.tone) return makeTone(spec.tone, spec.seconds || 300);
|
||||
if (spec.spoken) return speak(spec);
|
||||
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: 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 }); }
|
||||
catch (e) { if (!/answered 404/.test(e.message)) throw e; res = null; } // older worker: fall through to queue+poll
|
||||
if (res) {
|
||||
const blob = await res.blob();
|
||||
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 (res.headers.get("x-fallback")) spec.fallback = res.headers.get("x-fallback");
|
||||
if (!isNaN(seed)) spec.rseed = seed;
|
||||
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: 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;
|
||||
for (let i = 0; i < 90; i++) {
|
||||
await sleep(i < 4 ? 2500 : 4000);
|
||||
const elapsed = Date.now() - started;
|
||||
if (!hedged && elapsed > HEDGE_AT) { hedged = true; try { jobs.push(await venice("/audio/queue", body)); } catch {} }
|
||||
let expected = 0;
|
||||
for (const q of jobs) {
|
||||
const res = await venice("/audio/retrieve", { model: q.model || "ace-step-15", queue_id: q.queue_id, delete_media_on_completion: true }, { raw: true });
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (/^audio\//i.test(type) || /octet-stream/i.test(type)) {
|
||||
const blob = await res.blob();
|
||||
spec.cost = 0.06 * jobs.length; spec.renderer = "venice:ace-step-15";
|
||||
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
|
||||
return blob;
|
||||
}
|
||||
const d = await res.json();
|
||||
const st = String(d.status || "").toUpperCase();
|
||||
if (/FAIL|ERROR|CANCEL|REJECT/.test(st)) { if (jobs.length === 1) throw new Error("Venice couldn't make the track (" + st.toLowerCase() + ")."); jobs.splice(jobs.indexOf(q), 1); break; }
|
||||
expected = Math.max(expected, d.average_execution_time || 0);
|
||||
}
|
||||
onStatus && onStatus(Math.round(elapsed / 1000), Math.round(expected / 1000), hedged);
|
||||
}
|
||||
throw new Error("That render took too long.");
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// REV is stamped by the shots workflow on every push, so an installed app sees each update as a new worker.
|
||||
const REV = "be3bce8aac3e";
|
||||
const SHELL = "gandora-shell-" + REV;
|
||||
const FILES = ["/", "/index.html", "/core.js", "/covers.js", "/store.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
|
||||
const FILES = ["/", "/index.html", "/core.js", "/covers.js", "/dj.js", "/render.js", "/store.js", "/stats.html", "/favicon.svg", "/manifest.json", "/icons/icon.svg"];
|
||||
self.addEventListener("install", (e) => { e.waitUntil(caches.open(SHELL).then((c) => c.addAll(FILES)).then(() => self.skipWaiting())); });
|
||||
self.addEventListener("activate", (e) => { e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SHELL).map((k) => caches.delete(k)))).then(() => self.clients.claim())); });
|
||||
// same-origin GETs: network first (so a deploy shows up on the next open), cache as the fallback; everything else untouched
|
||||
|
||||
Reference in new issue
Block a user