@flamai/finesse 0.1.1 → 0.2.0

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 CHANGED
@@ -1,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1 (unreleased)
3
+ ## 0.2.0
4
+
5
+ - Direct realtime sessions: `realtime.sessions.create({ voice_ids, budget_chars })` and
6
+ `realtime.connect({ session })` / `{ getSession }` run the socket straight against the
7
+ synthesis GPU in the account's server region, for lower latency. `RealtimeSession.directSession`
8
+ reports its region and budget.
9
+ - Realtime: on a direct session, close code `4401` is `RealtimeError` with
10
+ `code === "unauthorized"` (the token was invalid, already used or past `connect_by`), not
11
+ `service_auth_failed`. Reusing a spent session throws `session_spent`; one past `connect_by`
12
+ throws `session_expired` without dialing.
13
+
14
+ ## 0.1.2
15
+
16
+ - `output_format: "mulaw"` (raw G.711 µ-law) for telephony, with `sample_rate: 8000`, on
17
+ `speech` and realtime contexts. 8 kHz is now produced as asked; the README shows Twilio Media
18
+ Streams.
19
+
20
+ ## 0.1.1
4
21
 
5
22
  - Published on npm as `@flamai/finesse` (was private, installed from GitHub).
6
23
  - Token clients (`accessToken`) retry only `429` and connections that never opened. The
package/README.md CHANGED
@@ -5,10 +5,12 @@
5
5
  </picture>
6
6
 
7
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>
8
9
  <img alt="Node 18+" src="https://img.shields.io/badge/node-18%2B-14151A?style=flat&labelColor=14151A&color=2547F0">
9
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">
10
11
  <img alt="TypeScript types included" src="https://img.shields.io/badge/types-included-14151A?style=flat&labelColor=14151A&color=2547F0">
11
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>
12
14
  </p>
13
15
 
14
16
  # Finesse TypeScript SDK
@@ -37,6 +39,7 @@ and stream it in realtime to voice agents, with a few lines of code.
37
39
  - [Text to speech](#text-to-speech)
38
40
  - [Streaming speech](#streaming-speech)
39
41
  - [Realtime](#realtime)
42
+ - [Direct sessions (lowest latency)](#direct-sessions-lowest-latency)
40
43
  - [Browsers and short-lived tokens](#browsers-and-short-lived-tokens)
41
44
  - [Which method should I use?](#which-method-should-i-use)
42
45
  - [Audio formats](#audio-formats)
@@ -47,6 +50,11 @@ and stream it in realtime to voice agents, with a few lines of code.
47
50
  - [Request IDs](#request-ids)
48
51
  - [Advanced](#advanced)
49
52
  - [Runtimes](#runtimes)
53
+ - [Requirements](#requirements)
54
+ - [Versioning](#versioning)
55
+ - [Getting help](#getting-help)
56
+ - [Security](#security)
57
+ - [Contributing](#contributing)
50
58
 
51
59
  ## Installation
52
60
 
@@ -56,8 +64,14 @@ Realtime in Node also needs the `ws` package:
56
64
  npm install @flamai/finesse ws
57
65
  # or
58
66
  pnpm add @flamai/finesse ws
67
+ # or
68
+ yarn add @flamai/finesse ws
69
+ # or
70
+ bun add @flamai/finesse
59
71
  ```
60
72
 
73
+ `ws` is only needed for realtime in Node. Bun, Deno and browsers have a built-in `WebSocket`.
74
+
61
75
  ## Get an API key
62
76
 
63
77
  1. Sign in to the [Finesse dashboard](https://finesse.flamapp.ai/dashboard).
@@ -181,7 +195,7 @@ const audio = await client.speech.create({
181
195
  voice_id: voice.id,
182
196
  text: "The quick brown fox jumps over the lazy dog.",
183
197
  language: "en", // optional; defaults to the voice's language
184
- output_format: "wav", // default; or raw PCM: "s16le", "s24le", "s32le", "f32le", "f64le"
198
+ output_format: "wav", // default; or raw PCM: "s16le", "s24le", "s32le", "f32le", "f64le", or "mulaw"
185
199
  num_step: 32, // optional; 1-32, default 32; fewer steps is faster
186
200
  });
187
201
 
@@ -284,6 +298,52 @@ A turn that fails throws `RealtimeError` (for example `code === "voice_not_found
284
298
  - Text is billed per character as it is sent. A session reserves credits in blocks of up to
285
299
  1,000 characters and returns what it doesn't use when it ends.
286
300
 
301
+ ## Direct sessions (lowest latency)
302
+
303
+ A direct session runs the realtime socket **straight against the synthesis GPU** in your
304
+ account's server region, so audio never passes through the API and each sentence starts sooner.
305
+ Contexts, events, cancel and limits work exactly as above.
306
+
307
+ ```ts
308
+ // On a server, with your API key: a new session for the first connection and every reconnect.
309
+ const rt = await client.realtime.connect({
310
+ getSession: () => client.realtime.sessions.create({ voice_ids: [voiceId] }),
311
+ });
312
+ for await (const event of rt.say({ voice_id: voiceId, text: "Hello, straight from the GPU." })) {
313
+ if (event.type === "audio") play(event.pcm);
314
+ }
315
+ console.log(rt.directSession?.region); // "ap-south" or "us-east"
316
+ await rt.close();
317
+ ```
318
+
319
+ In a browser, create the session on your server and return it (it holds no API key), then
320
+ connect with it:
321
+
322
+ ```ts
323
+ // On your server
324
+ const session = await client.realtime.sessions.create({ voice_ids: [voiceId] });
325
+ return Response.json(session);
326
+
327
+ // In the browser
328
+ import { connectDirect } from "@flamai/finesse";
329
+
330
+ const getSession = async () => (await fetch("/api/finesse-session", { method: "POST" })).json();
331
+ const rt = await connectDirect({ getSession });
332
+ ```
333
+
334
+ How it differs from a session through the API:
335
+
336
+ - **Voices:** only the `voice_ids` you list (1-16). A context for any other voice fails with
337
+ `voice_not_found`; the session keeps running.
338
+ - **Credits:** `budget_chars` (default 20,000) is reserved when the session is created and the
339
+ rest is returned when it ends. Going past it ends the session with `insufficient_credits`.
340
+ - **Lifetime:** connect within 60 s of creating it (`connect_by`), and a session runs at most an
341
+ hour (`ttl_seconds`), then ends with `session_ended`.
342
+ - **Single use:** the session's token works for one connection. With `{ session }` alone, a
343
+ reconnect throws `RealtimeError` with `code === "session_spent"`; pass `getSession` so each
344
+ reconnect gets a new session. A refused token throws `code === "unauthorized"`.
345
+ - **Region:** set by the account that owns the API key, not per request.
346
+
287
347
  ## Browsers and short-lived tokens
288
348
 
289
349
  Never ship an API key to a browser. Your server mints a short-lived, single-use token with a key
@@ -331,11 +391,26 @@ outside `voice_ids` throws `PermissionDeniedError` with `code === "voice_not_all
331
391
  | `s16le` | Raw PCM, 16-bit signed little-endian | `speech.stream`, realtime |
332
392
  | `s24le`, `s32le` | Raw PCM, 24- or 32-bit signed | |
333
393
  | `f32le`, `f64le` | Raw PCM, 32- or 64-bit float | |
394
+ | `mulaw` | Raw G.711 µ-law, for telephony | needs `sample_rate: 8000` |
334
395
  | `opus` | Opus | realtime only |
335
396
 
336
- All audio is mono. The engine's native rate is 24 kHz. You can ask for a higher `sample_rate`,
337
- but lower ones still come back at 24 kHz, so always read the actual rate from
338
- `audio.sampleRate`, `stream.sampleRate` or the `task_started` event.
397
+ All audio is mono. The engine's native rate is 24 kHz. You can ask for a higher `sample_rate`, or
398
+ for 8000 (telephony); other lower rates still come back at 24 kHz, so always read the actual rate
399
+ from `audio.sampleRate`, `stream.sampleRate` or the `task_started` event.
400
+
401
+ ### Phone calls (Twilio)
402
+
403
+ Twilio Media Streams play 8 kHz µ-law. Ask for exactly that and send each chunk as the media
404
+ payload, base64-encoded; there is no WAV header to strip and nothing to resample:
405
+
406
+ ```ts
407
+ const ctx = rt.context({ voice_id: voiceId, output_format: "mulaw", sample_rate: 8000 });
408
+
409
+ for await (const audio of ctx.audio()) {
410
+ const payload = Buffer.from(audio).toString("base64");
411
+ twilioWs.send(JSON.stringify({ event: "media", streamSid, media: { payload } }));
412
+ }
413
+ ```
339
414
 
340
415
  ## Handling errors
341
416
 
@@ -486,22 +561,12 @@ const quick = client.withOptions({ timeout: 10_000, maxRetries: 0 });
486
561
  await quick.voices.get(voiceId);
487
562
  ```
488
563
 
489
- ### Environments
490
-
491
- The client talks to production by default. Point it elsewhere with `baseURL` or
492
- `FINESSE_BASE_URL`:
493
-
494
- ```ts
495
- const client = new Finesse({ baseURL: "https://finesse-proxy.example.com" });
496
- ```
497
-
498
564
  ### Configuration
499
565
 
500
566
  | Option | Environment variable | Default |
501
567
  | ------------------------- | -------------------- | ------------------------------------- |
502
568
  | `apiKey` | `FINESSE_API_KEY` | required, unless `accessToken` |
503
569
  | `accessToken` | | none (a short-lived `fin_tok_` token) |
504
- | `baseURL` | `FINESSE_BASE_URL` | `https://fi.production.flamapis.com` |
505
570
  | `timeout` | | 120 000 ms |
506
571
  | `maxRetries` | | 2 |
507
572
  | `defaultHeaders` | | none |
@@ -510,6 +575,8 @@ const client = new Finesse({ baseURL: "https://finesse-proxy.example.com" });
510
575
  | `logLevel` | `FINESSE_LOG` | `"off"` |
511
576
  | `logger` | | `console` |
512
577
 
578
+ An option passed to `new Finesse({...})` always wins over its environment variable.
579
+
513
580
  ### Custom fetch
514
581
 
515
582
  Pass your own `fetch` for proxies, logging or tests:
@@ -542,11 +609,28 @@ Browsers can't keep an API key secret, so a client holding a key refuses to run
542
609
  deprecated URL parameter, because browsers can't set WebSocket headers (as does Deno before
543
610
  2.5), and logs a warning once.
544
611
 
612
+ ## TypeScript types
613
+
614
+ Every request and response type is exported, so you can type your own code against the API:
615
+
616
+ ```ts
617
+ import type { Voice } from "@flamai/finesse";
618
+
619
+ function label(voice: Voice) {
620
+ return `${voice.name} (${voice.id})`;
621
+ }
622
+ ```
623
+
545
624
  ## Examples
546
625
 
547
626
  Runnable scripts are in [`examples/`](./examples): [`speak.ts`](./examples/speak.ts) and
548
627
  [`realtime.ts`](./examples/realtime.ts).
549
628
 
629
+ ## Requirements
630
+
631
+ - Node 18 or later, Bun, Deno, or a modern browser (see [Runtimes](#runtimes)).
632
+ - TypeScript 4.7 or later, if you use TypeScript.
633
+
550
634
  ## Versioning
551
635
 
552
636
  The SDK follows [semantic versioning](https://semver.org). Before 1.0, minor versions may
@@ -556,6 +640,18 @@ include breaking changes; they are listed in the [changelog](./CHANGELOG.md).
556
640
  import { VERSION } from "@flamai/finesse";
557
641
  ```
558
642
 
643
+ ## Getting help
644
+
645
+ - [Documentation](https://docs.finesse.flamapp.ai) for guides and the API reference.
646
+ - [GitHub issues](https://github.com/homingos/finesse-node/issues) for bugs and feature requests
647
+ in this SDK. Include the `requestId` from the error (see [Request IDs](#request-ids)) when
648
+ a request failed.
649
+
650
+ ## Security
651
+
652
+ Please don't report security problems in a public issue. See [SECURITY.md](./SECURITY.md) for
653
+ how to report one privately.
654
+
559
655
  ## Contributing
560
656
 
561
657
  ```sh
package/dist/index.cjs CHANGED
@@ -55,6 +55,7 @@ __export(index_exports, {
55
55
  VERSION: () => VERSION,
56
56
  VoicePage: () => VoicePage,
57
57
  VoicePagePromise: () => VoicePagePromise,
58
+ connectDirect: () => connectDirect,
58
59
  fileFromPath: () => fileFromPath,
59
60
  isReady: () => isReady
60
61
  });
@@ -225,7 +226,7 @@ function numberField(body2, key) {
225
226
  }
226
227
 
227
228
  // src/version.ts
228
- var VERSION = "0.1.1";
229
+ var VERSION = "0.2.0";
229
230
 
230
231
  // src/core.ts
231
232
  var DEFAULT_BASE_URL = "https://fi.production.flamapis.com";
@@ -588,7 +589,8 @@ var RealtimeError = class extends FinesseError {
588
589
  * voices), `invalid_request` (a parameter out of range), a `task_failed` code. Whole session:
589
590
  * `insufficient_credits`, `upstream_unavailable` (the engine could not be reached),
590
591
  * `upstream_busy` (it is at capacity), `upstream_error`, `upstream_closed`,
591
- * `connection_lost`, `token_spent`, ... */
592
+ * `connection_lost`, `token_spent`, `session_spent`, `session_expired`, `unauthorized` (a
593
+ * direct session's token was refused), ... */
592
594
  code;
593
595
  /** The context it applies to, or `null` for the whole session. */
594
596
  contextId;
@@ -605,8 +607,15 @@ var RealtimeError = class extends FinesseError {
605
607
  return RETRYABLE_CODES.has(this.code) || this.closeCode !== null && RETRYABLE_CLOSES.has(this.closeCode);
606
608
  }
607
609
  };
608
- function closeError(code, reason) {
610
+ function closeError(code, reason, direct = false) {
609
611
  const c = { closeCode: code };
612
+ if (direct && code === 4401) {
613
+ return new RealtimeError(
614
+ "unauthorized",
615
+ "The session token was refused (invalid, already used, or past connect_by). Create a new session.",
616
+ c
617
+ );
618
+ }
610
619
  if (code === 1e3) return null;
611
620
  if (code === 1008) return new RealtimeError(reason || "policy_violation", reason, c);
612
621
  if (code === 1009) return new RealtimeError("message_too_big", "A message was too large.", c);
@@ -822,7 +831,13 @@ var RealtimeSession = class {
822
831
  constructor(core, options = {}) {
823
832
  this.core = core;
824
833
  this.options = options;
825
- this.staticToken = options.accessToken ?? core.accessToken;
834
+ this.directMode = Boolean(options.session || options.getSession);
835
+ if (this.directMode && (options.accessToken || options.getAccessToken)) {
836
+ throw new FinesseError(
837
+ "Pass { session } / { getSession } or { accessToken } / { getAccessToken }, not both."
838
+ );
839
+ }
840
+ this.staticToken = this.directMode ? null : options.accessToken ?? core.accessToken;
826
841
  }
827
842
  core;
828
843
  options;
@@ -837,9 +852,19 @@ var RealtimeSession = class {
837
852
  closedWaiters = [];
838
853
  staticToken;
839
854
  tokenSpent = false;
855
+ directMode;
856
+ staticSessionSpent = false;
857
+ current = null;
858
+ /** Where the socket connects: the API's `/stream`, or a direct session's GPU URL. */
840
859
  get url() {
860
+ if (this.directMode) return this.current?.url ?? this.options.session?.url ?? "";
841
861
  return `${this.core.wsBaseURL}${API_PREFIX}/stream`;
842
862
  }
863
+ /** The direct session the socket runs on (its region and budget), or `null` when the session
864
+ * goes through the API. */
865
+ get directSession() {
866
+ return this.current;
867
+ }
843
868
  get isOpen() {
844
869
  return this.ws !== null && this.ws.readyState === OPEN;
845
870
  }
@@ -973,6 +998,31 @@ var RealtimeSession = class {
973
998
  }
974
999
  return null;
975
1000
  }
1001
+ /** The direct session for the next handshake. Like a token, a static session is used first;
1002
+ * once a handshake has spent it, `getSession` supplies one per reconnect. */
1003
+ async nextSession() {
1004
+ const fixed = this.options.session;
1005
+ if (fixed && !this.staticSessionSpent) {
1006
+ if (Date.parse(fixed.connect_by) <= Date.now()) {
1007
+ throw new RealtimeError(
1008
+ "session_expired",
1009
+ "This direct session's connect_by has passed. Create a new session."
1010
+ );
1011
+ }
1012
+ return fixed;
1013
+ }
1014
+ if (this.options.getSession) {
1015
+ const s = await this.options.getSession();
1016
+ if (!s || typeof s.url !== "string" || !s.url || typeof s.token !== "string" || !s.token) {
1017
+ throw new FinesseError("getSession must return a session with a url and a token.");
1018
+ }
1019
+ return s;
1020
+ }
1021
+ throw new RealtimeError(
1022
+ "session_spent",
1023
+ "This direct session was used by an earlier connection. Pass { getSession } so a reconnect can create a new one."
1024
+ );
1025
+ }
976
1026
  async dial() {
977
1027
  const timeout = this.options.openTimeout ?? DEFAULT_OPEN_TIMEOUT_MS;
978
1028
  const custom = this.options.WebSocket;
@@ -980,11 +1030,21 @@ var RealtimeSession = class {
980
1030
  headers: !isBrowser(),
981
1031
  open: (url2, h, t, protocols2) => h ? new custom(url2, protocols2, { headers: h, handshakeTimeout: t }) : new custom(url2, protocols2)
982
1032
  } : await defaultDialer();
983
- const token = await this.nextToken();
984
- const protocols = token ? [SUBPROTOCOL, token] : void 0;
1033
+ const direct = this.directMode ? await this.nextSession() : null;
1034
+ const token = direct ? null : await this.nextToken();
1035
+ let protocols = token ? [SUBPROTOCOL, token] : void 0;
985
1036
  let url = this.url;
986
1037
  let headers = null;
987
- if (token) {
1038
+ if (direct) {
1039
+ protocols = [direct.protocol || SUBPROTOCOL, direct.token];
1040
+ url = direct.url;
1041
+ if (dialer.headers) {
1042
+ headers = { ...this.core.headers };
1043
+ delete headers.Accept;
1044
+ delete headers["X-API-Key"];
1045
+ delete headers.Authorization;
1046
+ }
1047
+ } else if (token) {
988
1048
  if (dialer.headers) {
989
1049
  headers = { ...this.core.headers };
990
1050
  delete headers.Accept;
@@ -1043,6 +1103,10 @@ var RealtimeSession = class {
1043
1103
  throw new RealtimeError("session_closed", "This session was closed.");
1044
1104
  }
1045
1105
  if (token && token === this.staticToken) this.tokenSpent = true;
1106
+ if (direct) {
1107
+ if (direct === this.options.session) this.staticSessionSpent = true;
1108
+ this.current = direct;
1109
+ }
1046
1110
  this.ws = ws;
1047
1111
  this.pendingAudio = null;
1048
1112
  this.fatal = null;
@@ -1061,14 +1125,14 @@ var RealtimeSession = class {
1061
1125
  }
1062
1126
  onClose(ws, code, reason) {
1063
1127
  if (this.ws !== null && this.ws !== ws) {
1064
- const err = closeError(code, reason ?? "") ?? new RealtimeError("connection_closed");
1128
+ const err = closeError(code, reason ?? "", this.directMode) ?? new RealtimeError("connection_closed");
1065
1129
  for (const ctx of [...this.contexts.values()]) if (ctx.socket === ws) ctx._fail(err);
1066
1130
  return;
1067
1131
  }
1068
1132
  this.ws = null;
1069
1133
  let error = this.fatal;
1070
1134
  if (error) error.closeCode ??= code;
1071
- else error = closeError(code, reason ?? "");
1135
+ else error = closeError(code, reason ?? "", this.directMode);
1072
1136
  if (!error && this.contexts.size) {
1073
1137
  error = new RealtimeError("connection_closed", "The session ended.", { closeCode: code });
1074
1138
  }
@@ -1135,11 +1199,35 @@ var RealtimeSession = class {
1135
1199
  );
1136
1200
  }
1137
1201
  };
1202
+ var DirectSessions = class {
1203
+ constructor(core) {
1204
+ this.core = core;
1205
+ }
1206
+ core;
1207
+ /** Create a direct session. Call this on your server with an API key that has the `tts`
1208
+ * scope; it reserves `budget_chars` from your balance now. Connect within 60 s
1209
+ * (`connect_by`), with `realtime.connect({ session })`, or hand `url`, `protocol` and `token`
1210
+ * to a browser:
1211
+ *
1212
+ * ```ts
1213
+ * const session = await client.realtime.sessions.create({ voice_ids: [voiceId] });
1214
+ * const rt = await client.realtime.connect({ session });
1215
+ * ```
1216
+ */
1217
+ async create(params, options = {}) {
1218
+ const json = { voice_ids: params.voice_ids };
1219
+ if (params.budget_chars !== void 0) json.budget_chars = params.budget_chars;
1220
+ return this.core.json("POST", "/realtime/sessions", { ...options, json });
1221
+ }
1222
+ };
1138
1223
  var Realtime = class {
1139
1224
  constructor(core) {
1140
1225
  this.core = core;
1226
+ this.sessions = new DirectSessions(core);
1141
1227
  }
1142
1228
  core;
1229
+ /** Direct sessions: lower latency, straight to the GPU. */
1230
+ sessions;
1143
1231
  /** Open a session. */
1144
1232
  async connect(options = {}) {
1145
1233
  const session = new RealtimeSession(this.core, options);
@@ -1151,6 +1239,15 @@ var Realtime = class {
1151
1239
  return new RealtimeSession(this.core, options);
1152
1240
  }
1153
1241
  };
1242
+ async function connectDirect(options) {
1243
+ if (!options.session && !options.getSession) {
1244
+ throw new FinesseError("connectDirect needs { session } or { getSession }.");
1245
+ }
1246
+ const core = new Core({ accessToken: "direct-session-only" });
1247
+ const session = new RealtimeSession(core, options);
1248
+ await session.open();
1249
+ return session;
1250
+ }
1154
1251
  var warnedLoggers = /* @__PURE__ */ new WeakSet();
1155
1252
  function warnQueryKey(logger) {
1156
1253
  if (warnedLoggers.has(logger)) return;
@@ -1779,6 +1876,7 @@ var Finesse = class _Finesse {
1779
1876
  VERSION,
1780
1877
  VoicePage,
1781
1878
  VoicePagePromise,
1879
+ connectDirect,
1782
1880
  fileFromPath,
1783
1881
  isReady
1784
1882
  });