@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/CHANGELOG.md +49 -0
- package/LICENSE +202 -0
- package/README.md +625 -2
- package/dist/index.cjs +1785 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +831 -0
- package/dist/index.d.ts +831 -0
- package/dist/index.js +1722 -0
- package/dist/index.js.map +1 -0
- package/package.json +71 -4
package/README.md
CHANGED
|
@@ -1,3 +1,626 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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).
|