Skip to content

About

Official TypeScript SDK for the Finesse voice API: voice cloning, text to speech and realtime streaming (@flamai/finesse)

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Finesse by Flam: TypeScript SDK for voice cloning, text-to-speech and realtime streaming

npm version Node 18+ Bun and Deno TypeScript types included Status: beta License: Apache-2.0

Finesse TypeScript SDK

The official TypeScript and JavaScript library for Finesse, Flam's voice platform. Clone a voice from a few seconds of audio, turn text into speech in that voice, and stream it in realtime to voice agents, with a few lines of code.

  • Typed end to end, ESM and CommonJS, no heavy dependencies (it uses the built-in fetch).
  • Realtime over one WebSocket: stream text in as your LLM writes it, get audio back, cancel on barge-in.
  • Safe by default: retries that never double-bill, typed errors, timeouts, AbortSignal.

Documentation · API reference · Dashboard · Changelog

Contents

Installation

Realtime in Node also needs the ws package:

npm install @flamai/finesse ws
# or
pnpm add @flamai/finesse ws
# or
yarn add @flamai/finesse ws
# or
bun add @flamai/finesse

ws is only needed for realtime in Node. Bun, Deno and browsers have a built-in WebSocket.

Get an API key

  1. Sign in to the Finesse dashboard.
  2. Open Settings → API keys and create a key. Copy it right away: it is shown only once, and it starts with flv_.
  3. Put it in your environment, where the SDK finds it:
export FINESSE_API_KEY="flv_..."

Keep the key on your server. Anyone who has it can spend your workspace's credits.

The API Keys page in the Finesse dashboard: name a key, press Create key, and copy it once

Quickstart

import { writeFile } from "node:fs/promises";
import { Finesse, isReady } from "@flamai/finesse";

const client = new Finesse(); // reads FINESSE_API_KEY

// Pick any voice you can use: your own, or one from the public catalog.
let voiceId = "";
for await (const voice of client.voices.list()) {
  if (isReady(voice)) {
    voiceId = voice.id;
    break;
  }
}

const audio = await client.speech.create({ voice_id: voiceId, text: "Hello from Finesse!" });
await writeFile("hello.wav", audio.data);

hello.wav is a 24 kHz WAV file. That's it.

Voices

voices.list() returns your voices first (newest first), then the public platform voices. for await walks every page for you.

for await (const voice of client.voices.list()) {
  console.log(voice.id, voice.name, voice.status, voice.language, voice.labels);
}

const voice = await client.voices.get("665f1a2b3c4d5e6f7a8b9c0d");
await client.voices.update(voice.id, { name: "Narrator (UK)", labels: { accent: "british" } });
await client.voices.delete(voice.id);

Just the first page, with its totals:

const page = await client.voices.list({ page_size: 20 });
console.log(
  page.total,
  page.has_more,
  page.voices.map((v) => v.name),
);

A voice can speak once voice.status === "ready" (isReady(voice)). Its alias (for example narrator) also works as the voice on the OpenAI-compatible endpoint.

Request and response fields use the API's own names (voice_id, output_format, page_size), so they match the API reference exactly.

Clone a voice

Upload a clean reference clip of one speaker (10-30 seconds of clear speech works best; at least 10 seconds, at most 20 MiB) and wait for the voice to be ready. Cloning requires consent: true: you attest that you are the speaker or hold their explicit permission to clone their voice, and the attestation is recorded on the voice.

import { fileFromPath } from "@flamai/finesse";

const voice = await client.voices.cloneAndWait(
  {
    name: "Ana",
    file: await fileFromPath("reference.wav"), // or a Blob/File, bytes, or { data, filename }
    consent: true,
    language: "en",
    labels: { gender: "female", accent: "british" },
    ref_text: "What the speaker says in the clip, if you have it.",
  },
  { onUpdate: (job) => console.log("clone job:", job.status) },
);

cloneAndWait creates the voice, uploads the clip, and polls the clone job until it finishes. The first clone after the cloner has been idle can take a couple of minutes. To start the clone now and wait later:

const clone = await client.voices.clone({
  name: "Ana",
  file: await fileFromPath("reference.wav"),
  consent: true,
});
console.log(clone.voice.status, clone.job_id); // "processing", "6660aa00..."
const ready = await client.voices.waitUntilReady(clone, { timeout: 300_000 });

Cloning throws CloneFailedError if the job fails and CloneTimeoutError if it doesn't finish in time. To add a clip to a voice you already created, use client.voices.enroll(voiceId, { file, consent: true }).

Text to speech

speech.create returns the whole clip in one response (up to 10,000 characters of text):

const audio = await client.speech.create({
  voice_id: voice.id,
  text: "The quick brown fox jumps over the lazy dog.",
  language: "en", // optional; defaults to the voice's language
  output_format: "wav", // default; or raw PCM: "s16le", "s24le", "s32le", "f32le", "f64le", or "mulaw"
  num_step: 32, // optional; 1-32, default 32; fewer steps is faster
});

audio.data; // Uint8Array
audio.contentType; // "audio/wav"
audio.sampleRate; // 24000
audio.generationId; // id of this clip in your generation history
audio.audioURL; // stored copy of the clip, when storage is enabled (may be short-lived)

Identical requests are served from a cache at no charge. Speech is billed per character of text.

Streaming speech

speech.stream sends audio as it is generated, so playback can start before the whole clip is ready. Chunks are raw PCM (s16le, 24 kHz, mono, unless you set output_format).

const stream = client.speech.stream({ voice_id: voice.id, text: "Hello, streamed." });
for await (const chunk of stream) {
  speaker.write(chunk.pcm);
}
console.log(stream.done?.audio_duration_s, stream.done?.generation_id);

Or collect everything: const pcm = await client.speech.stream({ ... }).readAll();

Errors before the stream starts throw the usual errors (for example PaymentRequiredError). A failure after it starts throws StreamError.

Realtime

Realtime is for voice agents and anything conversational. One session is one WebSocket. You send text as it is produced and get audio back as fast as the engine makes it. Each turn of speech runs in a context, and a session can run several contexts at once.

Two contexts on one WebSocket session: text goes out as task_start and task_continue; audio comes back per context; cancelling a context drops the audio still in flight

const rt = await client.realtime.connect();

for await (const event of rt.say({ voice_id: voiceId, text: "Hi! How can I help you today?" })) {
  if (event.type === "audio") speaker.write(event.pcm); // s16le, 24 kHz, mono
}

await rt.close();

Stream text from an LLM

Send text a token or a sentence at a time, then finish() the turn:

const ctx = rt.context({ voice_id: voiceId });

const playing = (async () => {
  for await (const pcm of ctx.audio()) speaker.write(pcm);
})();

for await (const token of llm.stream(prompt)) await ctx.send(token);
await ctx.finish();
await playing;

Interruptions (barge-in)

When the user starts talking, cancel the turn. Audio still on its way for that context is dropped, and iterating the context ends with a task_cancelled event.

await ctx.cancel();

Events

Iterating a context yields events you can narrow on type:

type When
"task_started" The turn began. Has the sample_rate and audio_format of its audio.
"audio" A chunk of audio: pcm, sentence_index, chunk_index.
"sentence_done" A sentence finished: text, audio_duration_s.
"task_done" The turn finished (last event): total_audio_duration_s.
"task_cancelled" The turn was cancelled (last event).
"unknown" An event this SDK version doesn't know yet: event_type, data.

A turn that fails throws RealtimeError (for example code === "voice_not_found").

Limits and reconnects

  • A session runs up to 4 contexts at once, and holds one of your plan's concurrent-synthesis slots until it closes. Opening one more than the plan allows throws RateLimitError with code === "concurrency_limited" (wait error.retryAfter seconds), so reuse a session.
  • If the connection drops, running contexts throw RealtimeError. Check error.retryable. The next context reconnects automatically (turn this off with connect({ autoReconnect: false })).
  • Running out of credits ends the session with RealtimeError (code === "insufficient_credits").
  • Text is billed per character as it is sent. A session reserves credits in blocks of up to 1,000 characters and returns what it doesn't use when it ends.

Browsers and short-lived tokens

Never ship an API key to a browser. Your server mints a short-lived, single-use token with a key that has the tts scope, and returns only the token:

// On your server
const { token } = await client.tokens.create({ ttl_seconds: 60, voice_ids: [voiceId] });

In the browser, the client runs without dangerouslyAllowBrowser when it holds a token:

const getToken = async () => (await (await fetch("/api/finesse-token")).json()).token;

// One-shot speech: sent as "Authorization: Bearer fin_tok_..."
const tts = new Finesse({ accessToken: await getToken() });
const audio = await tts.speech.create({ voice_id: voiceId, text: "Hi!" });

// Realtime: the token rides in the WebSocket subprotocol list. A token is spent by the
// handshake that uses it, so pass getAccessToken for reconnects to fetch a fresh one.
const live = new Finesse({ accessToken: await getToken() });
const rt = await live.realtime.connect({ getAccessToken: getToken });

A token carries the tts scope only, works for one speech.create / speech.stream call or one realtime session, and dies when the key that minted it is revoked or expires. A request refused before it runs (a 429, a plan check) keeps its token, so you can retry with it. A spent, expired or unknown token throws AuthenticationError with code === "token_invalid"; a voice outside voice_ids throws PermissionDeniedError with code === "voice_not_allowed".

Which method should I use?

You want to... Use Why
Make an audio file, or speak a fixed piece of text speech.create Simplest. One request, one clip, cached.
Start playback fast for a known piece of text speech.stream Audio starts before the clip is finished.
Build a voice agent, or speak text as an LLM writes it realtime Text in, audio out, many turns on one connection, cancel on barge-in.

Audio formats

output_format What you get Default for
wav WAV file speech.create
s16le Raw PCM, 16-bit signed little-endian speech.stream, realtime
s24le, s32le Raw PCM, 24- or 32-bit signed
f32le, f64le Raw PCM, 32- or 64-bit float
mulaw Raw G.711 µ-law, for telephony needs sample_rate: 8000
opus Opus realtime only

All audio is mono. The engine's native rate is 24 kHz. You can ask for a higher sample_rate, or for 8000 (telephony); other lower rates still come back at 24 kHz, so always read the actual rate from audio.sampleRate, stream.sampleRate or the task_started event.

Phone calls (Twilio)

Twilio Media Streams play 8 kHz µ-law. Ask for exactly that and send each chunk as the media payload, base64-encoded; there is no WAV header to strip and nothing to resample:

const ctx = rt.context({ voice_id: voiceId, output_format: "mulaw", sample_rate: 8000 });

for await (const audio of ctx.audio()) {
  const payload = Buffer.from(audio).toString("base64");
  twilioWs.send(JSON.stringify({ event: "media", streamSid, media: { payload } }));
}

Handling errors

Every error extends FinesseError. HTTP errors carry status, message, a machine-readable code when the API sends one, and billingRemaining.

import {
  APIConnectionError,
  APIStatusError,
  PaymentRequiredError,
  RateLimitError,
} from "@flamai/finesse";

try {
  await client.speech.create({ voice_id: voice.id, text: "Hello!" });
} catch (err) {
  if (err instanceof PaymentRequiredError)
    console.log("Out of credits:", err.code, err.billingRemaining);
  else if (err instanceof RateLimitError) console.log("Slow down and retry.");
  else if (err instanceof APIStatusError) console.log(err.status, err.message);
  else if (err instanceof APIConnectionError) console.log("Could not reach Finesse.");
  else throw err;
}
Error Status When
BadRequestError 400 Invalid input, text over 10,000 characters, voice not enrolled
AuthenticationError 401 Missing, unknown or revoked key; key_expired; token_invalid
PaymentRequiredError 402 insufficient_credits, quota_exhausted or payment_required
PermissionDeniedError 403 insufficient_scope, voice_not_allowed (token), feature_disabled, or plan_limit_reached (see .feature)
NotFoundError 404 No such voice or job, or not visible to your workspace
ConflictError 409 A request with the same Idempotency-Key is still running
PayloadTooLargeError 413 Request body over 21 MiB
UnprocessableEntityError 422 invalid_input, or idempotency_key_reused
RateLimitError 429 rate_limited / concurrency_limited (see retryAfter), or too_many_sessions
InternalServerError 5xx upstream_error (502), upstream_busy / unavailable (503, see retryAfter) or internal_error: retry
APIConnectionError No response; APITimeoutError when it timed out
StreamError A speech stream failed after it started
RealtimeError A realtime context or session failed
CloneFailedError, CloneTimeoutError Waiting for a clone

Retries

Failed requests are retried twice with exponential backoff, honouring Retry-After: network errors, timeouts, 408, 429 and 5xx.

Calls that create something or cost credits (speech.create, voices.create, voices.clone, voices.enroll) send an Idempotency-Key, the same one on every retry, so the service runs them at most once. The SDK retries them this way only once the service has confirmed it honours keys (it echoes the key back); against a service that doesn't, they keep the cautious rules below, so nothing can run twice. A retry of a clone that already succeeded gets the first result back, and a retried synthesis is served from the cache at no charge. If the first attempt is still running, the SDK waits for it (up to the request timeout) without using up a retry. To choose the key yourself, pass { headers: { "Idempotency-Key": "..." } }.

speech.stream has no key, so it is only retried when the server certainly did no work: the connection never opened, 429, 502 or 503. These are also the rules for keyed calls until the service confirms it honours keys.

With an accessToken, only a 429 or a connection that never opened is retried. The service spends the token just before running the request, so a retry after anything later would only get token_invalid; the original error is thrown instead.

const client = new Finesse({ maxRetries: 5 }); // every request
await client.speech.create({ voice_id, text: "Hello!" }, { maxRetries: 0 }); // one request

Timeouts and cancellation

Requests time out after 120 seconds. Synthesis on a cold engine can take a while to start, so keep this generous. For streams the timeout is the longest wait between chunks, not the whole stream. A timeout throws APITimeoutError.

const client = new Finesse({ timeout: 60_000 }); // ms, every request
await client.voices.get(voiceId, { timeout: 10_000 }); // one request

Pass an AbortSignal to stop a request, its retries, and any waiting. The promise rejects with the signal's reason:

const controller = new AbortController();
setTimeout(() => controller.abort(), 5_000);
await client.speech.create({ voice_id, text: "Hello!" }, { signal: controller.signal });

Per-request options

Every request method takes { signal, timeout, maxRetries, headers } as its last argument, for that call only:

await client.speech.create(
  { voice_id, text: "Hello!" },
  { timeout: 30_000, maxRetries: 0, headers: { "X-Request-Id": "abc-123" } },
);

On jobs.wait, voices.waitUntilReady and voices.cloneAndWait, timeout means the overall time to wait for the clone.

Request IDs

Every response has an X-Request-Id. Errors carry it as error.requestId; quote it when you contact support.

try {
  await client.voices.get("665f1a2b3c4d5e6f7a8b9c0d");
} catch (err) {
  if (err instanceof NotFoundError) console.log(err.requestId); // "req_3f9c..."
}

Advanced

Raw responses

client.withRawResponse has the same methods and resolves to the HTTP response with the result:

const {
  data: audio,
  status,
  requestId,
  headers,
} = await client.withRawResponse.speech.create({
  voice_id,
  text: "Hello!",
});
console.log(status, requestId, headers.get("x-sample-rate"));

Logging

Set FINESSE_LOG=debug (or pass logLevel: "debug") to log every request (method, URL, status, timing, request id), or info to log only retries. Pass logger to send the lines somewhere other than console. The SDK never logs your API key, request bodies or audio.

Copying a client with other defaults

const quick = client.withOptions({ timeout: 10_000, maxRetries: 0 });
await quick.voices.get(voiceId);

Configuration

Option Environment variable Default
apiKey FINESSE_API_KEY required, unless accessToken
accessToken none (a short-lived fin_tok_ token)
timeout 120 000 ms
maxRetries 2
defaultHeaders none
fetch the global fetch
dangerouslyAllowBrowser false
logLevel FINESSE_LOG "off"
logger console

An option passed to new Finesse({...}) always wins over its environment variable.

Custom fetch

Pass your own fetch for proxies, logging or tests:

const client = new Finesse({ fetch: myFetch });

Attribution

Requests identify the SDK with an X-Finesse-Client header. If you build an integration on top of the SDK, name it so it shows up in your usage analytics:

const client = new Finesse({ defaultHeaders: { "X-Finesse-Client": "my-app/1.0.0" } });

Runtimes

Runtime HTTP Realtime
Node 18+ built-in fetch needs ws; the API key goes in a header
Bun built-in built-in WebSocket; the API key goes in a header
Deno built-in built-in WebSocket; from 2.5 the key goes in a header
Browsers with accessToken built-in WebSocket; the token is a subprotocol

Browsers can't keep an API key secret, so a client holding a key refuses to run in one. Use a short-lived token instead. For internal tools only, new Finesse({ dangerouslyAllowBrowser: true }) lifts this; realtime then sends the key in a deprecated URL parameter, because browsers can't set WebSocket headers (as does Deno before 2.5), and logs a warning once.

TypeScript types

Every request and response type is exported, so you can type your own code against the API:

import type { Voice } from "@flamai/finesse";

function label(voice: Voice) {
  return `${voice.name} (${voice.id})`;
}

Examples

Runnable scripts are in examples/: speak.ts and realtime.ts.

Requirements

  • Node 18 or later, Bun, Deno, or a modern browser (see Runtimes).
  • TypeScript 4.7 or later, if you use TypeScript.

Versioning

The SDK follows semantic versioning. Before 1.0, minor versions may include breaking changes; they are listed in the changelog.

import { VERSION } from "@flamai/finesse";

Getting help

Security

Please don't report security problems in a public issue. See SECURITY.md for how to report one privately.

Contributing

pnpm install
pnpm test                  # unit tests, no network
pnpm typecheck && pnpm lint && pnpm build

Contract tests run against a real API (they synthesize a few short sentences, which is billed) and refuse to run against production:

FINESSE_LIVE=1 FINESSE_API_KEY=flv_... \
FINESSE_BASE_URL=<non-production API URL> pnpm vitest run tests/live

License

Apache License 2.0. See LICENSE.

About

Official TypeScript SDK for the Finesse voice API: voice cloning, text to speech and realtime streaming (@flamai/finesse)

Topics

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages