spoken breaks are read by a TTS voice the DJ keeps (welcome, recap, shout-out, tease); sung breaks stay on the song model; errors name the renderer, not Venice
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 02:14:53 +00:00
1 parent 2c53627bac
commit 5f8afacda9
1 file changed
+20 -8
+20 -8
View File
@@ -605,8 +605,9 @@ async function venice(path, body, { raw = false } = {}) {
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 why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: "Venice turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy at Venice." }[res.status];
throw new Error(why || ("Venice answered " + res.status + (said ? ": " + said : "")));
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();
}
@@ -676,7 +677,18 @@ async function askDJ() {
/* 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. */
const VOICES = ["af_sky", "af_bella", "af_nova", "af_river", "af_sarah", "am_adam", "am_echo", "am_michael", "am_puck", "bf_emma", "bm_george"];
async function speak(spec) {
const text = String(spec.lyrics || "").replace(/\[[^\]]*\]/g, " ").replace(/\s+/g, " ").trim().slice(0, 1800);
const voice = state.djVoice || VOICES[hash32(state.djName || state.seed || "dj") % VOICES.length];
const res = await venice("/speech", { model: "tts-kokoro", input: text, voice, response_format: "mp3", speed: 1 }, { raw: true });
spec.cost = 0.001; spec.renderer = "venice:tts-kokoro"; spec.voice = voice;
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return res.blob();
}
async function renderTrack(spec, onStatus) {
if (spec.spoken && !state.key) 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 {
@@ -738,7 +750,7 @@ const db = {
async function saveStation() {
if (!state.stationId) return;
const specs = state.history.slice(-8).map(({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter }) => ({ id, title, tags, bpm, key, energy, reaction, made, seconds, meter }));
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0), look: state.look || "rings", palette: state.palette || "duo", djName: state.djName || "", welcomed: !!state.welcomed }, "stations");
await db.put({ id: state.stationId, seed: state.seed, name: state.station, taste: state.taste, history: specs, made: +state.stationId.split("-")[1] || Date.now(), last: Date.now(), played: (state.played || 0), look: state.look || "rings", palette: state.palette || "duo", djName: state.djName || "", djVoice: state.djVoice || "", welcomed: !!state.welcomed }, "stations");
}
async function listStations() {
const list = await db.all("stations"); const box = $("#stations"), row = $("#stationRow"); row.innerHTML = "";
@@ -817,8 +829,8 @@ function shareLink() {
return `${location.origin}/?t=${b64u(JSON.stringify(t))}`;
}
function setUrl() { try { history.replaceState(null, "", state.seed ? "/#" + encodeURIComponent(state.seed).replace(/%20/g, "+") : "/"); } catch {} }
async function openStation(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", welcomed = false } = {}) {
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
async function openStation(seed, { id, name = "", taste, history, played, first, look, palette: pal, djName = "", djVoice = "", welcomed = false } = {}) {
state.look = look || "rings"; state.palette = pal || "duo"; state.djName = djName; state.djVoice = djVoice; state.welcomed = welcomed; state.sinceAd = 0; state.breaks = 0;
if (state.stationId !== id) { state.audio.pause(); state.playing = false; setPlayIcon(); state.current = null; state.nextJob = null; state.recent = []; state.pos = -1; state.tape = null; }
state.seed = seed; state.station = name; state.stationId = id || `s-${Date.now()}-${hash32(seed)}`;
state.taste = taste || { like: [], avoid: [], notes: [] }; state.history = history || []; state.played = played || 0;
@@ -909,17 +921,17 @@ async function askBreak(kind) {
Seed: "${state.seed}"${state.station ? `, station "${state.station}"` : ""}.${state.djName ? ` Your DJ name is ${state.djName}.` : ""} 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: ${BREAK_BRIEF[kind]}
${spoken ? "It is SPOKEN: tags must start with \"spoken word, radio DJ voice, talking over\" and then describe a soft instrumental bed in the station's style; lyrics are what the DJ says, 4-7 short lines, natural speech, no rhyming." : "4-8 lines of lyrics."}
${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 && !/spoken/i.test(spec.tags)) spec.tags = "spoken word, radio DJ voice, talking over, " + spec.tags;
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") { state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; state.welcomed = true; saveStation().catch(() => {}); }
if (kind === "welcome") { state.djName = String(spec.title || "").replace(/^(dj|this is)\s+/i, "").slice(0, 40) || "the DJ"; spec.title = state.djName; 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;
}