zelispeech
v0.3.1
Published
ZeliSpeech JavaScript SDK — self-hosted, low-latency streaming text-to-speech.
Maintainers
Readme
ZeliSpeech — JavaScript SDK
A small, zero-dependency client for ZeliSpeech —
self-hosted, low-latency streaming text-to-speech. Point it at your running
server and get audio back in a couple of lines. It is the JavaScript twin of
the zeli-tts Python SDK, and talks the
same authenticated /v1 API documented in the
API reference.
Works in Node ≥ 18 and in the browser (plain fetch — no WebSocket needed).
Install
npm install zelispeechQuickstart
import { ZeliSpeech, play, save, stream } from "zelispeech";
const client = new ZeliSpeech({
baseUrl: "https://voice.zeligate.com",
apiKey: "sk-zeli-...", // optional if the box runs open
});
// 1) Stream — first audio after the first sentence, not the whole passage
const audio = client.textToSpeech.stream("zeli-voice-1", "Hey there, this is Zeli.");
for await (const chunk of audio) {
// mp3 bytes (Uint8Array) as they generate
}
// 2) Stream + play live (Node-only, needs ffplay)
await stream(client.textToSpeech.stream("zeli-voice-1", "Playing as I generate."));
// 3) One-shot — a finished clip
const clip = await client.textToSpeech.convert("zeli-voice-1", "Hello world.");
await save(clip, "hello.mp3");Client
new ZeliSpeech({ baseUrl, apiKey, timeoutMs = 60000 })baseUrl—http://host:8000orhttps://voice.zeligate.com; do not include a trailing/v1.apiKey— sent asAuthorization: Bearer …on every request. Required only when the box setsZELI_API_KEY; a loopback/embedded box can run open.client.capabilities()/client.health()/client.isReady()— engine info and readiness (handy while a box is warming up).
Synthesis — client.textToSpeech
client.textToSpeech.stream(voiceId, text, {
modelId = "zeli-turbo", voiceSettings, outputFormat = "mp3_44100_128", timeoutMs,
}) // -> AudioStream
await client.textToSpeech.convert(voiceId, text, { ...same }) // -> Uint8Arraystream(...)returns anAudioStream—for awaitit for encodedUint8Arraychunks;await audio.read()drains it to one blob. It exposes.outputFormat,.sampleRate,.voice(the resolved voice id), and.requestId.convert(...)returns the finished clip in the requestedoutputFormat.outputFormataccepts the full menu —mp3_*,wav_*, rawpcm_*,ulaw_8000,alaw_8000,opus_48000_*— see the output formats reference.voiceSettings(stability/style/speed/…) maps onto Turbo delivery — see voice settings.
Voices — client.voices
await client.voices.list(); // -> Voice[]
await client.voices.get("zeli-voice-1"); // -> Voice (404s on unknown ids)
await client.voices.add({ name, file, description }); // zero-shot clone (Turbo engine)
await client.voices.delete("custom-…");file may be a path (Node), a Blob/File, a Uint8Array, or an
ArrayBuffer. ~10–20 s of clean single-speaker audio works best.
Voice management requires a full API key — ephemeral tokens get
401 insufficient_scope here.
Ephemeral tokens — client.tokens
Never ship a real API key to a browser: anyone can read it from the page. The
intended flow is that your backend (which holds a full apiKey) mints a
short-lived, synthesis-only token and hands it to the page; the browser then
talks to the API directly with that token.
await client.tokens.createEphemeral({ ttlSeconds: 300 })
// -> { token: "zsk_temp_…", expires_at: 1770000000, scope: "tts" }ttlSecondsis clamped to 30–600 (default 300).expires_atis the epoch second the token dies — enforced server-side on every request, so a leaked token is usable for minutes at most.- The returned token has scope
"tts": it can call synthesis and read endpoints (textToSpeech.stream/convert,voices.list/get) but notvoices.add/deleteortokens.createEphemeral— those return401 insufficient_scope. - Minting itself requires a full key; it fails with
401 insufficient_scopewhen attempted with a temp token, and501 not_supportedon a server without the portal keys table configured.
End-to-end example (Express backend + browser):
// backend — the real key stays here
import { ZeliSpeech } from "zelispeech";
const zeli = new ZeliSpeech({
baseUrl: "https://voice.zeligate.com",
apiKey: process.env.ZELISPEECH_API_KEY,
});
app.post("/api/tts-token", async (req, res) => {
res.json(await zeli.tokens.createEphemeral({ ttlSeconds: 300 }));
});
// browser — only ever sees the temp token
const { token } = await (await fetch("/api/tts-token", { method: "POST" })).json();
const speech = new ZeliSpeech({ baseUrl: "https://voice.zeligate.com", apiKey: token });
const clip = await speech.textToSpeech.convert("zeli-voice-1", "Hello from the browser!");
new Audio(URL.createObjectURL(new Blob([clip], { type: "audio/mpeg" }))).play();More examples
// Delivery control (stability / style / speed map onto Turbo knobs)
const expressive = await client.textToSpeech.convert(
"zeli-voice-1", "Same words, very different delivery!",
{ voiceSettings: { stability: 0.2, style: 0.9, speed: 1.1 } },
);
// Raw PCM for your own audio pipeline (24 kHz mono int16, no container)
const pcm = await client.textToSpeech.convert("zeli-voice-1", "Raw samples.", {
outputFormat: "pcm_24000",
});
// Stream straight to a file as it generates (Node)
import { createWriteStream } from "node:fs";
const out = createWriteStream("out.mp3");
for await (const chunk of client.textToSpeech.stream("zeli-voice-1", "Written as it speaks.")) {
out.write(chunk);
}
out.end();
// Clone a voice, use it, remove it
const voice = await client.voices.add({ name: "My narrator", file: "reference.wav" });
await client.textToSpeech.convert(voice.id, "Cloned voice speaking.");
await client.voices.delete(voice.id);
// Robust error handling
import { APIError, ConnectionError } from "zelispeech";
try {
await client.textToSpeech.convert("zeli-voice-1", "…");
} catch (err) {
if (err instanceof APIError && err.status === "invalid_api_key") {
// rotate/refresh the key
} else if (err instanceof APIError && err.statusCode === 503) {
// model still loading — retry shortly
} else if (err instanceof ConnectionError) {
// box unreachable / stopped
}
}Audio helpers
await save(bytes, path); // write to disk (Node-only; pcm_* wrapped when .wav)
await play(clip); // system player: afplay / ffplay / aplay (Node-only)
await stream(audioStream); // play chunks live via ffplay; returns the bytes (Node-only)
pcmToWav(pcm, sampleRate); // wrap raw PCM in a WAV container (works everywhere)In the browser, build a blob and play it instead:
const clip = await client.textToSpeech.convert("zeli-voice-1", "Hi!");
new Audio(URL.createObjectURL(new Blob([clip], { type: "audio/mpeg" }))).play();Errors
Everything the SDK throws derives from ZeliSpeechError:
| Exception | When |
|---|---|
| ConfigurationError | bad baseUrl, or a missing player |
| ConnectionError | server unreachable (refused / DNS / timeout) |
| APIError | non-2xx from an HTTP endpoint (.statusCode, .status, .body) |
| GenerationError | the stream broke mid-synthesis |
Auth failures are APIErrors whose .status tells you why:
| .statusCode | .status | When |
|---|---|---|
| 401 | missing_api_key | auth required but no key sent |
| 401 | invalid_api_key | unknown, revoked, or expired key/token |
| 401 | insufficient_scope | an ephemeral token hit a management route |
| 501 | not_supported | minting on a server without the keys table |
Migrating from 0.1.x
0.2.0 moves the SDK onto the authenticated /v1 API: stream(text, { voice })
→ stream(voiceId, text), audio now arrives in outputFormat (mp3 by default)
instead of raw PCM, engine-knob options are replaced by voiceSettings, and
streaming no longer uses WebSocket (plain fetch everywhere).
License
MIT
