@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.
package/README.md CHANGED
@@ -4,6 +4,8 @@ The VocalStack speech API for Node (20 or newer) and browsers: one client,
4
4
  `VocalStack`, with one group per resource, typed from the API's one OpenAPI
5
5
  document (`/v1/openapi.json`, generated into `@vocalstack/contract`).
6
6
 
7
+ Read more about the [VocalStack JavaScript SDK](https://vocalstack.com/#developers) and our other SDKs on the [VocalStack website](https://vocalstack.com/).
8
+
7
9
  ```ts
8
10
  import { VocalStack } from '@vocalstack/js-sdk';
9
11
 
@@ -21,7 +23,9 @@ const srt = await (await vs.recordings.export(job.recordingId, 'srt')).text();
21
23
 
22
24
  ## Install
23
25
 
24
- Not on npm yet. The API serves its own builds to every account: [https://api.vocalstack.com/docs/guides/quickstart.md](https://api.vocalstack.com/docs/guides/quickstart.md) has the install command for the current build (it sends your API key to that host only).
26
+ ```sh
27
+ npm install @vocalstack/js-sdk
28
+ ```
25
29
 
26
30
  ## Guides
27
31
 
package/SKILL.md CHANGED
@@ -33,11 +33,8 @@ npm init -y && npm pkg set type=module # in a new folder: the programs below a
33
33
  npm i @vocalstack/js-sdk # Node 20 or newer
34
34
  ```
35
35
 
36
- Before the package is published, any account installs
37
- the private build its environment serves: the API reference's Install section
38
- (`<base>/docs`, or Developers -> API reference in the app) and `<base>/sdk/latest`
39
- give the exact commands, which send `VOCALSTACK_API_KEY` to that host only.
40
- Or install it from a clone of https://github.com/vocalstack/js-sdk. Build it
36
+ To run code newer than the npm release, install it from a clone of
37
+ https://github.com/vocalstack/js-sdk. Build it
41
38
  first (`npm run build` also builds the bundled `contract/` package):
42
39
 
43
40
  ```sh
@@ -105,7 +102,7 @@ const vs = new VocalStack({ baseUrl: process.env.VOCALSTACK_BASE_URL, apiKey: pr
105
102
 
106
103
  const tx = await vs.transcriptions.create({
107
104
  file: process.argv[2], // a path (mp3, wav, flac, ogg, m4a, mp4 ...), a Blob or bytes
108
- settings: { spokenLanguage: 'en', speakerLabels: 'on' }, // both optional; speakerLabels: 'off' (default) | 'auto' | 'on'
105
+ settings: { spokenLanguage: 'en', speakerLabels: 'on' }, // both optional; speakerLabels: 'auto' (default) | 'on' | 'off'
109
106
  // Off unless asked for: settings.summary, settings.title, settings.summaryTranslations,
110
107
  // settings.voice (spoken translations), and shareLink: true (a public share link).
111
108
  });
package/dist/errors.d.ts CHANGED
@@ -1,6 +1,4 @@
1
1
  import type { ErrorCode } from '@vocalstack/contract';
2
- /** The OAuth errors `vs.connect.token()` raises (RFC 6749 §5.2): the token
3
- * endpoint answers `400 { error, error_description }`, not the envelope. */
4
2
  export type ConnectTokenErrorCode = 'invalid_request' | 'invalid_grant' | 'unsupported_grant_type';
5
3
  export interface VocalStackErrorOptions {
6
4
  retryable?: boolean;
@@ -9,32 +7,16 @@ export interface VocalStackErrorOptions {
9
7
  headers?: Headers;
10
8
  requestId?: string;
11
9
  }
12
- /** Every failure this SDK surfaces: an HTTP error envelope, a socket
13
- * `error` frame, or one the transport synthesizes (`capacity_exceeded`). */
14
10
  export declare class VocalStackError extends Error {
15
- /** The envelope's code; on `vs.connect.token()`, the OAuth `error`. */
16
11
  readonly code: ErrorCode | ConnectTokenErrorCode;
17
12
  readonly retryable: boolean;
18
- /** HTTP status, when the error came from an HTTP response. */
19
13
  readonly status?: number;
20
- /** The envelope's `data.details`: for example `{ balance, required,
21
- * plan, topUpUrl }` on `insufficient_credit`, code suggestions on a
22
- * link conflict, `{ recordingId, takeover: true }` when another device
23
- * is recording. */
24
14
  readonly details?: unknown;
25
- /** The response headers (`Retry-After`, `RateLimit-*`,
26
- * `WWW-Authenticate`), when the error came from an HTTP response. */
27
15
  readonly headers?: Headers;
28
- /** The request's id (the envelope's `requestId`, else the `X-Request-Id`
29
- * header): quote it when reporting a failure. */
30
16
  readonly requestId?: string;
31
17
  constructor(code: ErrorCode | ConnectTokenErrorCode, message: string, opts?: VocalStackErrorOptions);
32
- /** Seconds the server asked the caller to wait (`Retry-After`), if it said. */
33
18
  get retryAfter(): number | undefined;
34
19
  }
35
20
  export declare function capacityExceeded(message: string): VocalStackError;
36
21
  export declare function fromEnvelope(body: unknown, status?: number, headers?: Headers): VocalStackError;
37
- /** A token endpoint's `400 { error, error_description }` (RFC 6749 §5.2) as
38
- * a `VocalStackError` whose `code` is the `error`; any other answer goes
39
- * through the envelope. */
40
22
  export declare function fromOAuth(body: unknown, status?: number, headers?: Headers): VocalStackError;
package/dist/errors.js CHANGED
@@ -1,21 +1,9 @@
1
- /** Every failure this SDK surfaces: an HTTP error envelope, a socket
2
- * `error` frame, or one the transport synthesizes (`capacity_exceeded`). */
3
1
  export class VocalStackError extends Error {
4
- /** The envelope's code; on `vs.connect.token()`, the OAuth `error`. */
5
2
  code;
6
3
  retryable;
7
- /** HTTP status, when the error came from an HTTP response. */
8
4
  status;
9
- /** The envelope's `data.details`: for example `{ balance, required,
10
- * plan, topUpUrl }` on `insufficient_credit`, code suggestions on a
11
- * link conflict, `{ recordingId, takeover: true }` when another device
12
- * is recording. */
13
5
  details;
14
- /** The response headers (`Retry-After`, `RateLimit-*`,
15
- * `WWW-Authenticate`), when the error came from an HTTP response. */
16
6
  headers;
17
- /** The request's id (the envelope's `requestId`, else the `X-Request-Id`
18
- * header): quote it when reporting a failure. */
19
7
  requestId;
20
8
  constructor(code, message, opts = {}) {
21
9
  super(message);
@@ -27,7 +15,6 @@ export class VocalStackError extends Error {
27
15
  this.headers = opts.headers;
28
16
  this.requestId = opts.requestId;
29
17
  }
30
- /** Seconds the server asked the caller to wait (`Retry-After`), if it said. */
31
18
  get retryAfter() {
32
19
  const v = this.headers?.get('retry-after');
33
20
  if (v == null || v === '')
@@ -44,8 +31,6 @@ export function capacityExceeded(message) {
44
31
  }
45
32
  export function fromEnvelope(body, status, headers) {
46
33
  const b = body;
47
- // The envelope names the request; a header the page cannot read across
48
- // origins (when it is not exposed) is the fallback.
49
34
  const requestId = (b && typeof b.requestId === 'string' ? b.requestId : undefined) ?? headers?.get('x-request-id') ?? undefined;
50
35
  if (b && typeof b.code === 'string') {
51
36
  return new VocalStackError(b.code, b.message ?? b.code, {
@@ -58,9 +43,6 @@ export function fromEnvelope(body, status, headers) {
58
43
  }
59
44
  return new VocalStackError('internal', `unexpected error (status ${status})`, { status, headers, requestId });
60
45
  }
61
- /** A token endpoint's `400 { error, error_description }` (RFC 6749 §5.2) as
62
- * a `VocalStackError` whose `code` is the `error`; any other answer goes
63
- * through the envelope. */
64
46
  export function fromOAuth(body, status, headers) {
65
47
  const b = body;
66
48
  if (status === 400 && b && typeof b.error === 'string') {
package/dist/http.d.ts CHANGED
@@ -1,75 +1,43 @@
1
1
  export type Query = Record<string, string | number | boolean | undefined | null>;
2
2
  export interface ClientOptions {
3
- /** API origin; `https://api.vocalstack.com` when absent. */
4
3
  baseUrl?: string;
5
- /** An API key (`vs_key_…`), on a server: keys are refused from web
6
- * pages. Absent (and no `token`) = anonymous (a listener). */
7
4
  apiKey?: string;
8
- /** In a browser instead: a signed-in person's access token or a
9
- * short-lived browser token (`vs_bt_…`), or a getter so a refreshed one
10
- * is picked up per request. Not with `apiKey`. */
11
5
  token?: string | (() => string | undefined | null | Promise<string | undefined | null>);
12
- /** Act for an organization the user belongs to (`X-Acting-Principal`). */
13
6
  actingPrincipal?: string;
14
- /** A stable per-device id (correlation only, `X-Device-Id`). */
15
7
  deviceId?: string;
16
8
  fetch?: typeof fetch;
17
- /** How often a `429 rate_limited`, or a `503 unavailable` on a call that
18
- * is safe to repeat (GET, HEAD, DELETE, or one with an `Idempotency-Key`),
19
- * is retried after its `Retry-After` (at most 60 s each). Default 2; 0
20
- * turns it off. */
21
9
  maxRetries?: number;
22
- /** Default deadline for a realtime socket to report ready, in ms. */
23
10
  readyDeadlineMs?: number;
24
11
  }
25
- /** Per-call options every method takes last. */
26
12
  export interface CallOptions {
27
- /** Sent as `Idempotency-Key` on the creates that honour it. */
28
13
  idempotencyKey?: string;
29
14
  signal?: AbortSignal;
30
15
  }
31
- /** How a listener reads something that is not theirs: a private
32
- * broadcast's listener token, or a share link's code. */
33
16
  export interface ReadAs {
34
17
  listenerToken?: string;
35
18
  shareCode?: string;
36
19
  }
37
- /** A listener-side read's query: the wire spells both as the options do
38
- * (`?listenerToken=`, `?shareCode=`), so this only picks them out. */
39
20
  export declare function listenerQuery(as: ReadAs | undefined): Query;
40
21
  export interface RequestInit2 extends CallOptions {
41
22
  query?: Query;
42
23
  body?: unknown;
43
24
  headers?: Record<string, string>;
44
- /** Send no credential even when the client has one. */
45
25
  anonymous?: boolean;
46
- /** Never retry, whatever the answer (a single-use code). */
47
26
  noRetry?: boolean;
48
- /** Read a 400 as OAuth's `{ error, error_description }`. */
49
27
  oauthErrors?: boolean;
50
28
  }
51
29
  export declare const DEFAULT_BASE_URL = "https://api.vocalstack.com";
52
30
  export declare function qs(q: Query | undefined): string;
53
31
  export declare const seg: (s: string | number) => string;
54
- /** Whether a failed call is retried after its `Retry-After` (up to
55
- * `maxRetries` times): `429 rate_limited` always (nothing ran), and
56
- * `503 unavailable` (the database is waking) only when repeating the call
57
- * cannot do the work twice: GET, HEAD and DELETE, or a call carrying an
58
- * `Idempotency-Key`. */
59
32
  export declare function retries(status: number, code: string, method: string, idempotencyKey?: string): boolean;
60
- /** The one HTTP path every resource method goes through. */
61
33
  export declare class Transport {
62
34
  #private;
63
35
  readonly baseUrl: string;
64
36
  readonly opts: ClientOptions;
65
37
  constructor(opts: ClientOptions);
66
- /** What goes in `Authorization: Bearer`: the API key, else the token. */
67
38
  bearer(): Promise<string | undefined>;
68
39
  url(path: string, query?: Query): string;
69
40
  wsUrl(path: string, query?: Query): string;
70
- /** The raw response, after error mapping. Redirects are not followed
71
- * (`redirect: 'manual'`), so a 302 comes back as itself. */
72
41
  raw(method: string, path: string, init?: RequestInit2): Promise<Response>;
73
- /** A JSON call; an empty body resolves to `undefined`. */
74
42
  json<T>(method: string, path: string, init?: RequestInit2): Promise<T>;
75
43
  }
package/dist/http.js CHANGED
@@ -1,6 +1,4 @@
1
- import { fromEnvelope, fromOAuth } from './errors.js';
2
- /** A listener-side read's query: the wire spells both as the options do
3
- * (`?listenerToken=`, `?shareCode=`), so this only picks them out. */
1
+ import { VocalStackError, fromEnvelope, fromOAuth } from './errors.js';
4
2
  export function listenerQuery(as) {
5
3
  return { listenerToken: as?.listenerToken, shareCode: as?.shareCode };
6
4
  }
@@ -18,13 +16,7 @@ export function qs(q) {
18
16
  }
19
17
  export const seg = (s) => encodeURIComponent(String(s));
20
18
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
- /** The wait before retrying `503 unavailable` when it names none. */
22
19
  const UNAVAILABLE_WAIT_S = 2;
23
- /** Whether a failed call is retried after its `Retry-After` (up to
24
- * `maxRetries` times): `429 rate_limited` always (nothing ran), and
25
- * `503 unavailable` (the database is waking) only when repeating the call
26
- * cannot do the work twice: GET, HEAD and DELETE, or a call carrying an
27
- * `Idempotency-Key`. */
28
20
  export function retries(status, code, method, idempotencyKey) {
29
21
  if (status === 429)
30
22
  return true;
@@ -33,7 +25,6 @@ export function retries(status, code, method, idempotencyKey) {
33
25
  const m = method.toUpperCase();
34
26
  return m === 'GET' || m === 'HEAD' || m === 'DELETE' || !!idempotencyKey;
35
27
  }
36
- /** The one HTTP path every resource method goes through. */
37
28
  export class Transport {
38
29
  baseUrl;
39
30
  opts;
@@ -45,7 +36,6 @@ export class Transport {
45
36
  this.opts = opts;
46
37
  this.#fetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
47
38
  }
48
- /** What goes in `Authorization: Bearer`: the API key, else the token. */
49
39
  async bearer() {
50
40
  if (this.opts.apiKey)
51
41
  return this.opts.apiKey;
@@ -59,8 +49,6 @@ export class Transport {
59
49
  wsUrl(path, query) {
60
50
  return `${this.baseUrl.replace(/^http/, 'ws')}${path}${qs(query)}`;
61
51
  }
62
- /** The raw response, after error mapping. Redirects are not followed
63
- * (`redirect: 'manual'`), so a 302 comes back as itself. */
64
52
  async raw(method, path, init = {}) {
65
53
  const headers = { accept: 'application/json', ...init.headers };
66
54
  const form = typeof FormData !== 'undefined' && init.body instanceof FormData;
@@ -81,13 +69,23 @@ export class Transport {
81
69
  const body = init.body === undefined ? undefined : form ? init.body : urlencoded ? String(init.body) : JSON.stringify(init.body);
82
70
  const maxRetries = init.noRetry ? 0 : (this.opts.maxRetries ?? 2);
83
71
  for (let attempt = 0;; attempt++) {
84
- const res = await this.#fetch(this.url(path, init.query), {
85
- method,
86
- headers,
87
- body,
88
- redirect: 'manual',
89
- signal: init.signal,
90
- });
72
+ let res;
73
+ try {
74
+ res = await this.#fetch(this.url(path, init.query), {
75
+ method,
76
+ headers,
77
+ body,
78
+ redirect: 'manual',
79
+ signal: init.signal,
80
+ });
81
+ }
82
+ catch (e) {
83
+ if (e?.name === 'AbortError')
84
+ throw e;
85
+ const err = new VocalStackError('unavailable', 'could not reach the VocalStack API', { retryable: true });
86
+ err.cause = e;
87
+ throw err;
88
+ }
91
89
  if (res.ok || (res.status >= 300 && res.status < 400))
92
90
  return res;
93
91
  const text = await res.text().catch(() => '');
@@ -100,8 +98,6 @@ export class Transport {
100
98
  }
101
99
  const err = (init.oauthErrors ? fromOAuth : fromEnvelope)(json, res.status, res.headers);
102
100
  if (retries(res.status, err.code, method, init.idempotencyKey) && attempt < maxRetries) {
103
- // `unavailable` (a waking database) asks for Retry-After: 2; a
104
- // missing header means the same.
105
101
  const wait = err.retryAfter ?? (err.code === 'unavailable' ? UNAVAILABLE_WAIT_S : undefined);
106
102
  if (wait !== undefined && wait <= 60) {
107
103
  await sleep(wait * 1000);
@@ -111,12 +107,18 @@ export class Transport {
111
107
  throw err;
112
108
  }
113
109
  }
114
- /** A JSON call; an empty body resolves to `undefined`. */
115
110
  async json(method, path, init = {}) {
116
111
  const res = await this.raw(method, path, init);
117
112
  if (res.status >= 300 && res.status < 400)
118
113
  return { location: res.headers.get('location') };
119
114
  const text = await res.text();
120
- return (text ? JSON.parse(text) : undefined);
115
+ if (!text)
116
+ return undefined;
117
+ try {
118
+ return JSON.parse(text);
119
+ }
120
+ catch {
121
+ throw new VocalStackError('internal', 'the API answered with a body that is not JSON', { status: res.status, headers: res.headers, requestId: res.headers.get('x-request-id') ?? undefined });
122
+ }
121
123
  }
122
124
  }
package/dist/index.d.ts CHANGED
@@ -1,22 +1,6 @@
1
- /**
2
- * The VocalStack SDK: one client, `VocalStack`, grouped by resource.
3
- *
4
- * import { VocalStack } from '@vocalstack/js-sdk';
5
- * const vs = new VocalStack({ apiKey: process.env.VOCALSTACK_API_KEY });
6
- *
7
- * const tx = await vs.transcriptions.createFromUrl('https://example.com/talk.mp3');
8
- * const job = await vs.jobs.wait(tx.job!.recordingId);
9
- * const { sentences } = await vs.recordings.transcript(job.recordingId);
10
- *
11
- * What is what (the API's glossary): a realtime session is a signed token
12
- * plus one socket, nothing stored (`vs.realtime`); a broadcast is the link
13
- * listeners open; a transcription is a file, link, microphone or stream
14
- * transcribed and stored; a recording is one take inside either; a job is
15
- * a file or link recording's transcription work.
16
- */
17
1
  import { type ClientOptions } from './http.js';
18
2
  import { Realtime } from './realtime.js';
19
- import { Batches, Billing, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Transcriptions, Voices } from './resources.js';
3
+ import { Batches, Billing, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Tags, Transcriptions, Voices } from './resources.js';
20
4
  export type { ClientOptions, CallOptions, ReadAs, Query } from './http.js';
21
5
  export { DEFAULT_BASE_URL } from './http.js';
22
6
  export { VocalStackError } from './errors.js';
@@ -29,7 +13,8 @@ export type { ConnectOptions, TranscribeOptions, RealtimeTranscript } from './re
29
13
  export { Subscription } from './subscription.js';
30
14
  export type { SubscribeOptions, SubscriptionEvent } from './subscription.js';
31
15
  export type { WsLike } from './ws.js';
32
- export { Batches, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Transcriptions, Voices, } from './resources.js';
16
+ export { Batches, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Tags, Transcriptions, Voices, playableFormat, } from './resources.js';
17
+ export type { AudioKind, MediaAuth, MediaFormat, RenderVoice } from './resources.js';
33
18
  export type { AddBatchFileInput, ConnectToken, ConnectTokenInput, CreateTranscriptionInput, LifecycleFilter, Me, PageOptions, RecordingPageOptions, SearchOptions, Upload, UsageChargesOptions, UsageOptions } from './resources.js';
34
19
  export type * from '@vocalstack/contract';
35
20
  export type VocalStackOptions = ClientOptions;
@@ -43,6 +28,7 @@ export declare class VocalStack {
43
28
  readonly jobs: Jobs;
44
29
  readonly corrections: Corrections;
45
30
  readonly shareLinks: ShareLinks;
31
+ readonly tags: Tags;
46
32
  readonly voices: Voices;
47
33
  readonly search: Search;
48
34
  readonly me: MeApi;
package/dist/index.js CHANGED
@@ -1,28 +1,12 @@
1
- /**
2
- * The VocalStack SDK: one client, `VocalStack`, grouped by resource.
3
- *
4
- * import { VocalStack } from '@vocalstack/js-sdk';
5
- * const vs = new VocalStack({ apiKey: process.env.VOCALSTACK_API_KEY });
6
- *
7
- * const tx = await vs.transcriptions.createFromUrl('https://example.com/talk.mp3');
8
- * const job = await vs.jobs.wait(tx.job!.recordingId);
9
- * const { sentences } = await vs.recordings.transcript(job.recordingId);
10
- *
11
- * What is what (the API's glossary): a realtime session is a signed token
12
- * plus one socket, nothing stored (`vs.realtime`); a broadcast is the link
13
- * listeners open; a transcription is a file, link, microphone or stream
14
- * transcribed and stored; a recording is one take inside either; a job is
15
- * a file or link recording's transcription work.
16
- */
17
1
  import { Transport } from './http.js';
18
2
  import { Realtime } from './realtime.js';
19
- import { Batches, Billing, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Transcriptions, Voices, } from './resources.js';
3
+ import { Batches, Billing, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Tags, Transcriptions, Voices, } from './resources.js';
20
4
  export { DEFAULT_BASE_URL } from './http.js';
21
5
  export { VocalStackError } from './errors.js';
22
6
  export { RealtimeSession } from './realtime.js';
23
7
  export { Realtime, applySentence } from './realtime.js';
24
8
  export { Subscription } from './subscription.js';
25
- export { Batches, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Transcriptions, Voices, } from './resources.js';
9
+ export { Batches, Broadcasts, Connect, Corrections, Events, Jobs, Languages, MeApi, Meta, Recordings, Rtmp, Search, ShareLinks, Tags, Transcriptions, Voices, playableFormat, } from './resources.js';
26
10
  export class VocalStack {
27
11
  baseUrl;
28
12
  realtime;
@@ -33,6 +17,7 @@ export class VocalStack {
33
17
  jobs;
34
18
  corrections;
35
19
  shareLinks;
20
+ tags;
36
21
  voices;
37
22
  search;
38
23
  me;
@@ -53,6 +38,7 @@ export class VocalStack {
53
38
  this.jobs = new Jobs(t);
54
39
  this.corrections = new Corrections(t);
55
40
  this.shareLinks = new ShareLinks(t);
41
+ this.tags = new Tags(t);
56
42
  this.voices = new Voices(t);
57
43
  this.search = new Search(t);
58
44
  this.me = new MeApi(t);
package/dist/mic.d.ts CHANGED
@@ -1,51 +1,16 @@
1
1
  import type { RealtimeSession } from './realtime.js';
2
- /** Why capture stopped producing audio (see `MicCaptureOptions.onStall`). */
3
- export type MicStallReason =
4
- /** The input track ended: device unplugged, headset switched, permission revoked. */
5
- 'track_ended'
6
- /** The track is muted by the OS/hardware (no samples until it unmutes). */
7
- | 'track_muted'
8
- /** The AudioContext was suspended (phone call, screen lock, route change). */
9
- | 'context_suspended'
10
- /** The AudioContext was closed underneath us. */
11
- | 'context_closed'
12
- /** Seconds of exact-zero samples: a silently denied or hardware-muted mic. */
13
- | 'silent';
2
+ export type MicStallReason = 'track_ended' | 'track_muted' | 'context_suspended' | 'context_closed' | 'silent';
14
3
  export interface MicCaptureOptions {
15
4
  chunkSeconds?: number;
16
5
  sampleRate?: number;
17
6
  audio?: MediaTrackConstraints;
18
- /**
19
- * Called every ~0.5s with (pcm, wall): cumulative output samples
20
- * produced and seconds since capture start. Wire it to
21
- * `session.sendClock` so the server can detect capture loss (a
22
- * suspended context or dead track produces no samples while wall
23
- * time advances) and mark the transcript with honest gaps. Driven
24
- * by a timer, not by sample arrival, so it keeps reporting while
25
- * capture is stalled — that is the case it exists for.
26
- */
27
7
  onClock?: (pcm: number, wall: number) => void;
28
- /**
29
- * Capture stopped producing audio. Recovery is attempted
30
- * automatically (context resume, track re-acquire); `onRecover`
31
- * fires when samples flow again. Use it to show a "microphone
32
- * lost" state instead of "Listening".
33
- */
34
8
  onStall?: (reason: MicStallReason) => void;
35
- /** Audio is flowing again after a stall. */
36
9
  onRecover?: () => void;
37
- /**
38
- * Exact-zero run length (seconds) that flags a silently denied or
39
- * hardware-muted mic (real mics always carry a noise floor). Default
40
- * 3; 0 disables.
41
- */
42
10
  silentSeconds?: number;
43
11
  }
44
- /** The input device the capture is reading, as the browser reports it. */
45
12
  export interface MicDevice {
46
- /** The device's name, e.g. "MacBook Pro Microphone" ("" before permission is granted). */
47
13
  label: string;
48
- /** Its native rate (the capture resamples to `sampleRate`). */
49
14
  sampleRate?: number;
50
15
  channelCount?: number;
51
16
  noiseSuppression?: boolean;
@@ -54,20 +19,7 @@ export interface MicDevice {
54
19
  export interface MicCapture {
55
20
  stop(): Promise<void>;
56
21
  readonly sampleRate: number;
57
- /** The microphone being read now (a re-acquired track may be another one). */
58
22
  readonly device: MicDevice;
59
- /** Hold the capture open but stop sending what the microphone hears.
60
- *
61
- * A side conversation should not have to end the session, and pausing
62
- * is not the same thing: pausing closes the stream and the listeners
63
- * lose it. Muting keeps every listener connected and the recording
64
- * running, and simply says nothing.
65
- *
66
- * Muted chunks are emitted as SILENCE rather than dropped. The server
67
- * measures media time by the audio it receives, so dropping would
68
- * compress the timeline and every later timestamp would be wrong;
69
- * silence keeps the clock honest and transcribes to nothing, which is
70
- * exactly what happened. */
71
23
  setMuted(muted: boolean): void;
72
24
  readonly muted: boolean;
73
25
  }
package/dist/mic.js CHANGED
@@ -1,7 +1,3 @@
1
- // The worklet runs on the browser's realtime audio thread and does one
2
- // allocation-light thing: post every 128-frame block to the main thread.
3
- // MessagePort queues are unbounded and never overwrite, so a busy main
4
- // thread (a big transcript re-render) delivers audio late, never drops it.
5
1
  const WORKLET_SOURCE = `
6
2
  class PCMProcessor extends AudioWorkletProcessor {
7
3
  process(inputs) {
@@ -15,9 +11,7 @@ class PCMProcessor extends AudioWorkletProcessor {
15
11
  }
16
12
  registerProcessor('pcm-processor', PCMProcessor);
17
13
  `;
18
- /** Retry cadence for context resume / track re-acquire while stalled. */
19
14
  const RECOVER_MS = 1000;
20
- /** Capture clock heartbeat cadence. */
21
15
  const CLOCK_MS = 500;
22
16
  export async function captureMicrophone(onChunk, opts = {}) {
23
17
  if (typeof navigator === 'undefined' || !navigator.mediaDevices) {
@@ -32,10 +26,6 @@ export async function captureMicrophone(onChunk, opts = {}) {
32
26
  };
33
27
  let muted = false;
34
28
  let media = await navigator.mediaDevices.getUserMedia({ audio: constraints });
35
- // Ask for the target rate directly: Chrome/Firefox/Safari 14.1+ then
36
- // resample on the audio thread and our converter is a pass-through.
37
- // Browsers that refuse the option fall back to the default rate and the
38
- // linear-interpolation resampler below.
39
29
  let ctx;
40
30
  try {
41
31
  ctx = new AudioContext({ sampleRate: targetRate });
@@ -58,15 +48,11 @@ export async function captureMicrophone(onChunk, opts = {}) {
58
48
  let buffered = [];
59
49
  let readPos = 0;
60
50
  let pending = null;
61
- // Capture clock: cumulative resampled output samples vs wall time.
62
51
  const t0 = performance.now();
63
52
  let outSamples = 0;
64
- // Zero-run detector state (input-rate samples).
65
53
  const silentLimit = Math.round((opts.silentSeconds ?? 3) * inputRate);
66
54
  let zeroRun = 0;
67
55
  let silentFlagged = false;
68
- // Stall bookkeeping: one active reason at a time; `onRecover` fires
69
- // once when it clears.
70
56
  let stopped = false;
71
57
  let stall = null;
72
58
  let recoverTimer = null;
@@ -130,27 +116,21 @@ export async function captureMicrophone(onChunk, opts = {}) {
130
116
  }
131
117
  if (out.length) {
132
118
  outSamples += out.length;
133
- // Samples flowing again clears a track/context stall; the silent
134
- // stall clears only on a nonzero sample (handled above).
135
119
  if (stall !== null && stall !== 'silent')
136
120
  clearStall();
137
121
  pending = pending ? concat(pending, out) : Float32Array.from(out);
138
122
  while (pending.length >= chunkSamples) {
139
123
  const chunk = pending.slice(0, chunkSamples);
140
- // Silence, not nothing: see `MicCapture.setMuted`.
141
124
  onChunk(muted ? new Float32Array(chunk.length) : chunk);
142
125
  pending = pending.slice(chunkSamples);
143
126
  }
144
127
  }
145
128
  };
146
- // Heartbeat on a timer so a stalled capture is still reported: pcm
147
- // stops while wall advances, and the server declares the gap.
148
129
  const clockTimer = opts.onClock
149
130
  ? setInterval(() => {
150
131
  opts.onClock?.(outSamples, (performance.now() - t0) / 1000);
151
132
  }, CLOCK_MS)
152
133
  : null;
153
- // --- recovery -------------------------------------------------------
154
134
  const scheduleRecover = (fn) => {
155
135
  if (stopped || recoverTimer)
156
136
  return;
@@ -185,7 +165,6 @@ export async function captureMicrophone(onChunk, opts = {}) {
185
165
  source = ctx.createMediaStreamSource(media);
186
166
  source.connect(node);
187
167
  watchTracks(media);
188
- // `onRecover` fires from the first delivered block.
189
168
  })
190
169
  .catch(() => scheduleRecover(reacquireTrack));
191
170
  };
@@ -201,7 +180,6 @@ export async function captureMicrophone(onChunk, opts = {}) {
201
180
  if (!stopped)
202
181
  setStall('track_muted');
203
182
  });
204
- // `unmute` needs no handler: the next block clears the stall.
205
183
  }
206
184
  };
207
185
  watchTracks(media);