Files
site/render.js
T

145 lines
11 KiB
JavaScript

import { DIRECT, LS, SHARED, sleep, state } from "./core.js";
import { hash32, prng } from "./covers.js";
import { speak } from "./dj.js";
export function wavBlob(samples, sr) {
const n = samples.length, buf = new ArrayBuffer(44 + n * 2), dv = new DataView(buf);
const str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, "RIFF"); dv.setUint32(4, 36 + n * 2, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 2, true); dv.setUint16(32, 2, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 2, true);
new Int16Array(buf, 44).set(samples);
return new Blob([buf], { type: "audio/wav" });
}
/* ---------- Venice ---------- */
async function veniceFetch(path, body, { raw = false } = {}) {
const headers = { "Content-Type": "application/json", Accept: raw ? "*/*" : "application/json" };
if (state.key) headers.Authorization = "Bearer " + state.key; else headers["x-app"] = "gandora";
const res = await fetch((state.key ? DIRECT : SHARED) + path, { method: body ? "POST" : "GET", headers, body: body ? JSON.stringify(body) : undefined });
if (!res.ok) {
let d = null; try { d = await res.json(); } catch {}
const said = d && (d.error && (d.error.message || d.error) || d.message) || "";
const who = state.key ? "Venice" : "The renderer";
const why = { 401: "Venice didn't accept that key.", 402: "Venice says the key is out of credit.", 422: who + " turned that down: " + said, 429: state.key ? "Venice is rate-limiting. Breathe." : (said || "Over today's shared budget."), 503: "That model is busy.", 504: "The render timed out; the next one will try again." }[res.status];
throw new Error(why || (who + " answered " + res.status + (said ? ": " + said : "")));
}
return raw ? res : res.json();
}
/* the fetcher is injectable: the harness fragments stub it (render.setFetcher); every call goes through venice() */
let fetcher = veniceFetch;
export const setFetcher = (fn) => { fetcher = fn || veniceFetch; };
export const venice = (...a) => fetcher(...a);
const HEDGE_AT = 60000;
const BANDS = { delta: 2.5, theta: 6, alpha: 10, beta: 18, gamma: 40 };
const SOLFEGGIO = [174, 285, 396, 417, 528, 639, 741, 852, 963];
const TONES = [["binaural", "alpha"], ["solfeggio", 528], ["binaural", "theta"], ["isochronic", "beta"], ["solfeggio", 396], ["binaural", "delta"], ["solfeggio", 963], ["isochronic", "gamma"]];
export function toneSpec() {
const s = String(state.seed).toLowerCase(), n = state.history.filter((t) => t.tone).length;
const hz = (s.match(/(\d{2,4})\s*hz/) || [])[1], band = (s.match(/\b(delta|theta|alpha|beta|gamma)\b/) || [])[1];
let [kind, what] = TONES[n % TONES.length];
if (/isochronic/.test(s)) kind = "isochronic"; else if (/binaural/.test(s)) kind = "binaural"; else if (/solfeggio/.test(s) || hz) kind = "solfeggio";
if (kind === "solfeggio") what = hz ? +hz : typeof what === "number" ? what : 528; else if (band) what = band; else if (typeof what === "number") what = "alpha";
const carrier = hz && kind !== "solfeggio" ? +hz : 200, beat = kind === "solfeggio" ? 0 : BANDS[what];
const title = kind === "solfeggio" ? `${what} Hz` : `${what} ${kind} · ${beat} Hz`;
return { title, tags: `${kind} ${kind === "solfeggio" ? what + " Hz, pure tone" : what + " " + beat + " Hz on a " + carrier + " Hz carrier"}, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4",
tone: { kind, hz: kind === "solfeggio" ? what : carrier, beat }, station: state.station || "the tone room", line: kind === "binaural" ? "headphones: one ear each" : "breathe with it", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
}
export async function makeTone({ kind, hz, beat }, seconds) {
const sr = 16000, n = sr * seconds, L = new Int16Array(n), R = new Int16Array(n), amp = 0.28 * 32767;
for (let i = 0; i < n; i++) {
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0));
const t = i / sr, fade = Math.min(1, t / 3, (seconds - t) / 3);
let l, r;
if (kind === "binaural") { l = Math.sin(2 * Math.PI * hz * t); r = Math.sin(2 * Math.PI * (hz + beat) * t); }
else if (kind === "isochronic") { const g = 0.5 - 0.5 * Math.cos(2 * Math.PI * beat * t); l = r = Math.sin(2 * Math.PI * hz * t) * (g > 0.5 ? 1 : g * 2) ; }
else { l = r = Math.sin(2 * Math.PI * hz * t) * 0.8 + Math.sin(2 * Math.PI * hz * 2 * t) * 0.08; }
L[i] = l * amp * fade; R[i] = r * amp * fade;
}
return wavBlob2(L, R, sr);
}
function wavBlob2(L, R, sr) {
const n = L.length, buf = new ArrayBuffer(44 + n * 4), dv = new DataView(buf), str = (o, s) => { for (let i = 0; i < s.length; i++) dv.setUint8(o + i, s.charCodeAt(i)); };
str(0, "RIFF"); dv.setUint32(4, 36 + n * 4, true); str(8, "WAVE"); str(12, "fmt "); dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 2, true);
dv.setUint32(24, sr, true); dv.setUint32(28, sr * 4, true); dv.setUint16(32, 4, true); dv.setUint16(34, 16, true); str(36, "data"); dv.setUint32(40, n * 4, true);
const out = new Int16Array(buf, 44); for (let i = 0; i < n; i++) { out[2 * i] = L[i]; out[2 * i + 1] = R[i]; }
return new Blob([buf], { type: "audio/wav" });
}
const NOISES = ["brown", "pink", "rain", "white", "crackle"];
export function noiseSpec() {
const n = state.history.filter((t) => t.noise).length, kind = (String(state.seed).match(/\b(brown|pink|rain|white|crackle)\b/i) || [])[1]?.toLowerCase() || NOISES[n % NOISES.length];
return { title: `${kind} noise`, tags: `${kind} noise, room tone, made on this device`, lyrics: "", bpm: 60, key: "—", energy: 0.1, seconds: 300, meter: "4/4", noise: kind, station: state.station || "the noise floor", line: "nothing to learn here, just the room", seed: state.seed, made: Date.now(), look: state.look || "rings", palette: state.palette || "duo" };
}
export async function makeNoise(kind, seconds) {
const sr = 16000, n = sr * seconds, out = new Int16Array(n), rnd = prng(hash32(kind + seconds) || 1);
let b = 0, b0 = 0, b1 = 0, b2 = 0, lp = 0, drop = 0, dropLen = 0, dropAmp = 0;
for (let i = 0; i < n; i++) {
if (i % (sr * 5) === 0 && i) await new Promise((r) => setTimeout(r, 0)); // a slice at a time: the page keeps breathing
const w = rnd() * 2 - 1; let v;
if (kind === "white") v = w * 0.25;
else if (kind === "brown") { b = (b + 0.02 * w) / 1.02; v = b * 2.2; }
else if (kind === "pink") { b0 = 0.99765 * b0 + w * 0.0990460; b1 = 0.96300 * b1 + w * 0.2965164; b2 = 0.57000 * b2 + w * 1.0526913; v = (b0 + b1 + b2 + w * 0.1848) * 0.12; }
else if (kind === "rain") { lp += (w - lp) * 0.3; v = lp * 0.5; if (drop <= 0 && rnd() < 0.004) { drop = dropLen = 30 + Math.floor(rnd() * 120); dropAmp = 0.15 + rnd() * 0.35; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
else { lp += (w - lp) * 0.08; v = lp * 1.0; if (rnd() < 0.0015) { drop = dropLen = 20 + Math.floor(rnd() * 80); dropAmp = 0.3 + rnd() * 0.5; } if (drop > 0) { v += dropAmp * (drop / dropLen) * w; drop--; } }
const fade = Math.min(1, i / (sr * 2), (n - i) / (sr * 2));
out[i] = Math.max(-1, Math.min(1, v * fade)) * 32767;
}
return wavBlob(out, sr);
}
/* voices: "model/voice". Kokoro (plain, cheap, no style), Qwen3 (takes a style prompt: whisper, ASMR, excited),
Orpheus (expressive), ElevenLabs turbo (polished). A bare name is Kokoro. */
export async function renderTrack(spec, onStatus) {
if (spec.noise) return makeNoise(spec.noise, spec.seconds || 300);
if (spec.tone) return makeTone(spec.tone, spec.seconds || 300);
if (spec.spoken) return speak(spec);
if (!state.key) { // shared worker: one call, audio back (Runware when the factory has a key, else Venice)
const started = Date.now(), tick = setInterval(() => onStatus && onStatus(Math.round((Date.now() - started) / 1000), 0, false), 1000);
try {
const body = { prompt: spec.tags, lyrics: spec.lyrics || "", duration: spec.seconds || 120, bpm: spec.bpm, key: spec.key, timeSignature: spec.meter || "4/4",
reference: spec.refAudio || undefined,
seed: spec.rseed ?? (hash32((spec.seed || "") + "|" + (spec.title || "") + "|" + (spec.tags || "")) >>> 1), quality: spec.quality || "turbo" };
let res;
try { res = await venice("/render", body, { raw: true }); }
catch (e) { if (!/answered 404/.test(e.message)) throw e; res = null; } // older worker: fall through to queue+poll
if (res) {
const blob = await res.blob();
const cost = parseFloat(res.headers.get("x-cost") || ""), seed = parseInt(res.headers.get("x-seed") || "");
spec.renderer = res.headers.get("x-renderer") || "venice:ace-step-15"; if (res.headers.get("x-fallback")) spec.fallback = res.headers.get("x-fallback");
if (!isNaN(seed)) spec.rseed = seed;
spec.cost = isNaN(cost) ? 0.0005 * (spec.seconds || 120) : cost;
delete spec.refAudio; // the reference did its job; don't keep megabytes on the sheet
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return blob;
}
} finally { clearInterval(tick); }
}
const body = { model: "ace-step-15", prompt: spec.tags, duration_seconds: Math.max(60, Math.min(210, spec.seconds || 120)), ...(spec.lyrics ? { lyrics_prompt: spec.lyrics } : {}) };
const jobs = [await venice("/audio/queue", body)];
const started = Date.now();
let hedged = false;
for (let i = 0; i < 90; i++) {
await sleep(i < 4 ? 2500 : 4000);
const elapsed = Date.now() - started;
if (!hedged && elapsed > HEDGE_AT) { hedged = true; try { jobs.push(await venice("/audio/queue", body)); } catch {} }
let expected = 0;
for (const q of jobs) {
const res = await venice("/audio/retrieve", { model: q.model || "ace-step-15", queue_id: q.queue_id, delete_media_on_completion: true }, { raw: true });
const type = res.headers.get("content-type") || "";
if (/^audio\//i.test(type) || /octet-stream/i.test(type)) {
const blob = await res.blob();
spec.cost = 0.06 * jobs.length; spec.renderer = "venice:ace-step-15";
state.cost += spec.cost; localStorage.setItem(LS.cost, String(state.cost));
return blob;
}
const d = await res.json();
const st = String(d.status || "").toUpperCase();
if (/FAIL|ERROR|CANCEL|REJECT/.test(st)) { if (jobs.length === 1) throw new Error("Venice couldn't make the track (" + st.toLowerCase() + ")."); jobs.splice(jobs.indexOf(q), 1); break; }
expected = Math.max(expected, d.average_execution_time || 0);
}
onStatus && onStatus(Math.round(elapsed / 1000), Math.round(expected / 1000), hedged);
}
throw new Error("That render took too long.");
}