@happyvertical/speech 0.97.0 → 0.98.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 (61) hide show
  1. package/AGENT.md +29 -3
  2. package/README.md +277 -2
  3. package/dist/__tests__/fake-websocket.d.ts +38 -0
  4. package/dist/__tests__/fake-websocket.d.ts.map +1 -0
  5. package/dist/adapters/local/abort.d.ts +6 -0
  6. package/dist/adapters/local/abort.d.ts.map +1 -0
  7. package/dist/adapters/local/audio.d.ts +45 -0
  8. package/dist/adapters/local/audio.d.ts.map +1 -0
  9. package/dist/adapters/local/env.d.ts +15 -0
  10. package/dist/adapters/local/env.d.ts.map +1 -0
  11. package/dist/adapters/local/runtime.d.ts +58 -0
  12. package/dist/adapters/local/runtime.d.ts.map +1 -0
  13. package/dist/adapters/local/transcriber.d.ts +40 -0
  14. package/dist/adapters/local/transcriber.d.ts.map +1 -0
  15. package/dist/adapters/local/transformers-shim.d.ts +8 -0
  16. package/dist/adapters/local/transformers-shim.d.ts.map +1 -0
  17. package/dist/adapters/local/types.d.ts +92 -0
  18. package/dist/adapters/local/types.d.ts.map +1 -0
  19. package/dist/adapters/local/worker.d.ts +118 -0
  20. package/dist/adapters/local/worker.d.ts.map +1 -0
  21. package/dist/adapters/openai-compatible.d.ts +2 -1
  22. package/dist/adapters/openai-compatible.d.ts.map +1 -1
  23. package/dist/adapters/openai-realtime.d.ts +48 -0
  24. package/dist/adapters/openai-realtime.d.ts.map +1 -0
  25. package/dist/adapters/voxtral-realtime.d.ts +44 -0
  26. package/dist/adapters/voxtral-realtime.d.ts.map +1 -0
  27. package/dist/chunks/registry-DfID41Ew.js +343 -0
  28. package/dist/chunks/registry-DfID41Ew.js.map +1 -0
  29. package/dist/index.d.ts +9 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1862 -326
  32. package/dist/index.js.map +1 -1
  33. package/dist/local.d.ts +21 -0
  34. package/dist/local.d.ts.map +1 -0
  35. package/dist/local.js +963 -0
  36. package/dist/local.js.map +1 -0
  37. package/dist/shared/env.d.ts +5 -0
  38. package/dist/shared/env.d.ts.map +1 -1
  39. package/dist/shared/factory.d.ts.map +1 -1
  40. package/dist/shared/pcm.d.ts +21 -0
  41. package/dist/shared/pcm.d.ts.map +1 -0
  42. package/dist/shared/realtime-auth.d.ts +34 -0
  43. package/dist/shared/realtime-auth.d.ts.map +1 -0
  44. package/dist/shared/realtime-session.d.ts +240 -0
  45. package/dist/shared/realtime-session.d.ts.map +1 -0
  46. package/dist/shared/registry.d.ts +9 -0
  47. package/dist/shared/registry.d.ts.map +1 -0
  48. package/dist/shared/streaming-client-secret.d.ts +90 -0
  49. package/dist/shared/streaming-client-secret.d.ts.map +1 -0
  50. package/dist/shared/streaming-factory.d.ts +65 -0
  51. package/dist/shared/streaming-factory.d.ts.map +1 -0
  52. package/dist/shared/streaming-types.d.ts +303 -0
  53. package/dist/shared/streaming-types.d.ts.map +1 -0
  54. package/dist/shared/turn-limit.d.ts +84 -0
  55. package/dist/shared/turn-limit.d.ts.map +1 -0
  56. package/dist/shared/types.d.ts +24 -6
  57. package/dist/shared/types.d.ts.map +1 -1
  58. package/dist/shared/websocket.d.ts +56 -0
  59. package/dist/shared/websocket.d.ts.map +1 -0
  60. package/metadata.json +6 -1
  61. package/package.json +19 -3
package/dist/local.js ADDED
@@ -0,0 +1,963 @@
1
+ import { C as SpeechProviderError, S as SpeechError, f as readEnv, h as reportSpeechUsage, o as defaultEnv, p as resolveTranscriberConfig, r as registerOptionalTranscriber, v as normalizeAudioInput, x as SpeechConfigurationError, y as normalizeMimeType } from "./chunks/registry-DfID41Ew.js";
2
+ //#region src/adapters/local/env.ts
3
+ /**
4
+ * Environment configuration for the `local` transcriber. Explicit options win,
5
+ * then the factory context, then `HAVE_SPEECH_TRANSCRIBER_*` variables (Node).
6
+ */
7
+ /** Local-only environment variables, in priority order. */
8
+ var LOCAL_TRANSCRIBER_ENV_KEYS = {
9
+ device: ["HAVE_SPEECH_TRANSCRIBER_DEVICE"],
10
+ dtype: ["HAVE_SPEECH_TRANSCRIBER_DTYPE"],
11
+ cacheDir: ["HAVE_SPEECH_TRANSCRIBER_CACHE_DIR"],
12
+ modelHost: ["HAVE_SPEECH_TRANSCRIBER_MODEL_HOST"]
13
+ };
14
+ /**
15
+ * Merges explicit options with the environment. `model` and `maxBytes` reuse
16
+ * the shared `HAVE_SPEECH_TRANSCRIBER_MODEL`/`_MAX_BYTES` keys.
17
+ */
18
+ function resolveLocalTranscriberOptions(options = {}, context = {}) {
19
+ const env = context.env ?? defaultEnv();
20
+ const shared = resolveTranscriberConfig({
21
+ model: options.model,
22
+ maxBytes: options.maxBytes
23
+ }, { env });
24
+ return {
25
+ ...options,
26
+ type: "local",
27
+ model: shared.model,
28
+ maxBytes: shared.maxBytes,
29
+ device: options.device ?? readEnv(env, ...LOCAL_TRANSCRIBER_ENV_KEYS.device),
30
+ dtype: options.dtype ?? readEnv(env, ...LOCAL_TRANSCRIBER_ENV_KEYS.dtype),
31
+ cacheDir: options.cacheDir ?? readEnv(env, ...LOCAL_TRANSCRIBER_ENV_KEYS.cacheDir),
32
+ modelHost: options.modelHost ?? readEnv(env, ...LOCAL_TRANSCRIBER_ENV_KEYS.modelHost)
33
+ };
34
+ }
35
+ //#endregion
36
+ //#region src/adapters/local/runtime.ts
37
+ /**
38
+ * Loads the optional `@huggingface/transformers` peer dependency and builds
39
+ * `automatic-speech-recognition` pipelines from it.
40
+ *
41
+ * This is the only module that names the peer. It is reachable only from the
42
+ * `@happyvertical/speech/local` entry, never from the core entry.
43
+ */
44
+ var TRANSFORMERS_PACKAGE = "@huggingface/transformers";
45
+ var TRANSFORMERS_INSTALL_HINT = `Local transcription needs the optional peer dependency ${TRANSFORMERS_PACKAGE}. Install it with \`pnpm add ${TRANSFORMERS_PACKAGE}\` (or npm/yarn equivalent).`;
46
+ /**
47
+ * Resolves the transformers.js module: an injected module or loader, else a
48
+ * dynamic import of the peer. A missing peer becomes a
49
+ * `SpeechConfigurationError` with an install hint.
50
+ */
51
+ async function loadTransformers(runtime) {
52
+ let loaded;
53
+ try {
54
+ loaded = typeof runtime === "function" ? await runtime() : runtime ?? await importTransformers();
55
+ } catch (error) {
56
+ if (error instanceof SpeechConfigurationError) throw error;
57
+ const configurationError = new SpeechConfigurationError(`${TRANSFORMERS_INSTALL_HINT} (${errorMessage$2(error)})`, "local");
58
+ configurationError.cause = error;
59
+ throw configurationError;
60
+ }
61
+ const candidate = loaded;
62
+ const module = typeof candidate?.pipeline === "function" ? candidate : candidate?.default;
63
+ if (typeof module?.pipeline !== "function") throw new SpeechConfigurationError(`${TRANSFORMERS_PACKAGE} did not export pipeline(); expected v4 or later`, "local");
64
+ return module;
65
+ }
66
+ /** Isolated so tests can observe whether the peer was ever imported. */
67
+ function importTransformers() {
68
+ return import("@huggingface/transformers");
69
+ }
70
+ /**
71
+ * Creates the ASR pipeline. `device: 'auto'` resolves to WebGPU when a
72
+ * browser adapter is available (retrying on WASM if WebGPU fails to
73
+ * initialise) and to CPU elsewhere.
74
+ */
75
+ async function createAsrPipeline(transformers, options) {
76
+ const device = options.device === "auto" ? await detectAutoDevice() : options.device;
77
+ const build = (selected) => transformers.pipeline("automatic-speech-recognition", options.model, {
78
+ device: selected,
79
+ ...options.dtype ? { dtype: options.dtype } : {},
80
+ ...options.revision ? { revision: options.revision } : {},
81
+ ...options.cacheDir ? { cache_dir: options.cacheDir } : {},
82
+ ...options.onProgress ? { progress_callback: options.onProgress } : {}
83
+ });
84
+ try {
85
+ return {
86
+ pipeline: await build(device),
87
+ device
88
+ };
89
+ } catch (error) {
90
+ if (options.device === "auto" && device === "webgpu") return {
91
+ pipeline: await build("wasm"),
92
+ device: "wasm"
93
+ };
94
+ throw error;
95
+ }
96
+ }
97
+ /** `webgpu` when a browser exposes a usable adapter, `wasm` in other browsers, `cpu` in Node. */
98
+ async function detectAutoDevice() {
99
+ if (isNodeRuntime()) return "cpu";
100
+ const gpu = globalThis.navigator?.gpu;
101
+ if (gpu) try {
102
+ if (await gpu.requestAdapter()) return "webgpu";
103
+ } catch {}
104
+ return "wasm";
105
+ }
106
+ function isNodeRuntime() {
107
+ return Boolean(globalThis.process?.versions?.node);
108
+ }
109
+ function errorMessage$2(error) {
110
+ return error instanceof Error ? error.message : String(error);
111
+ }
112
+ //#endregion
113
+ //#region src/adapters/local/abort.ts
114
+ /**
115
+ * Rejects with the signal's reason as soon as it aborts, without waiting for
116
+ * `promise` (which keeps running; its eventual result is ignored).
117
+ */
118
+ function raceAbort(promise, signal) {
119
+ if (!signal) return promise;
120
+ if (signal.aborted) {
121
+ promise.catch(() => void 0);
122
+ return Promise.reject(signal.reason);
123
+ }
124
+ return new Promise((resolve, reject) => {
125
+ const onAbort = () => reject(signal.reason);
126
+ signal.addEventListener("abort", onAbort, { once: true });
127
+ promise.then((value) => {
128
+ signal.removeEventListener("abort", onAbort);
129
+ resolve(value);
130
+ }, (error) => {
131
+ signal.removeEventListener("abort", onAbort);
132
+ reject(error);
133
+ });
134
+ });
135
+ }
136
+ //#endregion
137
+ //#region src/adapters/local/audio.ts
138
+ /**
139
+ * Audio decoding for on-device inference: turns encoded bytes into 16 kHz
140
+ * mono Float32 PCM, the input Whisper and Moonshine expect.
141
+ *
142
+ * - WAV (PCM 8/16/24/32-bit, IEEE float 32/64, WAVE_FORMAT_EXTENSIBLE) and raw
143
+ * `audio/pcm` are decoded in-process in every runtime, including workers.
144
+ * - Anything else goes to the caller's `decodeAudio` hook, or, in browsers,
145
+ * to `OfflineAudioContext.decodeAudioData` (MediaRecorder WebM/Opus, Safari
146
+ * `audio/mp4`, MP3, ...).
147
+ */
148
+ /** Sample rate expected by Whisper and Moonshine feature extractors. */
149
+ var LOCAL_SAMPLE_RATE = 16e3;
150
+ var ADAPTER = "local";
151
+ var WAV_MIME_TYPES = /* @__PURE__ */ new Set([
152
+ "audio/wav",
153
+ "audio/wave",
154
+ "audio/x-wav",
155
+ "audio/vnd.wave"
156
+ ]);
157
+ var PCM_MIME_TYPES = /* @__PURE__ */ new Set([
158
+ "audio/pcm",
159
+ "audio/x-pcm",
160
+ "audio/raw"
161
+ ]);
162
+ /**
163
+ * Decodes `bytes` and returns 16 kHz mono Float32 samples.
164
+ *
165
+ * Raw PCM (`audio/pcm`) is little-endian signed 16-bit by default; pass
166
+ * `encoding=f32le` for Float32. The rate comes from the `rate` MIME parameter
167
+ * or `sampleRate`, the channel count from `channels` (default 1).
168
+ */
169
+ async function decodeToPcm16k(bytes, options) {
170
+ return toPcm16k(await raceAbort(decodeAudioBytes(bytes, options), options.signal));
171
+ }
172
+ /** Downmixes decoded audio to mono and resamples it to 16 kHz. */
173
+ function toPcm16k(decoded) {
174
+ return resample(downmix(decoded.samples), decoded.sampleRate, LOCAL_SAMPLE_RATE);
175
+ }
176
+ /**
177
+ * True for WAV and raw PCM, which this module decodes itself in every
178
+ * runtime, including Web Workers.
179
+ */
180
+ function isInProcessDecodable(bytes, mimeType) {
181
+ const essence = normalizeMimeType(mimeType);
182
+ return Boolean(essence && (WAV_MIME_TYPES.has(essence) || PCM_MIME_TYPES.has(essence)) || isRiffWave(bytes));
183
+ }
184
+ /**
185
+ * Decodes a format without an in-process decoder through the caller's
186
+ * `decodeAudio` hook or, in browsers, `OfflineAudioContext`. Resolves
187
+ * `undefined` when neither is available.
188
+ */
189
+ async function decodeExternally(bytes, options) {
190
+ if (options.decodeAudio) {
191
+ const decoded = await options.decodeAudio({
192
+ bytes,
193
+ mimeType: options.mimeType,
194
+ signal: options.signal
195
+ });
196
+ assertDecodedAudio(decoded);
197
+ return decoded;
198
+ }
199
+ if (hasOfflineAudioContext()) return decodeWithAudioContext(bytes);
200
+ }
201
+ async function decodeAudioBytes(bytes, options) {
202
+ const essence = normalizeMimeType(options.mimeType);
203
+ if (essence && WAV_MIME_TYPES.has(essence) || isRiffWave(bytes)) return decodeWav(bytes);
204
+ if (essence && PCM_MIME_TYPES.has(essence)) return decodeRawPcm(bytes, options);
205
+ const decoded = await decodeExternally(bytes, options);
206
+ if (decoded) return decoded;
207
+ throw new SpeechConfigurationError(`Cannot decode '${options.mimeType}' audio on this runtime: pass WAV or audio/pcm, or supply a decodeAudio hook (e.g. ffmpeg) for compressed formats`, ADAPTER);
208
+ }
209
+ function isRiffWave(bytes) {
210
+ return bytes.byteLength >= 12 && ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 4) === "WAVE";
211
+ }
212
+ /** Parses a RIFF/WAVE file into per-channel Float32 samples. */
213
+ function decodeWav(bytes) {
214
+ if (!isRiffWave(bytes)) throw new SpeechConfigurationError("Invalid WAV data: missing RIFF/WAVE header", ADAPTER);
215
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
216
+ let format;
217
+ let offset = 12;
218
+ while (offset + 8 <= bytes.byteLength) {
219
+ const id = ascii(bytes, offset, 4);
220
+ const size = view.getUint32(offset + 4, true);
221
+ const body = offset + 8;
222
+ if (id === "fmt ") format = readWavFormat(view, body, size);
223
+ else if (id === "data") {
224
+ if (!format) throw new SpeechConfigurationError("Invalid WAV data: data chunk before fmt chunk", ADAPTER);
225
+ const available = bytes.byteLength - body;
226
+ const length = size === 0 || size > available ? available : size;
227
+ return {
228
+ samples: deinterleave(bytes.subarray(body, body + length), format.channels, format.encoding),
229
+ sampleRate: format.sampleRate
230
+ };
231
+ }
232
+ offset = body + size + size % 2;
233
+ }
234
+ throw new SpeechConfigurationError("Invalid WAV data: no data chunk", ADAPTER);
235
+ }
236
+ var WAVE_FORMAT_PCM = 1;
237
+ var WAVE_FORMAT_IEEE_FLOAT = 3;
238
+ var WAVE_FORMAT_EXTENSIBLE = 65534;
239
+ function readWavFormat(view, body, size) {
240
+ if (size < 16) throw new SpeechConfigurationError("Invalid WAV data: fmt chunk too short", ADAPTER);
241
+ let tag = view.getUint16(body, true);
242
+ const channels = view.getUint16(body + 2, true);
243
+ const sampleRate = view.getUint32(body + 4, true);
244
+ const bitsPerSample = view.getUint16(body + 14, true);
245
+ if (tag === WAVE_FORMAT_EXTENSIBLE && size >= 26) tag = view.getUint16(body + 24, true);
246
+ const encoding = tag === WAVE_FORMAT_PCM ? {
247
+ 8: "u8",
248
+ 16: "s16le",
249
+ 24: "s24le",
250
+ 32: "s32le"
251
+ }[bitsPerSample] : tag === WAVE_FORMAT_IEEE_FLOAT ? {
252
+ 32: "f32le",
253
+ 64: "f64le"
254
+ }[bitsPerSample] : void 0;
255
+ if (!encoding || channels < 1 || sampleRate < 1) throw new SpeechConfigurationError(`Unsupported WAV encoding (format ${tag}, ${bitsPerSample}-bit, ${channels} channels); use PCM or IEEE float, or supply a decodeAudio hook`, ADAPTER);
256
+ return {
257
+ channels,
258
+ sampleRate,
259
+ encoding
260
+ };
261
+ }
262
+ function decodeRawPcm(bytes, options) {
263
+ const params = mimeParameters(options.mimeType);
264
+ const sampleRate = positiveInteger(params.rate) ?? explicitPositiveInteger(options.sampleRate, "sampleRate");
265
+ const channels = positiveInteger(params.channels) ?? explicitPositiveInteger(options.channels, "channels") ?? 1;
266
+ const encoding = (params.encoding ?? "s16le").toLowerCase();
267
+ if (!sampleRate) throw new SpeechConfigurationError("Raw PCM audio needs a sample rate: use 'audio/pcm;rate=16000' or AudioInput.sampleRate", ADAPTER);
268
+ if (encoding !== "s16le" && encoding !== "f32le") throw new SpeechConfigurationError(`Unsupported raw PCM encoding '${encoding}' (expected s16le or f32le)`, ADAPTER);
269
+ return {
270
+ samples: deinterleave(bytes, channels, encoding),
271
+ sampleRate
272
+ };
273
+ }
274
+ var BYTES_PER_SAMPLE = {
275
+ u8: 1,
276
+ s16le: 2,
277
+ s24le: 3,
278
+ s32le: 4,
279
+ f32le: 4,
280
+ f64le: 8
281
+ };
282
+ function deinterleave(bytes, channels, encoding) {
283
+ const width = BYTES_PER_SAMPLE[encoding];
284
+ const frames = Math.floor(bytes.byteLength / (width * channels));
285
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
286
+ const output = Array.from({ length: channels }, () => new Float32Array(frames));
287
+ for (let frame = 0; frame < frames; frame++) for (let channel = 0; channel < channels; channel++) {
288
+ const at = (frame * channels + channel) * width;
289
+ output[channel][frame] = readSample(view, at, encoding);
290
+ }
291
+ return output;
292
+ }
293
+ function readSample(view, at, encoding) {
294
+ switch (encoding) {
295
+ case "u8": return (view.getUint8(at) - 128) / 128;
296
+ case "s16le": return view.getInt16(at, true) / 32768;
297
+ case "s24le": return (view.getUint8(at) | view.getUint8(at + 1) << 8 | view.getInt8(at + 2) << 16) / 8388608;
298
+ case "s32le": return view.getInt32(at, true) / 2147483648;
299
+ case "f32le": return view.getFloat32(at, true);
300
+ case "f64le": return view.getFloat64(at, true);
301
+ }
302
+ }
303
+ /** Averages channels into one mono track. */
304
+ function downmix(samples) {
305
+ if (samples instanceof Float32Array) return samples;
306
+ if (samples.length === 1) return samples[0];
307
+ const length = Math.min(...samples.map((channel) => channel.length));
308
+ const mono = new Float32Array(length);
309
+ for (const channel of samples) for (let index = 0; index < length; index++) mono[index] = mono[index] + channel[index] / samples.length;
310
+ return mono;
311
+ }
312
+ /**
313
+ * Resamples mono audio with linear interpolation. When downsampling, a box
314
+ * filter one source period wide is applied first to limit aliasing; this is
315
+ * adequate for speech recognition, not for music playback.
316
+ */
317
+ function resample(samples, fromRate, toRate) {
318
+ if (fromRate === toRate || samples.length === 0) return samples;
319
+ const ratio = fromRate / toRate;
320
+ const source = ratio > 1 ? boxFilter(samples, Math.round(ratio)) : samples;
321
+ const length = Math.max(1, Math.round(samples.length / ratio));
322
+ const output = new Float32Array(length);
323
+ for (let index = 0; index < length; index++) {
324
+ const position = index * ratio;
325
+ const left = Math.floor(position);
326
+ const right = Math.min(left + 1, source.length - 1);
327
+ const fraction = position - left;
328
+ output[index] = source[left] * (1 - fraction) + source[right] * fraction;
329
+ }
330
+ return output;
331
+ }
332
+ function boxFilter(samples, width) {
333
+ if (width <= 1) return samples;
334
+ const output = new Float32Array(samples.length);
335
+ const half = Math.floor(width / 2);
336
+ let sum = 0;
337
+ let count = 0;
338
+ let start = 0;
339
+ let end = 0;
340
+ for (let index = 0; index < samples.length; index++) {
341
+ const windowStart = Math.max(0, index - half);
342
+ const windowEnd = Math.min(samples.length, index - half + width);
343
+ while (end < windowEnd) {
344
+ sum += samples[end];
345
+ count++;
346
+ end++;
347
+ }
348
+ while (start < windowStart) {
349
+ sum -= samples[start];
350
+ count--;
351
+ start++;
352
+ }
353
+ output[index] = sum / count;
354
+ }
355
+ return output;
356
+ }
357
+ function hasOfflineAudioContext() {
358
+ return typeof globalThis.OfflineAudioContext === "function";
359
+ }
360
+ /**
361
+ * Browser decode path. Decoding through a 16 kHz `OfflineAudioContext` makes
362
+ * the browser resample with its own high-quality resampler. Not available in
363
+ * Web Workers: decode on the main thread (see the worker client).
364
+ */
365
+ async function decodeWithAudioContext(bytes) {
366
+ const context = new OfflineAudioContext(1, 1, LOCAL_SAMPLE_RATE);
367
+ const copy = new Uint8Array(bytes.byteLength);
368
+ copy.set(bytes);
369
+ let buffer;
370
+ try {
371
+ buffer = await context.decodeAudioData(copy.buffer);
372
+ } catch (error) {
373
+ throw new SpeechConfigurationError(`The browser could not decode the audio: ${errorMessage$1(error)}`, ADAPTER);
374
+ }
375
+ return {
376
+ samples: Array.from({ length: buffer.numberOfChannels }, (_, index) => buffer.getChannelData(index)),
377
+ sampleRate: buffer.sampleRate
378
+ };
379
+ }
380
+ function assertDecodedAudio(decoded) {
381
+ const channels = decoded?.samples instanceof Float32Array ? [decoded.samples] : decoded?.samples;
382
+ if (!Array.isArray(channels) || channels.length === 0 || !channels.every((channel) => channel instanceof Float32Array) || !(decoded.sampleRate > 0)) throw new SpeechConfigurationError("decodeAudio must return { samples: Float32Array | Float32Array[], sampleRate }", ADAPTER);
383
+ }
384
+ function mimeParameters(mimeType) {
385
+ const params = {};
386
+ for (const part of mimeType.split(";").slice(1)) {
387
+ const [key, value] = part.split("=");
388
+ if (key && value) params[key.trim().toLowerCase()] = value.trim().replace(/^"|"$/g, "");
389
+ }
390
+ return params;
391
+ }
392
+ function positiveInteger(value) {
393
+ const parsed = value ? Number.parseInt(value, 10) : NaN;
394
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
395
+ }
396
+ /** Validates a caller-supplied `AudioInput.sampleRate`/`channels`. */
397
+ function explicitPositiveInteger(value, name) {
398
+ if (value === void 0 || value === null) return;
399
+ if (!Number.isInteger(value) || value < 1) throw new SpeechConfigurationError(`AudioInput.${name} must be a positive integer, got ${String(value)}`, ADAPTER);
400
+ return value;
401
+ }
402
+ function ascii(bytes, offset, length) {
403
+ return String.fromCharCode(...bytes.subarray(offset, offset + length));
404
+ }
405
+ function errorMessage$1(error) {
406
+ return error instanceof Error ? error.message : String(error);
407
+ }
408
+ //#endregion
409
+ //#region src/adapters/local/transcriber.ts
410
+ /** Small multilingual Whisper (~80 MB at the default q8 quantisation). */
411
+ var DEFAULT_LOCAL_MODEL = "onnx-community/whisper-base";
412
+ var DEFAULT_CHUNK_LENGTH_SECONDS = 30;
413
+ var WHISPER_WINDOW_SECONDS = 30;
414
+ var WHISPER_MODEL_TYPES = /* @__PURE__ */ new Set(["whisper", "lite-whisper"]);
415
+ /**
416
+ * On-device speech-to-text with transformers.js (Whisper or Moonshine ONNX).
417
+ * Runs on WebGPU/WASM in browsers and onnxruntime-node in Node; no audio
418
+ * leaves the device and no API key is involved.
419
+ */
420
+ var LocalTranscriber = class {
421
+ type = "local";
422
+ options;
423
+ model;
424
+ maxBytes;
425
+ onUsage;
426
+ runtime;
427
+ pipelines = /* @__PURE__ */ new Map();
428
+ /** Serialises inference: ONNX sessions (especially WebGPU) run one call at a time. */
429
+ queue = Promise.resolve();
430
+ constructor(options = {}) {
431
+ this.options = options;
432
+ this.model = options.model?.trim() || "onnx-community/whisper-base";
433
+ this.maxBytes = options.maxBytes ?? 26214400;
434
+ this.onUsage = options.onUsage;
435
+ }
436
+ /**
437
+ * Downloads (first time) and initialises the model so the first
438
+ * `transcribe()` is fast. Progress goes to `onProgress`.
439
+ */
440
+ async preload(model, signal) {
441
+ await raceAbort(this.loadPipeline(model?.trim() || this.model), signal);
442
+ }
443
+ async transcribe(request) {
444
+ const { signal } = request;
445
+ const audio = await normalizeAudioInput(request.audio, {
446
+ mimeType: request.mimeType,
447
+ maxBytes: request.maxBytes ?? this.maxBytes,
448
+ adapter: this.type,
449
+ signal
450
+ });
451
+ const format = pcmFormat(request.audio);
452
+ const samples = await decodeToPcm16k(new Uint8Array(await audio.blob.arrayBuffer()), {
453
+ mimeType: audio.mimeType,
454
+ sampleRate: format.sampleRate,
455
+ channels: format.channels,
456
+ decodeAudio: this.options.decodeAudio,
457
+ signal
458
+ });
459
+ signal?.throwIfAborted();
460
+ const model = request.model?.trim() || this.model;
461
+ const loaded = await raceAbort(this.loadPipeline(model), signal);
462
+ const audioSeconds = samples.length / LOCAL_SAMPLE_RATE;
463
+ const output = await raceAbort(this.enqueue(() => this.run(loaded.pipeline, samples, audioSeconds, request)), signal);
464
+ const usage = {
465
+ operation: "transcription",
466
+ provider: this.type,
467
+ model,
468
+ audioSeconds,
469
+ bytes: audio.bytes
470
+ };
471
+ const result = {
472
+ ...mapAsrOutput(output, request.timestampGranularities?.includes("word") ?? false),
473
+ language: request.language,
474
+ durationSeconds: audioSeconds,
475
+ provider: this.type,
476
+ model,
477
+ raw: output,
478
+ usage
479
+ };
480
+ await reportSpeechUsage(usage, this.onUsage, request.onUsage);
481
+ return result;
482
+ }
483
+ /** Waits for in-flight inference, then releases every loaded model session. */
484
+ async dispose() {
485
+ await this.queue;
486
+ const loaded = [...this.pipelines.values()];
487
+ this.pipelines.clear();
488
+ await Promise.all(loaded.map(async (entry) => {
489
+ const { pipeline } = await entry.catch(() => ({ pipeline: void 0 }));
490
+ await pipeline?.dispose?.();
491
+ }));
492
+ }
493
+ async run(pipeline, samples, audioSeconds, request) {
494
+ const { signal } = request;
495
+ signal?.throwIfAborted();
496
+ const transformers = await this.loadRuntime();
497
+ const options = {};
498
+ if (WHISPER_MODEL_TYPES.has(pipeline.model?.config?.model_type ?? "whisper")) {
499
+ const granularities = request.timestampGranularities ?? [];
500
+ if (granularities.includes("word")) options.return_timestamps = "word";
501
+ else if (granularities.includes("segment")) options.return_timestamps = true;
502
+ const chunkLength = this.options.chunkLengthSeconds ?? DEFAULT_CHUNK_LENGTH_SECONDS;
503
+ if (chunkLength > 0 && audioSeconds > WHISPER_WINDOW_SECONDS) options.chunk_length_s = chunkLength;
504
+ if (request.language) {
505
+ options.language = request.language;
506
+ options.task = "transcribe";
507
+ }
508
+ }
509
+ let stopping;
510
+ if (signal && transformers.InterruptableStoppingCriteria) {
511
+ stopping = new transformers.InterruptableStoppingCriteria();
512
+ options.stopping_criteria = stopping;
513
+ }
514
+ const onAbort = () => stopping?.interrupt();
515
+ signal?.addEventListener("abort", onAbort, { once: true });
516
+ try {
517
+ const output = await pipeline(samples, options);
518
+ return Array.isArray(output) ? output[0] ?? { text: "" } : output;
519
+ } catch (error) {
520
+ if (signal?.aborted) throw signal.reason;
521
+ const hint = options.return_timestamps === "word" && /cross attentions/i.test(errorMessage(error)) ? " (word timestamps need a model exported with cross attentions, e.g. onnx-community/whisper-base_timestamped)" : "";
522
+ throw new SpeechProviderError(this.type, `Local transcription failed: ${errorMessage(error)}${hint}`, { cause: error });
523
+ } finally {
524
+ signal?.removeEventListener("abort", onAbort);
525
+ }
526
+ }
527
+ enqueue(task) {
528
+ const next = this.queue.then(task, task);
529
+ this.queue = next.catch(() => void 0);
530
+ return next;
531
+ }
532
+ loadRuntime() {
533
+ if (!this.runtime) {
534
+ const { transformers, configureEnv, modelHost } = this.options;
535
+ this.runtime = loadTransformers(transformers).then((module) => {
536
+ if (module.env) {
537
+ if (modelHost) module.env.remoteHost = modelHost;
538
+ configureEnv?.(module.env);
539
+ }
540
+ return module;
541
+ });
542
+ this.runtime.catch(() => {
543
+ this.runtime = void 0;
544
+ });
545
+ }
546
+ return this.runtime;
547
+ }
548
+ loadPipeline(model) {
549
+ let loaded = this.pipelines.get(model);
550
+ if (!loaded) {
551
+ loaded = this.loadRuntime().then((transformers) => createAsrPipeline(transformers, {
552
+ model,
553
+ device: this.options.device ?? "auto",
554
+ dtype: this.options.dtype,
555
+ revision: this.options.revision,
556
+ cacheDir: this.options.cacheDir,
557
+ onProgress: this.options.onProgress
558
+ }).catch((error) => {
559
+ if (error instanceof SpeechConfigurationError) throw error;
560
+ throw new SpeechProviderError(this.type, `Failed to load local model '${model}': ${errorMessage(error)}`, { cause: error });
561
+ }));
562
+ this.pipelines.set(model, loaded);
563
+ loaded.catch(() => {
564
+ if (this.pipelines.get(model) === loaded) this.pipelines.delete(model);
565
+ });
566
+ }
567
+ return loaded;
568
+ }
569
+ };
570
+ /**
571
+ * Maps transformers.js chunks onto `words` (when word timestamps were
572
+ * requested) or `segments`.
573
+ */
574
+ function mapAsrOutput(output, wordLevel) {
575
+ const result = { text: (output.text ?? "").trim() };
576
+ const chunks = output.chunks;
577
+ if (!chunks?.length) return result;
578
+ if (wordLevel) result.words = chunks.filter((chunk) => chunk.text.trim()).map((chunk) => ({
579
+ word: chunk.text.trim(),
580
+ startSeconds: chunk.timestamp[0],
581
+ endSeconds: chunk.timestamp[1] ?? chunk.timestamp[0]
582
+ }));
583
+ else result.segments = chunks.map((chunk) => ({
584
+ text: chunk.text.trim(),
585
+ startSeconds: chunk.timestamp[0],
586
+ endSeconds: chunk.timestamp[1] ?? void 0
587
+ }));
588
+ return result;
589
+ }
590
+ function pcmFormat(audio) {
591
+ if (audio && typeof audio === "object" && "data" in audio && !(audio instanceof Uint8Array)) return {
592
+ sampleRate: audio.sampleRate,
593
+ channels: audio.channels
594
+ };
595
+ return {};
596
+ }
597
+ function errorMessage(error) {
598
+ return error instanceof Error ? error.message : String(error);
599
+ }
600
+ //#endregion
601
+ //#region src/adapters/local/worker.ts
602
+ /**
603
+ * Web Worker support for the `local` transcriber.
604
+ *
605
+ * Inference runs in a worker (`serveLocalTranscriber`) so long recordings never
606
+ * block the UI thread. The main-thread client (`LocalTranscriberWorkerClient`)
607
+ * transfers WAV and raw PCM bytes to the worker, which parses, downmixes, and
608
+ * resamples them there. Only formats that need `AudioContext` (which workers
609
+ * lack) or the client's `decodeAudio` hook are decoded on the main thread;
610
+ * their Float32 channels are then transferred and resampled in the worker.
611
+ */
612
+ var PCM_MIME_TYPE = `audio/pcm;rate=${LOCAL_SAMPLE_RATE};encoding=f32le`;
613
+ /**
614
+ * Runs a {@link LocalTranscriber} inside a worker and answers client
615
+ * requests. Call it from your worker module:
616
+ *
617
+ * ```ts
618
+ * // transcriber.worker.ts
619
+ * import { serveLocalTranscriber } from '@happyvertical/speech/local';
620
+ * serveLocalTranscriber({ model: 'onnx-community/whisper-base' });
621
+ * ```
622
+ *
623
+ * Returns a function that stops listening and disposes the model.
624
+ */
625
+ function serveLocalTranscriber(options = {}, endpoint = globalThis) {
626
+ const userProgress = options.onProgress;
627
+ const transcriber = new LocalTranscriber({
628
+ ...resolveLocalTranscriberOptions(options),
629
+ maxBytes: Number.POSITIVE_INFINITY,
630
+ onProgress: (progress) => {
631
+ userProgress?.(progress);
632
+ endpoint.postMessage({
633
+ kind: "progress",
634
+ progress: toCloneable(progress)
635
+ });
636
+ }
637
+ });
638
+ const pending = /* @__PURE__ */ new Map();
639
+ const reply = (message) => endpoint.postMessage(message);
640
+ const listener = (event) => {
641
+ const message = event.data;
642
+ if (!message || typeof message !== "object") return;
643
+ if (message.kind === "abort") {
644
+ pending.get(message.id)?.abort();
645
+ return;
646
+ }
647
+ const controller = new AbortController();
648
+ pending.set(message.id, controller);
649
+ (message.kind === "preload" ? transcriber.preload(message.model, controller.signal).then(() => {
650
+ reply({
651
+ kind: "result",
652
+ id: message.id
653
+ });
654
+ }) : Promise.resolve().then(() => transcriber.transcribe({
655
+ audio: toAudioInput(message.audio),
656
+ model: message.model,
657
+ language: message.language,
658
+ timestampGranularities: message.timestampGranularities,
659
+ signal: controller.signal
660
+ })).then((result) => {
661
+ reply({
662
+ kind: "result",
663
+ id: message.id,
664
+ result
665
+ });
666
+ })).catch((error) => {
667
+ reply({
668
+ kind: "error",
669
+ id: message.id,
670
+ error: serializeError(error)
671
+ });
672
+ }).finally(() => pending.delete(message.id));
673
+ };
674
+ endpoint.addEventListener("message", listener);
675
+ return async () => {
676
+ endpoint.removeEventListener("message", listener);
677
+ for (const controller of pending.values()) controller.abort();
678
+ await transcriber.dispose();
679
+ };
680
+ }
681
+ /**
682
+ * Main-thread {@link Transcriber} that delegates inference to a worker running
683
+ * {@link serveLocalTranscriber}.
684
+ *
685
+ * ```ts
686
+ * const worker = new Worker(new URL('./transcriber.worker.ts', import.meta.url), { type: 'module' });
687
+ * const transcriber = new LocalTranscriberWorkerClient(worker, { onProgress });
688
+ * const { text } = await transcriber.transcribe({ audio: recordingBlob });
689
+ * ```
690
+ */
691
+ var LocalTranscriberWorkerClient = class {
692
+ type = "local";
693
+ endpoint;
694
+ options;
695
+ calls = /* @__PURE__ */ new Map();
696
+ nextId = 1;
697
+ closed = false;
698
+ /** Aborted by `close()` to reject calls still buffering or decoding. */
699
+ closing = new AbortController();
700
+ listener = (event) => this.onMessage(event.data);
701
+ constructor(endpoint, options = {}) {
702
+ this.endpoint = endpoint;
703
+ this.options = options;
704
+ endpoint.addEventListener("message", this.listener);
705
+ }
706
+ /** Loads the model in the worker ahead of the first transcription. */
707
+ async preload(model, signal) {
708
+ await this.call({
709
+ kind: "preload",
710
+ model
711
+ }, signal);
712
+ }
713
+ async transcribe(request) {
714
+ this.assertOpen();
715
+ const { signal } = request;
716
+ const { audio, bytes } = await raceAbort(this.prepare(request), this.closing.signal);
717
+ const result = await this.call({
718
+ kind: "transcribe",
719
+ audio,
720
+ model: request.model,
721
+ language: request.language,
722
+ timestampGranularities: request.timestampGranularities
723
+ }, signal, transferList(audio));
724
+ if (!result) throw new SpeechProviderError(this.type, "Worker returned no result");
725
+ const usage = {
726
+ ...result.usage ?? {
727
+ operation: "transcription",
728
+ provider: this.type
729
+ },
730
+ bytes
731
+ };
732
+ await reportSpeechUsage(usage, this.options.onUsage, request.onUsage);
733
+ return {
734
+ ...result,
735
+ usage
736
+ };
737
+ }
738
+ /**
739
+ * Stops listening, rejects in-flight and later calls, and asks the worker to
740
+ * abort posted work. Does not terminate the worker.
741
+ */
742
+ close() {
743
+ if (this.closed) return;
744
+ this.closed = true;
745
+ this.endpoint.removeEventListener("message", this.listener);
746
+ this.closing.abort(closedError());
747
+ for (const [id, call] of this.calls) {
748
+ this.endpoint.postMessage({
749
+ kind: "abort",
750
+ id
751
+ });
752
+ call.reject(closedError());
753
+ }
754
+ this.calls.clear();
755
+ }
756
+ /** Buffers the input and, only when the worker cannot, decodes it here. */
757
+ async prepare(request) {
758
+ const { signal } = request;
759
+ const normalized = await normalizeAudioInput(request.audio, {
760
+ mimeType: request.mimeType,
761
+ maxBytes: request.maxBytes ?? this.options.maxBytes ?? 26214400,
762
+ adapter: this.type,
763
+ signal
764
+ });
765
+ const bytes = new Uint8Array(await normalized.blob.arrayBuffer());
766
+ if (!isInProcessDecodable(bytes, normalized.mimeType)) {
767
+ const decoded = await raceAbort(decodeExternally(bytes, {
768
+ mimeType: normalized.mimeType,
769
+ decodeAudio: this.options.decodeAudio,
770
+ signal
771
+ }), signal);
772
+ if (decoded) return {
773
+ audio: {
774
+ kind: "decoded",
775
+ samples: (decoded.samples instanceof Float32Array ? [decoded.samples] : decoded.samples).map(ownedCopy),
776
+ sampleRate: decoded.sampleRate
777
+ },
778
+ bytes: normalized.bytes
779
+ };
780
+ }
781
+ const wrapped = typeof request.audio === "object" && "data" in request.audio && !(request.audio instanceof Uint8Array) ? request.audio : void 0;
782
+ return {
783
+ audio: {
784
+ kind: "encoded",
785
+ bytes: ownedCopy(bytes),
786
+ mimeType: normalized.mimeType,
787
+ sampleRate: wrapped?.sampleRate,
788
+ channels: wrapped?.channels
789
+ },
790
+ bytes: normalized.bytes
791
+ };
792
+ }
793
+ assertOpen() {
794
+ if (this.closed) throw closedError();
795
+ }
796
+ call(message, signal, transfer = []) {
797
+ this.assertOpen();
798
+ signal?.throwIfAborted();
799
+ const id = this.nextId++;
800
+ return new Promise((resolve, reject) => {
801
+ const onAbort = () => {
802
+ this.calls.delete(id);
803
+ this.endpoint.postMessage({
804
+ kind: "abort",
805
+ id
806
+ });
807
+ reject(signal?.reason);
808
+ };
809
+ const settle = () => signal?.removeEventListener("abort", onAbort);
810
+ this.calls.set(id, {
811
+ resolve: (result) => {
812
+ settle();
813
+ resolve(result);
814
+ },
815
+ reject: (error) => {
816
+ settle();
817
+ reject(error);
818
+ }
819
+ });
820
+ signal?.addEventListener("abort", onAbort, { once: true });
821
+ this.endpoint.postMessage({
822
+ ...message,
823
+ id
824
+ }, transfer);
825
+ });
826
+ }
827
+ onMessage(message) {
828
+ if (!message || typeof message !== "object") return;
829
+ if (message.kind === "progress") {
830
+ this.options.onProgress?.(message.progress);
831
+ return;
832
+ }
833
+ const call = this.calls.get(message.id);
834
+ if (!call) return;
835
+ this.calls.delete(message.id);
836
+ if (message.kind === "result") call.resolve(message.result);
837
+ else call.reject(deserializeError(message.error));
838
+ }
839
+ };
840
+ function closedError() {
841
+ return new SpeechConfigurationError("Worker client closed", "local");
842
+ }
843
+ /** Rebuilds the transcriber input from a worker message, resampling decoded channels here. */
844
+ function toAudioInput(audio) {
845
+ if (audio.kind === "decoded") {
846
+ const pcm = toPcm16k({
847
+ samples: audio.samples,
848
+ sampleRate: audio.sampleRate
849
+ });
850
+ return {
851
+ data: new Uint8Array(pcm.buffer, pcm.byteOffset, pcm.byteLength),
852
+ mimeType: PCM_MIME_TYPE
853
+ };
854
+ }
855
+ return {
856
+ data: audio.bytes,
857
+ mimeType: audio.mimeType,
858
+ sampleRate: audio.sampleRate,
859
+ channels: audio.channels
860
+ };
861
+ }
862
+ /**
863
+ * Returns `view` when it spans a whole ordinary `ArrayBuffer` (safe to
864
+ * transfer), otherwise a copy. Views of a larger buffer would transfer
865
+ * unrelated bytes, and a `SharedArrayBuffer` cannot be transferred at all.
866
+ */
867
+ function ownedCopy(view) {
868
+ const { buffer } = view;
869
+ return isPlainArrayBuffer(buffer) && view.byteOffset === 0 && view.byteLength === buffer.byteLength ? view : view.slice();
870
+ }
871
+ function isPlainArrayBuffer(buffer) {
872
+ return Object.prototype.toString.call(buffer) === "[object ArrayBuffer]";
873
+ }
874
+ /** Distinct buffers to transfer (two channels may share one). */
875
+ function transferList(audio) {
876
+ const views = audio.kind === "decoded" ? audio.samples : [audio.bytes];
877
+ return [...new Set(views.map((view) => view.buffer))];
878
+ }
879
+ function serializeError(error) {
880
+ if (error instanceof SpeechError) return {
881
+ name: error.name,
882
+ message: error.message,
883
+ code: error.code
884
+ };
885
+ if (error instanceof Error) return {
886
+ name: error.name,
887
+ message: error.message
888
+ };
889
+ return {
890
+ name: "Error",
891
+ message: String(error)
892
+ };
893
+ }
894
+ function deserializeError(error) {
895
+ switch (error.name) {
896
+ case "SpeechConfigurationError": return new SpeechConfigurationError(error.message, "local");
897
+ case "SpeechProviderError": return new SpeechProviderError("local", error.message);
898
+ case "AbortError": return new DOMException(error.message, "AbortError");
899
+ default: {
900
+ const restored = new Error(error.message);
901
+ restored.name = error.name;
902
+ return restored;
903
+ }
904
+ }
905
+ }
906
+ /** Progress objects cross `postMessage`, so drop anything not cloneable. */
907
+ function toCloneable(progress) {
908
+ const clone = { status: progress.status };
909
+ for (const [key, value] of Object.entries(progress)) if (value === null || [
910
+ "string",
911
+ "number",
912
+ "boolean"
913
+ ].includes(typeof value)) clone[key] = value;
914
+ return clone;
915
+ }
916
+ //#endregion
917
+ //#region src/local.ts
918
+ /**
919
+ * @happyvertical/speech/local
920
+ *
921
+ * On-device speech-to-text with transformers.js (`@huggingface/transformers`,
922
+ * an optional peer dependency loaded on first use). Importing this entry
923
+ * registers `type: 'local'` with `getTranscriber()` and `getSpeech()`.
924
+ *
925
+ * @example
926
+ * ```typescript
927
+ * import { getTranscriber } from '@happyvertical/speech';
928
+ * import '@happyvertical/speech/local';
929
+ *
930
+ * const transcriber = await getTranscriber({ type: 'local' });
931
+ * const { text } = await transcriber.transcribe({ audio: wavBytes, mimeType: 'audio/wav' });
932
+ * ```
933
+ *
934
+ * @packageDocumentation
935
+ */
936
+ /**
937
+ * Creates an on-device transcriber. Explicit options win over
938
+ * `HAVE_SPEECH_TRANSCRIBER_*` environment variables (see
939
+ * `LOCAL_TRANSCRIBER_ENV_KEYS`). The model loads on first use.
940
+ */
941
+ function createLocalTranscriber(options = {}, context = {}) {
942
+ return new LocalTranscriber(resolveLocalTranscriberOptions(options, context));
943
+ }
944
+ /**
945
+ * Resolves `true` when `@huggingface/transformers` can be imported in this
946
+ * runtime. This imports the runtime, so call it only when you intend to use it.
947
+ */
948
+ async function isLocalTranscriberAvailable() {
949
+ try {
950
+ await loadTransformers(void 0);
951
+ return true;
952
+ } catch {
953
+ return false;
954
+ }
955
+ }
956
+ registerOptionalTranscriber("local", (options, context) => createLocalTranscriber({
957
+ ...options,
958
+ type: "local"
959
+ }, context));
960
+ //#endregion
961
+ export { DEFAULT_LOCAL_MODEL, LOCAL_SAMPLE_RATE, LOCAL_TRANSCRIBER_ENV_KEYS, LocalTranscriber, LocalTranscriberWorkerClient, TRANSFORMERS_INSTALL_HINT, TRANSFORMERS_PACKAGE, createLocalTranscriber, decodeToPcm16k, isLocalTranscriberAvailable, serveLocalTranscriber };
962
+
963
+ //# sourceMappingURL=local.js.map