@vocalstack/js-sdk 1.1.0 → 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/ApiVersion.d.ts +0 -2
  25. package/dist/ApiVersion.js +0 -32
  26. package/dist/RestApi.d.ts +0 -21
  27. package/dist/RestApi.js +0 -95
  28. package/dist/VocalStackApi.d.ts +0 -19
  29. package/dist/VocalStackApi.js +0 -60
  30. package/dist/WebsocketApi.d.ts +0 -38
  31. package/dist/WebsocketApi.js +0 -150
  32. package/dist/apis/Billing.d.ts +0 -59
  33. package/dist/apis/Billing.js +0 -21
  34. package/dist/apis/LiveTranscription.d.ts +0 -58
  35. package/dist/apis/LiveTranscription.js +0 -55
  36. package/dist/apis/Polyglot.d.ts +0 -113
  37. package/dist/apis/Polyglot.js +0 -68
  38. package/dist/apis/Security.d.ts +0 -25
  39. package/dist/apis/Security.js +0 -35
  40. package/dist/apis/Transcriptions.d.ts +0 -60
  41. package/dist/apis/Transcriptions.js +0 -38
  42. package/dist/apis/UrlTranscription.d.ts +0 -59
  43. package/dist/apis/UrlTranscription.js +0 -53
  44. package/dist/apis/index.d.ts +0 -6
  45. package/dist/apis/index.js +0 -22
  46. package/dist/esm/ApiVersion.d.ts +0 -2
  47. package/dist/esm/ApiVersion.js +0 -28
  48. package/dist/esm/RestApi.d.ts +0 -21
  49. package/dist/esm/RestApi.js +0 -91
  50. package/dist/esm/VocalStackApi.d.ts +0 -19
  51. package/dist/esm/VocalStackApi.js +0 -56
  52. package/dist/esm/WebsocketApi.d.ts +0 -38
  53. package/dist/esm/WebsocketApi.js +0 -146
  54. package/dist/esm/apis/Billing.d.ts +0 -59
  55. package/dist/esm/apis/Billing.js +0 -17
  56. package/dist/esm/apis/LiveTranscription.d.ts +0 -58
  57. package/dist/esm/apis/LiveTranscription.js +0 -51
  58. package/dist/esm/apis/Polyglot.d.ts +0 -113
  59. package/dist/esm/apis/Polyglot.js +0 -64
  60. package/dist/esm/apis/Security.d.ts +0 -25
  61. package/dist/esm/apis/Security.js +0 -31
  62. package/dist/esm/apis/Transcriptions.d.ts +0 -60
  63. package/dist/esm/apis/Transcriptions.js +0 -34
  64. package/dist/esm/apis/UrlTranscription.d.ts +0 -59
  65. package/dist/esm/apis/UrlTranscription.js +0 -49
  66. package/dist/esm/apis/index.d.ts +0 -6
  67. package/dist/esm/apis/index.js +0 -6
  68. package/dist/esm/generics.zod.d.ts +0 -83
  69. package/dist/esm/generics.zod.js +0 -47
  70. package/dist/esm/index.d.ts +0 -2
  71. package/dist/esm/index.js +0 -2
  72. package/dist/generics.zod.d.ts +0 -83
  73. package/dist/generics.zod.js +0 -50
@@ -0,0 +1,787 @@
1
+ import { VocalStackError } from './errors.js';
2
+ import { listenerQuery, seg } from './http.js';
3
+ import { RealtimeSession } from './realtime.js';
4
+ import { Subscription } from './subscription.js';
5
+ import { openWebSocket } from './ws.js';
6
+ async function toBlob(file) {
7
+ if (typeof file === 'string') {
8
+ const fsSpecifier = 'node:' + 'fs';
9
+ const { openAsBlob } = (await import(/* @vite-ignore */ fsSpecifier));
10
+ return { blob: await openAsBlob(file), name: file.split(/[\\/]/).pop() };
11
+ }
12
+ if (typeof Blob !== 'undefined' && file instanceof Blob) {
13
+ return { blob: file, name: file.name || undefined };
14
+ }
15
+ return { blob: new Blob([file]) };
16
+ }
17
+ /** Every item of a paged list: each page asked for with the previous
18
+ * page's `nextCursor` until one comes back without it. */
19
+ async function allPages(page) {
20
+ const out = [];
21
+ let cursor;
22
+ for (;;) {
23
+ const r = await page(cursor);
24
+ out.push(...(r.items ?? []));
25
+ if (!r.nextCursor)
26
+ return out;
27
+ cursor = r.nextCursor;
28
+ }
29
+ }
30
+ /** What broadcasts and transcriptions have in common: both hold
31
+ * recordings and share links. */
32
+ class ParentResource {
33
+ t;
34
+ constructor(t) {
35
+ this.t = t;
36
+ }
37
+ p(id, rest = '') {
38
+ return `${this.base}/${seg(id)}${rest}`;
39
+ }
40
+ /** Every one, newest first: a paged list is read to its last page
41
+ * (`transcriptions.listPage` reads one). */
42
+ list(lifecycle = 'active') {
43
+ return allPages(async (cursor) => {
44
+ const r = await this.t.json('GET', this.base, { query: { lifecycle, cursor } });
45
+ return { items: r[this.key], nextCursor: r.nextCursor };
46
+ });
47
+ }
48
+ get(id) {
49
+ return this.t.json('GET', this.p(id));
50
+ }
51
+ /** Rename, change the default capture settings (a broadcast: also its access, password or link). */
52
+ update(id, req) {
53
+ return this.t.json('PATCH', this.p(id), { body: req });
54
+ }
55
+ /** Hidden, its links dead, its data kept until `restore` or `purge`. */
56
+ archive(id) {
57
+ return this.t.json('POST', this.p(id, '/archive'));
58
+ }
59
+ restore(id) {
60
+ return this.t.json('POST', this.p(id, '/restore'));
61
+ }
62
+ /** Delete an archived one and everything in it, for good. */
63
+ async purge(id) {
64
+ await this.t.json('POST', this.p(id, '/purge'));
65
+ }
66
+ /** Every recording, newest first (every page of `listRecordingsPage`). */
67
+ listRecordings(id, lifecycle = 'active') {
68
+ return allPages(async (cursor) => {
69
+ const r = await this.listRecordingsPage(id, { lifecycle, cursor });
70
+ return { items: r.recordings, nextCursor: r.nextCursor };
71
+ });
72
+ }
73
+ /** One page of recordings, newest first: pass its `nextCursor` as `cursor` for the next. */
74
+ listRecordingsPage(id, opts = {}) {
75
+ return this.t.json('GET', this.p(id, '/recordings'), { query: { lifecycle: opts.lifecycle, cursor: opts.cursor, limit: opts.limit } });
76
+ }
77
+ /** Start a live recording. The grant's `streamUrl` is the publisher
78
+ * socket (`vs.recordings.publish(grant)`). A 409 `conflict` with
79
+ * `details.takeover` means another device is recording: retry with
80
+ * `{ takeover: true }` to record here instead. */
81
+ startRecording(id, req = {}, call = {}) {
82
+ return this.t.json('POST', this.p(id, '/recordings'), { body: req, ...call });
83
+ }
84
+ async listShareLinks(id) {
85
+ const r = await this.t.json('GET', this.p(id, '/share-links'));
86
+ return r.links ?? [];
87
+ }
88
+ createShareLink(id, req) {
89
+ return this.t.json('POST', this.p(id, '/share-links'), { body: req });
90
+ }
91
+ /** The events socket a waiting screen needs: `recording.started` (it is
92
+ * live), `recording.ended`, `job.progress`, `stream.ingest`.
93
+ * Each names the transcription the recording is made in (`transcriptionId`). */
94
+ async events(id, as = {}) {
95
+ const token = as.asOwner === false ? undefined : await this.t.bearer();
96
+ return openWebSocket(this.t.wsUrl(this.p(id, '/events'), listenerQuery(as)), token);
97
+ }
98
+ }
99
+ /** `vs.broadcasts`: the links listeners open (design 77). */
100
+ export class Broadcasts extends ParentResource {
101
+ base = '/v1/broadcasts';
102
+ key = 'broadcasts';
103
+ create(req, call = {}) {
104
+ return this.t.json('POST', this.base, { body: req, ...call });
105
+ }
106
+ /** Give a broadcast a stream it can record from (a YouTube live, an
107
+ * internet radio address). Returns the broadcast. */
108
+ addAudioSource(id, req) {
109
+ return this.t.json('POST', this.p(id, '/audio-sources'), { body: req });
110
+ }
111
+ removeAudioSource(id, sourceId) {
112
+ return this.t.json('DELETE', this.p(id, `/audio-sources/${seg(sourceId)}`));
113
+ }
114
+ /** Whether that source can be recorded right now; `fresh` skips the short cache. */
115
+ checkAudioSource(id, sourceId, opts = {}) {
116
+ return this.t.json('GET', this.p(id, `/audio-sources/${seg(sourceId)}/check`), { query: { fresh: opts.fresh || undefined } });
117
+ }
118
+ }
119
+ /** `vs.transcriptions`: a file, a link, a microphone or a stream, transcribed and stored. */
120
+ export class Transcriptions extends ParentResource {
121
+ base = '/v1/transcriptions';
122
+ key = 'transcriptions';
123
+ /** One page of transcriptions, newest first: pass its `nextCursor` as
124
+ * `cursor` for the next; the last page has none. */
125
+ listPage(opts = {}) {
126
+ return this.t.json('GET', this.base, { query: { lifecycle: opts.lifecycle, cursor: opts.cursor, limit: opts.limit } });
127
+ }
128
+ /** A file or link recording in this transcription: no live phase, transcribed at full speed.
129
+ * It has no job: open the grant's socket with `vs.recordings.publish(grant)` (with `url`
130
+ * the server fetches the link) and read it until the session is done. */
131
+ uploadRecording(id, req = {}, call = {}) {
132
+ return this.t.json('POST', this.p(id, '/recordings/upload'), { body: req, ...call });
133
+ }
134
+ /** From a file (`file`) or a public link (`url`). The answer's
135
+ * `job.recordingId` is the job: `vs.jobs.wait(recordingId)`,
136
+ * then `vs.recordings.transcript(recordingId)`. */
137
+ async create(req, call = {}) {
138
+ const { file, fileName, ...options } = req;
139
+ if (file === undefined)
140
+ return this.t.json('POST', this.base, { body: options, ...call });
141
+ const { blob, name } = await toBlob(file);
142
+ const fd = new FormData();
143
+ fd.append('options', JSON.stringify(options));
144
+ fd.append('file', blob, fileName ?? name ?? 'upload');
145
+ return this.t.json('POST', this.base, { body: fd, ...call });
146
+ }
147
+ /** `create({ url, ...opts })`. */
148
+ createFromUrl(url, opts = {}, call = {}) {
149
+ return this.create({ ...opts, url }, call);
150
+ }
151
+ /** A transcription captured as it is spoken: a microphone, or a stream
152
+ * the server pulls. Created idle: `startRecording(id)` starts it. */
153
+ createLive(req, call = {}) {
154
+ return this.t.json('POST', `${this.base}/live`, { body: req, ...call });
155
+ }
156
+ /** Whether a link is live (a live transcription) or a file. */
157
+ probe(url) {
158
+ return this.t.json('GET', `${this.base}/probe`, { query: { url } });
159
+ }
160
+ /** Feed a broadcast from a live transcription. Returns the transcription. */
161
+ attachBroadcast(id, broadcastId) {
162
+ return this.t.json('PUT', this.p(id, `/broadcasts/${seg(broadcastId)}`));
163
+ }
164
+ detachBroadcast(id, broadcastId) {
165
+ return this.t.json('DELETE', this.p(id, `/broadcasts/${seg(broadcastId)}`));
166
+ }
167
+ }
168
+ /** `vs.batches`: several files transcribed together, for less than a
169
+ * transcription each (one create, one minimum charge). Create a batch, add
170
+ * each file or link with `addFile`, then `start` it; `get` shows its files
171
+ * (each a transcription with its job) and their counts. A batch's files get
172
+ * no summary, title, share link or spoken translations. */
173
+ export class Batches {
174
+ #t;
175
+ constructor(t) {
176
+ this.#t = t;
177
+ }
178
+ #p(id, rest = '') {
179
+ return `/v1/batches/${seg(id)}${rest}`;
180
+ }
181
+ /** An open batch, ready for files (`name` defaults to "Batch of <date>"). */
182
+ create(req = {}, call = {}) {
183
+ return this.#t.json('POST', '/v1/batches', { body: req, ...call });
184
+ }
185
+ /** Every batch, newest first (every page of `listPage`); each with its counts, not its files. */
186
+ list() {
187
+ return allPages(async (cursor) => {
188
+ const r = await this.listPage({ cursor });
189
+ return { items: r.batches, nextCursor: r.nextCursor };
190
+ });
191
+ }
192
+ /** One page of batches, newest first: pass its `nextCursor` as `cursor` for the next. */
193
+ listPage(opts = {}) {
194
+ return this.#t.json('GET', '/v1/batches', { query: { cursor: opts.cursor, limit: opts.limit } });
195
+ }
196
+ /** A batch with its files (`files`, each a transcription with its job), in the order they were added. */
197
+ get(id) {
198
+ return this.#t.json('GET', this.#p(id));
199
+ }
200
+ /** Discard a batch that was never started, with its files (nothing was charged). */
201
+ async delete(id) {
202
+ await this.#t.json('DELETE', this.#p(id));
203
+ }
204
+ /** Add one file (`file`) or link (`url`) to an open batch: a transcription
205
+ * of its own (`batchId`), waiting for the batch's `start`. */
206
+ async addFile(id, req, call = {}) {
207
+ const { file, fileName, ...options } = req;
208
+ if (file === undefined)
209
+ return this.#t.json('POST', this.#p(id, '/files'), { body: options, ...call });
210
+ const { blob, name } = await toBlob(file);
211
+ const fd = new FormData();
212
+ fd.append('options', JSON.stringify(options));
213
+ fd.append('file', blob, fileName ?? name ?? 'upload');
214
+ return this.#t.json('POST', this.#p(id, '/files'), { body: fd, ...call });
215
+ }
216
+ /** `addFile(id, { url, ...opts })`. */
217
+ addUrl(id, url, opts = {}, call = {}) {
218
+ return this.addFile(id, { ...opts, url }, call);
219
+ }
220
+ /** Seal the batch and queue its files, longest first; `wait` follows it to the end. */
221
+ start(id, call = {}) {
222
+ return this.#t.json('POST', this.#p(id, '/start'), call);
223
+ }
224
+ /** Every finished file's transcript in one `format` (as `recordings.export`
225
+ * makes one), in one zip, as a `Response`: `.arrayBuffer()` it. Files
226
+ * still running or failed are left out. */
227
+ export(id, format) {
228
+ return this.#t.raw('GET', this.#p(id, '/export'), { headers: { accept: '*/*' }, query: { format } });
229
+ }
230
+ /** Stop every file not finished yet: queued files never start, a running
231
+ * one is cancelled and billed for what it transcribed. */
232
+ stop(id) {
233
+ return this.#t.json('POST', this.#p(id, '/stop'));
234
+ }
235
+ /** Poll a started batch until it is `done` (every file finished or
236
+ * failed) or `stopped`, and return it with its files. It does not throw
237
+ * on a failed file: check `counts.failed` and each file's `job.state`.
238
+ * `onProgress` sees every read, the last one included. */
239
+ async wait(id, opts = {}) {
240
+ const interval = opts.intervalMs ?? 5000;
241
+ const deadline = opts.timeoutMs ? Date.now() + opts.timeoutMs : Infinity;
242
+ for (;;) {
243
+ const batch = await this.get(id);
244
+ opts.onProgress?.(batch);
245
+ // Closed: every file over (a stopped batch's running file too).
246
+ if ((batch.state === 'done' || batch.state === 'stopped') && batch.finishedAt)
247
+ return batch;
248
+ if (batch.state === 'open')
249
+ throw new VocalStackError('invalid_state', 'the batch is open: start it before waiting for it');
250
+ if (Date.now() + interval > deadline)
251
+ throw new VocalStackError('timeout', `batch still ${batch.state} after ${opts.timeoutMs} ms`, { retryable: true });
252
+ if (opts.signal?.aborted)
253
+ throw opts.signal.reason ?? new Error('aborted');
254
+ await new Promise((r) => setTimeout(r, interval));
255
+ }
256
+ }
257
+ }
258
+ /** `vs.recordings`: one take, live or uploaded. */
259
+ export class Recordings {
260
+ #t;
261
+ constructor(t) {
262
+ this.#t = t;
263
+ }
264
+ #p(id, rest = '') {
265
+ return `/v1/recordings/${seg(id)}${rest}`;
266
+ }
267
+ get(id, as) {
268
+ return this.#t.json('GET', this.#p(id), { query: listenerQuery(as) });
269
+ }
270
+ pause(id) {
271
+ return this.#t.json('POST', this.#p(id, '/pause'));
272
+ }
273
+ resume(id) {
274
+ return this.#t.json('POST', this.#p(id, '/resume'));
275
+ }
276
+ /** Only from paused. */
277
+ stop(id, reason) {
278
+ return this.#t.json('POST', this.#p(id, '/stop'), { body: { reason } });
279
+ }
280
+ settings(id, patch) {
281
+ return this.#t.json('PATCH', this.#p(id, '/settings'), { body: patch });
282
+ }
283
+ /** A new publisher grant for a recording that is already live. */
284
+ publishToken(id, opts = {}) {
285
+ return this.#t.json('POST', this.#p(id, '/publish-token'), { body: { takeover: opts.takeover ?? false } });
286
+ }
287
+ /** Open the publisher socket a grant names and wait until it is ready:
288
+ * send raw PCM (f32le, 16 kHz, mono) with `sendAudio`, end with `eof()`. */
289
+ publish(grant, opts = {}) {
290
+ if (!grant.streamUrl)
291
+ throw new VocalStackError('invalid_state', 'this grant has no streamUrl (an upload recording has no publisher socket)');
292
+ return RealtimeSession.connect({
293
+ sessionId: grant.sessionId ?? grant.recording.id,
294
+ streamUrl: grant.streamUrl,
295
+ readyDeadlineMs: opts.readyDeadlineMs ?? this.#t.opts.readyDeadlineMs,
296
+ onLoading: opts.onLoading,
297
+ onFrame: opts.onFrame,
298
+ });
299
+ }
300
+ archive(id) {
301
+ return this.#t.json('POST', this.#p(id, '/archive'));
302
+ }
303
+ restore(id) {
304
+ return this.#t.json('POST', this.#p(id, '/restore'));
305
+ }
306
+ async purge(id) {
307
+ await this.#t.json('POST', this.#p(id, '/purge'));
308
+ }
309
+ /** The transcript: segments (with word timings), sentences and speakers,
310
+ * up to `lastSeq`; `after` continues from a cursor, `lang` reads it translated. */
311
+ transcript(id, opts = {}) {
312
+ return this.#t.json('GET', this.#p(id, '/transcript'), {
313
+ query: { ...listenerQuery(opts), after: opts.after, lang: opts.lang, role: opts.asListener ? 'listener' : undefined },
314
+ });
315
+ }
316
+ /** Translate the given sentences (at most 40 ids per call); the answer
317
+ * carries only what this call translated. */
318
+ translation(id, lang, sentences = [], as) {
319
+ return this.#t.json('GET', this.#p(id, '/translation'), {
320
+ query: { ...listenerQuery(as), lang, sentences: sentences.length ? sentences.join(',') : undefined },
321
+ });
322
+ }
323
+ /** Queue the AI title and summary of a finished recording. */
324
+ summarize(id, call = {}) {
325
+ return this.#t.json('POST', this.#p(id, '/summarize'), call);
326
+ }
327
+ /** The title and summary as they stand, in `lang` when given. */
328
+ summary(id, opts = {}) {
329
+ return this.#t.json('GET', this.#p(id, '/summary'), { query: { ...listenerQuery(opts), lang: opts.lang } });
330
+ }
331
+ /** An export file (JSON, SRT, XLSX, PDF) as a `Response`: `.text()` or
332
+ * `.arrayBuffer()` it. `lang` makes it read in that language. */
333
+ export(id, format, opts = {}) {
334
+ return this.#t.raw('GET', this.#p(id, '/export'), { headers: { accept: '*/*' }, query: { ...listenerQuery(opts), format, lang: opts.lang } });
335
+ }
336
+ /** The export as a plain link, for a browser download with a browser
337
+ * access token (it rides in the query). Never use it with an API key:
338
+ * the API refuses a key in a URL. */
339
+ async exportUrl(id, format, opts = {}) {
340
+ const token = opts.shareCode || opts.listenerToken ? undefined : await this.#t.bearer();
341
+ return this.#t.url(this.#p(id, '/export'), { format, lang: opts.lang, shareCode: opts.shareCode, listenerToken: opts.listenerToken, access_token: token });
342
+ }
343
+ /** The signed media URL the server redirects to. `kind`: `delivery`
344
+ * (default), `master`, or `source` (a transcription's original upload). */
345
+ async audioUrl(id, opts = {}) {
346
+ const res = await this.#t.raw('GET', this.#p(id, '/audio'), {
347
+ query: { ...listenerQuery(opts), kind: opts.kind, lang: opts.lang, format: opts.format },
348
+ });
349
+ const location = res.headers.get('location');
350
+ if (!location)
351
+ throw new VocalStackError('not_found', 'no signed URL in the redirect');
352
+ return location;
353
+ }
354
+ /** A stretch of the recording's audio, `startS` to `endS` seconds (at most 120 s). */
355
+ clip(id, startS, endS, as) {
356
+ return this.#t.raw('GET', this.#p(id, '/audio/clip'), { headers: { accept: '*/*' }, query: { ...listenerQuery(as), startS, endS } });
357
+ }
358
+ /** The synthesized voice tracks of a recording (per language). */
359
+ voice(id, lang, as) {
360
+ return this.#t.json('GET', this.#p(id, '/voice'), { query: { ...listenerQuery(as), lang } });
361
+ }
362
+ /** Synthesize the voice track in these languages. */
363
+ renderVoice(id, langs, call = {}) {
364
+ return this.#t.json('POST', this.#p(id, '/voice'), { body: { langs }, ...call });
365
+ }
366
+ /** One sentence spoken in `lang`. */
367
+ voiceClip(id, sentence, lang, opts = {}) {
368
+ return this.#t.raw('GET', this.#p(id, `/voice/${seg(sentence)}`), { headers: { accept: '*/*' }, query: { ...listenerQuery(opts), lang, format: opts.format } });
369
+ }
370
+ /** A short-lived token for the recording's media URLs. */
371
+ mediaToken(id) {
372
+ return this.#t.json('GET', this.#p(id, '/media-token'));
373
+ }
374
+ /** Name a speaker (the diarizer's index); `null` clears the name. */
375
+ async nameSpeaker(id, speaker, name) {
376
+ await this.#t.json('PUT', this.#p(id, `/speakers/${seg(speaker)}`), { body: { name } });
377
+ }
378
+ /** The durable event log with wall times (owner only): every event
379
+ * after `after` (a `seq`; the whole log without it), read `limit` a page. */
380
+ events(id, opts = {}) {
381
+ return allPages(async (cursor) => {
382
+ const r = await this.eventsPage(id, cursor ? { cursor, limit: opts.limit } : { after: opts.after, limit: opts.limit });
383
+ return { items: r.events, nextCursor: r.nextCursor };
384
+ });
385
+ }
386
+ /** One page of the event log (500 by default, 1000 at most): from `after`
387
+ * a `seq`, or the previous page's `nextCursor` as `cursor`. */
388
+ eventsPage(id, opts = {}) {
389
+ return this.#t.json('GET', this.#p(id, '/events'), { query: { cursor: opts.cursor, after: opts.after, limit: opts.limit } });
390
+ }
391
+ /** Attach to the live fan-out; resolves once the backfill snapshot is in place. */
392
+ async subscribe(id, opts = {}) {
393
+ const token = opts.asOwner === false ? undefined : await this.#t.bearer();
394
+ const url = this.#t.wsUrl(this.#p(id, '/subscribe'), {
395
+ lang: opts.lang,
396
+ voice: opts.voice,
397
+ ...listenerQuery(opts),
398
+ role: opts.asOwner === false ? 'listener' : undefined,
399
+ device: this.#t.opts.deviceId,
400
+ });
401
+ return Subscription.open(id, url, token, opts, (after) => this.transcript(id, {
402
+ after,
403
+ lang: opts.lang,
404
+ listenerToken: opts.listenerToken,
405
+ shareCode: opts.shareCode,
406
+ asListener: opts.asOwner === false,
407
+ }));
408
+ }
409
+ }
410
+ const TERMINAL = new Set(['done', 'failed', 'stopped', 'cropping']);
411
+ /** `vs.jobs`: the transcription job of a file or link recording
412
+ * (`/v1/recordings/{id}/job`). States: queued, fetching, transcribing,
413
+ * done, failed, and cropping (waiting for `start`). */
414
+ export class Jobs {
415
+ #t;
416
+ constructor(t) {
417
+ this.#t = t;
418
+ }
419
+ get(recordingId) {
420
+ return this.#t.json('GET', `/v1/recordings/${seg(recordingId)}/job`);
421
+ }
422
+ /** Release a job waiting in `cropping`: the window to transcribe, in seconds. */
423
+ start(recordingId, window = {}) {
424
+ return this.#t.json('POST', `/v1/recordings/${seg(recordingId)}/job/start`, { body: window });
425
+ }
426
+ /** Re-queue a failed job from its stored source. */
427
+ retry(recordingId) {
428
+ return this.#t.json('POST', `/v1/recordings/${seg(recordingId)}/job/retry`);
429
+ }
430
+ /** The file the server fetched for a link job waiting in `cropping`. */
431
+ preview(recordingId) {
432
+ return this.#t.raw('GET', `/v1/recordings/${seg(recordingId)}/job/preview`, { headers: { accept: '*/*' } });
433
+ }
434
+ /** Poll until the job is `done`, `failed` or `cropping`, and return it.
435
+ * It does not throw on `failed`: check `job.state` (and `job.error`).
436
+ * `onProgress` sees every job read, the last one included: its `state`
437
+ * and `progress` (`pct`, `processedS`, the fetch phase). */
438
+ async wait(recordingId, opts = {}) {
439
+ const interval = opts.intervalMs ?? 2000;
440
+ const deadline = opts.timeoutMs ? Date.now() + opts.timeoutMs : Infinity;
441
+ for (;;) {
442
+ const job = await this.get(recordingId);
443
+ opts.onProgress?.(job);
444
+ if (TERMINAL.has(job.state))
445
+ return job;
446
+ if (Date.now() + interval > deadline)
447
+ throw new VocalStackError('timeout', `job still ${job.state} after ${opts.timeoutMs} ms`, { retryable: true });
448
+ if (opts.signal?.aborted)
449
+ throw opts.signal.reason ?? new Error('aborted');
450
+ await new Promise((r) => setTimeout(r, interval));
451
+ }
452
+ }
453
+ }
454
+ /** `vs.corrections`: edits to a finished transcript, each revertible. */
455
+ export class Corrections {
456
+ #t;
457
+ constructor(t) {
458
+ this.#t = t;
459
+ }
460
+ /** Every correction, newest first (every page of `listPage`). */
461
+ list(recordingId) {
462
+ return allPages(async (cursor) => {
463
+ const r = await this.listPage(recordingId, { cursor });
464
+ return { items: r.corrections, nextCursor: r.nextCursor };
465
+ });
466
+ }
467
+ /** One page of corrections, newest first: pass its `nextCursor` as `cursor` for the next. */
468
+ listPage(recordingId, opts = {}) {
469
+ return this.#t.json('GET', `/v1/recordings/${seg(recordingId)}/corrections`, { query: { cursor: opts.cursor, limit: opts.limit } });
470
+ }
471
+ /** Apply a staged batch atomically; every target must be settled. */
472
+ async apply(recordingId, batch) {
473
+ const r = await this.#t.json('POST', `/v1/recordings/${seg(recordingId)}/corrections`, { body: batch });
474
+ return r.corrections ?? [];
475
+ }
476
+ /** Read lines again with the language forced; lands as `line` corrections. */
477
+ async retranscribe(recordingId, req) {
478
+ const r = await this.#t.json('POST', `/v1/recordings/${seg(recordingId)}/retranscribe`, { body: req });
479
+ return r.corrections ?? [];
480
+ }
481
+ revert(correctionId) {
482
+ return this.#t.json('POST', `/v1/corrections/${seg(correctionId)}/revert`);
483
+ }
484
+ }
485
+ /** `vs.shareLinks`: named public links to a transcription or broadcast
486
+ * (create and list them on their owner). */
487
+ export class ShareLinks {
488
+ #t;
489
+ constructor(t) {
490
+ this.#t = t;
491
+ }
492
+ update(id, req) {
493
+ return this.#t.json('PATCH', `/v1/share-links/${seg(id)}`, { body: req });
494
+ }
495
+ revoke(id) {
496
+ return this.#t.json('DELETE', `/v1/share-links/${seg(id)}`);
497
+ }
498
+ }
499
+ /** `vs.voices`: the account's cloned voices, and the catalogue a language offers. */
500
+ export class Voices {
501
+ #t;
502
+ constructor(t) {
503
+ this.#t = t;
504
+ }
505
+ async list() {
506
+ const r = await this.#t.json('GET', '/v1/voices');
507
+ return r.voices ?? [];
508
+ }
509
+ get(id) {
510
+ return this.#t.json('GET', `/v1/voices/${seg(id)}`);
511
+ }
512
+ /** Build a voice from a sample (`sample`) or audio the server fetches
513
+ * (`sampleUrl`). Calling it asserts you have the speaker's permission,
514
+ * unless `draft` (a draft is named and consented to later with `update`). */
515
+ async create(req, call = {}) {
516
+ const { sample, fileName, ...meta } = req;
517
+ const form = new FormData();
518
+ form.append('meta', JSON.stringify({ consent: !meta.draft, ...meta }));
519
+ if (sample !== undefined) {
520
+ const { blob, name } = await toBlob(sample);
521
+ form.append('sample', blob, fileName ?? name ?? 'sample.webm');
522
+ }
523
+ return this.#t.json('POST', '/v1/voices', { body: form, ...call });
524
+ }
525
+ /** Build a voice from one speaker of a recording (asserts permission). */
526
+ createFromSpeaker(req, call = {}) {
527
+ return this.#t.json('POST', '/v1/voices/from-speaker', { body: { consent: true, ...req }, ...call });
528
+ }
529
+ /** Rename, recolour, switch off; `{ name, consent: true, draft: false }` finishes a draft. */
530
+ update(id, patch) {
531
+ return this.#t.json('PATCH', `/v1/voices/${seg(id)}`, { body: patch });
532
+ }
533
+ async delete(id) {
534
+ await this.#t.json('DELETE', `/v1/voices/${seg(id)}`);
535
+ }
536
+ /** The voices that can speak `lang`, best first (with a token, also the
537
+ * account's ready clones). `recording` also offers the cloned voices that
538
+ * recording uses, to whoever may read it: its owner, or a listener with
539
+ * `listenerToken` or `shareCode`. */
540
+ available(lang, opts = {}) {
541
+ return this.#t.json('GET', '/v1/voices/available', {
542
+ query: { ...listenerQuery(opts), lang, recording: opts.recording },
543
+ anonymous: opts.asOwner === false,
544
+ });
545
+ }
546
+ /** A catalogue voice speaking `lang` (Ogg/Opus; `caf` for Apple). 202 while it is prepared. */
547
+ preview(voice, lang, opts = {}) {
548
+ return this.#t.raw('GET', '/v1/voices/preview', { headers: { accept: '*/*' }, query: { voice, lang, format: opts.format } });
549
+ }
550
+ /** A cloned voice speaking `lang`; 409 while it is not ready. With
551
+ * `recording`, as it speaks in that recording, for whoever may read it
552
+ * (its owner, or a listener with `listenerToken` or `shareCode`). */
553
+ clonePreview(id, opts = {}) {
554
+ return this.#t.raw('GET', `/v1/voices/${seg(id)}/preview`, {
555
+ headers: { accept: '*/*' },
556
+ query: { ...listenerQuery(opts), lang: opts.lang, format: opts.format, recording: opts.recording },
557
+ anonymous: opts.asOwner === false,
558
+ });
559
+ }
560
+ /** The audio a voice was made from. */
561
+ sample(id) {
562
+ return this.#t.raw('GET', `/v1/voices/${seg(id)}/sample`, { headers: { accept: '*/*' } });
563
+ }
564
+ }
565
+ /** `vs.search`: full-text search over the caller's transcripts. */
566
+ export class Search {
567
+ #t;
568
+ constructor(t) {
569
+ this.#t = t;
570
+ }
571
+ /** `q` is 2 or more characters. */
572
+ query(q, opts = {}) {
573
+ const query = {
574
+ q,
575
+ broadcasts: opts.broadcasts?.join(','),
576
+ listenerTokens: opts.listenerTokens?.join(','),
577
+ recording: opts.recording,
578
+ lang: opts.lang,
579
+ limit: opts.limit,
580
+ };
581
+ return this.#t.json('GET', '/v1/search', { query });
582
+ }
583
+ }
584
+ /** `vs.me`: who the credential is. */
585
+ export class MeApi {
586
+ #t;
587
+ constructor(t) {
588
+ this.#t = t;
589
+ }
590
+ get() {
591
+ return this.#t.json('GET', '/v1/me');
592
+ }
593
+ }
594
+ /** `vs.connect`: one-click Connect, on the app's server. */
595
+ export class Connect {
596
+ #t;
597
+ constructor(t) {
598
+ this.#t = t;
599
+ }
600
+ /** Redeem the `code` the dashboard sent to the app's `redirect_uri` for
601
+ * an API key (OAuth 2.0 authorization code + PKCE). No credential is
602
+ * sent, even when the client has one, and the call is never retried:
603
+ * the code works once. A refusal is a `VocalStackError` whose `code` is
604
+ * OAuth's `error` (`invalid_grant`: unknown, spent or expired code, or a
605
+ * verifier or `redirect_uri` that does not match). */
606
+ token(input, opts = {}) {
607
+ const body = new URLSearchParams({
608
+ grant_type: 'authorization_code',
609
+ code: input.code,
610
+ code_verifier: input.codeVerifier,
611
+ redirect_uri: input.redirectUri,
612
+ });
613
+ return this.#t.json('POST', '/v1/connect/token', { body, anonymous: true, noRetry: true, oauthErrors: true, signal: opts.signal });
614
+ }
615
+ }
616
+ /** `vs.billing`: the account's plan, credit balance and limits, the price
617
+ * list, the ledger, the per-recording charges, the usage report, the
618
+ * payments history, payment methods, invoice details and promo codes. */
619
+ export class Billing {
620
+ #t;
621
+ constructor(t) {
622
+ this.#t = t;
623
+ }
624
+ /** The account: plan, rates, balance, limits, subscription, top-up URL. */
625
+ get() {
626
+ return this.#t.json('GET', '/v1/billing');
627
+ }
628
+ /** The visible plans with their current rates and limits (no credential needed). */
629
+ plans() {
630
+ return this.#t.json('GET', '/v1/billing/plans');
631
+ }
632
+ /** The ledger, newest first. */
633
+ ledger(opts = {}) {
634
+ return this.#t.json('GET', '/v1/billing/ledger', { query: { cursor: opts.cursor, limit: opts.limit } });
635
+ }
636
+ /** The per-recording charges, newest first. */
637
+ charges(opts = {}) {
638
+ return this.#t.json('GET', '/v1/billing/charges', { query: { cursor: opts.cursor, limit: opts.limit } });
639
+ }
640
+ /** Open a checkout at the payment provider (a top-up or a subscription); send the customer to `url`.
641
+ * A signed-in person only, never an API key. */
642
+ createCheckout(req, opts = {}) {
643
+ return this.#t.json('POST', '/v1/billing/checkouts', { body: req, ...opts });
644
+ }
645
+ /** A checkout's status (by its id or the provider's). */
646
+ getCheckout(id) {
647
+ return this.#t.json('GET', `/v1/billing/checkouts/${seg(id)}`);
648
+ }
649
+ /** Cancel the subscription at the end of its period. */
650
+ cancelSubscription() {
651
+ return this.#t.json('POST', '/v1/billing/subscription/cancel');
652
+ }
653
+ /** Take back a scheduled cancellation. */
654
+ uncancelSubscription() {
655
+ return this.#t.json('POST', '/v1/billing/subscription/uncancel');
656
+ }
657
+ /** Turn auto top-up on (threshold and amount from `plans().autoTopUp`) or off. */
658
+ setAutoTopUp(req) {
659
+ return this.#t.json('PUT', '/v1/billing/auto-top-up', { body: req });
660
+ }
661
+ /** A link to the provider's customer portal (payment methods, invoices, receipts). */
662
+ portal() {
663
+ return this.#t.json('GET', '/v1/billing/portal');
664
+ }
665
+ /** Charge the configured auto top-up amount now (under the day's cap). */
666
+ retryAutoTopUp() {
667
+ return this.#t.json('POST', '/v1/billing/auto-top-up/retry');
668
+ }
669
+ /** Turn the low-balance email on or off and set its line. */
670
+ setLowBalanceAlert(req) {
671
+ return this.#t.json('PUT', '/v1/billing/low-balance-alert', { body: req });
672
+ }
673
+ /** Renew `month`ly or `year`ly from the next period, at `subscription.switchPrices` (needs `acceptTerms` and `noNewWithdrawalPeriodShown`). */
674
+ setInterval(req) {
675
+ return this.#t.json('POST', '/v1/billing/subscription/interval', { body: req });
676
+ }
677
+ /** Keep the subscription by switching to yearly when monthly billing is withdrawn (`subscription.yearlyOptIn`). */
678
+ optInYearly(req) {
679
+ return this.#t.json('POST', '/v1/billing/subscription/yearly-opt-in', { body: req });
680
+ }
681
+ /** Take back a scheduled interval switch. */
682
+ unsetInterval() {
683
+ return this.#t.json('DELETE', '/v1/billing/subscription/interval');
684
+ }
685
+ /** What the account spent per day, source (API key or Dashboard) and kind. */
686
+ usage(opts = {}) {
687
+ return this.#t.json('GET', '/v1/billing/usage', { query: { startDate: opts.startDate, endDate: opts.endDate, tz: opts.tz } });
688
+ }
689
+ /** The usage report's charges one by one, newest first, with how many match and their total. */
690
+ usageCharges(opts = {}) {
691
+ const query = { startDate: opts.startDate, endDate: opts.endDate, tz: opts.tz, kind: opts.kind, source: opts.source, cursor: opts.cursor, limit: opts.limit };
692
+ return this.#t.json('GET', '/v1/billing/usage/charges', { query });
693
+ }
694
+ /** The payments history, newest first. */
695
+ payments(opts = {}) {
696
+ return this.#t.json('GET', '/v1/billing/payments', { query: { cursor: opts.cursor, limit: opts.limit } });
697
+ }
698
+ /** A payment's invoice or receipt: `{url}`, or `{pending: true}` while the provider makes it. */
699
+ paymentDocument(id) {
700
+ return this.#t.json('GET', `/v1/billing/payments/${seg(id)}/document`);
701
+ }
702
+ /** The saved payment methods. */
703
+ paymentMethods() {
704
+ return this.#t.json('GET', '/v1/billing/payment-methods');
705
+ }
706
+ /** Remove a saved payment method. */
707
+ async deletePaymentMethod(id) {
708
+ await this.#t.json('DELETE', `/v1/billing/payment-methods/${seg(id)}`);
709
+ }
710
+ /** A link to the provider's portal to add a card or choose the default. */
711
+ managePaymentMethods() {
712
+ return this.#t.json('POST', '/v1/billing/payment-methods/manage');
713
+ }
714
+ /** The invoice details. */
715
+ details() {
716
+ return this.#t.json('GET', '/v1/billing/details');
717
+ }
718
+ /** Save the invoice details. */
719
+ setDetails(req) {
720
+ return this.#t.json('PUT', '/v1/billing/details', { body: req });
721
+ }
722
+ /** What a promo code gives for a top-up or a subscription. */
723
+ checkPromoCode(req) {
724
+ return this.#t.json('POST', '/v1/billing/promo-codes/check', { body: req });
725
+ }
726
+ }
727
+ /** `vs.languages`. */
728
+ export class Languages {
729
+ #t;
730
+ constructor(t) {
731
+ this.#t = t;
732
+ }
733
+ /** Spoken and translation languages. */
734
+ list() {
735
+ return this.#t.json('GET', '/v1/languages');
736
+ }
737
+ }
738
+ /** `vs.rtmp`. */
739
+ export class Rtmp {
740
+ #t;
741
+ constructor(t) {
742
+ this.#t = t;
743
+ }
744
+ /** The account's RTMP ingest: server URL (RTMPS where available), the stream key (in full only in the answer that made it, `maskedStreamKey` after), and whether an encoder is sending. */
745
+ status() {
746
+ return this.#t.json('GET', '/v1/rtmp');
747
+ }
748
+ /** A stream key of the app's own, beside the account's: `streamKey` is in full in this answer only. It works while the API key that made it does. */
749
+ createKey(req) {
750
+ return this.#t.json('POST', '/v1/rtmp/keys', { body: req });
751
+ }
752
+ /** The account's app stream keys, oldest first: masked, each with whether an encoder is sending on it (`live`, `audio`). */
753
+ async listKeys() {
754
+ const r = await this.#t.json('GET', '/v1/rtmp/keys');
755
+ return r.keys ?? [];
756
+ }
757
+ /** Delete an app stream key: an encoder publishing on it is dropped within seconds. */
758
+ async deleteKey(id) {
759
+ await this.#t.json('DELETE', `/v1/rtmp/keys/${seg(id)}`);
760
+ }
761
+ }
762
+ /** `vs.events`: the account socket. */
763
+ export class Events {
764
+ #t;
765
+ constructor(t) {
766
+ this.#t = t;
767
+ }
768
+ /** `events.ready`, then `lists.changed` whenever one of the owner's
769
+ * lists of broadcasts or transcriptions is out of date: re-read them. */
770
+ async account() {
771
+ return openWebSocket(this.#t.wsUrl('/v1/events'), await this.#t.bearer());
772
+ }
773
+ }
774
+ /** `vs.meta`. */
775
+ export class Meta {
776
+ #t;
777
+ constructor(t) {
778
+ this.#t = t;
779
+ }
780
+ health() {
781
+ return this.#t.json('GET', '/v1/health');
782
+ }
783
+ /** The OpenAPI document this SDK is generated from. */
784
+ openapi() {
785
+ return this.#t.json('GET', '/v1/openapi.json');
786
+ }
787
+ }