@vocalstack/js-sdk 1.0.13 → 2.0.0-alpha.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.
Files changed (73) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -69
  3. package/SKILL.md +252 -0
  4. package/dist/errors.d.ts +40 -0
  5. package/dist/errors.js +74 -0
  6. package/dist/file.d.ts +2 -0
  7. package/dist/file.js +38 -0
  8. package/dist/http.d.ts +75 -0
  9. package/dist/http.js +122 -0
  10. package/dist/index.d.ts +56 -2
  11. package/dist/index.js +65 -17
  12. package/dist/mic.d.ts +75 -0
  13. package/dist/mic.js +258 -0
  14. package/dist/realtime.d.ts +132 -0
  15. package/dist/realtime.js +450 -0
  16. package/dist/resources.d.ts +521 -0
  17. package/dist/resources.js +787 -0
  18. package/dist/subscription.d.ts +55 -0
  19. package/dist/subscription.js +164 -0
  20. package/dist/ws.d.ts +12 -0
  21. package/dist/ws.js +15 -0
  22. package/package.json +32 -36
  23. package/skills/vocalstack/SKILL.md +252 -0
  24. package/dist/RestApi.d.ts +0 -21
  25. package/dist/RestApi.js +0 -94
  26. package/dist/VocalStackApi.d.ts +0 -20
  27. package/dist/VocalStackApi.js +0 -51
  28. package/dist/WebsocketApi.d.ts +0 -38
  29. package/dist/WebsocketApi.js +0 -150
  30. package/dist/apis/Billing.d.ts +0 -59
  31. package/dist/apis/Billing.js +0 -21
  32. package/dist/apis/LiveTranscription.d.ts +0 -58
  33. package/dist/apis/LiveTranscription.js +0 -55
  34. package/dist/apis/Polyglot.d.ts +0 -113
  35. package/dist/apis/Polyglot.js +0 -68
  36. package/dist/apis/Security.d.ts +0 -25
  37. package/dist/apis/Security.js +0 -35
  38. package/dist/apis/Transcriptions.d.ts +0 -61
  39. package/dist/apis/Transcriptions.js +0 -43
  40. package/dist/apis/UrlTranscription.d.ts +0 -59
  41. package/dist/apis/UrlTranscription.js +0 -53
  42. package/dist/apis/index.d.ts +0 -6
  43. package/dist/apis/index.js +0 -22
  44. package/dist/esm/ApiVersion.d.ts +0 -2
  45. package/dist/esm/ApiVersion.js +0 -28
  46. package/dist/esm/RestApi.d.ts +0 -21
  47. package/dist/esm/RestApi.js +0 -91
  48. package/dist/esm/VocalStackApi.d.ts +0 -19
  49. package/dist/esm/VocalStackApi.js +0 -46
  50. package/dist/esm/WebsocketApi.d.ts +0 -38
  51. package/dist/esm/WebsocketApi.js +0 -146
  52. package/dist/esm/apis/Billing.d.ts +0 -59
  53. package/dist/esm/apis/Billing.js +0 -17
  54. package/dist/esm/apis/LiveTranscription.d.ts +0 -58
  55. package/dist/esm/apis/LiveTranscription.js +0 -51
  56. package/dist/esm/apis/Polyglot.d.ts +0 -113
  57. package/dist/esm/apis/Polyglot.js +0 -64
  58. package/dist/esm/apis/Security.d.ts +0 -25
  59. package/dist/esm/apis/Security.js +0 -31
  60. package/dist/esm/apis/Transcriptions.d.ts +0 -61
  61. package/dist/esm/apis/Transcriptions.js +0 -39
  62. package/dist/esm/apis/UrlTranscription.d.ts +0 -59
  63. package/dist/esm/apis/UrlTranscription.js +0 -49
  64. package/dist/esm/apis/index.d.ts +0 -6
  65. package/dist/esm/apis/index.js +0 -6
  66. package/dist/esm/generics.zod.d.ts +0 -77
  67. package/dist/esm/generics.zod.js +0 -45
  68. package/dist/esm/index.d.ts +0 -2
  69. package/dist/esm/index.js +0 -2
  70. package/dist/esm/version.d.ts +0 -1
  71. package/dist/esm/version.js +0 -1
  72. package/dist/generics.zod.d.ts +0 -77
  73. package/dist/generics.zod.js +0 -48
@@ -0,0 +1,450 @@
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
+ import { parseServerFrame } from '@vocalstack/contract';
6
+ import { capacityExceeded, fromEnvelope, VocalStackError } from './errors.js';
7
+ import { fileChunks } from './file.js';
8
+ import { openWebSocket, WS_OPEN } from './ws.js';
9
+ 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. */
16
+ export function upgradeRefused(status, body) {
17
+ let parsed;
18
+ try {
19
+ parsed = body ? JSON.parse(body) : undefined;
20
+ }
21
+ catch {
22
+ parsed = undefined;
23
+ }
24
+ if (status === 503) {
25
+ const code = parsed?.code;
26
+ if (code === 'unavailable')
27
+ return fromEnvelope(parsed, status);
28
+ return capacityExceeded('stream host answered 503 (fleet saturated)');
29
+ }
30
+ const ours = typeof parsed?.code === 'string';
31
+ if ((status === 502 || status === 504) && !ours)
32
+ return capacityExceeded(`stream host answered ${status} (no engine answered in time)`);
33
+ return fromEnvelope(parsed, status);
34
+ }
35
+ export class RealtimeSession {
36
+ sessionId;
37
+ #ws;
38
+ #queue = [];
39
+ #wake = null;
40
+ #endReason = null;
41
+ #stats = null;
42
+ #error = null;
43
+ #closed = false;
44
+ #onFrame;
45
+ constructor(sessionId, ws, onFrame) {
46
+ this.sessionId = sessionId;
47
+ this.#ws = ws;
48
+ this.#onFrame = onFrame;
49
+ }
50
+ static async connect(params) {
51
+ 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
+ });
142
+ });
143
+ return session;
144
+ }
145
+ /** Hands a frame to `onFrame`; a throwing callback never breaks the socket. */
146
+ #frame(frame) {
147
+ try {
148
+ this.#onFrame?.(frame);
149
+ }
150
+ catch {
151
+ }
152
+ }
153
+ #listen() {
154
+ this.#ws.addEventListener('message', (ev) => {
155
+ if (typeof ev.data !== 'string')
156
+ return;
157
+ let frame;
158
+ try {
159
+ frame = parseServerFrame(ev.data);
160
+ }
161
+ catch {
162
+ return;
163
+ }
164
+ if (!frame)
165
+ return;
166
+ this.#frame(frame);
167
+ switch (frame.type) {
168
+ case 'transcript.committed':
169
+ case 'transcript.draft':
170
+ case 'transcript.words':
171
+ case 'transcript.translated':
172
+ case 'transcript.amended':
173
+ case 'transcript.latency':
174
+ case 'transcript.speaker':
175
+ case 'transcript.sentence':
176
+ case 'transcript.progress':
177
+ this.#queue.push(frame);
178
+ this.#wake?.();
179
+ break;
180
+ case 'session.done':
181
+ this.#endReason = frame.reason;
182
+ this.#stats = frame.stats;
183
+ this.#wake?.();
184
+ break;
185
+ case 'error':
186
+ this.#error = new VocalStackError(frame.code, frame.message, {
187
+ retryable: frame.retryable,
188
+ });
189
+ this.#wake?.();
190
+ break;
191
+ default:
192
+ break;
193
+ }
194
+ });
195
+ this.#ws.addEventListener('close', () => {
196
+ this.#closed = true;
197
+ this.#wake?.();
198
+ });
199
+ }
200
+ sendAudio(chunk) {
201
+ if (this.#ws.readyState !== WS_OPEN)
202
+ return;
203
+ const data = chunk instanceof Float32Array
204
+ ? new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength)
205
+ : chunk;
206
+ this.#ws.send(data);
207
+ }
208
+ async sendFile(source, chunkBytes = 256 * 1024) {
209
+ const MAX_BUFFERED = 4 * 1024 * 1024;
210
+ for await (const chunk of source) {
211
+ for (let off = 0; off < chunk.byteLength; off += chunkBytes) {
212
+ if (this.#ws.readyState !== WS_OPEN)
213
+ return;
214
+ while (this.#ws.bufferedAmount > MAX_BUFFERED) {
215
+ await new Promise((r) => setTimeout(r, 10));
216
+ if (this.#ws.readyState !== WS_OPEN)
217
+ return;
218
+ }
219
+ this.#ws.send(chunk.subarray(off, Math.min(off + chunkBytes, chunk.byteLength)));
220
+ }
221
+ }
222
+ this.eof();
223
+ }
224
+ async pipe(source) {
225
+ const iter = Symbol.asyncIterator in source
226
+ ? source
227
+ : readableToAsyncIterable(source);
228
+ for await (const chunk of iter) {
229
+ this.sendAudio(chunk);
230
+ }
231
+ this.eof();
232
+ }
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
+ sendClock(pcm, wall) {
241
+ if (this.#ws.readyState === WS_OPEN) {
242
+ this.#ws.send(JSON.stringify({ type: 'clock', pcm: Math.floor(pcm), wall }));
243
+ }
244
+ }
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
+ update(changes) {
258
+ if (this.#ws.readyState !== WS_OPEN)
259
+ return;
260
+ this.#ws.send(JSON.stringify({
261
+ type: 'session.update',
262
+ ...(changes.latencyMode !== undefined && { latencyMode: changes.latencyMode }),
263
+ ...(changes.spokenLanguage !== undefined && { spokenLanguage: changes.spokenLanguage }),
264
+ ...(changes.speakerLabels !== undefined && { speakerLabels: changes.speakerLabels }),
265
+ ...(changes.translate !== undefined && { translate: changes.translate }),
266
+ }));
267
+ }
268
+ eof() {
269
+ if (this.#ws.readyState === WS_OPEN) {
270
+ this.#ws.send(JSON.stringify({ type: 'eof' }));
271
+ }
272
+ }
273
+ cancel() {
274
+ if (this.#ws.readyState === WS_OPEN) {
275
+ this.#ws.send(JSON.stringify({ type: 'cancel' }));
276
+ }
277
+ }
278
+ get doneReason() {
279
+ return this.#endReason;
280
+ }
281
+ get stats() {
282
+ return this.#stats;
283
+ }
284
+ close() {
285
+ this.#closed = true;
286
+ try {
287
+ this.#ws.close();
288
+ }
289
+ catch {
290
+ }
291
+ this.#wake?.();
292
+ }
293
+ async *[Symbol.asyncIterator]() {
294
+ for (;;) {
295
+ const item = this.#queue.shift();
296
+ if (item) {
297
+ yield item;
298
+ continue;
299
+ }
300
+ if (this.#error)
301
+ throw this.#error;
302
+ if (this.#endReason || this.#closed)
303
+ return;
304
+ await new Promise((resolve) => {
305
+ this.#wake = resolve;
306
+ });
307
+ this.#wake = null;
308
+ }
309
+ }
310
+ [Symbol.dispose]() {
311
+ this.close();
312
+ }
313
+ }
314
+ async function* readableToAsyncIterable(stream) {
315
+ const reader = stream.getReader();
316
+ try {
317
+ for (;;) {
318
+ const { done, value } = await reader.read();
319
+ if (done)
320
+ return;
321
+ if (value)
322
+ yield value;
323
+ }
324
+ }
325
+ finally {
326
+ reader.releaseLock();
327
+ }
328
+ }
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
+ export class Realtime {
332
+ #t;
333
+ constructor(t) {
334
+ this.#t = t;
335
+ }
336
+ /** `POST /v1/realtime/sessions`: validate the options, get a signed `streamUrl`. */
337
+ createSession(options = {}, call = {}) {
338
+ return this.#t.json('POST', '/v1/realtime/sessions', { body: options, ...call });
339
+ }
340
+ /** `createSession`, then open the socket and wait for `session.ready`. */
341
+ async connect(options = {}, connect = {}) {
342
+ const created = await this.createSession(options);
343
+ return RealtimeSession.connect({
344
+ sessionId: created.sessionId,
345
+ streamUrl: created.streamUrl,
346
+ options,
347
+ readyDeadlineMs: connect.readyDeadlineMs ?? this.#t.opts.readyDeadlineMs,
348
+ onLoading: connect.onLoading,
349
+ onFrame: connect.onFrame,
350
+ });
351
+ }
352
+ /** `GET /v1/realtime/capabilities`: languages, audio formats, limits. */
353
+ capabilities() {
354
+ return this.#t.json('GET', '/v1/realtime/capabilities');
355
+ }
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`. */
359
+ async transcribeFile(file, opts = {}) {
360
+ const { onSession, readyDeadlineMs, handlers, options } = split(opts);
361
+ const session = await this.connect({ ...options, input: { kind: 'file' } }, { readyDeadlineMs });
362
+ onSession?.(session);
363
+ await session.sendFile(fileChunks(file));
364
+ return collectTranscript(session, handlers);
365
+ }
366
+ /** The same for a link the engine fetches itself. */
367
+ async transcribeUrl(url, opts = {}) {
368
+ const { onSession, readyDeadlineMs, handlers, options } = split(opts);
369
+ const session = await this.connect({ ...options, input: { kind: 'url', url } }, { readyDeadlineMs });
370
+ onSession?.(session);
371
+ return collectTranscript(session, handlers);
372
+ }
373
+ }
374
+ function split(opts) {
375
+ const { onProgress, onCommit, onSession, onTranslated, onAmended, onSpeakerSettled, onSentence, readyDeadlineMs, ...options } = opts;
376
+ const handlers = { onProgress, onCommit, onTranslated, onAmended, onSpeakerSettled, onSentence };
377
+ return { onSession, readyDeadlineMs, handlers, options };
378
+ }
379
+ /** Upsert one sentence frame into a list kept in sentenceId order. */
380
+ export function applySentence(list, sentence) {
381
+ const i = list.findIndex((s) => s.sentenceId === sentence.sentenceId);
382
+ if (sentence.status === 'dropped') {
383
+ return i === -1 ? list : [...list.slice(0, i), ...list.slice(i + 1)];
384
+ }
385
+ if (i === -1) {
386
+ const next = [...list, sentence];
387
+ next.sort((a, b) => a.sentenceId - b.sentenceId);
388
+ return next;
389
+ }
390
+ const next = list.slice();
391
+ next[i] = sentence;
392
+ return next;
393
+ }
394
+ async function collectTranscript(session, h) {
395
+ const segments = [];
396
+ let sentences = [];
397
+ for await (const event of session) {
398
+ if (event.type === 'transcript.committed') {
399
+ segments.push(event.segment);
400
+ h.onCommit?.(event.segment);
401
+ }
402
+ else if (event.type === 'transcript.translated') {
403
+ const seg = segments.find((s) => s.seq === event.seq);
404
+ if (seg) {
405
+ seg.translations = event.translations;
406
+ seg.translationSources = event.sources;
407
+ }
408
+ h.onTranslated?.(event.seq, event.translations, event.sources ?? undefined);
409
+ }
410
+ else if (event.type === 'transcript.amended') {
411
+ const seg = segments.find((s) => s.seq === event.seq);
412
+ if (seg) {
413
+ seg.text = event.text;
414
+ if (event.speaker >= 0)
415
+ seg.speaker = event.speaker;
416
+ }
417
+ h.onAmended?.(event.seq, event.text, event.speaker, event.fixes ?? []);
418
+ }
419
+ else if (event.type === 'transcript.latency') {
420
+ const seg = segments.find((s) => s.seq === event.seq);
421
+ if (seg)
422
+ seg.latencyS = event.latencyS;
423
+ }
424
+ else if (event.type === 'transcript.speaker') {
425
+ const seg = segments.find((s) => s.seq === event.seq);
426
+ if (seg) {
427
+ if (event.speaker >= 0)
428
+ seg.speaker = event.speaker;
429
+ if (event.speakerConfidence != null)
430
+ seg.speakerConfidence = event.speakerConfidence;
431
+ seg.speakerProvisional = false;
432
+ }
433
+ h.onSpeakerSettled?.(event.seq, event.speaker, event.verdict, event.speakerConfidence ?? undefined);
434
+ }
435
+ else if (event.type === 'transcript.sentence') {
436
+ sentences = applySentence(sentences, event.sentence);
437
+ h.onSentence?.(event.sentence);
438
+ }
439
+ else if (event.type === 'transcript.progress') {
440
+ h.onProgress?.({ processedS: event.processedS, totalS: event.totalS ?? undefined });
441
+ }
442
+ }
443
+ return {
444
+ segments,
445
+ sentences,
446
+ text: (sentences.length ? sentences : segments).map((s) => s.text).join(' '),
447
+ doneReason: session.doneReason,
448
+ stats: session.stats,
449
+ };
450
+ }