141 lines
4.8 KiB
JavaScript
141 lines
4.8 KiB
JavaScript
// Kokoro TTS backend — synthesizes a topic summary into speech via Spark
|
|
// Control's OpenAI-compatible /v1/audio/speech endpoint.
|
|
//
|
|
// Kokoro-82M (Apache-2.0, hexgrad/Kokoro-82M) replaced Magpie in Spark
|
|
// Control v0.14.0. Magpie's NVIDIA Riva decoder had a structural
|
|
// truncation defect that capped end-to-end reliability at ~85% even with
|
|
// server-side retries + chunking; Kokoro renders cleanly at any length
|
|
// (100% in our testing, ~1s for a ~100-word summary, no truncation). So
|
|
// this backend is a single pass-through call — NONE of the Magpie-era
|
|
// fragmenting, pacing/recovery-gap, duration-check, retry, or WAV
|
|
// stitching is needed or present.
|
|
//
|
|
// Output: 24kHz mono 16-bit PCM. Kokoro can emit wav/mp3/opus/flac
|
|
// directly via response_format, so we request the caller's format (mp3
|
|
// by default — small + universally playable for the mobile/offline
|
|
// player) and never transcode client-side. durationSeconds is left null:
|
|
// Kokoro's WAV header carries a placeholder size field (bogus computed
|
|
// duration), and for mp3 we'd have to decode — the Recap side measures
|
|
// duration off the cached file / <audio> element instead.
|
|
|
|
import { lanFetch } from "../lan-fetch.js";
|
|
|
|
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
const DEFAULT_VOICE = "bm_george";
|
|
const DEFAULT_FORMAT = "mp3";
|
|
// One retry on a 5xx / network blip (per the Spark Control dev's
|
|
// error-handling guidance: 4xx = real client error, 5xx = retry once).
|
|
// Kokoro doesn't truncate, so there's no duration-based retry.
|
|
const RETRY_ON_5XX = 1;
|
|
|
|
const FORMAT_CONTENT_TYPE = {
|
|
wav: "audio/wav",
|
|
mp3: "audio/mpeg",
|
|
opus: "audio/ogg",
|
|
flac: "audio/flac",
|
|
};
|
|
|
|
function sleepMs(ms) {
|
|
return new Promise((r) => setTimeout(r, ms));
|
|
}
|
|
|
|
export function createKokoroBackend({
|
|
// Spark Control base URL (no path) — derived by the caller from
|
|
// relay_spark_control_url with the /api/endpoints suffix stripped.
|
|
sparkControlBaseURL = "",
|
|
defaultVoice = DEFAULT_VOICE,
|
|
defaultFormat = DEFAULT_FORMAT,
|
|
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
} = {}) {
|
|
const sparkBase = (sparkControlBaseURL || "")
|
|
.trim()
|
|
.replace(/\/$/, "")
|
|
.replace(/\/api\/endpoints$/, "");
|
|
|
|
async function callKokoro({ text, voice, format }) {
|
|
const url = `${sparkBase}/v1/audio/speech`;
|
|
let res;
|
|
try {
|
|
res = await lanFetch(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
redirect: "follow",
|
|
body: JSON.stringify({
|
|
model: "kokoro",
|
|
input: text,
|
|
voice,
|
|
response_format: format,
|
|
}),
|
|
signal: AbortSignal.timeout(timeoutMs),
|
|
});
|
|
} catch (err) {
|
|
const cause = err?.cause?.message || err?.cause?.code || err?.cause || "";
|
|
const detail = cause ? `${err.message} (cause: ${cause})` : err?.message || String(err);
|
|
const e = new Error(`Kokoro TTS network error at ${url}: ${detail}`);
|
|
e.status = 502;
|
|
throw e;
|
|
}
|
|
if (!res.ok) {
|
|
let body = "";
|
|
try {
|
|
body = await res.text();
|
|
} catch {}
|
|
const e = new Error(`Kokoro TTS ${res.status} at ${url}: ${body.slice(0, 300)}`);
|
|
e.status = res.status;
|
|
throw e;
|
|
}
|
|
return Buffer.from(await res.arrayBuffer());
|
|
}
|
|
|
|
return {
|
|
hasTts: !!sparkBase,
|
|
kind: "kokoro",
|
|
|
|
async synthesize({ text, voice, format }) {
|
|
if (!sparkBase) {
|
|
const e = new Error(
|
|
"Kokoro TTS is not configured — Spark Control discovery isn't reporting a ready kokoro endpoint"
|
|
);
|
|
e.status = 503;
|
|
throw e;
|
|
}
|
|
const cleaned = (text || "").replace(/\s+/g, " ").trim();
|
|
if (!cleaned) {
|
|
const e = new Error("TTS input text is empty");
|
|
e.status = 400;
|
|
throw e;
|
|
}
|
|
const chosenVoice = (voice || defaultVoice || "").trim() || DEFAULT_VOICE;
|
|
const fmt = (format || defaultFormat || DEFAULT_FORMAT).toLowerCase();
|
|
const contentType = FORMAT_CONTENT_TYPE[fmt] || "application/octet-stream";
|
|
|
|
let attempt = 0;
|
|
// Retry only on transient 5xx; a 4xx (bad voice/format) is
|
|
// deterministic and surfaces immediately.
|
|
while (true) {
|
|
try {
|
|
const audio = await callKokoro({ text: cleaned, voice: chosenVoice, format: fmt });
|
|
return {
|
|
audio,
|
|
contentType,
|
|
durationSeconds: null,
|
|
voice: chosenVoice,
|
|
model: "kokoro",
|
|
format: fmt,
|
|
attempts: attempt + 1,
|
|
};
|
|
} catch (err) {
|
|
const status = err?.status || 0;
|
|
if (status >= 400 && status < 500) throw err; // client error → no retry
|
|
if (attempt >= RETRY_ON_5XX) throw err;
|
|
attempt += 1;
|
|
console.warn(
|
|
`[kokoro] TTS call failed (${status || "network"}) — retry ${attempt}/${RETRY_ON_5XX}`
|
|
);
|
|
await sleepMs(500);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|