@voicelabs/sdk
v0.1.0
Published
Official TypeScript SDK for the VoiceLabs API — text-to-speech and transcription over HTTP.
Readme
@voicelabs/sdk
The official TypeScript client for the VoiceLabs API — generate speech in your own cloned voices, transcribe audio, and read your voices, captures, and generations over HTTP.
Hand-written, not generated. Zero runtime dependencies. ESM and CommonJS, types included.
npm install @voicelabs/sdkQuickstart
You need an API key. Sign in at app.voicelabs.now/connections, open the API keys tab, and create one. Tick voice:generate if the key needs to make audio — a key without it can read your account but cannot generate.
The secret is shown once, at creation. VoiceLabs stores a SHA-256 hash, so a lost key is replaced, not recovered.
import { VoiceLabs } from "@voicelabs/sdk";
const voicelabs = new VoiceLabs({ apiKey: process.env.VOICELABS_API_KEY! });
// Generate speech and wait for the audio (generation is asynchronous).
const generation = await voicelabs.generateSpeech({
text: "The quick brown fox jumps over the lazy dog.",
voice_name: "Narrator",
});
const { bytes, contentType } = await voicelabs.downloadAudio(generation);
await writeFile(`out.${contentType.split("/")[1]}`, bytes);Field names are the wire names, snake_case and all. There is no camelCase translation layer, on purpose: a mapping is a second declaration of the contract that can drift from the first, and when it drifts you get a silently-undefined field rather than a type error. What you read in the OpenAPI document, in a curl response, and in these types is one set of names.
The six operations
| Method | Scope | What it does |
| ------------------------------------ | ---------------- | -------------------------------------------------------------------- |
| listVoices() | voice:read | Voice profiles on the account: cloned voices and presets |
| listCaptures({ limit, offset }) | voice:read | Recent captures with their transcripts, most-recent first |
| getGeneration(id) | voice:read | Poll one generation for its status and, once done, its audio URL |
| createSpeech({ text, voice_name }) | voice:generate | Start generating speech; returns a handle immediately |
| createTranscription({ audio }) | voice:generate | Transcribe a base64 audio clip; returns the transcript synchronously |
| downloadAudio(generation) | — | Fetch the bytes a completed generation points at |
Plus two helpers over them: generateSpeech() (create + wait, in one call) and waitForGeneration(id).
Reading never draws on the account's audio allowance. Generating does.
Errors are typed, and two of them share a status on purpose
This is the part worth reading before you write a retry loop. The API returns two different problems under one HTTP status, twice:
| Status | Code | What it means | Retrying? |
| ------ | --------------------- | ----------------------------------------- | --------------- |
| 429 | rate_limit_exceeded | You are calling too fast | Yes — wait |
| 429 | quota_exhausted | The account's audio allowance is spent | No — futile |
| 403 | insufficient_scope | This key was not granted the scope | No — mint a key |
| 403 | feature_not_enabled | The plan does not include this capability | No — upgrade |
A client that branches on response.status gets both pairs wrong, and the second mistake is expensive: retrying quota_exhausted on a backoff loop burns your rate budget forever without ever succeeding. So each meaning has its own class, and QuotaExhaustedError is deliberately not a subclass of RateLimitError.
import {
InsufficientScopeError,
QuotaExhaustedError,
RateLimitError,
VoiceLabsAPIError,
} from "@voicelabs/sdk";
try {
await voicelabs.createSpeech({ text: "Hello." });
} catch (error) {
if (error instanceof RateLimitError) {
await sleep((error.retryAfterSeconds ?? 30) * 1000); // will succeed later
} else if (error instanceof QuotaExhaustedError) {
console.log(`Allowance spent. Upgrade at ${error.settingsUrl}`);
} else if (error instanceof InsufficientScopeError) {
console.log(`This key needs the "${error.requiredScope}" scope.`);
} else if (error instanceof VoiceLabsAPIError) {
console.log(error.code, error.detail, "retryable:", error.retryable);
}
}Every API error carries code (the stable token to branch on), status, detail, and retryable — which disagrees with the status where the status is misleading. A code this SDK version predates is kept verbatim on error.code and handled through VoiceLabsAPIError, never forced into a class it might not belong to.
VoiceLabsConnectionError is thrown when no HTTP response happened at all — a dropped connection, a DNS failure, a timeout. That is the case where a write may or may not have executed, which is what idempotency keys are for.
Retrying a write safely
await voicelabs.createSpeech({ text: "Charge me exactly once." }, { idempotencyKey: "order-9271" });Replaying the same key with the same body returns the original result instead of generating again. Replaying it with a different body is an error, not a silent overwrite.
Rate limits
Responses to an API-key request carry IETF draft-11 RateLimit headers — 600 requests/hour per key by default. The SDK parses them for you:
const voicelabs = new VoiceLabs({
apiKey: process.env.VOICELABS_API_KEY!,
onRateLimit: ({ remaining, resetSeconds }) => {
if (remaining !== null && remaining < 20) console.warn(`${remaining} left, ${resetSeconds}s`);
},
});The hook is not called when a response carries no budget — the OAuth 2.1 lane and keys with limiting switched off genuinely have no per-key counter. Absent means unknown, never zero.
Audio URLs
generation.audio_url is a signed, time-limited capability. The signature is the whole authorization: it carries no identity, so downloadAudio() fetches it without your API key — sending a long-lived credential to a URL that does not need one would leak it for nothing. Re-polling mints a fresh URL rather than reviving an expired one, so follow it promptly.
Configuration
new VoiceLabs({
apiKey: "vl_sandbox_…",
baseUrl: "https://app.voicelabs.now", // override for a staging host
timeoutMs: 60_000, // per request; 0 disables (bring your own signal)
onRateLimit: (info) => {},
fetch: myFetch, // substitute the HTTP layer
});Every method takes { signal, headers }; writes also take { idempotencyKey }. A caller-supplied header can never displace the API key.
Runtimes
Node 18+, Bun, Deno, Cloudflare Workers, and browsers. Nothing is imported from node:*, so the SDK runs unmodified at the edge. In a browser, only ever use a key you are willing to make public — the API serves a permissive CORS policy because it is credential-authenticated, which does not make a secret in client-side JavaScript any less exposed.
Versioning
Semantic versioning, tracking the API's /v1 contract, which is versioned separately and independently of this package. Additive API changes arrive in a minor release; a breaking change to this SDK's own surface is a major. Pre-1.0, minor releases may still change the SDK surface — pin an exact version if that matters to you.
Development
npm install
npm test # unit tests over a stubbed transport
npm run build # dual ESM + CJS build via two tsc passes, no bundlerThe SDK is also dogfooded: platform/tests/integration/v1/the-published-sdk-drives-the-real-v1-handlers-end-to-end.test.ts runs this exact source against the real API route handlers in-process, with a real database and really-issued keys. A drift between these types and the API fails CI rather than your build.
License
MIT © Devino Solutions
