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