@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/dist/index.d.ts CHANGED
@@ -234,6 +234,151 @@ declare class CloneTimeoutError extends FinesseError {
234
234
  constructor(jobId: string, timeoutMs: number);
235
235
  }
236
236
 
237
+ /** Response shapes. Field names match the API (snake_case); timestamps are Unix milliseconds.
238
+ * Media URLs (`original_audio_url`, `sample_audio_url`, `avatar_url`, `audio_url`) may be
239
+ * short-lived signed URLs: download them promptly, or fetch the object again for a fresh one. */
240
+ interface VoiceSettings {
241
+ stability?: number;
242
+ similarity?: number;
243
+ style?: number;
244
+ speed?: number;
245
+ pitch?: number;
246
+ volume?: number;
247
+ use_speaker_boost?: boolean;
248
+ emotion?: string;
249
+ engine_params?: Record<string, unknown>;
250
+ }
251
+ /** A voice. It can synthesize once `status === "ready"`. */
252
+ interface Voice {
253
+ id: string;
254
+ org_id: string;
255
+ name: string;
256
+ /** `draft`, `processing`, `ready`, `failed`, ... */
257
+ status: string;
258
+ clone_method: string;
259
+ /** `private` for your voices, `public` for platform catalog voices. */
260
+ visibility: string;
261
+ primary_language: string;
262
+ description?: string;
263
+ model?: string;
264
+ /** Human-friendly handle, also usable as `voice` on the OpenAI-compatible endpoint. */
265
+ alias?: string;
266
+ source?: string;
267
+ language?: string;
268
+ gender?: string;
269
+ /** Labels as a plain object (the API sends `[{key, value}]`; the SDK converts it). */
270
+ labels: Record<string, string>;
271
+ settings?: VoiceSettings;
272
+ external_ref?: string;
273
+ original_audio_url?: string;
274
+ sample_audio_url?: string;
275
+ avatar_url?: string;
276
+ base_voice_id?: string;
277
+ created_at?: number;
278
+ updated_at?: number;
279
+ last_used_at?: number;
280
+ expires_at?: number;
281
+ /** Prefix of the API key that created the voice; only on your own voices. */
282
+ created_via_key_prefix?: string;
283
+ [key: string]: unknown;
284
+ }
285
+ /** An asynchronous clone job. */
286
+ interface CloneJob {
287
+ id: string;
288
+ org_id: string;
289
+ /** `pending`, `running`, `succeeded`, `failed`, `cancelled` or `expired`. */
290
+ status: string;
291
+ job_type: string;
292
+ voice_id?: string;
293
+ /** Percent complete. Not updated while running; 100 once succeeded. */
294
+ progress: number;
295
+ attempts: number;
296
+ /** Failure detail, `{message, status_code}`. */
297
+ error?: Record<string, unknown> | null;
298
+ started_at?: number;
299
+ finished_at?: number;
300
+ created_at?: number;
301
+ [key: string]: unknown;
302
+ }
303
+ /** What `voices.clone` / `voices.enroll` return: the voice (`processing`) and its job id. */
304
+ interface CloneResult {
305
+ voice: Voice;
306
+ job_id: string;
307
+ }
308
+ /** A synthesized clip from `speech.create`. */
309
+ interface Audio {
310
+ data: Uint8Array;
311
+ /** `audio/wav` for WAV, `application/octet-stream` for raw PCM. */
312
+ contentType: string;
313
+ sampleRate: number | null;
314
+ /** Id of the generation history record (when object storage is enabled). */
315
+ generationId: string | null;
316
+ /** The stored clip, when object storage is enabled; may be a short-lived signed URL. */
317
+ audioURL: string | null;
318
+ billingRemaining: number | null;
319
+ }
320
+ /** One chunk of raw PCM from `speech.stream`. */
321
+ interface AudioChunk {
322
+ pcm: Uint8Array;
323
+ sampleRate: number | null;
324
+ /** PCM encoding, e.g. `s16le`. */
325
+ audioFormat: string | null;
326
+ index: number | null;
327
+ }
328
+ /** The final event of `speech.stream`. */
329
+ interface StreamDone {
330
+ audio_duration_s?: number;
331
+ generation_id?: string;
332
+ audio_url?: string;
333
+ }
334
+ /** `wav` (one-shot default) or raw little-endian PCM. */
335
+ /** A short-lived, single-use token from `tokens.create`. Hand `token` to a browser or device:
336
+ * it is good for one `speech.create` / `speech.stream` call or one realtime session, carries
337
+ * the `tts` scope only, and dies when the key that minted it is revoked or expires. */
338
+ interface AccessToken {
339
+ token: string;
340
+ /** Unix epoch ms. */
341
+ expires_at: number;
342
+ ttl_seconds: number;
343
+ }
344
+ /** A direct realtime session from `realtime.sessions.create`: a socket straight to the
345
+ * synthesis GPU in your account's server region. Pass it to `realtime.connect({ session })`. */
346
+ interface DirectSession {
347
+ session_id: string;
348
+ /** The GPU's WebSocket URL. Connect to it as returned. */
349
+ url: string;
350
+ /** The subprotocol to offer first (`finesse.v1`). */
351
+ protocol: string;
352
+ /** Single-use `ft1.` token, offered as the second subprotocol. */
353
+ token: string;
354
+ /** RFC 3339 time by which the socket must open (60 s after creation). */
355
+ connect_by: string;
356
+ /** The longest the session can run once connected. */
357
+ ttl_seconds: number;
358
+ /** Characters reserved for the session; it cannot synthesize more. */
359
+ budget_chars: number;
360
+ /** GPU region the session runs in: `ap-south` or `us-east`. */
361
+ region: string;
362
+ /** The only voices the session can use. */
363
+ voices: string[];
364
+ }
365
+ /** `mulaw` is raw G.711 µ-law for telephony and needs `sample_rate: 8000`. */
366
+ type OutputFormat = "wav" | "s16le" | "s24le" | "s32le" | "f32le" | "f64le" | "mulaw";
367
+ interface SpeechParams {
368
+ /** One of your voices or a public platform voice; it must be ready. */
369
+ voice_id: string;
370
+ /** At most 10,000 characters. Billed per character. */
371
+ text: string;
372
+ language?: string;
373
+ output_format?: OutputFormat;
374
+ /** Output rate in Hz, 8000-48000. The native rate is 24000; 8000 is produced as asked, other
375
+ * lower values still return 24000. */
376
+ sample_rate?: number;
377
+ /** Generation steps, 1-32 (default and maximum 32; over 32 is a 400); fewer is faster. */
378
+ num_step?: number;
379
+ }
380
+ declare const isReady: (voice: Voice) => boolean;
381
+
237
382
  /**
238
383
  * Realtime speech over one WebSocket. One session is one socket; each turn of speech runs in a
239
384
  * *context*, and a socket can run several contexts at once (up to 4). Audio arrives as raw PCM,
@@ -255,6 +400,11 @@ declare class CloneTimeoutError extends FinesseError {
255
400
  * in the WebSocket subprotocol list (`["finesse.v1", token]`), the one place a browser can put
256
401
  * a credential on a handshake. (With a key, browsers and Deno before 2.5 fall back to a
257
402
  * deprecated URL parameter, and the SDK logs a warning once.)
403
+ *
404
+ * For the lowest latency, run a *direct session*: the socket goes straight to the synthesis GPU
405
+ * in your account's server region instead of through the API. Create one on your server with
406
+ * `realtime.sessions.create` and connect with `{ session }` or `{ getSession }`. The messages
407
+ * and events are the same.
258
408
  */
259
409
 
260
410
  /** A realtime context or session failed. */
@@ -263,7 +413,8 @@ declare class RealtimeError extends FinesseError {
263
413
  * voices), `invalid_request` (a parameter out of range), a `task_failed` code. Whole session:
264
414
  * `insufficient_credits`, `upstream_unavailable` (the engine could not be reached),
265
415
  * `upstream_busy` (it is at capacity), `upstream_error`, `upstream_closed`,
266
- * `connection_lost`, `token_spent`, ... */
416
+ * `connection_lost`, `token_spent`, `session_spent`, `session_expired`, `unauthorized` (a
417
+ * direct session's token was refused), ... */
267
418
  readonly code: string;
268
419
  /** The context it applies to, or `null` for the whole session. */
269
420
  readonly contextId: string | null;
@@ -332,10 +483,11 @@ interface ContextOptions {
332
483
  voice_id: string;
333
484
  context_id?: string;
334
485
  language?: string;
335
- /** `s16le` (default), `s24le`, `s32le`, `f32le`, `f64le` or `opus`. */
336
- output_format?: "s16le" | "s24le" | "s32le" | "f32le" | "f64le" | "opus";
337
- /** Output rate in Hz, 8000-48000; the native rate is 24000 and lower values still produce
338
- * 24000. */
486
+ /** `s16le` (default), `s24le`, `s32le`, `f32le`, `f64le`, `opus`, or `mulaw` (G.711 µ-law
487
+ * for telephony, with `sample_rate: 8000`). */
488
+ output_format?: "s16le" | "s24le" | "s32le" | "f32le" | "f64le" | "opus" | "mulaw";
489
+ /** Output rate in Hz, 8000-48000; the native rate is 24000. 8000 is produced as asked, other
490
+ * lower values still produce 24000. */
339
491
  sample_rate?: number;
340
492
  /** Generation steps, 1-32 (default 32); fewer is faster. Values over 32 are capped. */
341
493
  num_step?: number;
@@ -357,9 +509,23 @@ interface ConnectOptions {
357
509
  /** Returns a fresh token, e.g. from your own backend running `tokens.create`. Used for the
358
510
  * first handshake when there is no `accessToken`, and for every reconnect. */
359
511
  getAccessToken?: () => string | Promise<string>;
512
+ /** A direct session from `realtime.sessions.create`, usually created by your server. Its token
513
+ * is single use, so pass `getSession` too if the session should reconnect. */
514
+ session?: DirectSession;
515
+ /** Returns a new direct session, e.g. `() => client.realtime.sessions.create({ voice_ids })`
516
+ * on a server, or a call to your own backend in a browser. Used for the first handshake when
517
+ * there is no `session`, and for every reconnect. */
518
+ getSession?: () => DirectSession | Promise<DirectSession>;
360
519
  /** A WebSocket implementation (defaults to the `ws` package in Node, the global elsewhere). */
361
520
  WebSocket?: unknown;
362
521
  }
522
+ interface CreateDirectSessionParams {
523
+ /** The voices the session may use: 1 to 16 of your voices or public platform voices. */
524
+ voice_ids: string[];
525
+ /** Characters to reserve, 1-200000 (default 20000). Unused characters are returned when the
526
+ * session ends. If the balance can't cover it, 1000 are reserved instead. */
527
+ budget_chars?: number;
528
+ }
363
529
  /**
364
530
  * One turn of speech. Create it with {@link RealtimeSession.context}. `send` text (the first
365
531
  * call opens the context), `finish` when the turn's text is complete, and iterate it for its
@@ -427,8 +593,15 @@ declare class RealtimeSession {
427
593
  private closedWaiters;
428
594
  private readonly staticToken;
429
595
  private tokenSpent;
596
+ private readonly directMode;
597
+ private staticSessionSpent;
598
+ private current;
430
599
  constructor(core: Core, options?: ConnectOptions);
600
+ /** Where the socket connects: the API's `/stream`, or a direct session's GPU URL. */
431
601
  get url(): string;
602
+ /** The direct session the socket runs on (its region and budget), or `null` when the session
603
+ * goes through the API. */
604
+ get directSession(): DirectSession | null;
432
605
  get isOpen(): boolean;
433
606
  /**
434
607
  * Open the socket (no-op when open). Throws `AuthenticationError`, `PaymentRequiredError` or
@@ -459,142 +632,52 @@ declare class RealtimeSession {
459
632
  /** The token for the next handshake, or `null` to use the API key. A static token is used
460
633
  * first; once a handshake has spent it, `getAccessToken` supplies one per reconnect. */
461
634
  private nextToken;
635
+ /** The direct session for the next handshake. Like a token, a static session is used first;
636
+ * once a handshake has spent it, `getSession` supplies one per reconnect. */
637
+ private nextSession;
462
638
  private dial;
463
639
  private onClose;
464
640
  private onAudio;
465
641
  private onMessage;
466
642
  }
643
+ /** `client.realtime.sessions`: direct sessions, which run straight against the synthesis GPU. */
644
+ declare class DirectSessions {
645
+ private readonly core;
646
+ constructor(core: Core);
647
+ /** Create a direct session. Call this on your server with an API key that has the `tts`
648
+ * scope; it reserves `budget_chars` from your balance now. Connect within 60 s
649
+ * (`connect_by`), with `realtime.connect({ session })`, or hand `url`, `protocol` and `token`
650
+ * to a browser:
651
+ *
652
+ * ```ts
653
+ * const session = await client.realtime.sessions.create({ voice_ids: [voiceId] });
654
+ * const rt = await client.realtime.connect({ session });
655
+ * ```
656
+ */
657
+ create(params: CreateDirectSessionParams, options?: RequestOptions): Promise<DirectSession>;
658
+ }
467
659
  /** `client.realtime`: open realtime sessions. */
468
660
  declare class Realtime {
469
661
  private readonly core;
662
+ /** Direct sessions: lower latency, straight to the GPU. */
663
+ readonly sessions: DirectSessions;
470
664
  constructor(core: Core);
471
665
  /** Open a session. */
472
666
  connect(options?: ConnectOptions): Promise<RealtimeSession>;
473
667
  /** Create a session without opening it; it opens on first use. */
474
668
  session(options?: ConnectOptions): RealtimeSession;
475
669
  }
476
-
477
- /** Response shapes. Field names match the API (snake_case); timestamps are Unix milliseconds.
478
- * Media URLs (`original_audio_url`, `sample_audio_url`, `avatar_url`, `audio_url`) may be
479
- * short-lived signed URLs: download them promptly, or fetch the object again for a fresh one. */
480
- interface VoiceSettings {
481
- stability?: number;
482
- similarity?: number;
483
- style?: number;
484
- speed?: number;
485
- pitch?: number;
486
- volume?: number;
487
- use_speaker_boost?: boolean;
488
- emotion?: string;
489
- engine_params?: Record<string, unknown>;
490
- }
491
- /** A voice. It can synthesize once `status === "ready"`. */
492
- interface Voice {
493
- id: string;
494
- org_id: string;
495
- name: string;
496
- /** `draft`, `processing`, `ready`, `failed`, ... */
497
- status: string;
498
- clone_method: string;
499
- /** `private` for your voices, `public` for platform catalog voices. */
500
- visibility: string;
501
- primary_language: string;
502
- description?: string;
503
- model?: string;
504
- /** Human-friendly handle, also usable as `voice` on the OpenAI-compatible endpoint. */
505
- alias?: string;
506
- source?: string;
507
- language?: string;
508
- gender?: string;
509
- /** Labels as a plain object (the API sends `[{key, value}]`; the SDK converts it). */
510
- labels: Record<string, string>;
511
- settings?: VoiceSettings;
512
- external_ref?: string;
513
- original_audio_url?: string;
514
- sample_audio_url?: string;
515
- avatar_url?: string;
516
- base_voice_id?: string;
517
- created_at?: number;
518
- updated_at?: number;
519
- last_used_at?: number;
520
- expires_at?: number;
521
- /** Prefix of the API key that created the voice; only on your own voices. */
522
- created_via_key_prefix?: string;
523
- [key: string]: unknown;
524
- }
525
- /** An asynchronous clone job. */
526
- interface CloneJob {
527
- id: string;
528
- org_id: string;
529
- /** `pending`, `running`, `succeeded`, `failed`, `cancelled` or `expired`. */
530
- status: string;
531
- job_type: string;
532
- voice_id?: string;
533
- /** Percent complete. Not updated while running; 100 once succeeded. */
534
- progress: number;
535
- attempts: number;
536
- /** Failure detail, `{message, status_code}`. */
537
- error?: Record<string, unknown> | null;
538
- started_at?: number;
539
- finished_at?: number;
540
- created_at?: number;
541
- [key: string]: unknown;
542
- }
543
- /** What `voices.clone` / `voices.enroll` return: the voice (`processing`) and its job id. */
544
- interface CloneResult {
545
- voice: Voice;
546
- job_id: string;
547
- }
548
- /** A synthesized clip from `speech.create`. */
549
- interface Audio {
550
- data: Uint8Array;
551
- /** `audio/wav` for WAV, `application/octet-stream` for raw PCM. */
552
- contentType: string;
553
- sampleRate: number | null;
554
- /** Id of the generation history record (when object storage is enabled). */
555
- generationId: string | null;
556
- /** The stored clip, when object storage is enabled; may be a short-lived signed URL. */
557
- audioURL: string | null;
558
- billingRemaining: number | null;
559
- }
560
- /** One chunk of raw PCM from `speech.stream`. */
561
- interface AudioChunk {
562
- pcm: Uint8Array;
563
- sampleRate: number | null;
564
- /** PCM encoding, e.g. `s16le`. */
565
- audioFormat: string | null;
566
- index: number | null;
567
- }
568
- /** The final event of `speech.stream`. */
569
- interface StreamDone {
570
- audio_duration_s?: number;
571
- generation_id?: string;
572
- audio_url?: string;
573
- }
574
- /** `wav` (one-shot default) or raw little-endian PCM. */
575
- /** A short-lived, single-use token from `tokens.create`. Hand `token` to a browser or device:
576
- * it is good for one `speech.create` / `speech.stream` call or one realtime session, carries
577
- * the `tts` scope only, and dies when the key that minted it is revoked or expires. */
578
- interface AccessToken {
579
- token: string;
580
- /** Unix epoch ms. */
581
- expires_at: number;
582
- ttl_seconds: number;
583
- }
584
- type OutputFormat = "wav" | "s16le" | "s24le" | "s32le" | "f32le" | "f64le";
585
- interface SpeechParams {
586
- /** One of your voices or a public platform voice; it must be ready. */
587
- voice_id: string;
588
- /** At most 10,000 characters. Billed per character. */
589
- text: string;
590
- language?: string;
591
- output_format?: OutputFormat;
592
- /** Output rate in Hz, 8000-48000. The native rate is 24000; lower values still return 24000. */
593
- sample_rate?: number;
594
- /** Generation steps, 1-32 (default and maximum 32; over 32 is a 400); fewer is faster. */
595
- num_step?: number;
596
- }
597
- declare const isReady: (voice: Voice) => boolean;
670
+ /**
671
+ * Open a direct session without a client, for a browser or device that holds no API key. Your
672
+ * server creates the session (`realtime.sessions.create`) and returns it; `getSession` fetches a
673
+ * new one for each reconnect.
674
+ *
675
+ * ```ts
676
+ * const getSession = async () => (await fetch("/api/finesse-session", { method: "POST" })).json();
677
+ * const rt = await connectDirect({ getSession });
678
+ * ```
679
+ */
680
+ declare function connectDirect(options: Omit<ConnectOptions, "accessToken" | "getAccessToken">): Promise<RealtimeSession>;
598
681
 
599
682
  interface WaitOptions {
600
683
  /** Give up after this many ms. Default 300 000 (the first clone after the cloner has been
@@ -823,6 +906,6 @@ declare class Finesse {
823
906
  get withRawResponse(): RawClient<FinesseResources>;
824
907
  }
825
908
 
826
- declare const VERSION = "0.1.1";
909
+ declare const VERSION = "0.2.0";
827
910
 
828
- export { APIConnectionError, APIStatusError, APITimeoutError, type AccessToken, type Audio, type AudioChunk, type AudioEvent, AuthenticationError, BadRequestError, type ClientOptions, CloneFailedError, type CloneJob, type CloneResult, CloneTimeoutError, type CloneVoiceParams, ConflictError, type ConnectOptions, type ConsentParams, type ContextOptions, type CreateTokenParams, type CreateVoiceParams, type EnrollVoiceParams, type FileInput, Finesse, FinesseError, InternalServerError, type ListVoicesParams, type LogLevel, type Logger, NotFoundError, type OutputFormat, PayloadTooLargeError, PaymentRequiredError, PermissionDeniedError, RateLimitError, type RawClient, type RawResponse, RealtimeContext, RealtimeError, type RealtimeEvent, RealtimeSession, type RequestOptions, type SentenceDone, type SpeechParams, SpeechStream, type StreamDone, StreamError, type TaskCancelled, type TaskDone, type TaskStarted, type UnknownEvent, UnprocessableEntityError, type UpdateVoiceParams, VERSION, type Voice, VoicePage, VoicePagePromise, type VoiceSettings, type WaitOptions, fileFromPath, isReady };
911
+ export { APIConnectionError, APIStatusError, APITimeoutError, type AccessToken, type Audio, type AudioChunk, type AudioEvent, AuthenticationError, BadRequestError, type ClientOptions, CloneFailedError, type CloneJob, type CloneResult, CloneTimeoutError, type CloneVoiceParams, ConflictError, type ConnectOptions, type ConsentParams, type ContextOptions, type CreateDirectSessionParams, type CreateTokenParams, type CreateVoiceParams, type DirectSession, type EnrollVoiceParams, type FileInput, Finesse, FinesseError, InternalServerError, type ListVoicesParams, type LogLevel, type Logger, NotFoundError, type OutputFormat, PayloadTooLargeError, PaymentRequiredError, PermissionDeniedError, RateLimitError, type RawClient, type RawResponse, RealtimeContext, RealtimeError, type RealtimeEvent, RealtimeSession, type RequestOptions, type SentenceDone, type SpeechParams, SpeechStream, type StreamDone, StreamError, type TaskCancelled, type TaskDone, type TaskStarted, type UnknownEvent, UnprocessableEntityError, type UpdateVoiceParams, VERSION, type Voice, VoicePage, VoicePagePromise, type VoiceSettings, type WaitOptions, connectDirect, fileFromPath, isReady };
package/dist/index.js CHANGED
@@ -163,7 +163,7 @@ function numberField(body2, key) {
163
163
  }
164
164
 
165
165
  // src/version.ts
166
- var VERSION = "0.1.1";
166
+ var VERSION = "0.2.0";
167
167
 
168
168
  // src/core.ts
169
169
  var DEFAULT_BASE_URL = "https://fi.production.flamapis.com";
@@ -526,7 +526,8 @@ var RealtimeError = class extends FinesseError {
526
526
  * voices), `invalid_request` (a parameter out of range), a `task_failed` code. Whole session:
527
527
  * `insufficient_credits`, `upstream_unavailable` (the engine could not be reached),
528
528
  * `upstream_busy` (it is at capacity), `upstream_error`, `upstream_closed`,
529
- * `connection_lost`, `token_spent`, ... */
529
+ * `connection_lost`, `token_spent`, `session_spent`, `session_expired`, `unauthorized` (a
530
+ * direct session's token was refused), ... */
530
531
  code;
531
532
  /** The context it applies to, or `null` for the whole session. */
532
533
  contextId;
@@ -543,8 +544,15 @@ var RealtimeError = class extends FinesseError {
543
544
  return RETRYABLE_CODES.has(this.code) || this.closeCode !== null && RETRYABLE_CLOSES.has(this.closeCode);
544
545
  }
545
546
  };
546
- function closeError(code, reason) {
547
+ function closeError(code, reason, direct = false) {
547
548
  const c = { closeCode: code };
549
+ if (direct && code === 4401) {
550
+ return new RealtimeError(
551
+ "unauthorized",
552
+ "The session token was refused (invalid, already used, or past connect_by). Create a new session.",
553
+ c
554
+ );
555
+ }
548
556
  if (code === 1e3) return null;
549
557
  if (code === 1008) return new RealtimeError(reason || "policy_violation", reason, c);
550
558
  if (code === 1009) return new RealtimeError("message_too_big", "A message was too large.", c);
@@ -760,7 +768,13 @@ var RealtimeSession = class {
760
768
  constructor(core, options = {}) {
761
769
  this.core = core;
762
770
  this.options = options;
763
- this.staticToken = options.accessToken ?? core.accessToken;
771
+ this.directMode = Boolean(options.session || options.getSession);
772
+ if (this.directMode && (options.accessToken || options.getAccessToken)) {
773
+ throw new FinesseError(
774
+ "Pass { session } / { getSession } or { accessToken } / { getAccessToken }, not both."
775
+ );
776
+ }
777
+ this.staticToken = this.directMode ? null : options.accessToken ?? core.accessToken;
764
778
  }
765
779
  core;
766
780
  options;
@@ -775,9 +789,19 @@ var RealtimeSession = class {
775
789
  closedWaiters = [];
776
790
  staticToken;
777
791
  tokenSpent = false;
792
+ directMode;
793
+ staticSessionSpent = false;
794
+ current = null;
795
+ /** Where the socket connects: the API's `/stream`, or a direct session's GPU URL. */
778
796
  get url() {
797
+ if (this.directMode) return this.current?.url ?? this.options.session?.url ?? "";
779
798
  return `${this.core.wsBaseURL}${API_PREFIX}/stream`;
780
799
  }
800
+ /** The direct session the socket runs on (its region and budget), or `null` when the session
801
+ * goes through the API. */
802
+ get directSession() {
803
+ return this.current;
804
+ }
781
805
  get isOpen() {
782
806
  return this.ws !== null && this.ws.readyState === OPEN;
783
807
  }
@@ -911,6 +935,31 @@ var RealtimeSession = class {
911
935
  }
912
936
  return null;
913
937
  }
938
+ /** The direct session for the next handshake. Like a token, a static session is used first;
939
+ * once a handshake has spent it, `getSession` supplies one per reconnect. */
940
+ async nextSession() {
941
+ const fixed = this.options.session;
942
+ if (fixed && !this.staticSessionSpent) {
943
+ if (Date.parse(fixed.connect_by) <= Date.now()) {
944
+ throw new RealtimeError(
945
+ "session_expired",
946
+ "This direct session's connect_by has passed. Create a new session."
947
+ );
948
+ }
949
+ return fixed;
950
+ }
951
+ if (this.options.getSession) {
952
+ const s = await this.options.getSession();
953
+ if (!s || typeof s.url !== "string" || !s.url || typeof s.token !== "string" || !s.token) {
954
+ throw new FinesseError("getSession must return a session with a url and a token.");
955
+ }
956
+ return s;
957
+ }
958
+ throw new RealtimeError(
959
+ "session_spent",
960
+ "This direct session was used by an earlier connection. Pass { getSession } so a reconnect can create a new one."
961
+ );
962
+ }
914
963
  async dial() {
915
964
  const timeout = this.options.openTimeout ?? DEFAULT_OPEN_TIMEOUT_MS;
916
965
  const custom = this.options.WebSocket;
@@ -918,11 +967,21 @@ var RealtimeSession = class {
918
967
  headers: !isBrowser(),
919
968
  open: (url2, h, t, protocols2) => h ? new custom(url2, protocols2, { headers: h, handshakeTimeout: t }) : new custom(url2, protocols2)
920
969
  } : await defaultDialer();
921
- const token = await this.nextToken();
922
- const protocols = token ? [SUBPROTOCOL, token] : void 0;
970
+ const direct = this.directMode ? await this.nextSession() : null;
971
+ const token = direct ? null : await this.nextToken();
972
+ let protocols = token ? [SUBPROTOCOL, token] : void 0;
923
973
  let url = this.url;
924
974
  let headers = null;
925
- if (token) {
975
+ if (direct) {
976
+ protocols = [direct.protocol || SUBPROTOCOL, direct.token];
977
+ url = direct.url;
978
+ if (dialer.headers) {
979
+ headers = { ...this.core.headers };
980
+ delete headers.Accept;
981
+ delete headers["X-API-Key"];
982
+ delete headers.Authorization;
983
+ }
984
+ } else if (token) {
926
985
  if (dialer.headers) {
927
986
  headers = { ...this.core.headers };
928
987
  delete headers.Accept;
@@ -981,6 +1040,10 @@ var RealtimeSession = class {
981
1040
  throw new RealtimeError("session_closed", "This session was closed.");
982
1041
  }
983
1042
  if (token && token === this.staticToken) this.tokenSpent = true;
1043
+ if (direct) {
1044
+ if (direct === this.options.session) this.staticSessionSpent = true;
1045
+ this.current = direct;
1046
+ }
984
1047
  this.ws = ws;
985
1048
  this.pendingAudio = null;
986
1049
  this.fatal = null;
@@ -999,14 +1062,14 @@ var RealtimeSession = class {
999
1062
  }
1000
1063
  onClose(ws, code, reason) {
1001
1064
  if (this.ws !== null && this.ws !== ws) {
1002
- const err = closeError(code, reason ?? "") ?? new RealtimeError("connection_closed");
1065
+ const err = closeError(code, reason ?? "", this.directMode) ?? new RealtimeError("connection_closed");
1003
1066
  for (const ctx of [...this.contexts.values()]) if (ctx.socket === ws) ctx._fail(err);
1004
1067
  return;
1005
1068
  }
1006
1069
  this.ws = null;
1007
1070
  let error = this.fatal;
1008
1071
  if (error) error.closeCode ??= code;
1009
- else error = closeError(code, reason ?? "");
1072
+ else error = closeError(code, reason ?? "", this.directMode);
1010
1073
  if (!error && this.contexts.size) {
1011
1074
  error = new RealtimeError("connection_closed", "The session ended.", { closeCode: code });
1012
1075
  }
@@ -1073,11 +1136,35 @@ var RealtimeSession = class {
1073
1136
  );
1074
1137
  }
1075
1138
  };
1139
+ var DirectSessions = class {
1140
+ constructor(core) {
1141
+ this.core = core;
1142
+ }
1143
+ core;
1144
+ /** Create a direct session. Call this on your server with an API key that has the `tts`
1145
+ * scope; it reserves `budget_chars` from your balance now. Connect within 60 s
1146
+ * (`connect_by`), with `realtime.connect({ session })`, or hand `url`, `protocol` and `token`
1147
+ * to a browser:
1148
+ *
1149
+ * ```ts
1150
+ * const session = await client.realtime.sessions.create({ voice_ids: [voiceId] });
1151
+ * const rt = await client.realtime.connect({ session });
1152
+ * ```
1153
+ */
1154
+ async create(params, options = {}) {
1155
+ const json = { voice_ids: params.voice_ids };
1156
+ if (params.budget_chars !== void 0) json.budget_chars = params.budget_chars;
1157
+ return this.core.json("POST", "/realtime/sessions", { ...options, json });
1158
+ }
1159
+ };
1076
1160
  var Realtime = class {
1077
1161
  constructor(core) {
1078
1162
  this.core = core;
1163
+ this.sessions = new DirectSessions(core);
1079
1164
  }
1080
1165
  core;
1166
+ /** Direct sessions: lower latency, straight to the GPU. */
1167
+ sessions;
1081
1168
  /** Open a session. */
1082
1169
  async connect(options = {}) {
1083
1170
  const session = new RealtimeSession(this.core, options);
@@ -1089,6 +1176,15 @@ var Realtime = class {
1089
1176
  return new RealtimeSession(this.core, options);
1090
1177
  }
1091
1178
  };
1179
+ async function connectDirect(options) {
1180
+ if (!options.session && !options.getSession) {
1181
+ throw new FinesseError("connectDirect needs { session } or { getSession }.");
1182
+ }
1183
+ const core = new Core({ accessToken: "direct-session-only" });
1184
+ const session = new RealtimeSession(core, options);
1185
+ await session.open();
1186
+ return session;
1187
+ }
1092
1188
  var warnedLoggers = /* @__PURE__ */ new WeakSet();
1093
1189
  function warnQueryKey(logger) {
1094
1190
  if (warnedLoggers.has(logger)) return;
@@ -1716,6 +1812,7 @@ export {
1716
1812
  VERSION,
1717
1813
  VoicePage,
1718
1814
  VoicePagePromise,
1815
+ connectDirect,
1719
1816
  fileFromPath,
1720
1817
  isReady
1721
1818
  };