From 5f8afacda9bb71f09e826eee1f33d3dbd073b49b Mon Sep 17 00:00:00 2001 From: teahubxyz Date: Wed, 21 Oct 2026 02:14:53 +0000 Subject: [PATCH] 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 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01K6897PWcUEwHiqZMcVnNF6 --- index.html | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index ff331bd..55e805f 100644 --- a/index.html +++ b/index.html @@ -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; }