@flamai/finesse 0.0.0-stage → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,626 @@
1
- # Temporary Holding Version
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="./.github/assets/banner-node-dark.png">
3
+ <source media="(prefers-color-scheme: light)" srcset="./.github/assets/banner-node-light.png">
4
+ <img alt="Finesse by Flam: TypeScript SDK for voice cloning, text-to-speech and realtime streaming" src="./.github/assets/banner-node-light.png" width="100%">
5
+ </picture>
2
6
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
7
+ <p>
8
+ <a href="https://www.npmjs.com/package/@flamai/finesse"><img alt="npm version" src="https://img.shields.io/npm/v/@flamai/finesse?style=flat&labelColor=14151A&color=2547F0"></a>
9
+ <img alt="Node 18+" src="https://img.shields.io/badge/node-18%2B-14151A?style=flat&labelColor=14151A&color=2547F0">
10
+ <img alt="Bun and Deno" src="https://img.shields.io/badge/also-Bun%20%C2%B7%20Deno-14151A?style=flat&labelColor=14151A&color=2547F0">
11
+ <img alt="TypeScript types included" src="https://img.shields.io/badge/types-included-14151A?style=flat&labelColor=14151A&color=2547F0">
12
+ <img alt="Status: beta" src="https://img.shields.io/badge/status-beta-14151A?style=flat&labelColor=14151A&color=63656A">
13
+ <a href="./LICENSE"><img alt="License: Apache-2.0" src="https://img.shields.io/badge/license-Apache--2.0-14151A?style=flat&labelColor=14151A&color=63656A"></a>
14
+ </p>
15
+
16
+ # Finesse TypeScript SDK
17
+
18
+ The official TypeScript and JavaScript library for [Finesse](https://finesse.flamapp.ai), Flam's
19
+ voice platform. Clone a voice from a few seconds of audio, turn text into speech in that voice,
20
+ and stream it in realtime to voice agents, with a few lines of code.
21
+
22
+ - **Typed** end to end, ESM and CommonJS, no heavy dependencies (it uses the built-in `fetch`).
23
+ - **Realtime** over one WebSocket: stream text in as your LLM writes it, get audio back, cancel
24
+ on barge-in.
25
+ - **Safe by default**: retries that never double-bill, typed errors, timeouts, `AbortSignal`.
26
+
27
+ **[Documentation](https://finesse.flamapp.ai/guides/overview)** ·
28
+ **[API reference](https://finesse.flamapp.ai/voice-api/list-voices)** ·
29
+ **[Dashboard](https://finesse.flamapp.ai/dashboard)** ·
30
+ **[Changelog](./CHANGELOG.md)**
31
+
32
+ ## Contents
33
+
34
+ - [Installation](#installation)
35
+ - [Get an API key](#get-an-api-key)
36
+ - [Quickstart](#quickstart)
37
+ - [Voices](#voices)
38
+ - [Clone a voice](#clone-a-voice)
39
+ - [Text to speech](#text-to-speech)
40
+ - [Streaming speech](#streaming-speech)
41
+ - [Realtime](#realtime)
42
+ - [Browsers and short-lived tokens](#browsers-and-short-lived-tokens)
43
+ - [Which method should I use?](#which-method-should-i-use)
44
+ - [Audio formats](#audio-formats)
45
+ - [Handling errors](#handling-errors)
46
+ - [Retries](#retries)
47
+ - [Timeouts and cancellation](#timeouts-and-cancellation)
48
+ - [Per-request options](#per-request-options)
49
+ - [Request IDs](#request-ids)
50
+ - [Advanced](#advanced)
51
+ - [Runtimes](#runtimes)
52
+ - [Requirements](#requirements)
53
+ - [Versioning](#versioning)
54
+ - [Getting help](#getting-help)
55
+ - [Security](#security)
56
+ - [Contributing](#contributing)
57
+
58
+ ## Installation
59
+
60
+ Realtime in Node also needs the `ws` package:
61
+
62
+ ```sh
63
+ npm install @flamai/finesse ws
64
+ # or
65
+ pnpm add @flamai/finesse ws
66
+ # or
67
+ yarn add @flamai/finesse ws
68
+ # or
69
+ bun add @flamai/finesse
70
+ ```
71
+
72
+ `ws` is only needed for realtime in Node. Bun, Deno and browsers have a built-in `WebSocket`.
73
+
74
+ ## Get an API key
75
+
76
+ 1. Sign in to the [Finesse dashboard](https://finesse.flamapp.ai/dashboard).
77
+ 2. Open **Settings → API keys** and create a key. Copy it right away: it is shown only once,
78
+ and it starts with `flv_`.
79
+ 3. Put it in your environment, where the SDK finds it:
80
+
81
+ ```sh
82
+ export FINESSE_API_KEY="flv_..."
83
+ ```
84
+
85
+ Keep the key on your server. Anyone who has it can spend your workspace's credits.
86
+
87
+ <p align="center">
88
+ <img alt="The API Keys page in the Finesse dashboard: name a key, press Create key, and copy it once" src="./.github/assets/api-keys.svg" width="100%">
89
+ </p>
90
+
91
+ ## Quickstart
92
+
93
+ ```ts
94
+ import { writeFile } from "node:fs/promises";
95
+ import { Finesse, isReady } from "@flamai/finesse";
96
+
97
+ const client = new Finesse(); // reads FINESSE_API_KEY
98
+
99
+ // Pick any voice you can use: your own, or one from the public catalog.
100
+ let voiceId = "";
101
+ for await (const voice of client.voices.list()) {
102
+ if (isReady(voice)) {
103
+ voiceId = voice.id;
104
+ break;
105
+ }
106
+ }
107
+
108
+ const audio = await client.speech.create({ voice_id: voiceId, text: "Hello from Finesse!" });
109
+ await writeFile("hello.wav", audio.data);
110
+ ```
111
+
112
+ `hello.wav` is a 24 kHz WAV file. That's it.
113
+
114
+ ## Voices
115
+
116
+ `voices.list()` returns your voices first (newest first), then the public platform voices.
117
+ `for await` walks every page for you.
118
+
119
+ ```ts
120
+ for await (const voice of client.voices.list()) {
121
+ console.log(voice.id, voice.name, voice.status, voice.language, voice.labels);
122
+ }
123
+
124
+ const voice = await client.voices.get("665f1a2b3c4d5e6f7a8b9c0d");
125
+ await client.voices.update(voice.id, { name: "Narrator (UK)", labels: { accent: "british" } });
126
+ await client.voices.delete(voice.id);
127
+ ```
128
+
129
+ Just the first page, with its totals:
130
+
131
+ ```ts
132
+ const page = await client.voices.list({ page_size: 20 });
133
+ console.log(
134
+ page.total,
135
+ page.has_more,
136
+ page.voices.map((v) => v.name),
137
+ );
138
+ ```
139
+
140
+ A voice can speak once `voice.status === "ready"` (`isReady(voice)`). Its `alias` (for example
141
+ `narrator`) also works as the `voice` on the
142
+ [OpenAI-compatible endpoint](https://finesse.flamapp.ai/voice-api/openai-compatible).
143
+
144
+ Request and response fields use the API's own names (`voice_id`, `output_format`,
145
+ `page_size`), so they match the API reference exactly.
146
+
147
+ ## Clone a voice
148
+
149
+ Upload a clean reference clip of one speaker (10-30 seconds of clear speech works best; at least
150
+ 10 seconds, at most 20 MiB) and wait for the voice to be ready. Cloning requires
151
+ `consent: true`: you attest that you are the speaker or hold their explicit permission to clone
152
+ their voice, and the attestation is recorded on the voice.
153
+
154
+ ```ts
155
+ import { fileFromPath } from "@flamai/finesse";
156
+
157
+ const voice = await client.voices.cloneAndWait(
158
+ {
159
+ name: "Ana",
160
+ file: await fileFromPath("reference.wav"), // or a Blob/File, bytes, or { data, filename }
161
+ consent: true,
162
+ language: "en",
163
+ labels: { gender: "female", accent: "british" },
164
+ ref_text: "What the speaker says in the clip, if you have it.",
165
+ },
166
+ { onUpdate: (job) => console.log("clone job:", job.status) },
167
+ );
168
+ ```
169
+
170
+ `cloneAndWait` creates the voice, uploads the clip, and polls the clone job until it finishes.
171
+ The first clone after the cloner has been idle can take a couple of minutes. To start the clone
172
+ now and wait later:
173
+
174
+ ```ts
175
+ const clone = await client.voices.clone({
176
+ name: "Ana",
177
+ file: await fileFromPath("reference.wav"),
178
+ consent: true,
179
+ });
180
+ console.log(clone.voice.status, clone.job_id); // "processing", "6660aa00..."
181
+ const ready = await client.voices.waitUntilReady(clone, { timeout: 300_000 });
182
+ ```
183
+
184
+ Cloning throws `CloneFailedError` if the job fails and `CloneTimeoutError` if it doesn't finish
185
+ in time. To add a clip to a voice you already created, use
186
+ `client.voices.enroll(voiceId, { file, consent: true })`.
187
+
188
+ ## Text to speech
189
+
190
+ `speech.create` returns the whole clip in one response (up to 10,000 characters of text):
191
+
192
+ ```ts
193
+ const audio = await client.speech.create({
194
+ voice_id: voice.id,
195
+ text: "The quick brown fox jumps over the lazy dog.",
196
+ language: "en", // optional; defaults to the voice's language
197
+ output_format: "wav", // default; or raw PCM: "s16le", "s24le", "s32le", "f32le", "f64le", or "mulaw"
198
+ num_step: 32, // optional; 1-32, default 32; fewer steps is faster
199
+ });
200
+
201
+ audio.data; // Uint8Array
202
+ audio.contentType; // "audio/wav"
203
+ audio.sampleRate; // 24000
204
+ audio.generationId; // id of this clip in your generation history
205
+ audio.audioURL; // stored copy of the clip, when storage is enabled (may be short-lived)
206
+ ```
207
+
208
+ Identical requests are served from a cache at no charge. Speech is billed per character of
209
+ `text`.
210
+
211
+ ## Streaming speech
212
+
213
+ `speech.stream` sends audio as it is generated, so playback can start before the whole clip is
214
+ ready. Chunks are raw PCM (`s16le`, 24 kHz, mono, unless you set `output_format`).
215
+
216
+ ```ts
217
+ const stream = client.speech.stream({ voice_id: voice.id, text: "Hello, streamed." });
218
+ for await (const chunk of stream) {
219
+ speaker.write(chunk.pcm);
220
+ }
221
+ console.log(stream.done?.audio_duration_s, stream.done?.generation_id);
222
+ ```
223
+
224
+ Or collect everything: `const pcm = await client.speech.stream({ ... }).readAll();`
225
+
226
+ Errors before the stream starts throw the usual errors (for example `PaymentRequiredError`).
227
+ A failure after it starts throws `StreamError`.
228
+
229
+ ## Realtime
230
+
231
+ Realtime is for voice agents and anything conversational. One session is one WebSocket. You
232
+ send text as it is produced and get audio back as fast as the engine makes it. Each turn of
233
+ speech runs in a **context**, and a session can run several contexts at once.
234
+
235
+ <p align="center">
236
+ <img alt="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" src="./.github/assets/realtime.svg" width="100%">
237
+ </p>
238
+
239
+ ```ts
240
+ const rt = await client.realtime.connect();
241
+
242
+ for await (const event of rt.say({ voice_id: voiceId, text: "Hi! How can I help you today?" })) {
243
+ if (event.type === "audio") speaker.write(event.pcm); // s16le, 24 kHz, mono
244
+ }
245
+
246
+ await rt.close();
247
+ ```
248
+
249
+ ### Stream text from an LLM
250
+
251
+ Send text a token or a sentence at a time, then `finish()` the turn:
252
+
253
+ ```ts
254
+ const ctx = rt.context({ voice_id: voiceId });
255
+
256
+ const playing = (async () => {
257
+ for await (const pcm of ctx.audio()) speaker.write(pcm);
258
+ })();
259
+
260
+ for await (const token of llm.stream(prompt)) await ctx.send(token);
261
+ await ctx.finish();
262
+ await playing;
263
+ ```
264
+
265
+ ### Interruptions (barge-in)
266
+
267
+ When the user starts talking, cancel the turn. Audio still on its way for that context is
268
+ dropped, and iterating the context ends with a `task_cancelled` event.
269
+
270
+ ```ts
271
+ await ctx.cancel();
272
+ ```
273
+
274
+ ### Events
275
+
276
+ Iterating a context yields events you can narrow on `type`:
277
+
278
+ | `type` | When |
279
+ | ------------------ | ---------------------------------------------------------------------- |
280
+ | `"task_started"` | The turn began. Has the `sample_rate` and `audio_format` of its audio. |
281
+ | `"audio"` | A chunk of audio: `pcm`, `sentence_index`, `chunk_index`. |
282
+ | `"sentence_done"` | A sentence finished: `text`, `audio_duration_s`. |
283
+ | `"task_done"` | The turn finished (last event): `total_audio_duration_s`. |
284
+ | `"task_cancelled"` | The turn was cancelled (last event). |
285
+ | `"unknown"` | An event this SDK version doesn't know yet: `event_type`, `data`. |
286
+
287
+ A turn that fails throws `RealtimeError` (for example `code === "voice_not_found"`).
288
+
289
+ ### Limits and reconnects
290
+
291
+ - A session runs up to 4 contexts at once, and holds one of your plan's concurrent-synthesis
292
+ slots until it closes. Opening one more than the plan allows throws `RateLimitError` with
293
+ `code === "concurrency_limited"` (wait `error.retryAfter` seconds), so reuse a session.
294
+ - If the connection drops, running contexts throw `RealtimeError`. Check `error.retryable`. The
295
+ next context reconnects automatically (turn this off with `connect({ autoReconnect: false })`).
296
+ - Running out of credits ends the session with `RealtimeError` (`code === "insufficient_credits"`).
297
+ - Text is billed per character as it is sent. A session reserves credits in blocks of up to
298
+ 1,000 characters and returns what it doesn't use when it ends.
299
+
300
+ ## Browsers and short-lived tokens
301
+
302
+ Never ship an API key to a browser. Your server mints a short-lived, single-use token with a key
303
+ that has the `tts` scope, and returns only the token:
304
+
305
+ ```ts
306
+ // On your server
307
+ const { token } = await client.tokens.create({ ttl_seconds: 60, voice_ids: [voiceId] });
308
+ ```
309
+
310
+ In the browser, the client runs without `dangerouslyAllowBrowser` when it holds a token:
311
+
312
+ ```ts
313
+ const getToken = async () => (await (await fetch("/api/finesse-token")).json()).token;
314
+
315
+ // One-shot speech: sent as "Authorization: Bearer fin_tok_..."
316
+ const tts = new Finesse({ accessToken: await getToken() });
317
+ const audio = await tts.speech.create({ voice_id: voiceId, text: "Hi!" });
318
+
319
+ // Realtime: the token rides in the WebSocket subprotocol list. A token is spent by the
320
+ // handshake that uses it, so pass getAccessToken for reconnects to fetch a fresh one.
321
+ const live = new Finesse({ accessToken: await getToken() });
322
+ const rt = await live.realtime.connect({ getAccessToken: getToken });
323
+ ```
324
+
325
+ A token carries the `tts` scope only, works for one `speech.create` / `speech.stream` call or one
326
+ realtime session, and dies when the key that minted it is revoked or expires. A request refused
327
+ before it runs (a `429`, a plan check) keeps its token, so you can retry with it. A spent,
328
+ expired or unknown token throws `AuthenticationError` with `code === "token_invalid"`; a voice
329
+ outside `voice_ids` throws `PermissionDeniedError` with `code === "voice_not_allowed"`.
330
+
331
+ ## Which method should I use?
332
+
333
+ | You want to... | Use | Why |
334
+ | ------------------------------------------------------ | --------------- | --------------------------------------------------------------------- |
335
+ | Make an audio file, or speak a fixed piece of text | `speech.create` | Simplest. One request, one clip, cached. |
336
+ | Start playback fast for a known piece of text | `speech.stream` | Audio starts before the clip is finished. |
337
+ | 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. |
338
+
339
+ ## Audio formats
340
+
341
+ | `output_format` | What you get | Default for |
342
+ | ---------------- | ------------------------------------ | ------------------------- |
343
+ | `wav` | WAV file | `speech.create` |
344
+ | `s16le` | Raw PCM, 16-bit signed little-endian | `speech.stream`, realtime |
345
+ | `s24le`, `s32le` | Raw PCM, 24- or 32-bit signed | |
346
+ | `f32le`, `f64le` | Raw PCM, 32- or 64-bit float | |
347
+ | `mulaw` | Raw G.711 µ-law, for telephony | needs `sample_rate: 8000` |
348
+ | `opus` | Opus | realtime only |
349
+
350
+ All audio is mono. The engine's native rate is 24 kHz. You can ask for a higher `sample_rate`, or
351
+ for 8000 (telephony); other lower rates still come back at 24 kHz, so always read the actual rate
352
+ from `audio.sampleRate`, `stream.sampleRate` or the `task_started` event.
353
+
354
+ ### Phone calls (Twilio)
355
+
356
+ Twilio Media Streams play 8 kHz µ-law. Ask for exactly that and send each chunk as the media
357
+ payload, base64-encoded; there is no WAV header to strip and nothing to resample:
358
+
359
+ ```ts
360
+ const ctx = rt.context({ voice_id: voiceId, output_format: "mulaw", sample_rate: 8000 });
361
+
362
+ for await (const audio of ctx.audio()) {
363
+ const payload = Buffer.from(audio).toString("base64");
364
+ twilioWs.send(JSON.stringify({ event: "media", streamSid, media: { payload } }));
365
+ }
366
+ ```
367
+
368
+ ## Handling errors
369
+
370
+ Every error extends `FinesseError`. HTTP errors carry `status`, `message`, a machine-readable
371
+ `code` when the API sends one, and `billingRemaining`.
372
+
373
+ ```ts
374
+ import {
375
+ APIConnectionError,
376
+ APIStatusError,
377
+ PaymentRequiredError,
378
+ RateLimitError,
379
+ } from "@flamai/finesse";
380
+
381
+ try {
382
+ await client.speech.create({ voice_id: voice.id, text: "Hello!" });
383
+ } catch (err) {
384
+ if (err instanceof PaymentRequiredError)
385
+ console.log("Out of credits:", err.code, err.billingRemaining);
386
+ else if (err instanceof RateLimitError) console.log("Slow down and retry.");
387
+ else if (err instanceof APIStatusError) console.log(err.status, err.message);
388
+ else if (err instanceof APIConnectionError) console.log("Could not reach Finesse.");
389
+ else throw err;
390
+ }
391
+ ```
392
+
393
+ | Error | Status | When |
394
+ | --------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------- |
395
+ | `BadRequestError` | 400 | Invalid input, text over 10,000 characters, voice not enrolled |
396
+ | `AuthenticationError` | 401 | Missing, unknown or revoked key; `key_expired`; `token_invalid` |
397
+ | `PaymentRequiredError` | 402 | `insufficient_credits`, `quota_exhausted` or `payment_required` |
398
+ | `PermissionDeniedError` | 403 | `insufficient_scope`, `voice_not_allowed` (token), `feature_disabled`, or `plan_limit_reached` (see `.feature`) |
399
+ | `NotFoundError` | 404 | No such voice or job, or not visible to your workspace |
400
+ | `ConflictError` | 409 | A request with the same `Idempotency-Key` is still running |
401
+ | `PayloadTooLargeError` | 413 | Request body over 21 MiB |
402
+ | `UnprocessableEntityError` | 422 | `invalid_input`, or `idempotency_key_reused` |
403
+ | `RateLimitError` | 429 | `rate_limited` / `concurrency_limited` (see `retryAfter`), or `too_many_sessions` |
404
+ | `InternalServerError` | 5xx | `upstream_error` (502), `upstream_busy` / `unavailable` (503, see `retryAfter`) or `internal_error`: retry |
405
+ | `APIConnectionError` | | No response; `APITimeoutError` when it timed out |
406
+ | `StreamError` | | A speech stream failed after it started |
407
+ | `RealtimeError` | | A realtime context or session failed |
408
+ | `CloneFailedError`, `CloneTimeoutError` | | Waiting for a clone |
409
+
410
+ ## Retries
411
+
412
+ Failed requests are retried twice with exponential backoff, honouring `Retry-After`: network
413
+ errors, timeouts, 408, 429 and 5xx.
414
+
415
+ Calls that create something or cost credits (`speech.create`, `voices.create`, `voices.clone`,
416
+ `voices.enroll`) send an `Idempotency-Key`, the same one on every retry, so the service runs
417
+ them at most once. The SDK retries them this way only once the service has confirmed it
418
+ honours keys (it echoes the key back); against a service that doesn't, they keep the cautious
419
+ rules below, so nothing can run twice. A retry of a clone that already succeeded gets the first result back, and a
420
+ retried synthesis is served from the cache at no charge. If the first attempt is still running,
421
+ the SDK waits for it (up to the request timeout) without using up a retry. To choose the key
422
+ yourself, pass `{ headers: { "Idempotency-Key": "..." } }`.
423
+
424
+ `speech.stream` has no key, so it is only retried when the server certainly did no work: the
425
+ connection never opened, 429, 502 or 503. These are also the rules for keyed calls until the
426
+ service confirms it honours keys.
427
+
428
+ With an `accessToken`, only a `429` or a connection that never opened is retried. The service
429
+ spends the token just before running the request, so a retry after anything later would only
430
+ get `token_invalid`; the original error is thrown instead.
431
+
432
+ ```ts
433
+ const client = new Finesse({ maxRetries: 5 }); // every request
434
+ await client.speech.create({ voice_id, text: "Hello!" }, { maxRetries: 0 }); // one request
435
+ ```
436
+
437
+ ## Timeouts and cancellation
438
+
439
+ Requests time out after 120 seconds. Synthesis on a cold engine can take a while to start, so
440
+ keep this generous. For streams the timeout is the longest wait between chunks, not the whole
441
+ stream. A timeout throws `APITimeoutError`.
442
+
443
+ ```ts
444
+ const client = new Finesse({ timeout: 60_000 }); // ms, every request
445
+ await client.voices.get(voiceId, { timeout: 10_000 }); // one request
446
+ ```
447
+
448
+ Pass an `AbortSignal` to stop a request, its retries, and any waiting. The promise rejects with
449
+ the signal's reason:
450
+
451
+ ```ts
452
+ const controller = new AbortController();
453
+ setTimeout(() => controller.abort(), 5_000);
454
+ await client.speech.create({ voice_id, text: "Hello!" }, { signal: controller.signal });
455
+ ```
456
+
457
+ ## Per-request options
458
+
459
+ Every request method takes `{ signal, timeout, maxRetries, headers }` as its last argument, for
460
+ that call only:
461
+
462
+ ```ts
463
+ await client.speech.create(
464
+ { voice_id, text: "Hello!" },
465
+ { timeout: 30_000, maxRetries: 0, headers: { "X-Request-Id": "abc-123" } },
466
+ );
467
+ ```
468
+
469
+ On `jobs.wait`, `voices.waitUntilReady` and `voices.cloneAndWait`, `timeout` means the overall
470
+ time to wait for the clone.
471
+
472
+ ## Request IDs
473
+
474
+ Every response has an `X-Request-Id`. Errors carry it as `error.requestId`; quote it when you
475
+ contact support.
476
+
477
+ ```ts
478
+ try {
479
+ await client.voices.get("665f1a2b3c4d5e6f7a8b9c0d");
480
+ } catch (err) {
481
+ if (err instanceof NotFoundError) console.log(err.requestId); // "req_3f9c..."
482
+ }
483
+ ```
484
+
485
+ ## Advanced
486
+
487
+ ### Raw responses
488
+
489
+ `client.withRawResponse` has the same methods and resolves to the HTTP response with the result:
490
+
491
+ ```ts
492
+ const {
493
+ data: audio,
494
+ status,
495
+ requestId,
496
+ headers,
497
+ } = await client.withRawResponse.speech.create({
498
+ voice_id,
499
+ text: "Hello!",
500
+ });
501
+ console.log(status, requestId, headers.get("x-sample-rate"));
502
+ ```
503
+
504
+ ### Logging
505
+
506
+ Set `FINESSE_LOG=debug` (or pass `logLevel: "debug"`) to log every request (method, URL, status,
507
+ timing, request id), or `info` to log only retries. Pass `logger` to send the lines somewhere
508
+ other than `console`. The SDK never logs your API key, request bodies or audio.
509
+
510
+ ### Copying a client with other defaults
511
+
512
+ ```ts
513
+ const quick = client.withOptions({ timeout: 10_000, maxRetries: 0 });
514
+ await quick.voices.get(voiceId);
515
+ ```
516
+
517
+ ### Configuration
518
+
519
+ | Option | Environment variable | Default |
520
+ | ------------------------- | -------------------- | ------------------------------------- |
521
+ | `apiKey` | `FINESSE_API_KEY` | required, unless `accessToken` |
522
+ | `accessToken` | | none (a short-lived `fin_tok_` token) |
523
+ | `timeout` | | 120 000 ms |
524
+ | `maxRetries` | | 2 |
525
+ | `defaultHeaders` | | none |
526
+ | `fetch` | | the global `fetch` |
527
+ | `dangerouslyAllowBrowser` | | `false` |
528
+ | `logLevel` | `FINESSE_LOG` | `"off"` |
529
+ | `logger` | | `console` |
530
+
531
+ An option passed to `new Finesse({...})` always wins over its environment variable.
532
+
533
+ ### Custom fetch
534
+
535
+ Pass your own `fetch` for proxies, logging or tests:
536
+
537
+ ```ts
538
+ const client = new Finesse({ fetch: myFetch });
539
+ ```
540
+
541
+ ### Attribution
542
+
543
+ Requests identify the SDK with an `X-Finesse-Client` header. If you build an integration on top
544
+ of the SDK, name it so it shows up in your usage analytics:
545
+
546
+ ```ts
547
+ const client = new Finesse({ defaultHeaders: { "X-Finesse-Client": "my-app/1.0.0" } });
548
+ ```
549
+
550
+ ## Runtimes
551
+
552
+ | Runtime | HTTP | Realtime |
553
+ | -------- | ------------------ | ------------------------------------------------------- |
554
+ | Node 18+ | built-in `fetch` | needs `ws`; the API key goes in a header |
555
+ | Bun | built-in | built-in `WebSocket`; the API key goes in a header |
556
+ | Deno | built-in | built-in `WebSocket`; from 2.5 the key goes in a header |
557
+ | Browsers | with `accessToken` | built-in `WebSocket`; the token is a subprotocol |
558
+
559
+ Browsers can't keep an API key secret, so a client holding a key refuses to run in one. Use a
560
+ [short-lived token](#browsers-and-short-lived-tokens) instead. For internal tools only,
561
+ `new Finesse({ dangerouslyAllowBrowser: true })` lifts this; realtime then sends the key in a
562
+ deprecated URL parameter, because browsers can't set WebSocket headers (as does Deno before
563
+ 2.5), and logs a warning once.
564
+
565
+ ## TypeScript types
566
+
567
+ Every request and response type is exported, so you can type your own code against the API:
568
+
569
+ ```ts
570
+ import type { Voice } from "@flamai/finesse";
571
+
572
+ function label(voice: Voice) {
573
+ return `${voice.name} (${voice.id})`;
574
+ }
575
+ ```
576
+
577
+ ## Examples
578
+
579
+ Runnable scripts are in [`examples/`](./examples): [`speak.ts`](./examples/speak.ts) and
580
+ [`realtime.ts`](./examples/realtime.ts).
581
+
582
+ ## Requirements
583
+
584
+ - Node 18 or later, Bun, Deno, or a modern browser (see [Runtimes](#runtimes)).
585
+ - TypeScript 4.7 or later, if you use TypeScript.
586
+
587
+ ## Versioning
588
+
589
+ The SDK follows [semantic versioning](https://semver.org). Before 1.0, minor versions may
590
+ include breaking changes; they are listed in the [changelog](./CHANGELOG.md).
591
+
592
+ ```ts
593
+ import { VERSION } from "@flamai/finesse";
594
+ ```
595
+
596
+ ## Getting help
597
+
598
+ - [Documentation](https://docs.finesse.flamapp.ai) for guides and the API reference.
599
+ - [GitHub issues](https://github.com/homingos/finesse-node/issues) for bugs and feature requests
600
+ in this SDK. Include the `requestId` from the error (see [Request IDs](#request-ids)) when
601
+ a request failed.
602
+
603
+ ## Security
604
+
605
+ Please don't report security problems in a public issue. See [SECURITY.md](./SECURITY.md) for
606
+ how to report one privately.
607
+
608
+ ## Contributing
609
+
610
+ ```sh
611
+ pnpm install
612
+ pnpm test # unit tests, no network
613
+ pnpm typecheck && pnpm lint && pnpm build
614
+ ```
615
+
616
+ Contract tests run against a real API (they synthesize a few short sentences, which is billed)
617
+ and refuse to run against production:
618
+
619
+ ```sh
620
+ FINESSE_LIVE=1 FINESSE_API_KEY=flv_... \
621
+ FINESSE_BASE_URL=<non-production API URL> pnpm vitest run tests/live
622
+ ```
623
+
624
+ ## License
625
+
626
+ Apache License 2.0. See [LICENSE](LICENSE).