@flamai/finesse 0.1.2 → 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,5 +1,16 @@
1
1
  # Changelog
2
2
 
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
+
3
14
  ## 0.1.2
4
15
 
5
16
  - `output_format: "mulaw"` (raw G.711 µ-law) for telephony, with `sample_rate: 8000`, on
package/README.md CHANGED
@@ -39,6 +39,7 @@ and stream it in realtime to voice agents, with a few lines of code.
39
39
  - [Text to speech](#text-to-speech)
40
40
  - [Streaming speech](#streaming-speech)
41
41
  - [Realtime](#realtime)
42
+ - [Direct sessions (lowest latency)](#direct-sessions-lowest-latency)
42
43
  - [Browsers and short-lived tokens](#browsers-and-short-lived-tokens)
43
44
  - [Which method should I use?](#which-method-should-i-use)
44
45
  - [Audio formats](#audio-formats)
@@ -297,6 +298,52 @@ A turn that fails throws `RealtimeError` (for example `code === "voice_not_found
297
298
  - Text is billed per character as it is sent. A session reserves credits in blocks of up to
298
299
  1,000 characters and returns what it doesn't use when it ends.
299
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
+
300
347
  ## Browsers and short-lived tokens
301
348
 
302
349
  Never ship an API key to a browser. Your server mints a short-lived, single-use token with a key
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.2";
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
  });