Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG
165 lines
18 KiB
JavaScript
165 lines
18 KiB
JavaScript
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;
|
|
}
|