diff --git a/.gitea/workflows/asmr.yml b/.gitea/workflows/asmr.yml new file mode 100644 index 0000000..a9bee01 --- /dev/null +++ b/.gitea/workflows/asmr.yml @@ -0,0 +1,33 @@ +name: asmr +# Render the ASMR voice bake-off (asmr/voices.json) into asmr/audio/ through the shared inference worker and commit. +on: + workflow_dispatch: + push: + branches: [main] + paths: [asmr/voices.json, tools/asmr.py, .gitea/workflows/asmr.yml] +permissions: + contents: write +concurrency: + group: asmr + cancel-in-progress: false +jobs: + render: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 1 + - name: ffmpeg + run: command -v ffmpeg || (sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg) || (apt-get update -qq && apt-get install -y -qq ffmpeg) + - name: render + run: git pull -q origin main && python3 tools/asmr.py + - name: commit + run: | + set -eu + git add asmr/audio + if git diff --cached --quiet; then echo "nothing new"; exit 0; fi + git -c user.name='gitea-actions[bot]' -c user.email='gitea-actions[bot]@users.noreply.teahub.xyz' commit -q -m "asmr: renders" + git push -q origin HEAD:main + - name: tell Teahub pages to pull + run: curl -fsS -X POST "https://gandora.yolopreneur.lol/_hook/gandora/site" || true diff --git a/.gitea/workflows/split.yml b/.gitea/workflows/split.yml new file mode 100644 index 0000000..f8a6f97 --- /dev/null +++ b/.gitea/workflows/split.yml @@ -0,0 +1,46 @@ +name: split +# One step of the module split, performed on the runner: rebuild from main's index.html with tools/split.js (the +# line map), apply tools/split-post.py, check, commit to the branch the workflow was dispatched on. Throwaway once +# the split has landed. +on: + workflow_dispatch: + inputs: + step: + description: "comma list of modules to extract (the rest stays inline), or all" + required: true + message: + description: "commit message (first line)" + required: true +permissions: + contents: write +jobs: + split: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: rebuild from main's index.html + env: + STEP_IN: ${{ github.event.inputs.step }} + run: | + set -eu + git show origin/main:index.html > index.html + if [ "$STEP_IN" = all ]; then WRITE=1 node tools/split.js > /dev/null; else STEP="$STEP_IN" WRITE=1 node tools/split.js > /dev/null; fi + python3 tools/split-post.py + node --check $(ls *.js ui/*.js 2>/dev/null | grep -v -e brain.js -e sw.js) + if [ -f covers.js ] && [ -f render.js ]; then node tools/check-pure.mjs; fi + wc -l index.html *.js ui/*.js 2>/dev/null || true + - name: commit + env: + MSG: ${{ github.event.inputs.message }} + run: | + set -eu + git add -A + if git diff --cached --quiet; then echo "nothing changed"; exit 0; fi + git -c user.name='Claude Fable 5.1' -c user.email='noreply@anthropic.com' commit -q -m "$MSG" -m "Co-Authored-By: Claude Fable 5.1 " -m "Claude-Session: https://claude.ai/code/session_01Dhu3iHseRewQ2WpBSVLokG" + git push -q origin "HEAD:${GITHUB_REF_NAME}" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3fccbf6..40cb7e9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,7 +1,32 @@ # How g*ndora fits together -One page, one script, one worker. This is the map: what each part owns, how a track moves through the system, and -the invariants that the playback code relies on. Keep it current when you change any of them. +One page, a handful of ES modules, one worker. This is the map: what each part owns, how a track moves through the +system, and the invariants that the playback code relies on. Keep it current when you change any of them. + +## File map + +Plain ` + + diff --git a/asmr/voices.json b/asmr/voices.json new file mode 100644 index 0000000..2ba186b --- /dev/null +++ b/asmr/voices.json @@ -0,0 +1,30 @@ +{ + "note": "One script, many voices. tools/asmr.py renders each entry through inference.yolopreneur.lol /v1/speech into asmr/audio/.mp3. Edit and push to re-render (existing files are kept; delete one to redo it).", + "script": "Hey... you're still up. ... Come here, closer. ... Just breathe with me for a second. ... In... ... and out. ... Can you hear that? The rain's started. ... I'll stay right here... until you fall asleep. ... Goodnight.", + "style": "Very soft ASMR whisper, breathy, intimate, close to the microphone, slow and gentle, long pauses.", + "pricing_note": "USD per 1M input characters, from Venice /models?type=tts on 2026-10-21", + "pricing": {"tts-kokoro": 3.5, "tts-inworld-1-5-max": 12.5, "tts-xai-v1": 18.75, "tts-gradium-v1": 47.5, "tts-chatterbox-hd": 50, "tts-orpheus": 62.5, "tts-elevenlabs-turbo-v2-5": 62.5, "tts-qwen3-1-7b": 112.5, "tts-minimax-speech-02-hd": 125, "tts-gemini-3-1-flash": 187.5}, + "voices": [ + {"id": "kokoro-nicole", "family": "Kokoro", "model": "tts-kokoro", "voice": "af_nicole", "speed": 0.8, "blurb": "Baseline. What gandora used. Nicole is Kokoro's breathiest voice."}, + {"id": "kokoro-sky", "family": "Kokoro", "model": "tts-kokoro", "voice": "af_sky", "speed": 0.8}, + {"id": "orpheus-tara", "family": "Orpheus", "model": "tts-orpheus", "voice": "tara", "speed": 0.85, "format": "wav", "tags": true, "blurb": "Emotive tags () in the text."}, + {"id": "orpheus-mia", "family": "Orpheus", "model": "tts-orpheus", "voice": "mia", "speed": 0.85, "format": "wav", "tags": true}, + {"id": "qwen3-serena", "family": "Qwen3 TTS", "model": "tts-qwen3-1-7b", "voice": "Serena", "style": true, "blurb": "Takes a written style direction: the whisper prompt above."}, + {"id": "qwen3-vivian", "family": "Qwen3 TTS", "model": "tts-qwen3-1-7b", "voice": "Vivian", "style": true}, + {"id": "chatterbox-aurora", "family": "Chatterbox HD", "model": "tts-chatterbox-hd", "voice": "Aurora", "speed": 0.85, "format": "wav", "blurb": "Expressive open model."}, + {"id": "chatterbox-siobhan", "family": "Chatterbox HD", "model": "tts-chatterbox-hd", "voice": "Siobhan", "speed": 0.85, "format": "wav"}, + {"id": "elevenlabs-lily", "family": "ElevenLabs Turbo 2.5", "model": "tts-elevenlabs-turbo-v2-5", "voice": "Lily", "speed": 0.85, "blurb": "Polished, but no whisper tags in this generation."}, + {"id": "elevenlabs-charlotte", "family": "ElevenLabs Turbo 2.5", "model": "tts-elevenlabs-turbo-v2-5", "voice": "Charlotte", "speed": 0.85}, + {"id": "inworld-olivia", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Olivia", "speed": 0.85, "format": "wav", "lead": "[whispering] ", "blurb": "Top of the quality charts. Script led with a [whispering] tag."}, + {"id": "inworld-luna", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Luna", "speed": 0.85, "format": "wav", "lead": "[whispering] "}, + {"id": "inworld-luna-slow", "family": "Inworld 1.5 Max", "model": "tts-inworld-1-5-max", "voice": "Luna", "speed": 0.77, "format": "wav", "lead": "[whispering] ", "label": "Luna · slow render"}, + {"id": "gemini-aoede-w", "family": "Gemini 3.1 Flash TTS", "model": "tts-gemini-3-1-flash", "voice": "Aoede", "lead": "Whisper this softly and slowly, close to the mic, like ASMR: ", "blurb": "Steered by a spoken-language direction in front of the script."}, + {"id": "gemini-leda-w", "family": "Gemini 3.1 Flash TTS", "model": "tts-gemini-3-1-flash", "voice": "Leda", "lead": "Whisper this softly and slowly, close to the mic, like ASMR: "}, + {"id": "minimax-calmwoman", "family": "MiniMax Speech 02 HD", "model": "tts-minimax-speech-02-hd", "voice": "CalmWoman", "speed": 0.85, "blurb": "Calm and sweet presets."}, + {"id": "minimax-sweetgirl", "family": "MiniMax Speech 02 HD", "model": "tts-minimax-speech-02-hd", "voice": "SweetGirl", "speed": 0.85}, + {"id": "xai-eve-w", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "eve", "speed": 0.85, "lead": "", "tail": "", "blurb": "Wrapped in a whisper tag."}, + {"id": "xai-eve-slow", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "eve", "speed": 0.77, "lead": "", "tail": "", "label": "eve · slow render"}, + {"id": "xai-ara-w", "family": "Grok TTS", "model": "tts-xai-v1", "voice": "ara", "speed": 0.85, "lead": "", "tail": ""} + ], + "next": [] +} diff --git a/brain.js b/brain.js index 0c42e97..0f0f684 100644 --- a/brain.js +++ b/brain.js @@ -7,12 +7,14 @@ ============================================================ */ /* ---------- signals: every reaction and every play, as one signed weight per track ---------- */ +const NEG = /\b(no|not|less|stop|without|drop|enough|never|skip|hate|too much|lose|kill|cut)\b/i; function reactionWeight(spec, row) { const r = String(spec.reaction || ""); let w = 0; for (const ch of r.replace(/"[^"]*"/g, "")) { if ("🔥❤️⚡🎸🎹🌧️".includes(ch) && ch !== "️") w += 1; if ("🚫😴".includes(ch)) w -= 1; } if (/\[skipped\]/.test(r)) w -= 1.5; - if (/"[^"]*"/.test(r)) w += 0.3; // words mean engagement, mildly warm + // typed words: engagement, mildly warm, unless they say no ("no violin", "less strings", "stop the classical") + for (const m of r.matchAll(/"([^"]*)"/g)) w += NEG.test(m[1]) ? -0.6 : 0.3; const plays = (row && row.plays) || spec.plays || 0; if (plays > 1) w += Math.log2(plays) * 0.6; // on repeat: 4 plays ≈ a 🔥, 100 ≈ four of them if (row && row.kept) w += 1.5; @@ -119,10 +121,13 @@ function tasteText() { const p0 = net.predict({ ...base, tags: "" }); const scored = [...vocab.keys()].filter((w) => vocab.get(w) >= 2).map((w) => [w, net.predict({ ...base, tags: w }) - p0]).sort((a, b) => b[1] - a[1]); const likes = scored.slice(0, 6).filter((s) => s[1] > 0.02).map((s) => s[0]).join(", "), dislikes = scored.slice(-4).filter((s) => s[1] < -0.02).map((s) => s[0]).join(", "); - const which = brains.station ? "this station" : "all their stations"; - return `Taste model (a small net trained on the listener's reactions and play counts across ${which}, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` + - (likes ? ` Words it warms to: ${likes}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") + - ` Aim near the likely region and lean on the warm words unless the listener's own words say otherwise; keep some variety.`; + // never recommend what this station was told to avoid; a young station borrows only the region from the others, not their words + const avoidWords = new Set([...(state.taste.avoid || []), ...(state.taste.notes || []).filter((n) => NEG.test(n))].flatMap((s) => tokens(s))); + const likesList = scored.slice(0, 8).filter((s) => s[1] > 0.02 && !avoidWords.has(s[0])).slice(0, 6).map((s) => s[0]).join(", "); + if (!brains.station) return `Taste prior (from the listener's other stations, ${net.n} signals; a faint hint only — this station's seed and the listener's own words come first): they tend to land around ${top}; less so ${low}.`; + return `Taste model (a small net trained on the listener's reactions and play counts on this station, ${net.n} signals): most likely to land: ${top}. Least likely: ${low}.` + + (likesList ? ` Words it warms to: ${likesList}.` : "") + (dislikes ? ` Words it cools on: ${dislikes}.` : "") + + ` Aim near the likely region; keep some variety. The listener's explicit asks above and below outrank this model.`; } /* the page's hooks (index.html › hooks): the taste text rides into the DJ's prompt, and a station opening retrains */ hooks.djPrompt = (base) => { diff --git a/core.js b/core.js new file mode 100644 index 0000000..61288d3 --- /dev/null +++ b/core.js @@ -0,0 +1,39 @@ +/* ============================================================ + gandora — a generative station. + seed → DJ (Venice chat) → ACE-Step (Venice /audio/queue) → player. + Every cover is drawn from the song's own settings: hue from key, + rings from BPM, density from energy, symmetry from the seed. + ============================================================ */ +export const DIRECT = "https://api.venice.ai/api/v1"; // with a personal key +export const SHARED = "https://inference.yolopreneur.lol/v1"; // without one: the factory's worker, quota'd per person +export const $ = (s) => document.querySelector(s); +export const LS = { key: "gandora.key", dj: "gandora.dj", cost: "gandora.cost" }; +export const state = { + key: localStorage.getItem(LS.key) || "", + dj: localStorage.getItem(LS.dj) || "claude-haiku-5-5", + seed: "", station: "", stationId: "", sinceAd: 0, + taste: { like: [], avoid: [], notes: [] }, // what the listener has told the station + history: [], // tracks played this session (specs) + current: null, nextJob: null, playing: false, + recent: [], pos: -1, // ids of tracks played this session, and where the player stands in them + audio: Object.assign(new Audio(), { volume: Math.min(1, Math.max(0, parseFloat(localStorage.getItem("gandora.vol") || "1"))) }), cost: parseFloat(localStorage.getItem(LS.cost) || "0"), +}; +state.audio.preload = "auto"; +/* hooks: the seams something loaded after the page (brain.js) plugs into. Each is null or a function; the page calls + them at the named moments. Assign, don't override the page's functions. */ +export const hooks = { + djPrompt: null, // (prompt) => prompt the DJ's prompt, to add to (brain.js adds the taste text) + jobMade: null, // (spec) a sheet about to be rendered (brain.js writes spec.guess) + stationOpened: null, // (seed, opts) after openStation finished + reacted: null, // (emoji, text) after a reaction was taken in +}; + +/* ---------- small utils ---------- */ +export const toast = (m, ms = 2400) => { const t = $("#toast"); t.textContent = m; t.classList.add("on"); clearTimeout(toast.h); toast.h = setTimeout(() => t.classList.remove("on"), ms); }; +export const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export const asDataUrl = (blob) => new Promise((ok, no) => { const r = new FileReader(); r.onload = () => ok(r.result); r.onerror = () => no(r.error); r.readAsDataURL(blob); }); + +export const ids = { seq: 0 }; // job ids; brain.js reaches it as window.seq + +export const mmss = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`; diff --git a/covers.js b/covers.js new file mode 100644 index 0000000..d0cdcec --- /dev/null +++ b/covers.js @@ -0,0 +1,200 @@ +export function hash32(str) { let h = 2166136261 >>> 0; for (const c of String(str)) { h ^= c.codePointAt(0); h = Math.imul(h, 16777619) >>> 0; } return h; } +export function prng(seed) { let a = seed >>> 0; return () => { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } +export const KEYS = ["C","G","D","A","E","B","F#","C#","G#","D#","A#","F"]; // circle of fifths → hue wheel +export function keyHue(k) { + const m = /^([A-G])(#|b)?/.exec(String(k || "C").trim()); if (!m) return 40; + let name = m[1] + (m[2] === "#" ? "#" : ""); + if (m[2] === "b") { const flat = { Db:"C#", Eb:"D#", Gb:"F#", Ab:"G#", Bb:"A#", Cb:"B", Fb:"E" }; name = flat[m[1] + "b"] || m[1]; } + const i = KEYS.indexOf(name); return (i < 0 ? 0 : i) * 30; +} + +/* ---------- the cover: a function of the song ---------- */ +// pulse: live signal from the analyser { bass, mid, high, t } in 0..1 (and seconds); without it the cover is still. +// The ground and grain depend only on the song, so they are drawn once per (song, size) and cached; the rings and +// the seed mark are redrawn on top every frame when the music is playing. +const groundCache = new Map(); +/* palette dial: mono (one hue, its shades), duo (the key's hue and a complement), tri (a triad) */ +export const PALETTES = ["mono", "duo", "tri"]; +function palette(spec) { + const energy = Math.min(1, Math.max(0, +spec.energy || 0.5)), minor = /m(in|inor)?\b/i.test(spec.key || "") && !/maj/i.test(spec.key || ""); + const hue = keyHue(spec.key), mode = spec.palette || "duo"; + const hue2 = mode === "mono" ? hue : mode === "tri" ? (hue + 120) % 360 : (hue + (minor ? 200 : 150)) % 360; + const hue3 = mode === "tri" ? (hue + 240) % 360 : hue2; + return { energy, minor, hue, hue2, hue3, mode, sat: 55 + energy * 35, light: minor ? 36 : 52, bpm: +spec.bpm || 100 }; +} +function drawGround(W, H, spec, lit) { + const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`; + if (groundCache.has(key)) return groundCache.get(key); + const off = document.createElement("canvas"); off.width = W; off.height = H; + const ctx = off.getContext("2d"), cx = W / 2, cy = H / 2, p = palette(spec); + const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) ^ 0x9e37); + const g = ctx.createLinearGradient(0, 0, 0, H); + g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .18}%)`); + ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); + ctx.globalCompositeOperation = "lighter"; + const spread = W * .46, dots = Math.floor(400 + p.energy * 2600); + for (let i = 0; i < dots; i++) { + const a = rnd() * Math.PI * 2, d = Math.sqrt(rnd()) * spread * 1.05; + ctx.fillStyle = `hsl(${i % 3 ? p.hue2 : p.hue3} ${p.sat}% ${p.light + (p.mode === "mono" ? 38 : 30)}% / ${rnd() * 0.5 * lit})`; + ctx.fillRect(cx + Math.cos(a) * d, cy + Math.sin(a) * d, (W / 380) * (0.8 + rnd() * 1.6), (W / 380) * (0.8 + rnd() * 1.6)); + } + if (groundCache.size > 24) groundCache.delete(groundCache.keys().next().value); + groundCache.set(key, off); + return off; +} +/* looks: every station has one; the seed mark in the centre is the same whichever look draws around it */ +export const LOOKS = ["rings", "chord", "attractor"]; +export function drawCover(canvas, spec, opts = {}) { + const look = spec.look || opts.look || "rings"; + if (look === "chord") drawChord(canvas, spec, opts); + else if (look === "attractor") drawAttractor(canvas, spec, opts); + else drawRings(canvas, spec, opts); + if (spec.break) drawOnAir(canvas, spec, opts); +} +/* an intermission wears the station's cover in monochrome, with the asterisk as the mark and an ON AIR ring */ +function drawOnAir(canvas, spec, { pulse = null } = {}) { + const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2; + const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0; + const ink = cssVar("--ink"), ground = cssVar("--bg"); + ctx.save(); + ctx.globalCompositeOperation = "saturation"; ctx.fillStyle = "#808080"; ctx.fillRect(0, 0, W, H); // the colour drains out + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = ground; ctx.globalAlpha = 0.22; ctx.fillRect(0, 0, W, H); ctx.globalAlpha = 1; + // the asterisk, square ends, over the petal mark + ctx.translate(cx, cy); ctx.scale(1 + 0.12 * bass, 1 + 0.12 * bass); + ctx.fillStyle = ground; ctx.beginPath(); ctx.arc(0, 0, W * 0.125, 0, Math.PI * 2); ctx.fill(); + ctx.strokeStyle = ink; ctx.lineWidth = W * 0.026; ctx.lineCap = "butt"; + for (let k = 0; k < 3; k++) { const a = Math.PI / 2 + k * Math.PI / 3; ctx.beginPath(); ctx.moveTo(-Math.cos(a) * W * 0.07, -Math.sin(a) * W * 0.07); ctx.lineTo(Math.cos(a) * W * 0.07, Math.sin(a) * W * 0.07); ctx.stroke(); } + ctx.setTransform(1, 0, 0, 1, 0, 0); + // the ring: dashed, turning slowly, lettered ON AIR · kind + ctx.save(); ctx.translate(cx, cy); ctx.rotate(tm * 0.15); + ctx.strokeStyle = ink; ctx.globalAlpha = 0.7; ctx.lineWidth = W * 0.006; ctx.setLineDash([W * 0.02, W * 0.014]); + ctx.beginPath(); ctx.arc(0, 0, W * 0.43, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); + const label = `ON AIR · ${String(spec.break).toUpperCase()}${spec.title && spec.break === "welcome" ? " · " + spec.title.toUpperCase() : ""}`; + ctx.font = `600 ${W * 0.028}px ui-monospace, Menlo, monospace`; ctx.textAlign = "center"; ctx.textBaseline = "middle"; + const tw = ctx.measureText(label).width + W * 0.05; + ctx.fillStyle = ground; ctx.globalAlpha = 0.85; ctx.fillRect(cx - tw / 2, H * 0.86 - W * 0.025, tw, W * 0.05); ctx.globalAlpha = 1; + ctx.fillStyle = ink; ctx.fillText(label, cx, H * 0.86); + ctx.restore(); +} +const cssVar = (n) => getComputedStyle(document.documentElement).getPropertyValue(n).trim() || "#ededf0"; +function drawMark(ctx, W, spec, p, lit, bass, tm) { + const core = prng(hash32(spec.seed || "")); + const petals = 3 + Math.floor(core() * 5); + ctx.save(); ctx.translate(W / 2, W / 2); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass); + ctx.fillStyle = `hsl(${p.hue} ${p.sat}% ${Math.min(92, p.light + 45)}% / ${0.85 * lit})`; + ctx.beginPath(); + for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) { + const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals)); + const x = Math.cos(a) * rr, y = Math.sin(a) * rr; + a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.closePath(); ctx.fill(); ctx.restore(); +} +/* chord: a harmonograph whose frequency ratio is the key's interval (major third 5:4, minor 6:5), damped by tempo, + phased by the seed; the bass swells the whole figure */ +function drawChord(canvas, spec, { lit = 1, pulse = null } = {}) { + const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2; + const p = palette(spec), rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || ""))); + const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0; + ctx.globalCompositeOperation = "source-over"; + const g = ctx.createRadialGradient(cx, cy, 0, cx, cy, W * .7); + g.addColorStop(0, `hsl(${p.hue} ${p.sat * .5}% ${p.light * .32}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .14}%)`); + ctx.fillStyle = g; ctx.fillRect(0, 0, W, H); + const [fa, fb] = p.minor ? [6, 5] : [5, 4]; + const fc = 1 + Math.round(p.bpm / 40); // a third pendulum: tempo adds a faster harmonic + const damp = 0.00045 + (p.bpm - 60) / 120 * 0.0006, ph1 = rnd() * Math.PI * 2, ph2 = rnd() * Math.PI * 2, ph3 = rnd() * Math.PI * 2; + const amp = W * 0.36 * (1 + 0.1 * bass), mix = 0.2 + 0.5 * p.energy, detune = 0.004 + 0.01 * rnd(); + ctx.globalCompositeOperation = "lighter"; + // three traces: a soft wide one, then two fine ones a hair apart in frequency, so the figure fills with moiré + for (let pass = 0; pass < 3; pass++) { + const dt = pass === 2 ? detune : 0; + ctx.beginPath(); + for (let t = 0; t < 2600; t += 0.6) { + const e = Math.exp(-damp * t); + const x = cx + amp * e * (Math.sin((fa + dt) * t * 0.02 + ph1 + tm * 0.02) + mix * 0.3 * Math.sin(fc * t * 0.02 + ph3)); + const y = cy + amp * e * (Math.sin((fb - dt) * t * 0.02 + ph2 - tm * 0.02) + mix * 0.3 * Math.cos(fc * t * 0.02 + ph3)); + t === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.lineWidth = pass ? (0.55 + 0.6 * high) * (W / 380) : (3 + 2 * bass) * (W / 380); + ctx.strokeStyle = pass ? `hsl(${pass === 2 ? p.hue2 : p.hue} ${p.sat}% ${p.light + 36}% / ${0.42 * lit})` : `hsl(${p.hue2} ${p.sat}% ${p.light + 12}% / ${0.12 * lit})`; + ctx.stroke(); + } + ctx.globalCompositeOperation = "source-over"; + drawMark(ctx, W, spec, p, lit, bass, tm); +} +/* attractor: a Clifford attractor whose four parameters come from key, tempo, energy and the seed; the point cloud is + drawn once and cached, the seed mark beats on top */ +const attractorCache = new Map(); +function drawAttractor(canvas, spec, { lit = 1, pulse = null } = {}) { + const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height; + const p = palette(spec); + const bass = pulse ? pulse.bass : 0, tm = pulse ? pulse.t : 0; + const key = `${spec.seed}|${spec.title}|${spec.tags}|${spec.palette || ""}|${W}|${lit}`; + let off = attractorCache.get(key); + if (!off) { + off = document.createElement("canvas"); off.width = W; off.height = H; + const o = off.getContext("2d"), rnd = prng(hash32(key)); + const g = o.createLinearGradient(0, 0, W, H); + g.addColorStop(0, `hsl(${p.hue} ${p.sat * .45}% ${p.light * .3}%)`); g.addColorStop(1, `hsl(${p.hue2} ${p.sat * .4}% ${p.light * .15}%)`); + o.fillStyle = g; o.fillRect(0, 0, W, H); + // parameters in the ranges that keep Clifford figures open and filamentary + const a = -1.4 - 0.6 * ((p.hue % 120) / 120), b = 1.2 + 0.8 * ((p.bpm - 60) / 120), c = 0.6 + 1.2 * p.energy, d = (p.minor ? -1 : 1) * (0.6 + 1.0 * rnd()); + let x = 0.1, y = 0.1; const n = 36000, pts = []; + let minX = 9, maxX = -9, minY = 9, maxY = -9; + for (let i = 0; i < n; i++) { const nx = Math.sin(a * y) + c * Math.cos(a * x), ny = Math.sin(b * x) + d * Math.cos(b * y); x = nx; y = ny; if (i > 200) { pts.push(x, y); if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y; } } + const sx = (W * 0.78) / Math.max(1e-6, maxX - minX), sy = (H * 0.78) / Math.max(1e-6, maxY - minY), s = Math.min(sx, sy); + const ox = W / 2 - (minX + maxX) / 2 * s, oy = H / 2 - (minY + maxY) / 2 * s; + o.globalCompositeOperation = "lighter"; + o.fillStyle = `hsl(${p.hue} ${p.sat}% ${p.light + 28}% / ${0.16 * lit})`; + const dot = Math.max(1, W / 380); + for (let i = 0; i < pts.length; i += 2) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot, dot); + o.fillStyle = `hsl(${p.hue2} ${p.sat}% ${p.light + 40}% / ${0.08 * lit})`; + for (let i = 0; i < pts.length; i += 6) o.fillRect(ox + pts[i] * s, oy + pts[i + 1] * s, dot * 2, dot * 2); + if (attractorCache.size > 16) attractorCache.delete(attractorCache.keys().next().value); + attractorCache.set(key, off); + } + ctx.globalCompositeOperation = "source-over"; + ctx.drawImage(off, 0, 0); + if (bass > 0.02) { ctx.globalAlpha = 0.5 * bass; ctx.drawImage(off, W / 2 - W * (0.5 + 0.03 * bass), H / 2 - H * (0.5 + 0.03 * bass), W * (1 + 0.06 * bass), H * (1 + 0.06 * bass)); ctx.globalAlpha = 1; } + drawMark(ctx, W, spec, p, lit, bass, tm); +} +function drawRings(canvas, spec, { lit = 1, pulse = null } = {}) { + const ctx = canvas.getContext("2d"), W = canvas.width, H = canvas.height, cx = W / 2, cy = H / 2; + const p = palette(spec), { energy, minor, hue, sat, light, bpm } = p; + const rnd = prng(hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || ""))); + const bass = pulse ? pulse.bass : 0, high = pulse ? pulse.high : 0, tm = pulse ? pulse.t : 0; + ctx.globalCompositeOperation = "source-over"; + ctx.drawImage(drawGround(W, H, spec, lit), 0, 0); + // rings: one per 10 bpm; the bass breathes them outward, the highs sharpen the line + const rings = Math.max(4, Math.round(bpm / 10)); + const spread = W * .46, sym = 3 + Math.floor(rnd() * 6); // symmetry order from the seed + ctx.globalCompositeOperation = "lighter"; + for (let r = 0; r < rings; r++) { + const t = (r + 1) / rings, rad = spread * Math.pow(t, .8) * (1 + 0.07 * bass * (1 - t)); + ctx.beginPath(); + const wob = 0.02 + energy * 0.08 + 0.03 * high, ph = rnd() * Math.PI * 2 + tm * (0.15 + 0.3 * t) * (r % 2 ? 1 : -1); + for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 90) { + const rr = rad * (1 + wob * Math.sin(a * sym + ph) + wob * 0.5 * Math.sin(a * sym * 2 + ph * 3)); + const x = cx + Math.cos(a) * rr, y = cy + Math.sin(a) * rr; + a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.lineWidth = (0.6 + energy * 2.2 + high * 1.5) * (W / 380) * (1 - t * 0.5); + ctx.strokeStyle = `hsl(${(hue + t * (minor ? -40 : 40)) % 360} ${sat}% ${light + t * 25}% / ${(0.12 + 0.6 * (1 - t)) * lit * (1 + 0.5 * bass)})`; + ctx.stroke(); + } + ctx.globalCompositeOperation = "source-over"; + // a core: the station's seed mark, same shape for every track of one seed; it beats with the bass + const core = prng(hash32(spec.seed || "")); + const petals = 3 + Math.floor(core() * 5); + ctx.save(); ctx.translate(cx, cy); ctx.rotate(core() * Math.PI + tm * 0.05); ctx.scale(1 + 0.3 * bass, 1 + 0.3 * bass); + ctx.fillStyle = `hsl(${hue} ${sat}% ${Math.min(92, light + 45)}% / ${0.85 * lit})`; + ctx.beginPath(); + for (let a = 0; a <= Math.PI * 2 + 0.01; a += Math.PI / 120) { + const rr = (W * .06) * (1 + 0.45 * Math.cos(a * petals)); + const x = Math.cos(a) * rr, y = Math.sin(a) * rr; + a === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.closePath(); ctx.fill(); ctx.restore(); +} diff --git a/dj.js b/dj.js new file mode 100644 index 0000000..28dd9b2 --- /dev/null +++ b/dj.js @@ -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 , and this is \". 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; +} diff --git a/index.html b/index.html index 027133f..a141bb5 100644 --- a/index.html +++ b/index.html @@ -74,16 +74,18 @@ input,textarea{font:inherit;color:inherit} /* ---------- player ---------- */ #player{min-height:100dvh;display:grid;grid-template-rows:auto 1fr auto;padding:calc(22px + env(safe-area-inset-top)) 16px calc(16px + env(safe-area-inset-bottom))} #top{display:flex;align-items:center;justify-content:space-between;font:600 12px var(--font);letter-spacing:.2em;color:var(--dim)} -#stage .station{font:600 11px var(--font);letter-spacing:.22em;color:var(--dim);max-width:min(78vw,380px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-4px} +#stage .station{font:600 16px var(--font);letter-spacing:.18em;color:var(--ink);max-width:min(86vw,420px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;text-transform:uppercase;margin-bottom:-2px} +#stage .station:hover{color:var(--dim)} #stage .station:hover{color:var(--acc)} #stations .s{position:relative} #stations .s .dots{position:absolute;top:-4px;right:2px;width:22px;height:22px;border-radius:50%;background:var(--card);border:1px solid var(--line);color:var(--dim);font-size:12px;line-height:1} #scrim{position:fixed;inset:0;z-index:5;background:rgba(0,0,0,.25)} #stationSheet,#picker,#tapePick{z-index:6} -#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)} +#stationSheet{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(480px,100%);max-height:88vh;max-height:88dvh;overflow-y:auto;-webkit-overflow-scrolling:touch;overscroll-behavior:contain;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:14px 14px calc(14px + env(safe-area-inset-bottom));z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)} +#stationSheet details{margin-top:10px}#stationSheet summary{cursor:pointer;color:var(--dim);font-size:12px;list-style:none;display:flex;align-items:center;gap:6px}#stationSheet summary::-webkit-details-marker{display:none}#stationSheet summary::before{content:"▸";font-size:10px}#stationSheet details[open] summary::before{content:"▾"} #stationSheet h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:0 0 10px} #upd{position:fixed;top:calc(10px + env(safe-area-inset-top));left:50%;transform:translateX(-50%);z-index:9;padding:9px 16px;border-radius:999px;background:var(--ink);color:var(--bg);font:600 11px var(--font);letter-spacing:.16em;box-shadow:0 10px 30px -10px rgba(0,0,0,.6)} -#stationSheet .x{position:absolute;top:8px;right:8px;width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1} +#stationSheet .x{position:sticky;top:-8px;float:right;margin:-8px -6px 0 0;z-index:1;background:var(--card);width:36px;height:36px;border-radius:50%;display:grid;place-items:center;color:var(--dim);font:300 22px var(--sans);line-height:1} #stationSheet .x:hover{color:var(--ink);background:var(--bg)} #stationSheet label{display:block;color:var(--dim);font-size:12px;margin:8px 0 4px} #stationSheet input{width:100%;background:var(--field);border:1px solid var(--line);border-radius:10px;padding:10px 12px;outline:none} @@ -144,7 +146,9 @@ input,textarea{font:inherit;color:inherit} #bar::before{content:"";position:absolute;left:0;right:0;top:-14px;bottom:-14px} /* a fat finger target around a thin line */ #bar i{display:block;height:100%;width:0;background:var(--acc);border-radius:2px;pointer-events:none} #bar:hover,#bar.drag{height:4px;margin:-1px 0} -#bar em{position:absolute;top:-16px;transform:translateX(-50%);font-size:11px;font-style:normal;line-height:1;pointer-events:none;opacity:.9} +#bar em{position:absolute;top:-20px;transform:translateX(-50%);font-size:11px;font-style:normal;line-height:1;cursor:pointer;opacity:.95;white-space:nowrap;background:var(--card);border:1px solid var(--line);border-radius:999px;padding:2px 5px} +#bar em.below{top:auto;bottom:-20px} +#bar em:hover{border-color:var(--ink)} #bar b{position:absolute;top:-28px;transform:translateX(-50%);font:11px var(--font);letter-spacing:.1em;color:var(--dim);background:var(--card);border:1px solid var(--line);border-radius:6px;padding:2px 6px;pointer-events:none;display:none;white-space:nowrap} #bar.drag b{display:block} #controls{display:flex;align-items:center;gap:18px} @@ -174,7 +178,7 @@ input,textarea{font:inherit;color:inherit} #controls #remix:hover{opacity:1} #controls #shuffle.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)} #controls #loop svg{width:18px;height:18px} -#controls #loop.on{opacity:1;color:var(--acc);background:rgba(233,216,166,.12)} +#controls #loop.on{opacity:1;color:var(--onacc);background:var(--acc);border-radius:999px} #next{font:11px var(--font);letter-spacing:.14em;color:var(--dim);min-height:1.4em;text-align:center} @@ -184,7 +188,7 @@ input,textarea{font:inherit;color:inherit} #emojis button:hover{background:var(--card);border-color:var(--line)} #emojis button.hit{transform:scale(1.25);border-color:var(--acc)} #emojis #more{font-size:20px;color:var(--dim);background:var(--card);border:1px solid var(--line)} -#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:5;box-shadow:0 -20px 60px rgba(0,0,0,.6)} +#picker{position:fixed;left:50%;bottom:0;transform:translateX(-50%);width:min(560px,100%);max-height:55dvh;background:var(--card);border:1px solid var(--line);border-bottom:0;border-radius:18px 18px 0 0;padding:12px 12px calc(12px + env(safe-area-inset-bottom));overflow:auto;z-index:6;box-shadow:0 -20px 60px rgba(0,0,0,.6)} #picker h4{font:600 10px var(--font);letter-spacing:.2em;color:var(--dim);margin:10px 4px 6px} #picker .g{display:grid;grid-template-columns:repeat(auto-fill,minmax(42px,1fr));gap:2px} #picker .g button{font-size:24px;height:42px;border-radius:10px} @@ -391,1568 +395,11 @@ input,textarea{font:inherit;color:inherit}
+ - - + + diff --git a/mashup.js b/mashup.js new file mode 100644 index 0000000..f763ce5 --- /dev/null +++ b/mashup.js @@ -0,0 +1,42 @@ +import { $, asDataUrl, state, toast } from "./core.js"; +import { wavBlob } from "./render.js"; +import { askMash } from "./dj.js"; +import { makeJob } from "./app.js"; +const MASH_MAX = 3, MASH_SR = 22050; +async function mashReference(blobs, seconds) { + const AC = window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!AC) return null; + const probe = new AC(1, 1, MASH_SR); + const bufs = []; + for (const b of blobs) { try { bufs.push(await probe.decodeAudioData(await b.arrayBuffer())); } catch {} } + if (bufs.length < 2) return null; + const per = seconds / bufs.length, fade = 2, total = seconds; + const ctx = new AC(1, Math.ceil(total * MASH_SR), MASH_SR); + let at = 0; + bufs.forEach((buf, i) => { + const src = ctx.createBufferSource(); src.buffer = buf; + const g = ctx.createGain(); src.connect(g); g.connect(ctx.destination); + const start = Math.max(0, at - (i ? fade : 0)), len = Math.min(buf.duration, per + fade); + const from = Math.max(0, Math.min(buf.duration - len, buf.duration * 0.25)); // skip the intro, take the meat + g.gain.setValueAtTime(i ? 0 : 1, start); if (i) g.gain.linearRampToValueAtTime(1, start + fade); + g.gain.setValueAtTime(1, start + len - fade); g.gain.linearRampToValueAtTime(0, start + len); + src.start(start, from, len); at = start + len; + }); + const out = await ctx.startRendering(); + const ch = out.getChannelData(0), pcm = new Int16Array(ch.length); + for (let i = 0; i < ch.length; i++) pcm[i] = Math.max(-1, Math.min(1, ch[i])) * 32767; + return asDataUrl(wavBlob(pcm, MASH_SR)); +} + +export 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; } + if (!state.stationId) { toast("Open a station first; the mash goes on it."); return; } + toast(`Mashing ${rows.length} tracks — it goes next.`, 4000); + try { + const [spec, ref] = await Promise.all([askMash(rows), mashReference(rows.map((t) => t.blob), Math.min(120, 30 * rows.length))]); + if (ref) spec.refAudio = ref; + const job = makeJob(spec); + if (state.nextJob && !state.nextJob.blob) state.after = state.nextJob; + state.nextJob = job; $("#next").textContent = "NEXT · " + spec.title.toUpperCase() + " · MASHUP"; + } catch (e) { toast(e.message, 5000); } +} diff --git a/player.js b/player.js new file mode 100644 index 0000000..215e34e --- /dev/null +++ b/player.js @@ -0,0 +1,389 @@ +import { drawCover, hash32, prng } from "./covers.js"; +import { $, asDataUrl, mmss, ids, state, toast } from "./core.js"; +import { wavBlob } from "./render.js"; +import { drawTicks, drawTrail, keepLabel, nowPlaying, setPlayIcon, showLyrics, showTags } from "./ui/player.js"; +import { db, logEvent, remember, saveStation, tapeById } from "./store.js"; +import { cookNext, makeJob } from "./app.js"; +import { playTape } from "./ui/library.js"; +import { askDJ } 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