@vocalstack/js-sdk 2.0.0-alpha.0 → 2.0.0-alpha.1

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.
@@ -1,4 +1,4 @@
1
- import type { Capabilities, CommittedSegment, CreateSessionResponse, Fix, LatencyMode, SentenceSegment, ServerFrame, SessionOptions, SessionStats, SpeakerLabels } from '@vocalstack/contract';
1
+ import type { Capabilities, CommittedSegment, CreateSessionResponse, Fix, LatencyMode, SentenceSegment, ServerFrame, SessionOptions, SessionStats, SpeakerLabels, WarmRequest } from '@vocalstack/contract';
2
2
  import { VocalStackError } from './errors.js';
3
3
  import { type FileInput } from './file.js';
4
4
  import type { CallOptions, Transport } from './http.js';
@@ -10,26 +10,12 @@ export interface ConnectParams {
10
10
  streamUrl: string;
11
11
  options?: SessionOptions;
12
12
  readyDeadlineMs?: number;
13
- /** Fires on every `session.loading` frame while the engine warms up
14
- * (model download / load after a machine wake) — lets a UI show
15
- * "warming up" instead of a generic connecting state. Each frame also
16
- * re-arms the ready deadline, so a long cold load only fails once the
17
- * engine goes silent, not merely slow. */
18
13
  onLoading?(stage: 'download' | 'model'): void;
19
- /** Every server text frame as it arrives, parsed, before the iterator
20
- * sees it: the lifecycle frames the iterator keeps to itself
21
- * (`session.loading`, `session.ready`, `session.done`, `error`) and the
22
- * transcript frames alike. For logs and protocol views; the iterator
23
- * is still the way to read the transcript. */
24
14
  onFrame?(frame: ServerFrame): void;
25
15
  }
26
- /** A refused socket upgrade as an error. A 503 carrying the error body
27
- * with `unavailable` (the database is waking; retry shortly) says so; any
28
- * other 503 — a bare one from a saturated proxy, or no body at all — is
29
- * `capacity_exceeded`. So is a 502 or 504 without the API's error body:
30
- * the proxy in front of the engines found none free in time. Every other
31
- * status is its own envelope. */
16
+ export declare function engineLost(closeCode?: number): VocalStackError;
32
17
  export declare function upgradeRefused(status: number | undefined, body: string): VocalStackError;
18
+ export declare const RESTART_CLOSE = 1012;
33
19
  export declare class RealtimeSession implements AsyncIterable<SessionEvent> {
34
20
  #private;
35
21
  readonly sessionId: string;
@@ -38,26 +24,7 @@ export declare class RealtimeSession implements AsyncIterable<SessionEvent> {
38
24
  sendAudio(chunk: Float32Array | ArrayBuffer | Uint8Array): void;
39
25
  sendFile(source: AsyncIterable<Uint8Array>, chunkBytes?: number): Promise<void>;
40
26
  pipe(source: AsyncIterable<Float32Array | ArrayBuffer | Uint8Array> | ReadableStream<Uint8Array>): Promise<void>;
41
- /**
42
- * Capture clock heartbeat: `pcm` = cumulative samples captured so far
43
- * (16kHz-equivalent), `wall` = seconds since capture start. Lets the
44
- * server detect capture loss (throttled tab, device switch) and mark
45
- * the transcript with honest gaps instead of gluing across them.
46
- * `streamMicrophone`/`captureMicrophone` call this automatically.
47
- */
48
27
  sendClock(pcm: number, wall: number): void;
49
- /**
50
- * Options update during the realtime session. Omitted fields are unchanged.
51
- * `latencyMode` switches the decode model policy from the next
52
- * engine step; `spokenLanguage` changes the transcription language from the
53
- * next engine step (`''` or `'auto'` = auto-detect); `speakerLabels`
54
- * turns speaker labelling on (`'on'` or `'auto'`) or off (`'off'`) from the next
55
- * engine step — enabling labels speakers from that point on;
56
- * `translate` replaces the whole target-language list (`[]` turns
57
- * translation off). Applies to subsequent transcript —
58
- * already-delivered segments and settled sentences are not
59
- * re-transcribed, re-attributed or re-translated.
60
- */
61
28
  update(changes: {
62
29
  latencyMode?: LatencyMode;
63
30
  spokenLanguage?: string;
@@ -73,60 +40,38 @@ export declare class RealtimeSession implements AsyncIterable<SessionEvent> {
73
40
  [Symbol.dispose](): void;
74
41
  }
75
42
  export interface ConnectOptions {
76
- /** See {@link ConnectParams.onLoading}: engine warm-up progress. */
77
43
  onLoading?(stage: 'download' | 'model'): void;
78
- /** See {@link ConnectParams.onFrame}: every server frame, lifecycle included. */
79
44
  onFrame?(frame: ServerFrame): void;
80
- /** Overrides the client-wide `readyDeadlineMs` for this socket. */
81
45
  readyDeadlineMs?: number;
82
46
  }
83
47
  export interface TranscribeOptions extends Omit<SessionOptions, 'input' | 'audio'> {
84
- /** The realtime session, once connected (to `cancel()` it, say). */
85
48
  onSession?(session: RealtimeSession): void;
86
49
  onProgress?(progress: {
87
50
  processedS: number;
88
51
  totalS?: number;
89
52
  }): void;
90
53
  onCommit?(segment: CommittedSegment): void;
91
- /** A committed segment's translation arrived; the segment (by `seq`)
92
- * already has its `translations` filled in. */
93
54
  onTranslated?(seq: number, translations: Record<string, string>, sources?: Record<string, string>): void;
94
- /** The engine rewrote a committed segment; it is patched in place first. */
95
55
  onAmended?(seq: number, text: string, speaker: number, fixes: Fix[]): void;
96
- /** A tentative speaker label (`speakerProvisional`) settled: `verdict` is
97
- * "confirmed", "amended" or "unverified". The segment is patched first. */
98
56
  onSpeakerSettled?(seq: number, speaker: number, verdict: string, confidence?: number): void;
99
- /** Every sentence upsert (`sentences.enabled`); the returned `sentences`
100
- * list is kept up to date (dropped sentences are removed). */
101
57
  onSentence?(sentence: SentenceSegment): void;
102
58
  readyDeadlineMs?: number;
103
59
  }
104
60
  export interface RealtimeTranscript {
105
61
  segments: CommittedSegment[];
106
- /** Sentence upserts folded into their final state (empty unless
107
- * `sentences.enabled`). */
108
62
  sentences: SentenceSegment[];
109
63
  text: string;
110
64
  doneReason: 'eof' | 'max_duration' | 'cancelled' | null;
111
65
  stats: SessionStats | null;
112
66
  }
113
- /** `vs.realtime`: a realtime session is a signed token plus one socket.
114
- * Nothing is stored: the transcript is what the socket sends. */
115
67
  export declare class Realtime {
116
68
  #private;
117
69
  constructor(t: Transport);
118
- /** `POST /v1/realtime/sessions`: validate the options, get a signed `streamUrl`. */
119
70
  createSession(options?: SessionOptions, call?: CallOptions): Promise<CreateSessionResponse>;
120
- /** `createSession`, then open the socket and wait for `session.ready`. */
121
71
  connect(options?: SessionOptions, connect?: ConnectOptions): Promise<RealtimeSession>;
122
- /** `GET /v1/realtime/capabilities`: languages, audio formats, limits. */
123
72
  capabilities(): Promise<Capabilities>;
124
- /** Stream an encoded file (mp3, wav, flac, ogg, m4a…) through a realtime
125
- * session and collect the transcript. Nothing is stored; for a stored,
126
- * shareable transcription use `vs.transcriptions.create`. */
73
+ warm(req: WarmRequest): Promise<void>;
127
74
  transcribeFile(file: FileInput, opts?: TranscribeOptions): Promise<RealtimeTranscript>;
128
- /** The same for a link the engine fetches itself. */
129
75
  transcribeUrl(url: string, opts?: TranscribeOptions): Promise<RealtimeTranscript>;
130
76
  }
131
- /** Upsert one sentence frame into a list kept in sentenceId order. */
132
77
  export declare function applySentence(list: SentenceSegment[], sentence: SentenceSegment): SentenceSegment[];
package/dist/realtime.js CHANGED
@@ -1,18 +1,12 @@
1
- /**
2
- * The realtime session: `Realtime` (`vs.realtime`: create a session, or
3
- * transcribe a file or a URL over one) and `RealtimeSession` (its socket).
4
- */
5
1
  import { parseServerFrame } from '@vocalstack/contract';
6
2
  import { capacityExceeded, fromEnvelope, VocalStackError } from './errors.js';
7
3
  import { fileChunks } from './file.js';
8
4
  import { openWebSocket, WS_OPEN } from './ws.js';
9
5
  const DEFAULT_READY_DEADLINE_MS = 30_000;
10
- /** A refused socket upgrade as an error. A 503 carrying the error body
11
- * with `unavailable` (the database is waking; retry shortly) says so; any
12
- * other 503 — a bare one from a saturated proxy, or no body at all — is
13
- * `capacity_exceeded`. So is a 502 or 504 without the API's error body:
14
- * the proxy in front of the engines found none free in time. Every other
15
- * status is its own envelope. */
6
+ export function engineLost(closeCode) {
7
+ const how = closeCode ? ` (close ${closeCode})` : '';
8
+ return new VocalStackError('engine_lost', `The connection to the transcription engine closed before the realtime session finished${how}; open a new realtime session to continue.`, { retryable: true });
9
+ }
16
10
  export function upgradeRefused(status, body) {
17
11
  let parsed;
18
12
  try {
@@ -32,6 +26,96 @@ export function upgradeRefused(status, body) {
32
26
  return capacityExceeded(`stream host answered ${status} (no engine answered in time)`);
33
27
  return fromEnvelope(parsed, status);
34
28
  }
29
+ export const RESTART_CLOSE = 1012;
30
+ const RESTART_TRIES = 3;
31
+ const MAX_PENDING_BYTES = 4 * 1024 * 1024;
32
+ async function openReady(url, deadlineMs, onLoading, onFrame, attach) {
33
+ const ws = await openWebSocket(url);
34
+ ws.binaryType = 'arraybuffer';
35
+ await new Promise((resolve, reject) => {
36
+ let settled = false;
37
+ let sawFrame = false;
38
+ let rejecting = false;
39
+ let timer;
40
+ const fail = (err) => {
41
+ if (settled)
42
+ return;
43
+ settled = true;
44
+ clearTimeout(timer);
45
+ try {
46
+ ws.close();
47
+ }
48
+ catch {
49
+ }
50
+ reject(err);
51
+ };
52
+ const arm = () => {
53
+ clearTimeout(timer);
54
+ timer = setTimeout(() => {
55
+ fail(sawFrame
56
+ ? new VocalStackError('engine_unavailable', `engine did not become ready within ${deadlineMs}ms`, { retryable: true })
57
+ : capacityExceeded(`no response from the engine within ${deadlineMs}ms — every machine is ` +
58
+ `busy with another session, or one is stuck holding a slot`));
59
+ }, deadlineMs);
60
+ };
61
+ arm();
62
+ ws.addEventListener('message', (ev) => {
63
+ if (settled || typeof ev.data !== 'string')
64
+ return;
65
+ sawFrame = true;
66
+ arm();
67
+ let frame;
68
+ try {
69
+ frame = parseServerFrame(ev.data);
70
+ }
71
+ catch {
72
+ return;
73
+ }
74
+ if (!frame)
75
+ return;
76
+ onFrame(frame);
77
+ if (frame.type === 'session.loading') {
78
+ onLoading?.(frame.stage);
79
+ }
80
+ else if (frame.type === 'session.ready') {
81
+ if (!settled) {
82
+ settled = true;
83
+ clearTimeout(timer);
84
+ attach(ws);
85
+ resolve();
86
+ }
87
+ }
88
+ else if (frame.type === 'error') {
89
+ fail(new VocalStackError(frame.code, frame.message, {
90
+ retryable: frame.retryable,
91
+ }));
92
+ }
93
+ });
94
+ const emitterOn = ws
95
+ .on?.bind(ws);
96
+ emitterOn?.('unexpected-response', (_req, res) => {
97
+ rejecting = true;
98
+ const status = res?.statusCode;
99
+ let body = '';
100
+ if (res && typeof res.on === 'function') {
101
+ res.on('data', (c) => (body += c.toString()));
102
+ res.on('end', () => fail(upgradeRefused(status, body)));
103
+ }
104
+ else {
105
+ fail(upgradeRefused(status, ''));
106
+ }
107
+ });
108
+ ws.addEventListener('close', () => {
109
+ if (rejecting)
110
+ return;
111
+ fail(new VocalStackError('engine_unavailable', 'connection closed before ready', {
112
+ retryable: true,
113
+ }));
114
+ });
115
+ ws.addEventListener('error', () => {
116
+ });
117
+ });
118
+ }
35
119
  export class RealtimeSession {
36
120
  sessionId;
37
121
  #ws;
@@ -41,108 +125,85 @@ export class RealtimeSession {
41
125
  #stats = null;
42
126
  #error = null;
43
127
  #closed = false;
128
+ #closedByUs = false;
44
129
  #onFrame;
45
- constructor(sessionId, ws, onFrame) {
130
+ #params;
131
+ #reconnecting = false;
132
+ #pending = [];
133
+ #pendingBytes = 0;
134
+ #reconnects = 0;
135
+ constructor(sessionId, ws, params) {
46
136
  this.sessionId = sessionId;
47
137
  this.#ws = ws;
48
- this.#onFrame = onFrame;
138
+ this.#onFrame = params.onFrame;
139
+ this.#params = params;
49
140
  }
50
141
  static async connect(params) {
51
142
  const deadlineMs = params.readyDeadlineMs ?? DEFAULT_READY_DEADLINE_MS;
52
- const ws = await openWebSocket(params.streamUrl);
53
- ws.binaryType = 'arraybuffer';
54
- const session = new RealtimeSession(params.sessionId, ws, params.onFrame);
55
- await new Promise((resolve, reject) => {
56
- let settled = false;
57
- let sawFrame = false;
58
- let rejecting = false;
59
- let timer;
60
- const fail = (err) => {
61
- if (settled)
62
- return;
63
- settled = true;
64
- clearTimeout(timer);
65
- try {
66
- ws.close();
67
- }
68
- catch {
69
- }
70
- reject(err);
71
- };
72
- const arm = () => {
73
- clearTimeout(timer);
74
- timer = setTimeout(() => {
75
- fail(sawFrame
76
- ? new VocalStackError('engine_unavailable', `engine did not become ready within ${deadlineMs}ms`, { retryable: true })
77
- : capacityExceeded(`no response from the engine within ${deadlineMs}ms — every machine is ` +
78
- `busy with another session, or one is stuck holding a slot`));
79
- }, deadlineMs);
80
- };
81
- arm();
82
- ws.addEventListener('message', (ev) => {
83
- // Once ready, #listen reads the socket; this one stops re-arming
84
- // the deadline (it used to leave a timer running for 30 s).
85
- if (settled || typeof ev.data !== 'string')
86
- return;
87
- sawFrame = true;
88
- arm();
89
- let frame;
90
- try {
91
- frame = parseServerFrame(ev.data);
92
- }
93
- catch {
94
- return;
95
- }
96
- if (!frame)
97
- return;
98
- session.#frame(frame);
99
- if (frame.type === 'session.loading') {
100
- params.onLoading?.(frame.stage);
101
- }
102
- else if (frame.type === 'session.ready') {
103
- if (!settled) {
104
- settled = true;
105
- clearTimeout(timer);
106
- session.#listen();
107
- resolve();
108
- }
109
- }
110
- else if (frame.type === 'error') {
111
- fail(new VocalStackError(frame.code, frame.message, {
112
- retryable: frame.retryable,
113
- }));
114
- }
115
- });
116
- const emitterOn = ws
117
- .on?.bind(ws);
118
- emitterOn?.('unexpected-response', (_req, res) => {
119
- rejecting = true;
120
- const status = res?.statusCode;
121
- let body = '';
122
- if (res && typeof res.on === 'function') {
123
- res.on('data', (c) => (body += c.toString()));
124
- res.on('end', () => fail(upgradeRefused(status, body)));
125
- }
126
- else {
127
- fail(upgradeRefused(status, ''));
128
- }
129
- });
130
- ws.addEventListener('close', () => {
131
- if (rejecting)
132
- return;
133
- // No HTTP answer at all (a refused upgrade is `unexpected-response`
134
- // above): the connection dropped on the way, as the Python and PHP
135
- // SDKs say it — retryable, not a bad token.
136
- fail(new VocalStackError('engine_unavailable', 'connection closed before ready', {
137
- retryable: true,
138
- }));
139
- });
140
- ws.addEventListener('error', () => {
141
- });
143
+ const early = [];
144
+ let session;
145
+ await openReady(params.streamUrl, deadlineMs, params.onLoading, (f) => early.push(f), (ws) => {
146
+ const s = new RealtimeSession(params.sessionId, ws, params);
147
+ for (const f of early)
148
+ s.#frame(f);
149
+ s.#listen();
150
+ session = s;
142
151
  });
143
152
  return session;
144
153
  }
145
- /** Hands a frame to `onFrame`; a throwing callback never breaks the socket. */
154
+ async #reconnect(code) {
155
+ this.#reconnecting = true;
156
+ const deadlineMs = this.#params.readyDeadlineMs ?? DEFAULT_READY_DEADLINE_MS;
157
+ while (this.#reconnects < RESTART_TRIES && !this.#closedByUs) {
158
+ await new Promise((r) => setTimeout(r, 1000 * 2 ** this.#reconnects));
159
+ this.#reconnects++;
160
+ if (this.#closedByUs)
161
+ break;
162
+ try {
163
+ await openReady(this.#params.streamUrl, deadlineMs, this.#params.onLoading, (f) => this.#frame(f), (ws) => {
164
+ this.#ws = ws;
165
+ this.#reconnecting = false;
166
+ this.#listen();
167
+ const waiting = this.#pending;
168
+ this.#pending = [];
169
+ this.#pendingBytes = 0;
170
+ for (const d of waiting)
171
+ this.#send(d);
172
+ });
173
+ if (this.#closedByUs)
174
+ this.close();
175
+ return;
176
+ }
177
+ catch (e) {
178
+ if (e instanceof VocalStackError && !e.retryable) {
179
+ this.#fail(e);
180
+ return;
181
+ }
182
+ }
183
+ }
184
+ this.#fail(engineLost(code));
185
+ }
186
+ #fail(err) {
187
+ this.#reconnecting = false;
188
+ this.#pending = [];
189
+ this.#closed = true;
190
+ if (!this.#endReason && !this.#error && !this.#closedByUs)
191
+ this.#error = err;
192
+ this.#wake?.();
193
+ }
194
+ #send(data) {
195
+ if (this.#reconnecting) {
196
+ const n = typeof data === 'string' ? data.length : data.byteLength;
197
+ if (this.#pendingBytes + n <= MAX_PENDING_BYTES) {
198
+ this.#pending.push(data);
199
+ this.#pendingBytes += n;
200
+ }
201
+ return;
202
+ }
203
+ if (this.#ws.readyState !== WS_OPEN)
204
+ return;
205
+ this.#ws.send(data);
206
+ }
146
207
  #frame(frame) {
147
208
  try {
148
209
  this.#onFrame?.(frame);
@@ -151,8 +212,9 @@ export class RealtimeSession {
151
212
  }
152
213
  }
153
214
  #listen() {
154
- this.#ws.addEventListener('message', (ev) => {
155
- if (typeof ev.data !== 'string')
215
+ const sock = this.#ws;
216
+ sock.addEventListener('message', (ev) => {
217
+ if (sock !== this.#ws || typeof ev.data !== 'string')
156
218
  return;
157
219
  let frame;
158
220
  try {
@@ -192,31 +254,42 @@ export class RealtimeSession {
192
254
  break;
193
255
  }
194
256
  });
195
- this.#ws.addEventListener('close', () => {
257
+ const ws = this.#ws;
258
+ ws.addEventListener('close', (ev) => {
259
+ if (ws !== this.#ws)
260
+ return;
261
+ const code = typeof ev?.code === 'number' ? ev.code : undefined;
262
+ if (code === RESTART_CLOSE && !this.#endReason && !this.#error && !this.#closedByUs) {
263
+ void this.#reconnect(code);
264
+ return;
265
+ }
196
266
  this.#closed = true;
267
+ if (!this.#endReason && !this.#error && !this.#closedByUs) {
268
+ this.#error = engineLost(code);
269
+ }
197
270
  this.#wake?.();
198
271
  });
199
272
  }
200
273
  sendAudio(chunk) {
201
- if (this.#ws.readyState !== WS_OPEN)
202
- return;
203
274
  const data = chunk instanceof Float32Array
204
275
  ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
205
276
  : chunk;
206
- this.#ws.send(data);
277
+ this.#send(this.#reconnecting ? (data instanceof Uint8Array ? data.slice() : data.slice(0)) : data);
207
278
  }
208
279
  async sendFile(source, chunkBytes = 256 * 1024) {
209
280
  const MAX_BUFFERED = 4 * 1024 * 1024;
210
281
  for await (const chunk of source) {
211
282
  for (let off = 0; off < chunk.byteLength; off += chunkBytes) {
283
+ while (this.#reconnecting)
284
+ await new Promise((r) => setTimeout(r, 50));
212
285
  if (this.#ws.readyState !== WS_OPEN)
213
286
  return;
214
287
  while (this.#ws.bufferedAmount > MAX_BUFFERED) {
215
288
  await new Promise((r) => setTimeout(r, 10));
216
- if (this.#ws.readyState !== WS_OPEN)
289
+ if (this.#ws.readyState !== WS_OPEN && !this.#reconnecting)
217
290
  return;
218
291
  }
219
- this.#ws.send(chunk.subarray(off, Math.min(off + chunkBytes, chunk.byteLength)));
292
+ this.#send(chunk.subarray(off, Math.min(off + chunkBytes, chunk.byteLength)));
220
293
  }
221
294
  }
222
295
  this.eof();
@@ -230,34 +303,11 @@ export class RealtimeSession {
230
303
  }
231
304
  this.eof();
232
305
  }
233
- /**
234
- * Capture clock heartbeat: `pcm` = cumulative samples captured so far
235
- * (16kHz-equivalent), `wall` = seconds since capture start. Lets the
236
- * server detect capture loss (throttled tab, device switch) and mark
237
- * the transcript with honest gaps instead of gluing across them.
238
- * `streamMicrophone`/`captureMicrophone` call this automatically.
239
- */
240
306
  sendClock(pcm, wall) {
241
- if (this.#ws.readyState === WS_OPEN) {
242
- this.#ws.send(JSON.stringify({ type: 'clock', pcm: Math.floor(pcm), wall }));
243
- }
307
+ this.#send(JSON.stringify({ type: 'clock', pcm: Math.floor(pcm), wall }));
244
308
  }
245
- /**
246
- * Options update during the realtime session. Omitted fields are unchanged.
247
- * `latencyMode` switches the decode model policy from the next
248
- * engine step; `spokenLanguage` changes the transcription language from the
249
- * next engine step (`''` or `'auto'` = auto-detect); `speakerLabels`
250
- * turns speaker labelling on (`'on'` or `'auto'`) or off (`'off'`) from the next
251
- * engine step — enabling labels speakers from that point on;
252
- * `translate` replaces the whole target-language list (`[]` turns
253
- * translation off). Applies to subsequent transcript —
254
- * already-delivered segments and settled sentences are not
255
- * re-transcribed, re-attributed or re-translated.
256
- */
257
309
  update(changes) {
258
- if (this.#ws.readyState !== WS_OPEN)
259
- return;
260
- this.#ws.send(JSON.stringify({
310
+ this.#send(JSON.stringify({
261
311
  type: 'session.update',
262
312
  ...(changes.latencyMode !== undefined && { latencyMode: changes.latencyMode }),
263
313
  ...(changes.spokenLanguage !== undefined && { spokenLanguage: changes.spokenLanguage }),
@@ -266,14 +316,10 @@ export class RealtimeSession {
266
316
  }));
267
317
  }
268
318
  eof() {
269
- if (this.#ws.readyState === WS_OPEN) {
270
- this.#ws.send(JSON.stringify({ type: 'eof' }));
271
- }
319
+ this.#send(JSON.stringify({ type: 'eof' }));
272
320
  }
273
321
  cancel() {
274
- if (this.#ws.readyState === WS_OPEN) {
275
- this.#ws.send(JSON.stringify({ type: 'cancel' }));
276
- }
322
+ this.#send(JSON.stringify({ type: 'cancel' }));
277
323
  }
278
324
  get doneReason() {
279
325
  return this.#endReason;
@@ -283,6 +329,7 @@ export class RealtimeSession {
283
329
  }
284
330
  close() {
285
331
  this.#closed = true;
332
+ this.#closedByUs = true;
286
333
  try {
287
334
  this.#ws.close();
288
335
  }
@@ -326,18 +373,14 @@ async function* readableToAsyncIterable(stream) {
326
373
  reader.releaseLock();
327
374
  }
328
375
  }
329
- /** `vs.realtime`: a realtime session is a signed token plus one socket.
330
- * Nothing is stored: the transcript is what the socket sends. */
331
376
  export class Realtime {
332
377
  #t;
333
378
  constructor(t) {
334
379
  this.#t = t;
335
380
  }
336
- /** `POST /v1/realtime/sessions`: validate the options, get a signed `streamUrl`. */
337
381
  createSession(options = {}, call = {}) {
338
382
  return this.#t.json('POST', '/v1/realtime/sessions', { body: options, ...call });
339
383
  }
340
- /** `createSession`, then open the socket and wait for `session.ready`. */
341
384
  async connect(options = {}, connect = {}) {
342
385
  const created = await this.createSession(options);
343
386
  return RealtimeSession.connect({
@@ -349,13 +392,12 @@ export class Realtime {
349
392
  onFrame: connect.onFrame,
350
393
  });
351
394
  }
352
- /** `GET /v1/realtime/capabilities`: languages, audio formats, limits. */
353
395
  capabilities() {
354
396
  return this.#t.json('GET', '/v1/realtime/capabilities');
355
397
  }
356
- /** Stream an encoded file (mp3, wav, flac, ogg, m4a…) through a realtime
357
- * session and collect the transcript. Nothing is stored; for a stored,
358
- * shareable transcription use `vs.transcriptions.create`. */
398
+ async warm(req) {
399
+ await this.#t.json('POST', '/v1/realtime/warm', { body: req });
400
+ }
359
401
  async transcribeFile(file, opts = {}) {
360
402
  const { onSession, readyDeadlineMs, handlers, options } = split(opts);
361
403
  const session = await this.connect({ ...options, input: { kind: 'file' } }, { readyDeadlineMs });
@@ -363,7 +405,6 @@ export class Realtime {
363
405
  await session.sendFile(fileChunks(file));
364
406
  return collectTranscript(session, handlers);
365
407
  }
366
- /** The same for a link the engine fetches itself. */
367
408
  async transcribeUrl(url, opts = {}) {
368
409
  const { onSession, readyDeadlineMs, handlers, options } = split(opts);
369
410
  const session = await this.connect({ ...options, input: { kind: 'url', url } }, { readyDeadlineMs });
@@ -376,7 +417,6 @@ function split(opts) {
376
417
  const handlers = { onProgress, onCommit, onTranslated, onAmended, onSpeakerSettled, onSentence };
377
418
  return { onSession, readyDeadlineMs, handlers, options };
378
419
  }
379
- /** Upsert one sentence frame into a list kept in sentenceId order. */
380
420
  export function applySentence(list, sentence) {
381
421
  const i = list.findIndex((s) => s.sentenceId === sentence.sentenceId);
382
422
  if (sentence.status === 'dropped') {