@happyvertical/speech 0.102.4 → 0.102.5

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
@@ -287,8 +287,8 @@ A base URL may be `ws(s)://` or `http(s)://`: a server root gets `/v1/realtime`,
287
287
  Three entry points, none of which loads Node-only code:
288
288
 
289
289
  - `@happyvertical/speech` exports `transcriberInputFormat` and `TranscriberInputFormat`.
290
- - `@happyvertical/speech/pcm` (browser, workers, Node): `encodeWavPcm16(samples, sampleRate)`, `parseWavPcm16(bytes, { sampleRate?, channels? })`, `resampleMono(samples, fromRate, toRate)`, `float32ToPcm16`, `pcm16ToFloat32`, and `WavFormatError` (stable `reason`).
291
- - `@happyvertical/speech/browser` (SSR-safe to import): `createPcmCapture(stream, { sampleRate, maxDurationMs })`, `pcmCaptureSupported()`, and `PcmCaptureError`.
290
+ - `@happyvertical/speech/pcm` (browser, workers, Node): the pure voice activity detector (`createVadSegmenter`, `createUtteranceCollector`), `encodeWavPcm16(samples, sampleRate)`, `parseWavPcm16(bytes, { sampleRate?, channels? })`, `resampleMono(samples, fromRate, toRate)`, `float32ToPcm16`, `pcm16ToFloat32`, and `WavFormatError` (stable `reason`).
291
+ - `@happyvertical/speech/browser` (SSR-safe to import): `createPcmCapture(stream, { sampleRate, maxDurationMs })`, `pcmCaptureSupported()`, `PcmCaptureError`, and the hands-free `createVadCapture` (see [Hands-free capture](#hands-free-capture-voice-activity-detection)).
292
292
 
293
293
  `parseWavPcm16` is strict because it reads untrusted bytes. It rejects, with `WavFormatError`, anything that is not a well-formed RIFF/WAVE with `fmt ` before `data`, format tag 1 (or `WAVE_FORMAT_EXTENSIBLE` whose subtype GUID is PCM), 16 bits, a consistent block align and byte rate, a non-empty data chunk that is a whole number of frames, and any declared size (RIFF, chunk or data) that exceeds the buffer. Nothing is clamped, nothing is allocated from a declared size, and work is linear in the input. It returns `{ sampleRate, channels, frames, durationMs, samples, data }`.
294
294
 
@@ -310,6 +310,34 @@ for (const track of mic.getTracks()) track.stop();
310
310
  await fetch('/transcribe', { method: 'POST', headers: { 'Content-Type': 'audio/wav' }, body: wav });
311
311
  ```
312
312
 
313
+ ### Hands-free capture (voice activity detection)
314
+
315
+ `createVadCapture` opens the microphone and cuts it into utterances on the device, with no model and no download. It compares the loudness (RMS) of 20 ms frames with a noise floor learned from the room, so a fan or hum is absorbed instead of heard as speech. Each utterance is handed over as 16 kHz mono Float32 PCM, ready for `LocalTranscriber` or a WAV upload. Audio stays in memory and the microphone is released by `stop()`.
316
+
317
+ ```typescript
318
+ import { createVadCapture } from '@happyvertical/speech/browser';
319
+ import { createLocalTranscriber } from '@happyvertical/speech/local';
320
+ import { encodeWavPcm16 } from '@happyvertical/speech/pcm';
321
+
322
+ const transcriber = createLocalTranscriber({ model: 'onnx-community/moonshine-tiny-ONNX' });
323
+ const vad = await createVadCapture({ silenceMs: 800, sensitivity: 0.5 });
324
+ vad.on('speechstart', () => showSpeaking());
325
+ vad.on('level', ({ level }) => drawMeter(level)); // 0..1
326
+ vad.on('speechend', async ({ samples, sampleRate, reason }) => {
327
+ const wav = encodeWavPcm16(samples, sampleRate);
328
+ const { text } = await transcriber.transcribe({ audio: wav, mimeType: 'audio/wav' });
329
+ insert(text);
330
+ });
331
+ // ...when the person turns it off: delivers a final 'speechend' (reason 'flush'), releases the mic
332
+ await vad.stop();
333
+ ```
334
+
335
+ Options (all optional): `silenceMs` (pause that ends an utterance, default 800), `minSpeechMs` (shorter bursts such as clicks are ignored, 150), `preRollMs` (audio kept from before the onset so the first syllable is not clipped, and the lead-in the transcriber hears, 300; it is buffered from the first frame of capture and padded with silence when speech starts sooner, because Moonshine and Whisper do better with 200 to 300 ms of lead-in), `maxUtteranceMs` (longer speech is split and the next utterance starts at once, 30000), `sensitivity` (0 needs loud close speech, 1 hears quiet speech and more noise, 0.5), `calibrateMs` (the opening window in which the noise floor is learned from a low percentile of the energies, capped so speech at t=0 never becomes the floor; speech is still detected during it, 300), `stream` (use your own `MediaStream`; you keep ownership of its tracks) and `constraints` (for `getUserMedia`). Events: `speechstart`, `speechend` (`{ samples, sampleRate, durationMs, reason: 'silence' | 'max' | 'flush' }`) and `level`. `on()` returns an unsubscribe function; a listener that throws does not stop capture. `cancel()` releases the microphone and discards an utterance in progress. `createVadCapture` rejects with `PcmCaptureError` (`unsupported`) when Web Audio or the microphone API is missing, and with the browser's own error when permission is denied. `vadCaptureSupported()` reports the first case.
336
+
337
+ **Half-duplex gating (your own playback).** Browsers do not reliably cancel `speechSynthesis` or page audio from the microphone, so a hands-free app that speaks replies will transcribe its own voice. Call `vad.suspend()` when playback starts and `vad.resume()` when it ends: while suspended, frames are dropped, an utterance in progress is discarded (no `speechend`), the noise floor does not adapt to the played audio, and the microphone stays open (no new permission prompt). After `resume()`, a guard (`resumeGuardMs`, default 350) still ignores audio so the tail and reverb of the playback are not heard. `vad.suspended` reports the gate. By default `getUserMedia` also asks for `echoCancellation`, `noiseSuppression` and `autoGainControl` (override with `constraints`) as a secondary defence.
338
+
339
+ The detector is also available without a microphone from `@happyvertical/speech/pcm`: `createVadSegmenter` takes one frame energy at a time (`frameEnergy(samples)`) and returns `start` / `end` / `split` events plus a level; `createUtteranceCollector` takes raw mono PCM and returns finished utterances with their pre-roll. Both are DOM-free, so they also run in workers and Node.
340
+
313
341
  ## On-device Transcription
314
342
 
315
343
  `type: 'local'` runs Whisper or Moonshine ONNX models on the device with [transformers.js](https://huggingface.co/docs/transformers.js). In a browser it uses WebGPU or WASM; in Node it uses onnxruntime-node. Audio never leaves the device, there is no API key or per-call cost, and once the model is cached it works offline.
@@ -342,9 +370,17 @@ const result = await transcriber.transcribe({
342
370
  result.usage; // { operation, provider: 'local', model, audioSeconds, bytes }
343
371
  ```
344
372
 
345
- You can also call `createLocalTranscriber(options)` from the subpath directly. It returns a `LocalTranscriber` with `preload()` and `dispose()`. `getAvailableSpeechAdapters()` lists `local` once the subpath has been imported. `isLocalTranscriberAvailable()` checks whether the peer can be imported.
373
+ You can also call `createLocalTranscriber(options)` from the subpath directly. It returns a `LocalTranscriber` with `preload()`, `isCached()` (are the model files already in Cache Storage, so a download is not needed?) and `dispose()`. `LocalTranscriberWorkerClient` offers the same `preload()` and `isCached()` over the worker. `getAvailableSpeechAdapters()` lists `local` once the subpath has been imported. `isLocalTranscriberAvailable()` checks whether the peer can be imported.
374
+
375
+ - **Models.** Any transformers.js `automatic-speech-recognition` model id works. The default is `onnx-community/whisper-base` (multilingual, about 80 MB at `q8`). Recommended English choices for live dictation, with approximate `q8` download sizes:
376
+
377
+ | Model id | Family | Size | Notes |
378
+ | --- | --- | --- | --- |
379
+ | `onnx-community/moonshine-tiny-ONNX` | Moonshine | ~32 MB | English, fastest, best for live use |
380
+ | `onnx-community/moonshine-base-ONNX` | Moonshine | ~67 MB | English, more accurate |
381
+ | `onnx-community/whisper-tiny.en` | Whisper | ~40 MB | English only |
346
382
 
347
- - **Models.** Any transformers.js `automatic-speech-recognition` model id works. The default is `onnx-community/whisper-base` (multilingual, about 80 MB at `q8`). Smaller and faster choices are `onnx-community/whisper-tiny.en` (English), `onnx-community/moonshine-tiny-ONNX`, and `onnx-community/moonshine-base-ONNX`. For better accuracy, use `onnx-community/whisper-small` or `onnx-community/whisper-large-v3-turbo`. The first use downloads the weights; after that they load from cache. Call `preload()` with `onProgress` to show a download bar before the user records.
383
+ Moonshine and `*.en` Whisper models are English-only: a request `language` is accepted and ignored (their decoders reject `language` and `task`). For other languages use a multilingual Whisper such as `onnx-community/whisper-base`, or for better accuracy `onnx-community/whisper-small` or `onnx-community/whisper-large-v3-turbo`. The first use downloads the weights; after that they load from cache. Call `preload()` with `onProgress` to show a download bar before the user records.
348
384
  - **Devices.** With `device: 'auto'` (the default), a browser uses WebGPU when `navigator.gpu` returns an adapter and retries on WASM if WebGPU fails to initialise; Node uses `cpu`. WebGPU is available in current Chromium-based browsers, and in Safari and Firefox depending on version and platform. Without it, WASM still works, only more slowly. An explicit device never falls back. In Node, `cuda`/`dml`/`coreml` need the matching onnxruntime-node build.
349
385
  - **Timestamps.** `timestampGranularities: ['segment']` maps Whisper chunks onto `segments`. `['word']` maps word timings onto `words`, and needs a model exported with cross attentions, such as `onnx-community/whisper-base_timestamped`. Moonshine returns text only. Audio longer than 30 s is chunked (`chunkLengthSeconds`, default 30, `0` disables chunking).
350
386
  - **Audio decoding.** Input is decoded and resampled to 16 kHz mono Float32. WAV (PCM 8/16/24/32-bit, float, extensible) and raw `audio/pcm` (`audio/pcm;rate=24000;channels=1;encoding=s16le|f32le`, or `AudioInput.sampleRate`/`channels`) decode everywhere. Browsers decode other formats with `OfflineAudioContext.decodeAudioData`: MediaRecorder `audio/webm;codecs=opus`, Safari `audio/mp4`, MP3, and so on. Node has no `AudioContext`, so compressed formats need a `decodeAudio` hook, for example with ffmpeg:
@@ -24,12 +24,19 @@ export interface AsrPipeline {
24
24
  config?: {
25
25
  model_type?: string;
26
26
  };
27
+ generation_config?: {
28
+ is_multilingual?: boolean;
29
+ };
27
30
  };
28
31
  }
29
32
  /** The parts of the transformers.js module this adapter uses. */
30
33
  export interface TransformersModule {
31
34
  pipeline: (task: 'automatic-speech-recognition', model: string, options: Record<string, unknown>) => Promise<AsrPipeline>;
32
35
  env?: Record<string, unknown>;
36
+ /** transformers.js v4 cache inspection; absent on older builds. */
37
+ ModelRegistry?: {
38
+ is_pipeline_cached(task: 'automatic-speech-recognition', model: string, options?: Record<string, unknown>): Promise<boolean>;
39
+ };
33
40
  InterruptableStoppingCriteria?: new () => {
34
41
  interrupt(): void;
35
42
  };
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/runtime.ts"],"names":[],"mappings":"AACA;;;;;;GAMG;AAGH,OAAO,KAAK,EACV,sBAAsB,EACtB,qBAAqB,EACrB,gCAAgC,EAChC,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,oBAAoB,8BAA8B,CAAC;AAEhE,eAAO,MAAM,yBAAyB,QAE4C,CAAC;AAEnF,qDAAqD;AACrD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAA;KAAE,CAAC,CAAC;CACtE;AAED,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC1B,CACE,KAAK,EAAE,YAAY,EACnB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,SAAS,GAAG,SAAS,EAAE,CAAC,CAAC;IACpC,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,KAAK,CAAC,EAAE;QAAE,MAAM,CAAC,EAAE;YAAE,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,CAAA;KAAE,CAAC;CAC9C;AAED,iEAAiE;AACjE,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,CACR,IAAI,EAAE,8BAA8B,EACpC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,OAAO,CAAC,WAAW,CAAC,CAAC;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,6BAA6B,CAAC,EAAE,UAAU;QAAE,SAAS,IAAI,IAAI,CAAA;KAAE,CAAC;CACjE;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,wBAAwB,GAAG,SAAS,GAC5C,OAAO,CAAC,kBAAkB,CAAC,CAmC7B;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,IAAI,OAAO,CAAC,OAAO,CAAC,CAErD;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,sBAAsB,CAAC;IAC/B,KAAK,CAAC,EAAE,qBAAqB,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,gCAAgC,CAAC;CAC/C;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,YAAY,EAAE,kBAAkB,EAChC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC;IAAE,QAAQ,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAoBpD;AAED,iGAAiG;AACjG,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC,CAoBxD;AAED,wBAAgB,aAAa,IAAI,OAAO,CAKvC"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/runtime.ts"],"names":[],"mappings":"AACA;;;;;;GAMG;AAGH,OAAO,KAAK,EACV,sBAAsB,EACtB,qBAAqB,EACrB,gCAAgC,EAChC,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAEpB,eAAO,MAAM,oBAAoB,8BAA8B,CAAC;AAEhE,eAAO,MAAM,yBAAyB,QAE4C,CAAC;AAEnF,qDAAqD;AACrD,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC,CAAA;KAAE,CAAC,CAAC;CACtE;AAED,4EAA4E;AAC5E,MAAM,WAAW,WAAW;IAC1B,CACE,KAAK,EAAE,YAAY,EACnB,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,SAAS,GAAG,SAAS,EAAE,CAAC,CAAC;IACpC,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9B,KAAK,CAAC,EAAE;QACN,MAAM,CAAC,EAAE;YAAE,UAAU,CAAC,EAAE,MAAM,CAAA;SAAE,CAAC;QACjC,iBAAiB,CAAC,EAAE;YAAE,eAAe,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC;KACnD,CAAC;CACH;AAED,iEAAiE;AACjE,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,CACR,IAAI,EAAE,8BAA8B,EACpC,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC7B,OAAO,CAAC,WAAW,CAAC,CAAC;IAC1B,GAAG,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,mEAAmE;IACnE,aAAa,CAAC,EAAE;QACd,kBAAkB,CAChB,IAAI,EAAE,8BAA8B,EACpC,KAAK,EAAE,MAAM,EACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,OAAO,CAAC,OAAO,CAAC,CAAC;KACrB,CAAC;IACF,6BAA6B,CAAC,EAAE,UAAU;QAAE,SAAS,IAAI,IAAI,CAAA;KAAE,CAAC;CACjE;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,wBAAwB,GAAG,SAAS,GAC5C,OAAO,CAAC,kBAAkB,CAAC,CAmC7B;AAED,wEAAwE;AACxE,wBAAgB,kBAAkB,IAAI,OAAO,CAAC,OAAO,CAAC,CAErD;AAED,MAAM,WAAW,qBAAqB;IACpC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,sBAAsB,CAAC;IAC/B,KAAK,CAAC,EAAE,qBAAqB,CAAC;IAC9B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,UAAU,CAAC,EAAE,gCAAgC,CAAC;CAC/C;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,YAAY,EAAE,kBAAkB,EAChC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC;IAAE,QAAQ,EAAE,WAAW,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAoBpD;AAED,iGAAiG;AACjG,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,CAAC,CAoBxD;AAED,wBAAgB,aAAa,IAAI,OAAO,CAKvC"}
@@ -24,6 +24,12 @@ export declare class LocalTranscriber implements Transcriber {
24
24
  * `transcribe()` is fast. Progress goes to `onProgress`.
25
25
  */
26
26
  preload(model?: string, signal?: AbortSignal): Promise<void>;
27
+ /**
28
+ * Whether every file `model` needs is already in the model cache (Cache
29
+ * Storage in browsers), so a `preload()` would download nothing. Resolves
30
+ * `false` when the runtime cannot tell. Does not load the model.
31
+ */
32
+ isCached(model?: string): Promise<boolean>;
27
33
  transcribe(request: TranscriptionRequest): Promise<TranscriptResult>;
28
34
  /** Waits for in-flight inference, then releases every loaded model session. */
29
35
  dispose(): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"transcriber.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/transcriber.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAGV,WAAW,EACX,oBAAoB,EACpB,gBAAgB,EAGjB,MAAM,uBAAuB,CAAC;AAQ/B,OAAO,EACL,KAAK,SAAS,EAKf,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,0EAA0E;AAC1E,eAAO,MAAM,mBAAmB,gCAAgC,CAAC;AAWjE;;;;GAIG;AACH,qBAAa,gBAAiB,YAAW,WAAW;IAClD,QAAQ,CAAC,IAAI,EAAG,OAAO,CAAU;IAEjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAsB;IAC/C,OAAO,CAAC,OAAO,CAAC,CAA8B;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA8C;IACxE,sFAAsF;IACtF,OAAO,CAAC,KAAK,CAAuC;gBAExC,OAAO,GAAE,uBAA4B;IAOjD;;;OAGG;IACG,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAI5D,UAAU,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAwD1E,+EAA+E;IACzE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAYhB,GAAG;IAgEjB,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,WAAW;IAoBnB,OAAO,CAAC,YAAY;CA+BrB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,EACjB,SAAS,EAAE,OAAO,GACjB,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,CA6BvD"}
1
+ {"version":3,"file":"transcriber.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/transcriber.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAGV,WAAW,EACX,oBAAoB,EACpB,gBAAgB,EAGjB,MAAM,uBAAuB,CAAC;AAQ/B,OAAO,EACL,KAAK,SAAS,EAMf,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,0EAA0E;AAC1E,eAAO,MAAM,mBAAmB,gCAAgC,CAAC;AAWjE;;;;GAIG;AACH,qBAAa,gBAAiB,YAAW,WAAW;IAClD,QAAQ,CAAC,IAAI,EAAG,OAAO,CAAU;IAEjC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAClD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAsB;IAC/C,OAAO,CAAC,OAAO,CAAC,CAA8B;IAC9C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA8C;IACxE,sFAAsF;IACtF,OAAO,CAAC,KAAK,CAAuC;gBAExC,OAAO,GAAE,uBAA4B;IAOjD;;;OAGG;IACG,OAAO,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlE;;;;OAIG;IACG,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IA2B1C,UAAU,CAAC,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAwD1E,+EAA+E;IACzE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC;YAYhB,GAAG;IAkEjB,OAAO,CAAC,OAAO;IAMf,OAAO,CAAC,WAAW;IAoBnB,OAAO,CAAC,YAAY;CA+BrB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,SAAS,EACjB,SAAS,EAAE,OAAO,GACjB,IAAI,CAAC,gBAAgB,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,CA6BvD"}
@@ -43,6 +43,10 @@ export type LocalWorkerRequest = {
43
43
  kind: 'preload';
44
44
  id: number;
45
45
  model?: string;
46
+ } | {
47
+ kind: 'isCached';
48
+ id: number;
49
+ model?: string;
46
50
  } | {
47
51
  kind: 'abort';
48
52
  id: number;
@@ -52,6 +56,8 @@ export type LocalWorkerResponse = {
52
56
  kind: 'result';
53
57
  id: number;
54
58
  result?: TranscriptResult;
59
+ /** Answer to an `isCached` request. */
60
+ cached?: boolean;
55
61
  } | {
56
62
  kind: 'error';
57
63
  id: number;
@@ -111,6 +117,8 @@ export declare class LocalTranscriberWorkerClient implements Transcriber {
111
117
  private readonly closing;
112
118
  private readonly listener;
113
119
  constructor(endpoint: LocalWorkerEndpoint, options?: LocalTranscriberWorkerClientOptions);
120
+ /** Whether the worker finds every file for `model` in the model cache. */
121
+ isCached(model?: string): Promise<boolean>;
114
122
  /** Loads the model in the worker ahead of the first transcription. */
115
123
  preload(model?: string, signal?: AbortSignal): Promise<void>;
116
124
  transcribe(request: TranscriptionRequest): Promise<TranscriptResult>;
@@ -1 +1 @@
1
- {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/worker.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"worker.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/worker.ts"],"names":[],"mappings":"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"}
package/dist/browser.d.ts CHANGED
@@ -5,4 +5,5 @@
5
5
  */
6
6
  export { PcmCaptureError, type PcmCaptureErrorReason, } from './shared/errors.js';
7
7
  export { createPcmCapture, type PcmCapture, type PcmCaptureOptions, type PcmCaptureResult, pcmCaptureSupported, } from './shared/pcm-capture.js';
8
+ export { createVadCapture, VAD_CAPTURE_SAMPLE_RATE, type VadCapture, type VadCaptureEventMap, type VadCaptureEventType, type VadCaptureOptions, type VadEndReason, type VadOptions, vadCaptureSupported, } from './shared/vad-capture.js';
8
9
  //# sourceMappingURL=browser.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,eAAe,EACf,KAAK,qBAAqB,GAC3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,mBAAmB,GACpB,MAAM,yBAAyB,CAAC"}
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,eAAe,EACf,KAAK,qBAAqB,GAC3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,mBAAmB,GACpB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,mBAAmB,GACpB,MAAM,yBAAyB,CAAC"}
package/dist/browser.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { n as PcmCaptureError } from "./chunks/errors-Cs0vX-mV.js";
2
- import { o as resampleMono, t as encodeWavPcm16 } from "./chunks/wav-B0Djpj2r.js";
2
+ import { f as resampleMono, s as createUtteranceCollector, t as encodeWavPcm16 } from "./chunks/wav-vzrhhZbu.js";
3
3
  //#region src/shared/pcm-capture.ts
4
4
  /**
5
5
  * Browser PCM capture: `MediaStream` -> mono Float32 at a requested rate and a
@@ -59,6 +59,8 @@ function createPcmCapture(stream, options) {
59
59
  const onFrame = (event) => {
60
60
  if (finished) return;
61
61
  let frame = event.data;
62
+ options.onSamples?.(frame, context.sampleRate);
63
+ if (options.retain === false) return;
62
64
  const room = capSamples - captured;
63
65
  if (frame.length > room) {
64
66
  frame = frame.subarray(0, Math.max(0, room));
@@ -120,6 +122,7 @@ function createPcmCapture(stream, options) {
120
122
  ready.catch(() => void 0);
121
123
  let result;
122
124
  return {
125
+ ready,
123
126
  stop() {
124
127
  result ??= (async () => {
125
128
  try {
@@ -154,6 +157,142 @@ function createPcmCapture(stream, options) {
154
157
  };
155
158
  }
156
159
  //#endregion
157
- export { PcmCaptureError, createPcmCapture, pcmCaptureSupported };
160
+ //#region src/shared/vad-capture.ts
161
+ /**
162
+ * Hands-free microphone capture: opens the microphone, runs its audio through
163
+ * the on-device voice activity detector (`vad.ts`) and emits one finished
164
+ * utterance at a time as 16 kHz mono Float32 PCM. Audio stays in memory; the
165
+ * microphone is released by `stop()`. Nothing here touches Node APIs; the Web
166
+ * Audio globals are read when `createVadCapture` is called, so importing this
167
+ * module is SSR-safe.
168
+ */
169
+ /** Rate of the PCM in `speechend` events. */
170
+ var VAD_CAPTURE_SAMPLE_RATE = 16e3;
171
+ /** Whether this runtime can open a microphone and tap it with Web Audio. */
172
+ function vadCaptureSupported() {
173
+ return pcmCaptureSupported() && typeof navigator !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function";
174
+ }
175
+ /** One hour; with `retain: false` this is never reached, it only satisfies validation. */
176
+ var UNBOUNDED_MS = 36e5;
177
+ /**
178
+ * Opens the microphone (or uses `options.stream`) and starts detecting speech.
179
+ * Rejects with `PcmCaptureError` (`unsupported`) when Web Audio or the
180
+ * microphone API is missing, and with the browser's own error when permission
181
+ * is denied.
182
+ */
183
+ async function createVadCapture(options = {}) {
184
+ const makeCapture = options.createCapture ?? createPcmCapture;
185
+ if (!options.createCapture && !vadCaptureSupported() && !options.stream) throw new PcmCaptureError("unsupported", "Hands-free capture needs the microphone API and Web Audio");
186
+ const ownsStream = !options.stream;
187
+ const stream = options.stream ?? await navigator.mediaDevices.getUserMedia(options.constraints ?? { audio: {
188
+ echoCancellation: true,
189
+ noiseSuppression: true,
190
+ autoGainControl: true
191
+ } });
192
+ const listeners = {
193
+ speechstart: /* @__PURE__ */ new Set(),
194
+ speechend: /* @__PURE__ */ new Set(),
195
+ level: /* @__PURE__ */ new Set()
196
+ };
197
+ const emit = (type, event) => {
198
+ for (const listener of [...listeners[type]]) try {
199
+ listener(event);
200
+ } catch {}
201
+ };
202
+ let collector;
203
+ let closed = false;
204
+ let suspended = false;
205
+ let guardMsLeft = 0;
206
+ const resumeGuardMs = Math.max(0, options.resumeGuardMs ?? 350);
207
+ const releaseStream = () => {
208
+ if (!ownsStream) return;
209
+ for (const track of stream.getTracks()) track.stop();
210
+ };
211
+ let capture;
212
+ try {
213
+ capture = makeCapture(stream, {
214
+ sampleRate: VAD_CAPTURE_SAMPLE_RATE,
215
+ maxDurationMs: UNBOUNDED_MS,
216
+ retain: false,
217
+ onSamples: (samples, contextRate) => {
218
+ if (closed) return;
219
+ if (suspended) return;
220
+ if (guardMsLeft > 0) {
221
+ guardMsLeft -= samples.length / contextRate * 1e3;
222
+ return;
223
+ }
224
+ collector ??= createUtteranceCollector({
225
+ ...options,
226
+ sampleRate: contextRate,
227
+ onSpeaking: (speaking) => {
228
+ if (speaking) emit("speechstart", {});
229
+ },
230
+ onLevel: (level) => emit("level", { level }),
231
+ onUtterance: (utterance) => {
232
+ const pcm = resampleMono(utterance.pcm, utterance.sampleRate, VAD_CAPTURE_SAMPLE_RATE);
233
+ emit("speechend", {
234
+ samples: pcm,
235
+ sampleRate: VAD_CAPTURE_SAMPLE_RATE,
236
+ durationMs: utterance.durationMs,
237
+ reason: utterance.reason
238
+ });
239
+ }
240
+ });
241
+ collector.push(samples);
242
+ }
243
+ });
244
+ } catch (error) {
245
+ releaseStream();
246
+ throw error;
247
+ }
248
+ if (capture.ready) try {
249
+ await capture.ready;
250
+ } catch (error) {
251
+ closed = true;
252
+ capture.cancel();
253
+ releaseStream();
254
+ throw new PcmCaptureError("setup_failed", "Hands-free capture could not start", error);
255
+ }
256
+ const finish = async (flush) => {
257
+ if (closed) return;
258
+ if (flush) collector?.flush();
259
+ closed = true;
260
+ capture.cancel();
261
+ releaseStream();
262
+ };
263
+ return {
264
+ on(type, listener) {
265
+ const set = listeners[type];
266
+ set.add(listener);
267
+ return () => {
268
+ set.delete(listener);
269
+ };
270
+ },
271
+ get speaking() {
272
+ return collector?.speaking ?? false;
273
+ },
274
+ get suspended() {
275
+ return suspended;
276
+ },
277
+ suspend() {
278
+ if (closed || suspended) return;
279
+ suspended = true;
280
+ guardMsLeft = 0;
281
+ collector?.discard();
282
+ emit("level", { level: 0 });
283
+ },
284
+ resume() {
285
+ if (closed || !suspended) return;
286
+ suspended = false;
287
+ guardMsLeft = resumeGuardMs;
288
+ },
289
+ stop: () => finish(true),
290
+ cancel: () => {
291
+ finish(false);
292
+ }
293
+ };
294
+ }
295
+ //#endregion
296
+ export { PcmCaptureError, VAD_CAPTURE_SAMPLE_RATE, createPcmCapture, createVadCapture, pcmCaptureSupported, vadCaptureSupported };
158
297
 
159
298
  //# sourceMappingURL=browser.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"browser.js","names":[],"sources":["../src/shared/pcm-capture.ts"],"sourcesContent":["/**\n * Browser PCM capture: `MediaStream` -> mono Float32 at a requested rate and a\n * ready 16-bit PCM WAV, via `AudioContext` + `AudioWorklet`. The worklet code\n * is inlined and loaded from a Blob URL, so no bundler configuration is\n * needed. Nothing here touches Node APIs; the Web Audio globals are read when\n * `createPcmCapture` is called, so importing this module is SSR-safe.\n */\n\nimport { PcmCaptureError } from './errors.js';\nimport { resampleMono } from './resample.js';\nimport { encodeWavPcm16 } from './wav.js';\n\nconst PROCESSOR_NAME = 'hv-pcm-tap';\n\n// Mixes every input channel to mono and posts one transferable block per\n// render quantum. Returns true to stay alive until the node is disconnected.\nconst WORKLET_SOURCE = `\nclass HvPcmTap extends AudioWorkletProcessor {\n process(inputs) {\n const channels = inputs[0];\n if (channels && channels.length > 0 && channels[0].length > 0) {\n const frames = channels[0].length;\n const mono = new Float32Array(frames);\n for (let c = 0; c < channels.length; c += 1) {\n const data = channels[c];\n for (let i = 0; i < frames; i += 1) mono[i] += data[i];\n }\n if (channels.length > 1) {\n for (let i = 0; i < frames; i += 1) mono[i] /= channels.length;\n }\n this.port.postMessage(mono, [mono.buffer]);\n }\n return true;\n }\n}\nregisterProcessor('${PROCESSOR_NAME}', HvPcmTap);\n`;\n\nexport interface PcmCaptureOptions {\n /** Rate of the returned samples and WAV, in Hz (e.g. 16000 for Voxtral). */\n sampleRate: number;\n /** Audio beyond this duration is dropped, not stored. */\n maxDurationMs: number;\n /** Called once, when the cap is reached; the capture keeps running until stopped. */\n onLimit?: () => void;\n}\n\nexport interface PcmCaptureResult {\n /** Mono samples at `sampleRate`, -1..1. */\n samples: Float32Array;\n /** The same audio as a 16-bit PCM mono WAV. */\n wav: Uint8Array;\n /** True when audio past `maxDurationMs` was dropped. */\n truncated: boolean;\n}\n\nexport interface PcmCapture {\n /** Ends capture, tears everything down, and resolves with the audio. Idempotent. */\n stop(): Promise<PcmCaptureResult>;\n /** Discards the audio and tears everything down. Idempotent; a pending `stop()` rejects. */\n cancel(): void;\n}\n\n/** Whether this runtime has the Web Audio pieces `createPcmCapture` needs. */\nexport function pcmCaptureSupported(): boolean {\n return (\n typeof AudioContext !== 'undefined' &&\n typeof AudioWorkletNode !== 'undefined' &&\n typeof Blob !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function'\n );\n}\n\n/**\n * Starts capturing `stream`. Throws `PcmCaptureError` (`unsupported`) when Web\n * Audio is unavailable. The caller keeps ownership of the stream and its\n * tracks: stop them yourself after `stop()` or `cancel()`.\n */\nexport function createPcmCapture(\n stream: MediaStream,\n options: PcmCaptureOptions,\n): PcmCapture {\n const { sampleRate, maxDurationMs } = options;\n if (!Number.isInteger(sampleRate) || sampleRate < 1) {\n throw new RangeError('sampleRate must be a positive integer');\n }\n if (!Number.isFinite(maxDurationMs) || maxDurationMs <= 0) {\n throw new RangeError('maxDurationMs must be a positive number');\n }\n if (!pcmCaptureSupported()) {\n throw new PcmCaptureError(\n 'unsupported',\n 'PCM capture needs AudioContext, AudioWorkletNode and Blob URLs',\n );\n }\n\n const context = new AudioContext();\n const capSamples = Math.ceil((maxDurationMs / 1000) * context.sampleRate);\n const frames: Float32Array[] = [];\n let captured = 0;\n let truncated = false;\n let limitFired = false;\n let cancelled = false;\n let finished = false;\n let url: string | undefined;\n let source: MediaStreamAudioSourceNode | undefined;\n let node: AudioWorkletNode | undefined;\n let closing: Promise<void> | undefined;\n\n const onFrame = (event: MessageEvent<Float32Array>): void => {\n if (finished) return;\n let frame = event.data;\n const room = capSamples - captured;\n if (frame.length > room) {\n frame = frame.subarray(0, Math.max(0, room));\n truncated = true;\n }\n if (frame.length > 0) {\n frames.push(frame);\n captured += frame.length;\n }\n if (captured >= capSamples && !limitFired) {\n limitFired = true;\n options.onLimit?.();\n }\n };\n\n const revoke = (): void => {\n if (url !== undefined) {\n const stale = url;\n url = undefined;\n try {\n URL.revokeObjectURL(stale);\n } catch {\n // already revoked\n }\n }\n };\n\n const teardown = (): Promise<void> => {\n if (closing) return closing;\n finished = true;\n revoke();\n if (node) node.port.onmessage = null;\n try {\n source?.disconnect();\n node?.disconnect();\n } catch {\n // graph already torn down\n }\n closing = (async () => {\n try {\n if (context.state !== 'closed') await context.close();\n } catch {\n // closing a dead context is not worth reporting\n }\n })();\n return closing;\n };\n\n const ready = (async (): Promise<void> => {\n url = URL.createObjectURL(\n new Blob([WORKLET_SOURCE], { type: 'text/javascript' }),\n );\n try {\n await context.audioWorklet.addModule(url);\n } finally {\n revoke();\n }\n if (finished) return;\n source = context.createMediaStreamSource(stream);\n node = new AudioWorkletNode(context, PROCESSOR_NAME, {\n numberOfInputs: 1,\n numberOfOutputs: 1,\n outputChannelCount: [1],\n });\n node.port.onmessage = onFrame;\n source.connect(node);\n // A worklet only runs while connected to the destination; it emits silence.\n node.connect(context.destination);\n if (context.state === 'suspended') await context.resume();\n })();\n // Failure is reported by stop(); never an unhandled rejection.\n ready.catch(() => undefined);\n\n let result: Promise<PcmCaptureResult> | undefined;\n\n return {\n stop(): Promise<PcmCaptureResult> {\n result ??= (async () => {\n try {\n await ready;\n } catch (error) {\n await teardown();\n throw new PcmCaptureError(\n 'setup_failed',\n 'PCM capture could not start',\n error,\n );\n }\n await teardown();\n if (cancelled) {\n throw new PcmCaptureError('cancelled', 'PCM capture was cancelled');\n }\n const joined = new Float32Array(captured);\n let offset = 0;\n for (const frame of frames.splice(0)) {\n joined.set(frame, offset);\n offset += frame.length;\n }\n const samples = resampleMono(joined, context.sampleRate, sampleRate);\n return { samples, wav: encodeWavPcm16(samples, sampleRate), truncated };\n })();\n return result;\n },\n cancel(): void {\n if (cancelled) return;\n cancelled = true;\n frames.length = 0;\n void teardown();\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"browser.js","names":[],"sources":["../src/shared/pcm-capture.ts","../src/shared/vad-capture.ts"],"sourcesContent":["/**\n * Browser PCM capture: `MediaStream` -> mono Float32 at a requested rate and a\n * ready 16-bit PCM WAV, via `AudioContext` + `AudioWorklet`. The worklet code\n * is inlined and loaded from a Blob URL, so no bundler configuration is\n * needed. Nothing here touches Node APIs; the Web Audio globals are read when\n * `createPcmCapture` is called, so importing this module is SSR-safe.\n */\n\nimport { PcmCaptureError } from './errors.js';\nimport { resampleMono } from './resample.js';\nimport { encodeWavPcm16 } from './wav.js';\n\nconst PROCESSOR_NAME = 'hv-pcm-tap';\n\n// Mixes every input channel to mono and posts one transferable block per\n// render quantum. Returns true to stay alive until the node is disconnected.\nconst WORKLET_SOURCE = `\nclass HvPcmTap extends AudioWorkletProcessor {\n process(inputs) {\n const channels = inputs[0];\n if (channels && channels.length > 0 && channels[0].length > 0) {\n const frames = channels[0].length;\n const mono = new Float32Array(frames);\n for (let c = 0; c < channels.length; c += 1) {\n const data = channels[c];\n for (let i = 0; i < frames; i += 1) mono[i] += data[i];\n }\n if (channels.length > 1) {\n for (let i = 0; i < frames; i += 1) mono[i] /= channels.length;\n }\n this.port.postMessage(mono, [mono.buffer]);\n }\n return true;\n }\n}\nregisterProcessor('${PROCESSOR_NAME}', HvPcmTap);\n`;\n\nexport interface PcmCaptureOptions {\n /** Rate of the returned samples and WAV, in Hz (e.g. 16000 for Voxtral). */\n sampleRate: number;\n /** Audio beyond this duration is dropped, not stored. */\n maxDurationMs: number;\n /** Called once, when the cap is reached; the capture keeps running until stopped. */\n onLimit?: () => void;\n /**\n * Streams every mono block as it arrives, at the audio context's own rate\n * (`contextSampleRate`), before any cap or resampling. The block is yours to\n * keep. Used by `createVadCapture` for live analysis.\n */\n onSamples?: (samples: Float32Array, contextSampleRate: number) => void;\n /**\n * Keep the audio for `stop()`. Default true. `false` only streams to\n * `onSamples`: memory stays flat for open-ended listening, `maxDurationMs`\n * never trims, and `stop()` resolves with empty samples.\n */\n retain?: boolean;\n}\n\nexport interface PcmCaptureResult {\n /** Mono samples at `sampleRate`, -1..1. */\n samples: Float32Array;\n /** The same audio as a 16-bit PCM mono WAV. */\n wav: Uint8Array;\n /** True when audio past `maxDurationMs` was dropped. */\n truncated: boolean;\n}\n\nexport interface PcmCapture {\n /** Settles once the audio graph is running; rejects if setup failed. Optional for custom capture factories. */\n readonly ready?: Promise<void>;\n /** Ends capture, tears everything down, and resolves with the audio. Idempotent. */\n stop(): Promise<PcmCaptureResult>;\n /** Discards the audio and tears everything down. Idempotent; a pending `stop()` rejects. */\n cancel(): void;\n}\n\n/** Whether this runtime has the Web Audio pieces `createPcmCapture` needs. */\nexport function pcmCaptureSupported(): boolean {\n return (\n typeof AudioContext !== 'undefined' &&\n typeof AudioWorkletNode !== 'undefined' &&\n typeof Blob !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function'\n );\n}\n\n/**\n * Starts capturing `stream`. Throws `PcmCaptureError` (`unsupported`) when Web\n * Audio is unavailable. The caller keeps ownership of the stream and its\n * tracks: stop them yourself after `stop()` or `cancel()`.\n */\nexport function createPcmCapture(\n stream: MediaStream,\n options: PcmCaptureOptions,\n): PcmCapture {\n const { sampleRate, maxDurationMs } = options;\n if (!Number.isInteger(sampleRate) || sampleRate < 1) {\n throw new RangeError('sampleRate must be a positive integer');\n }\n if (!Number.isFinite(maxDurationMs) || maxDurationMs <= 0) {\n throw new RangeError('maxDurationMs must be a positive number');\n }\n if (!pcmCaptureSupported()) {\n throw new PcmCaptureError(\n 'unsupported',\n 'PCM capture needs AudioContext, AudioWorkletNode and Blob URLs',\n );\n }\n\n const context = new AudioContext();\n const capSamples = Math.ceil((maxDurationMs / 1000) * context.sampleRate);\n const frames: Float32Array[] = [];\n let captured = 0;\n let truncated = false;\n let limitFired = false;\n let cancelled = false;\n let finished = false;\n let url: string | undefined;\n let source: MediaStreamAudioSourceNode | undefined;\n let node: AudioWorkletNode | undefined;\n let closing: Promise<void> | undefined;\n\n const onFrame = (event: MessageEvent<Float32Array>): void => {\n if (finished) return;\n let frame = event.data;\n options.onSamples?.(frame, context.sampleRate);\n if (options.retain === false) return;\n const room = capSamples - captured;\n if (frame.length > room) {\n frame = frame.subarray(0, Math.max(0, room));\n truncated = true;\n }\n if (frame.length > 0) {\n frames.push(frame);\n captured += frame.length;\n }\n if (captured >= capSamples && !limitFired) {\n limitFired = true;\n options.onLimit?.();\n }\n };\n\n const revoke = (): void => {\n if (url !== undefined) {\n const stale = url;\n url = undefined;\n try {\n URL.revokeObjectURL(stale);\n } catch {\n // already revoked\n }\n }\n };\n\n const teardown = (): Promise<void> => {\n if (closing) return closing;\n finished = true;\n revoke();\n if (node) node.port.onmessage = null;\n try {\n source?.disconnect();\n node?.disconnect();\n } catch {\n // graph already torn down\n }\n closing = (async () => {\n try {\n if (context.state !== 'closed') await context.close();\n } catch {\n // closing a dead context is not worth reporting\n }\n })();\n return closing;\n };\n\n const ready = (async (): Promise<void> => {\n url = URL.createObjectURL(\n new Blob([WORKLET_SOURCE], { type: 'text/javascript' }),\n );\n try {\n await context.audioWorklet.addModule(url);\n } finally {\n revoke();\n }\n if (finished) return;\n source = context.createMediaStreamSource(stream);\n node = new AudioWorkletNode(context, PROCESSOR_NAME, {\n numberOfInputs: 1,\n numberOfOutputs: 1,\n outputChannelCount: [1],\n });\n node.port.onmessage = onFrame;\n source.connect(node);\n // A worklet only runs while connected to the destination; it emits silence.\n node.connect(context.destination);\n if (context.state === 'suspended') await context.resume();\n })();\n // Failure is reported by stop(); never an unhandled rejection.\n ready.catch(() => undefined);\n\n let result: Promise<PcmCaptureResult> | undefined;\n\n return {\n ready,\n stop(): Promise<PcmCaptureResult> {\n result ??= (async () => {\n try {\n await ready;\n } catch (error) {\n await teardown();\n throw new PcmCaptureError(\n 'setup_failed',\n 'PCM capture could not start',\n error,\n );\n }\n await teardown();\n if (cancelled) {\n throw new PcmCaptureError('cancelled', 'PCM capture was cancelled');\n }\n const joined = new Float32Array(captured);\n let offset = 0;\n for (const frame of frames.splice(0)) {\n joined.set(frame, offset);\n offset += frame.length;\n }\n const samples = resampleMono(joined, context.sampleRate, sampleRate);\n return { samples, wav: encodeWavPcm16(samples, sampleRate), truncated };\n })();\n return result;\n },\n cancel(): void {\n if (cancelled) return;\n cancelled = true;\n frames.length = 0;\n void teardown();\n },\n };\n}\n","/**\n * Hands-free microphone capture: opens the microphone, runs its audio through\n * the on-device voice activity detector (`vad.ts`) and emits one finished\n * utterance at a time as 16 kHz mono Float32 PCM. Audio stays in memory; the\n * microphone is released by `stop()`. Nothing here touches Node APIs; the Web\n * Audio globals are read when `createVadCapture` is called, so importing this\n * module is SSR-safe.\n */\n\nimport { PcmCaptureError } from './errors.js';\nimport {\n createPcmCapture,\n type PcmCapture,\n type PcmCaptureOptions,\n pcmCaptureSupported,\n} from './pcm-capture.js';\nimport { resampleMono } from './resample.js';\nimport {\n createUtteranceCollector,\n type UtteranceCollector,\n type VadEndReason,\n type VadOptions,\n} from './vad.js';\n\n/** Rate of the PCM in `speechend` events. */\nexport const VAD_CAPTURE_SAMPLE_RATE = 16_000;\n\n/** Events emitted by a `VadCapture`. */\nexport interface VadCaptureEventMap {\n /** An utterance began. */\n speechstart: Record<string, never>;\n /** An utterance ended (a pause, the length cap, or `stop()`). */\n speechend: {\n /** Mono samples, -1..1, at `sampleRate`, including the pre-roll. */\n samples: Float32Array;\n sampleRate: number;\n durationMs: number;\n reason: VadEndReason;\n };\n /** Loudness 0..1 for each analysed frame, for a meter. */\n level: { level: number };\n}\n\nexport type VadCaptureEventType = keyof VadCaptureEventMap;\n\nexport interface VadCaptureOptions extends VadOptions {\n /**\n * Use this stream instead of opening the microphone. The caller keeps\n * ownership: `stop()` does not stop its tracks.\n */\n stream?: MediaStream;\n /**\n * Constraints for the microphone when no `stream` is given. Default:\n * `{ audio: { echoCancellation, noiseSuppression, autoGainControl: true } }`,\n * a secondary defence against hearing the page's own playback. Browsers do\n * not reliably cancel `speechSynthesis`; use `suspend()` while it plays.\n */\n constraints?: MediaStreamConstraints;\n /**\n * After `resume()`, audio of this length is still ignored so the tail of the\n * playback and its room reverb are not heard as speech. Default 350.\n */\n resumeGuardMs?: number;\n /** Test seam: replaces `createPcmCapture`. */\n createCapture?: (\n stream: MediaStream,\n options: PcmCaptureOptions,\n ) => PcmCapture;\n}\n\nexport interface VadCapture {\n /** Subscribes to an event; returns the unsubscribe function. */\n on<T extends VadCaptureEventType>(\n type: T,\n listener: (event: VadCaptureEventMap[T]) => void,\n ): () => void;\n /** Currently inside an utterance. */\n readonly speaking: boolean;\n /** Half-duplex gate is closed (between `suspend()` and `resume()`). */\n readonly suspended: boolean;\n /**\n * Half-duplex gate: stops listening while the page itself is playing audio\n * (for example `speechSynthesis`). Frames are dropped, an utterance in\n * progress is discarded (no `speechend`), the noise floor does not adapt to\n * the played audio, and the microphone stays open. Idempotent.\n */\n suspend(): void;\n /**\n * Reopens the gate after `resumeGuardMs` of further audio has been ignored.\n * No-op when not suspended.\n */\n resume(): void;\n /**\n * Ends an utterance in progress (a final `speechend` with reason `flush`),\n * then releases the microphone. Idempotent.\n */\n stop(): Promise<void>;\n /** Releases the microphone and discards any utterance in progress. Idempotent. */\n cancel(): void;\n}\n\n/** Whether this runtime can open a microphone and tap it with Web Audio. */\nexport function vadCaptureSupported(): boolean {\n return (\n pcmCaptureSupported() &&\n typeof navigator !== 'undefined' &&\n typeof navigator.mediaDevices?.getUserMedia === 'function'\n );\n}\n\n/** One hour; with `retain: false` this is never reached, it only satisfies validation. */\nconst UNBOUNDED_MS = 3_600_000;\n\n/**\n * Opens the microphone (or uses `options.stream`) and starts detecting speech.\n * Rejects with `PcmCaptureError` (`unsupported`) when Web Audio or the\n * microphone API is missing, and with the browser's own error when permission\n * is denied.\n */\nexport async function createVadCapture(\n options: VadCaptureOptions = {},\n): Promise<VadCapture> {\n const makeCapture = options.createCapture ?? createPcmCapture;\n if (!options.createCapture && !vadCaptureSupported() && !options.stream) {\n throw new PcmCaptureError(\n 'unsupported',\n 'Hands-free capture needs the microphone API and Web Audio',\n );\n }\n\n const ownsStream = !options.stream;\n const stream =\n options.stream ??\n (await navigator.mediaDevices.getUserMedia(\n options.constraints ?? {\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n },\n },\n ));\n\n const listeners: {\n [T in VadCaptureEventType]: Set<(event: VadCaptureEventMap[T]) => void>;\n } = { speechstart: new Set(), speechend: new Set(), level: new Set() };\n const emit = <T extends VadCaptureEventType>(\n type: T,\n event: VadCaptureEventMap[T],\n ): void => {\n for (const listener of [...listeners[type]]) {\n try {\n listener(event);\n } catch {\n // A throwing listener must not break capture or its siblings.\n }\n }\n };\n\n let collector: UtteranceCollector | undefined;\n let closed = false;\n let suspended = false;\n // Milliseconds of audio still to drop after `resume()`.\n let guardMsLeft = 0;\n const resumeGuardMs = Math.max(0, options.resumeGuardMs ?? 350);\n const releaseStream = (): void => {\n if (!ownsStream) return;\n for (const track of stream.getTracks()) track.stop();\n };\n\n let capture: PcmCapture;\n try {\n capture = makeCapture(stream, {\n sampleRate: VAD_CAPTURE_SAMPLE_RATE,\n maxDurationMs: UNBOUNDED_MS,\n retain: false,\n onSamples: (samples, contextRate) => {\n if (closed) return;\n if (suspended) return;\n if (guardMsLeft > 0) {\n guardMsLeft -= (samples.length / contextRate) * 1000;\n return;\n }\n collector ??= createUtteranceCollector({\n ...options,\n sampleRate: contextRate,\n onSpeaking: (speaking) => {\n if (speaking) emit('speechstart', {});\n },\n onLevel: (level) => emit('level', { level }),\n onUtterance: (utterance) => {\n const pcm = resampleMono(\n utterance.pcm,\n utterance.sampleRate,\n VAD_CAPTURE_SAMPLE_RATE,\n );\n emit('speechend', {\n samples: pcm,\n sampleRate: VAD_CAPTURE_SAMPLE_RATE,\n durationMs: utterance.durationMs,\n reason: utterance.reason,\n });\n },\n });\n collector.push(samples);\n },\n });\n } catch (error) {\n releaseStream();\n throw error;\n }\n\n // A worklet that fails to load (CSP, blocked autoplay) must not look like a\n // working microphone: release the stream and surface the failure.\n if (capture.ready) {\n try {\n await capture.ready;\n } catch (error) {\n closed = true;\n capture.cancel();\n releaseStream();\n throw new PcmCaptureError(\n 'setup_failed',\n 'Hands-free capture could not start',\n error,\n );\n }\n }\n\n const finish = async (flush: boolean): Promise<void> => {\n if (closed) return;\n // Flush before `closed` so the final utterance is still delivered.\n if (flush) collector?.flush();\n closed = true;\n capture.cancel();\n releaseStream();\n };\n\n return {\n on(type, listener) {\n const set = listeners[type] as Set<typeof listener>;\n set.add(listener);\n return () => {\n set.delete(listener);\n };\n },\n get speaking() {\n return collector?.speaking ?? false;\n },\n get suspended() {\n return suspended;\n },\n suspend() {\n if (closed || suspended) return;\n suspended = true;\n guardMsLeft = 0;\n collector?.discard();\n emit('level', { level: 0 });\n },\n resume() {\n if (closed || !suspended) return;\n suspended = false;\n guardMsLeft = resumeGuardMs;\n },\n stop: () => finish(true),\n cancel: () => {\n void finish(false);\n },\n };\n}\n\nexport type { VadEndReason, VadOptions } from './vad.js';\n"],"mappings":"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"}