@happyvertical/speech 0.96.1 → 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 (75) hide show
  1. package/AGENT.md +42 -3
  2. package/README.md +335 -3
  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-transcriber.d.ts +23 -0
  22. package/dist/adapters/openai-compatible-transcriber.d.ts.map +1 -0
  23. package/dist/adapters/openai-compatible.d.ts +2 -1
  24. package/dist/adapters/openai-compatible.d.ts.map +1 -1
  25. package/dist/adapters/openai-realtime.d.ts +48 -0
  26. package/dist/adapters/openai-realtime.d.ts.map +1 -0
  27. package/dist/adapters/studio-server.d.ts.map +1 -1
  28. package/dist/adapters/voxtral-realtime.d.ts +44 -0
  29. package/dist/adapters/voxtral-realtime.d.ts.map +1 -0
  30. package/dist/chunks/registry-DfID41Ew.js +343 -0
  31. package/dist/chunks/registry-DfID41Ew.js.map +1 -0
  32. package/dist/index.d.ts +12 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +2148 -85
  35. package/dist/index.js.map +1 -1
  36. package/dist/local.d.ts +21 -0
  37. package/dist/local.d.ts.map +1 -0
  38. package/dist/local.js +963 -0
  39. package/dist/local.js.map +1 -0
  40. package/dist/shared/audio.d.ts +47 -0
  41. package/dist/shared/audio.d.ts.map +1 -0
  42. package/dist/shared/env.d.ts +75 -0
  43. package/dist/shared/env.d.ts.map +1 -0
  44. package/dist/shared/errors.d.ts +3 -0
  45. package/dist/shared/errors.d.ts.map +1 -1
  46. package/dist/shared/factory.d.ts +1 -4
  47. package/dist/shared/factory.d.ts.map +1 -1
  48. package/dist/shared/http.d.ts +29 -3
  49. package/dist/shared/http.d.ts.map +1 -1
  50. package/dist/shared/pcm.d.ts +21 -0
  51. package/dist/shared/pcm.d.ts.map +1 -0
  52. package/dist/shared/realtime-auth.d.ts +34 -0
  53. package/dist/shared/realtime-auth.d.ts.map +1 -0
  54. package/dist/shared/realtime-session.d.ts +240 -0
  55. package/dist/shared/realtime-session.d.ts.map +1 -0
  56. package/dist/shared/registry.d.ts +9 -0
  57. package/dist/shared/registry.d.ts.map +1 -0
  58. package/dist/shared/retry.d.ts +31 -0
  59. package/dist/shared/retry.d.ts.map +1 -0
  60. package/dist/shared/streaming-client-secret.d.ts +90 -0
  61. package/dist/shared/streaming-client-secret.d.ts.map +1 -0
  62. package/dist/shared/streaming-factory.d.ts +65 -0
  63. package/dist/shared/streaming-factory.d.ts.map +1 -0
  64. package/dist/shared/streaming-types.d.ts +303 -0
  65. package/dist/shared/streaming-types.d.ts.map +1 -0
  66. package/dist/shared/turn-limit.d.ts +84 -0
  67. package/dist/shared/turn-limit.d.ts.map +1 -0
  68. package/dist/shared/types.d.ts +84 -6
  69. package/dist/shared/types.d.ts.map +1 -1
  70. package/dist/shared/usage.d.ts +35 -0
  71. package/dist/shared/usage.d.ts.map +1 -0
  72. package/dist/shared/websocket.d.ts +56 -0
  73. package/dist/shared/websocket.d.ts.map +1 -0
  74. package/metadata.json +7 -1
  75. package/package.json +19 -3
package/AGENT.md CHANGED
@@ -27,8 +27,8 @@ pnpm --filter @happyvertical/speech clean
27
27
 
28
28
  ## Ecosystem Relationships
29
29
  - Provides: Speech provider abstraction for STT and TTS backends
30
- - Implements: Studio Server STT, Studio Server TTS, Qwen3 TTS, OpenAI-compatible TTS
31
- - Requires: none
30
+ - Implements: Studio Server STT, OpenAI-compatible STT, OpenAI Realtime streaming STT, Voxtral Realtime streaming STT, On-device STT, Studio Server TTS, Qwen3 TTS, OpenAI-compatible TTS
31
+ - Requires: @huggingface/transformers
32
32
  - Stability: experimental (Marked as preview or experimental in package guidance.)
33
33
  <!-- END AGENT:GENERATED -->
34
34
 
@@ -46,6 +46,45 @@ Adapter constructors are internal implementation details. Keep new backends behi
46
46
  ## Adapters
47
47
 
48
48
  - Studio Server STT (`type: 'studio-server'`) posts multipart audio to `/v1/transcribe`.
49
+ - OpenAI-compatible STT (`type: 'openai-compatible'`) posts multipart `file`/`model` to `<base>/audio/transcriptions`.
50
+ - OpenAI Realtime streaming STT (`type: 'openai-realtime'`) streams raw PCM16/G.711 over a WebSocket to `<base>/realtime?intent=transcription` via `getStreamingTranscriber()`; `getTranscriber()` wraps it for record-then-send callers.
51
+ - Voxtral Realtime streaming STT (`type: 'voxtral-realtime'`, served by vLLM) streams PCM16 16 kHz mono to `<base>/realtime` with vLLM's protocol: a top-level `session.update` model, a non-final commit to start each turn, a `final: true` commit to end it, `transcription.delta`/`done` events, manual turns only (plus automatic rollover before `maxTurnSeconds`, default 270), and a required `baseUrl`.
52
+ - On-device STT (`type: 'local'`) runs Whisper/Moonshine ONNX models with transformers.js behind the `@happyvertical/speech/local` subpath; importing that subpath registers the type with the factory.
49
53
  - Studio Server TTS (`type: 'studio-server'`) posts multipart form data to `/v1/tts/synthesize`.
50
54
  - Qwen3 TTS (`type: 'qwen3-tts'`) posts multipart form data to `/v1/audio/speech`.
51
- - OpenAI-compatible TTS (`type: 'openai-compatible'`) posts OpenAI-shaped JSON to `/v1/audio/speech`.
55
+ - OpenAI-compatible TTS (`type: 'openai-compatible'`) posts OpenAI-shaped JSON to `<base>/audio/speech`, resolved like the transcriber (`resolveOpenAICompatibleUrl`: server root → `/v1/audio/speech`, `/vN` root → `/audio/speech`, full endpoint as-is; an explicit `speechPath` resolves relative to `baseUrl` instead).
56
+
57
+ ## Shared Building Blocks
58
+
59
+ New adapters (including streaming/realtime transcribers) should reuse these modules in `src/shared/` instead of re-implementing them:
60
+
61
+ - `usage.ts`: `SpeechUsage`, `SpeechUsageCallback`, `reportSpeechUsage()` (adapter `onUsage` first, then request `onUsage`; callback errors propagate), and `audioSecondsFromProviderUsage()`.
62
+ - `env.ts`: `resolveTranscriberConfig(options, context)` reads `HAVE_SPEECH_TRANSCRIBER_*` (then legacy `HAVE_SPEECH_STT_*`/`STT_*`) with explicit options winning; headers merge env → context → options via `mergeHeaderInits()`.
63
+ - `audio.ts`: `normalizeAudioInput()` accepts `AudioInput` or a bare Blob/Buffer/Uint8Array/ArrayBuffer/ReadableStream, enforces `maxBytes` while buffering, and derives the multipart filename through `mimeTypeToAudioExtension()`.
64
+ - `retry.ts`: `withSpeechRetry()` retries 429/5xx `SpeechProviderError`s with exponential backoff, honours `Retry-After` (`retryAfterMs`), and stops on abort.
65
+ - `http.ts`: `HttpSpeechAdapter.post(..., retry)` sends auth/extra headers (`headers` option plus per-request `headers`), applies `timeoutMs` per attempt, and redacts the API key from provider error bodies; `resolveOpenAICompatibleUrl()` normalises OpenAI-style base URLs.
66
+
67
+ - `registry.ts`: `registerOptionalTranscriber()` lets subpath entries add opt-in transcriber types without the core entry importing them. `getTranscriber()` throws `SpeechConfigurationError` naming the subpath when such a type is requested before registration.
68
+
69
+ The OpenAI-compatible transcriber holds an API key and is server-side only.
70
+
71
+ ## Streaming (Realtime) Transcribers
72
+
73
+ - Contract: `src/shared/streaming-types.ts` (`StreamingTranscriber.start()` → `StreamingSession` with `write()`, `commit()`, `end()`, `abort()`, `on()`). Keep it additive; `Transcriber` stays request/response.
74
+ - `src/shared/realtime-session.ts`: `RealtimeTranscriptionSession` owns the socket, ordered write queue, backpressure (`write()` resolves after the socket drains to `highWaterMark`; `maxBufferedBytes` rejects), connect/final timeouts, turn bookkeeping, events, the joined `TranscriptResult`, and usage. A mid-session socket drop fails the session; there is no reconnect.
75
+ - A new realtime adapter implements `RealtimeProtocol` (`sessionMessages`, `appendMessage`, `commitMessage`, `parse` → `RealtimeProtocolEvent[]`, plus `commitAck`, `endCommit`, and optional `turnStartMessage` for servers that need an explicit turn start; the session then holds audio written after a commit until outstanding turns are acknowledged) and a small `StreamingTranscriber` class that resolves auth/URL/format and constructs the session. See `src/adapters/openai-realtime.ts` and `src/adapters/voxtral-realtime.ts`.
76
+ - The end timeout and the held-turn wait are inactivity timeouts: every server message restarts them.
77
+ - Turn limits: `src/shared/turn-limit.ts`. `resolveTurnLimit()` resolves `maxTurnSeconds`/`rollover` (session options → adapter options, which carry `HAVE_SPEECH_STREAMING_MAX_TURN_SECONDS` → `RealtimeProtocol.maxTurnSeconds`), and `TurnSplitter` measures the current turn in write order from bytes and the negotiated format. It picks a rollover cut at the first `minSilenceMs` of RMS-quiet 20 ms frames inside the window before the cap, or at the cap. The session splits the write there, queues an automatic non-final `commit()`, and lets the existing post-commit hold keep later audio. With `rollover: false`, `write()` rejects with the non-fatal `SPEECH_TURN_TOO_LONG`. `voxtral-realtime` sets `maxTurnSeconds: 270` on its protocol; `openai-realtime` applies a configured limit only to `manual` sessions and sets `minCommitSeconds: 0.1`, so a session with a turn limit pads a shorter non-empty turn (a rollover tail) with silence instead of having it rejected as empty. Rollover tuning merges per field across option layers. `StreamingTranscriber.turnLimit()` (optional) lets `wrapStreamingTranscriber()` reject oversize clips before connecting.
78
+ - For `commitAck: 'final'` protocols the session fails closed when the server ends a turn it was not asked to end, and when a final reports `audioConsumed: false` for a turn that carried audio (vLLM's stale end marker), instead of losing audio silently. Supplied header values are redacted from provider text alongside the API key and client secret.
79
+ - Register new types in `STREAMING_TRANSCRIBER_TYPES` and the `getStreamingTranscriber()` switch (`src/shared/streaming-factory.ts`), and extend `StreamingTranscriberType`; `getTranscriber()` then wraps them automatically.
80
+ - Env: `HAVE_SPEECH_STREAMING_*` (`STREAMING_TRANSCRIBER_ENV_KEYS`), explicit options first.
81
+ - Auth: `apiKey` → `Authorization` header (Node only, refused in browsers); `clientSecret` (string or per-session mint function) → subprotocol, with `sk-…` keys refused in browsers. Shared helpers live in `src/shared/realtime-auth.ts`. Never put long-lived keys in browser code paths.
82
+ - Browser tokens: `createStreamingClientSecret()` (`src/shared/streaming-client-secret.ts`) mints OpenAI `ek_…` secrets server-side via `POST /v1/realtime/client_secrets` (default TTL 60 s), echoing `tenantId`/`sessionId`/`metadata` for the caller's ledger. It throws `SpeechConfigurationError` for `voxtral-realtime` (vLLM has no ephemeral tokens; consumers need their own gateway) and in browsers.
83
+ - Tests use an in-memory fake WebSocket (`src/__tests__/fake-websocket.ts`) injected through `WebSocket`/`createWebSocket`, and an injected `fetch`; no network. `voxtral-realtime.live.test.ts` is opt-in (`HAVE_SPEECH_STREAMING_LIVE=1`).
84
+
85
+ ## Optional Peer Isolation (`local`)
86
+
87
+ - `@huggingface/transformers` is an optional peer dependency (dev dependency for tests). Only `src/adapters/local/runtime.ts` names it, through a dynamic `import()` reached solely from `src/local.ts`. Never import `adapters/local/*` from `src/index.ts` or `src/shared/*`, except as `import type`.
88
+ - `pnpm --filter @happyvertical/speech build` runs `scripts/check-core-isolation.mjs`. The check fails if `dist/index.js`, or any module reachable from it through static or dynamic imports, references the peer. `src/__tests__/local-isolation.test.ts` asserts the same at runtime.
89
+ - The package tsconfig maps the peer to `src/adapters/local/transformers-shim.ts`, because the peer's own declarations (4.3.0) fail this repo's `skipLibCheck: false`. The adapter uses structural types in `runtime.ts` instead.
90
+ - Unit tests inject a fake transformers module (`transformers` option); never download models in CI. The real-model smoke test is opt-in via `HV_SPEECH_MODEL_TESTS=1`.
package/README.md CHANGED
@@ -46,14 +46,338 @@ const spoken = await speech.synthesize({
46
46
  | Provider | Type | Method | Path | Encoding |
47
47
  | --- | --- | --- | --- | --- |
48
48
  | Studio Server STT | `studio-server` | `POST` | `/v1/transcribe` | Multipart (`audio`) |
49
+ | OpenAI-compatible STT | `openai-compatible` | `POST` | `<base>/audio/transcriptions` | Multipart (`file`) |
50
+ | OpenAI Realtime STT (streaming) | `openai-realtime` | WebSocket | `<base>/realtime?intent=transcription` | JSON events, base64 PCM16/G.711 |
51
+ | On-device STT | `local` | In-process | `@happyvertical/speech/local` | 16 kHz mono PCM |
52
+ | Voxtral Realtime STT via vLLM (streaming) | `voxtral-realtime` | WebSocket | `<base>/realtime` | JSON events, base64 PCM16 16 kHz |
49
53
  | Studio Server TTS | `studio-server` | `POST` | `/v1/tts/synthesize` | Multipart |
50
54
  | Qwen3 TTS | `qwen3-tts` | `POST` | `/v1/audio/speech` | Multipart |
51
- | OpenAI-compatible TTS | `openai-compatible` | `POST` | `/v1/audio/speech` | JSON |
55
+ | OpenAI-compatible TTS | `openai-compatible` | `POST` | `<base>/audio/speech` | JSON |
56
+
57
+ The OpenAI-compatible TTS base URL resolves like the transcriber's: a server root (`http://gateway:8080`) gets `/v1/audio/speech`, a base ending in a version segment (`http://gateway/tts/v1`) gets `/audio/speech`, and a URL already ending in `/audio/speech` is used as-is. The query string is preserved and a fragment is dropped. An explicit `speechPath` skips this: it resolves relative to the base URL, or is used as-is when absolute.
52
58
 
53
59
  Studio Server and Qwen3 accept pre-extracted provider voice prompts through `SpeechVoice.prompt`; the adapters forward these as the multipart `voice_prompt` field.
54
60
 
61
+ ## OpenAI-compatible Transcription
62
+
63
+ `type: 'openai-compatible'` speaks the OpenAI audio API, so one adapter covers OpenAI, Groq, Fireworks, LiteLLM and Bifrost gateways, vLLM, speaches/faster-whisper-server, and whisper.cpp server.
64
+
65
+ > **Server-side only.** The adapter holds the provider API key. Never construct it in a browser. Browsers should record audio (for example with `MediaRecorder`) and post it to your own server route, which calls the transcriber.
66
+
67
+ ```typescript
68
+ import { getTranscriber } from '@happyvertical/speech';
69
+
70
+ const transcriber = await getTranscriber({
71
+ type: 'openai-compatible',
72
+ baseUrl: 'http://bifrost:8080/openai/v1', // or https://api.openai.com
73
+ apiKey: process.env.OPENAI_API_KEY,
74
+ model: 'whisper-1',
75
+ headers: { 'x-bf-vk': virtualKey }, // gateway routing / org / project headers
76
+ onUsage: (usage) => recordUsage(tenantId, usage),
77
+ });
78
+
79
+ // Inside a server route that received the browser upload:
80
+ const result = await transcriber.transcribe({
81
+ audio: request.body, // Blob | Buffer | Uint8Array | ArrayBuffer | ReadableStream
82
+ mimeType: 'audio/webm;codecs=opus', // Safari sends audio/mp4
83
+ language: 'en',
84
+ timestampGranularities: ['word', 'segment'],
85
+ headers: { 'x-bf-vk': tenantVirtualKey }, // per-request headers win
86
+ signal: abortController.signal,
87
+ });
88
+
89
+ result.text;
90
+ result.segments; // present when the server returns verbose_json
91
+ result.words; // present when word timestamps were requested and supported
92
+ result.usage; // { operation, provider, model, audioSeconds, bytes, providerUsage }
93
+ ```
94
+
95
+ - **Base URL.** A server root gets `/v1/audio/transcriptions`; a base ending in a version segment (`http://gateway/stt/v1`) gets `/audio/transcriptions`; a URL already ending in `/audio/transcriptions` is used as-is. Any query string (for example `?api-version=…`) is preserved and a fragment is dropped.
96
+ - **Audio formats.** The multipart filename extension comes from the MIME type (codec parameters are ignored): `webm`, `m4a` (`audio/mp4`), `mp4` (`video/mp4`), `wav`, `ogg`, `mp3`, `flac`. Pass `audio: { data, filename }` to choose the name yourself.
97
+ - **Limits.** `maxBytes` (adapter or request) defaults to 25 MB, the OpenAI upload cap. Oversized or empty input fails with `SpeechConfigurationError` before any request; streams are cancelled as soon as they exceed the limit.
98
+ - **Response formats.** Defaults to `verbose_json` so segments, duration, and (with `timestampGranularities`) word timings map onto `TranscriptResult`. Models that only return `json`/`text` (`gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, including gateway-prefixed names) are sent `json` without timestamps. If any other server rejects `verbose_json` or `timestamp_granularities[]` with a 400/422, the adapter retries once with plain `json`.
99
+ - **Retries.** 429 and 5xx responses are retried with exponential backoff (default 2 retries, 500 ms initial delay, 30 s cap), waiting at least `Retry-After`. A `Retry-After` longer than `maxDelayMs` ends retrying. Configure with `retry: { maxRetries, initialDelayMs, maxDelayMs }` or disable with `retry: false`; non-finite values (`NaN`, `Infinity`) throw `SpeechConfigurationError`. `timeoutMs` applies per attempt; `signal` cancels the request and any backoff wait.
100
+ - **Usage.** `result.usage` and `onUsage` (adapter-level, then request-level) report the model, audio seconds (provider duration, then `usage.seconds`, then `AudioInput.durationSeconds`), uploaded bytes, and the provider `usage` block untouched. Errors thrown by `onUsage` propagate.
101
+ - **Errors.** HTTP failures throw `SpeechProviderError` with `status`, `responseBody`, and `retryAfterMs`. The API key never appears in error messages and is redacted from response bodies.
102
+
103
+ ## Realtime (Streaming) Transcription
104
+
105
+ `getStreamingTranscriber()` opens WebSocket sessions that take raw audio as it is captured and emit partial and final transcripts. It sits alongside the request/response `Transcriber`; nothing about `getTranscriber()` changes for HTTP adapters.
106
+
107
+ `type: 'openai-realtime'` speaks the OpenAI Realtime API (GA) transcription session: `session.update` with `session.type: "transcription"`, `input_audio_buffer.append`/`commit`, and `conversation.item.input_audio_transcription.delta`/`completed`. It also fits local servers that imitate that protocol. See the [OpenAI realtime transcription guide](https://developers.openai.com/api/docs/guides/realtime-transcription).
108
+
109
+ ```typescript
110
+ import { getStreamingTranscriber } from '@happyvertical/speech';
111
+
112
+ // Node: the API key travels in the Authorization header.
113
+ const streaming = getStreamingTranscriber({
114
+ type: 'openai-realtime',
115
+ apiKey: process.env.OPENAI_API_KEY,
116
+ model: 'gpt-4o-transcribe',
117
+ headers: { 'x-bf-vk': virtualKey }, // gateway routing (Node only)
118
+ onUsage: (usage) => recordUsage(tenantId, usage),
119
+ });
120
+
121
+ const session = streaming.start({
122
+ format: { encoding: 'pcm16', sampleRate: 24000, channels: 1 },
123
+ language: 'en',
124
+ turnDetection: { type: 'server_vad', silenceDurationMs: 500 },
125
+ });
126
+
127
+ session.on('partial', ({ text, itemId }) => showCaption(itemId, text));
128
+ session.on('final', ({ text, itemId }) => commitCaption(itemId, text));
129
+ session.on('error', (error) => console.error(error));
130
+
131
+ for await (const chunk of microphonePcm16) {
132
+ await session.write(chunk); // resolves once the socket has drained
133
+ }
134
+
135
+ const result = await session.end(); // all finals joined, plus usage
136
+ ```
137
+
138
+ - **Audio format.** Negotiated explicitly as `{ encoding, sampleRate, channels }`. The adapter never decodes or resamples. OpenAI accepts `pcm16` at 24 kHz mono (the default) and `g711_ulaw`/`g711_alaw` at 8 kHz mono; anything else throws `SpeechConfigurationError` before connecting.
139
+ - **Turn detection.** `server_vad` (default: the provider ends a turn after silence and emits one `final` per turn), `semantic_vad` (`eagerness`), or `manual` (nothing is transcribed until `session.commit()` or `end()`). Use `manual` for `gpt-live-transcribe`, which does not accept VAD; pass its `delay`/`languages` through `transcriptionOptions` (the typed `model`, `language`, and `prompt` win over the same keys there).
140
+ - **Events.** `open`, `partial` (`delta` plus the turn's text so far), `final`, `speech_started`, `speech_stopped`, `error` (once, fatal), and `close`. `on()` returns an unsubscribe function. A listener that throws fails the session, so the bug surfaces from `end()`.
141
+ - **Backpressure.** `write()` returns a promise that resolves after the chunk is handed to the socket and the socket's `bufferedAmount` is at or below `highWaterMark` (default 1 MiB). Writes are queued in order, including before the socket opens. A write that would queue more than `maxBufferedBytes` (default 16 MiB) of unsent audio rejects with code `SPEECH_BACKPRESSURE`. A socket that stays above `highWaterMark` for `timeoutMs` fails the session instead of leaving `write()` pending.
142
+ - **`end()`.** Flushes queued audio, commits whatever the provider has not committed, waits for every pending turn's `final` (failing after `timeoutMs`, default 30 s, without any server message; every message restarts the timer, so a long clip that is still being transcribed is not cut off), closes the socket, and resolves with `{ text, segments, durationSeconds, usage, raw }`. Finals are ordered by commit order. Under server VAD, a commit preceded by `speech_stopped` for the same item is treated as the provider's own, so it never stands in for the acknowledgement of the end commit. Calling it again returns the same promise.
143
+ - **Reconnect policy: none.** The provider holds the audio buffer and turn state, so a dropped socket cannot be resumed without losing or duplicating text. An unexpected close fails the session: queued audio is discarded, pending `write()` calls and `end()` reject with `SpeechProviderError` (including the close code), and `error` then `close` are emitted. Finals already emitted stay valid; start a new session to continue.
144
+ - **Timeouts and cancellation.** `connectTimeoutMs` (default 10 s) bounds token minting plus the handshake. `signal` or `session.abort()` fails the session immediately.
145
+ - **Errors.** Provider `error` events and failed turns are fatal and throw `SpeechProviderError` with the provider code in the message. An empty-buffer rejection of the final commit is tolerated. Credentials (the API key, client secret, and supplied handshake header values such as `Authorization` or a gateway `x-bf-vk`) are redacted from surfaced provider text.
146
+ - **Usage.** `result.usage` and `onUsage` (adapter-level, then session-level) report the model, audio seconds (provider `usage.seconds` when present, otherwise computed from the bytes streamed), bytes streamed, and the provider usage blocks of all turns summed.
147
+
148
+ ### Voxtral Realtime (vLLM)
149
+
150
+ `type: 'voxtral-realtime'` streams to Mistral's Voxtral Realtime models (for example `mistralai/Voxtral-Mini-4B-Realtime-2602`) served by vLLM at `/v1/realtime`. vLLM borrows OpenAI's event names, but its protocol differs, so it has its own adapter. The adapter was verified against vLLM's realtime source and a live server:
151
+
152
+ - Connect, then `session.update` with a top-level `model` (vLLM sends `session.created` but never acknowledges the update).
153
+ - A non-final `input_audio_buffer.commit` starts generation; the adapter sends it before each turn's first audio.
154
+ - `input_audio_buffer.append` carries base64 PCM16 at **16 kHz mono**, the only accepted format. Write whole samples (even byte counts).
155
+ - vLLM streams `transcription.delta` events while audio arrives (the adapter drops the many empty ones) and transcribes at roughly real-time speed.
156
+ - `input_audio_buffer.commit` with `final: true` ends the turn, and vLLM answers with `transcription.done` (`{ text, usage }`, token counts only).
157
+
158
+ ```typescript
159
+ const streaming = getStreamingTranscriber({
160
+ type: 'voxtral-realtime',
161
+ baseUrl: 'http://vllm.internal:8000', // or a gateway's /v1 root
162
+ model: 'voxtral-mini-4b-realtime', // whatever vLLM serves (--served-model-name)
163
+ apiKey: process.env.VLLM_API_KEY, // optional; Authorization: Bearer (Node only)
164
+ });
165
+
166
+ const session = streaming.start(); // pcm16, 16 kHz, mono
167
+ session.on('partial', ({ text }) => showCaption(text));
168
+ for await (const chunk of microphonePcm16At16k) await session.write(chunk);
169
+ const { text, usage } = await session.end();
170
+ ```
171
+
172
+ - **Turns are manual.** vLLM has no voice-activity detection: partials stream continuously and you get one `final` per `session.commit()` or `end()`. `server_vad`/`semantic_vad` throw `SpeechConfigurationError`. After `commit()`, the session holds later audio until vLLM finishes the turn, because vLLM clears its buffer at the end of each turn. It then opens the next turn on the same socket, so no audio is lost. The hold, like `end()`, fails only after `timeoutMs` without any server message. `end()` without any uncommitted audio sends nothing.
173
+ - **Turn length and automatic rollover.** A turn's audio and generated tokens share the model context (`max_model_len`). Voxtral Mini Realtime spends about 12.5 tokens per second of audio, plus a 39-token prompt. With `max_model_len` 4096, a 322-second turn completed. A 352-second turn made vLLM report `error` `processing_error` ("EngineCore encountered an issue"), and on the vLLM build tested it also took the engine down until the server restarted. The limit applies to each turn, not the session: vLLM clears the context after each final commit. So the adapter caps turns at `maxTurnSeconds` (default **270**) and, by default, rolls over to a new turn before the cap. A session that never calls `commit()` (a long dictation, say) can run indefinitely. See [Turn limits and automatic rollover](#turn-limits-and-automatic-rollover).
174
+ - **Fail-closed turn checks.** If vLLM ever sends `transcription.done` for a turn that was not committed, the session emits that `final` and then fails with `SpeechProviderError`. A final commit crossing such a `done` would leave vLLM a stale end-of-turn marker, which ends the next turn at once and silently drops its audio (verified live by sending a stray final commit). The adapter detects that audio-less turn (`usage.prompt_tokens` of 1; any audio costs 39) and fails the session too. Start a new session to continue.
175
+ - **Not sent:** `language` and `prompt`, because vLLM's realtime session accepts only `model`.
176
+ - **Errors.** vLLM reports problems as `{ type: 'error', error, code }` (for example `model_not_found`, `invalid_audio`) and keeps the socket open. The adapter treats them as fatal.
177
+ - **Usage.** `providerUsage` holds vLLM's token counts summed over turns. `audioSeconds` comes from the bytes streamed.
178
+ - `getTranscriber({ type: 'voxtral-realtime', baseUrl })` works for record-then-send callers with a 16 kHz PCM16 WAV or `audio/pcm;rate=16000`. A clip longer than `maxTurnSeconds` is split into rollover turns, never sent as one turn.
179
+
180
+ ### Turn limits and automatic rollover
181
+
182
+ Some providers cap the audio of a single turn. A session with a turn limit measures the current turn from the bytes written and the negotiated format (sample rate × channels × bytes per sample), counting audio still queued as well as audio sent.
183
+
184
+ ```typescript
185
+ const streaming = getStreamingTranscriber({
186
+ type: 'voxtral-realtime',
187
+ baseUrl: 'http://vllm.internal:8000',
188
+ maxTurnSeconds: 270, // default for voxtral-realtime
189
+ rollover: { windowSeconds: 15, silenceThreshold: 0.01, minSilenceMs: 300 }, // defaults
190
+ });
191
+ ```
192
+
193
+ - **Rollover (default).** Starting `windowSeconds` before the cap, the session looks for `minSilenceMs` of quiet audio: consecutive 20 ms frames whose RMS level is below `silenceThreshold` (0 to 1 of full scale; `0.01` is about -40 dBFS). This is a plain energy check, not a voice-activity model, and it works for PCM16 and G.711. The session commits the turn as soon as it finds such a stretch. If none turns up, it cuts at the cap itself, splitting a write if needed. The turn's transcript arrives as a normal `final` event. The next turn opens on the same socket, and audio written meanwhile is held, never dropped, until the provider finishes the turn. `end()` joins every turn, as it does for manual commits. A manual `commit()` also ends the turn, which restarts the count.
194
+ - **Hard cap (`rollover: false`).** No automatic commits. A `write()` that would push the turn past the cap rejects with a `SpeechError` with code `SPEECH_TURN_TOO_LONG`. That error is not fatal: the session stays open, so call `commit()` and keep writing.
195
+ - **Record-then-send.** `getTranscriber()` and `wrapStreamingTranscriber()` stream an oversize clip as several rollover turns, or, with `rollover: false`, reject it with `SPEECH_TURN_TOO_LONG` before connecting.
196
+ - **Which sessions.** `voxtral-realtime` enables the limit by default. `openai-realtime` has no default, because server VAD ends its turns. Its `manual` sessions can opt in by setting `maxTurnSeconds`, and a limit is ignored under `server_vad` or `semantic_vad`. OpenAI rejects a commit with less than 100 ms of audio, so in a session with a turn limit a shorter non-empty turn (such as the tail after a rollover) is padded with silence to 100 ms before it is committed. The padding is not counted in `usage.bytes` or `durationSeconds`. A `maxTurnSeconds` below that minimum throws `SpeechConfigurationError`, because the padding would exceed it. `maxTurnSeconds: Infinity` removes the limit. `streaming.turnLimit(sessionOptions)` reports the limit a session would use: `{ maxTurnSeconds, rollover, maxTurnBytes }`.
197
+ - **Precedence.** `start()` options, then adapter options, then `HAVE_SPEECH_STREAMING_MAX_TURN_SECONDS`, then the adapter default. `rollover` tuning merges field by field across those layers, so `start({ rollover: true })` keeps the adapter's `windowSeconds`. Custom realtime protocols declare their default as `RealtimeProtocol.maxTurnSeconds`.
198
+
199
+ ### Browsers and per-tenant tokens
200
+
201
+ Long-lived API keys never belong in browser code. The adapters throw if `apiKey` or `headers` are set in a browser runtime, and they refuse a browser `clientSecret` that looks like a long-lived `sk-…` key. Mint a short-lived credential per tenant session on your server with `createStreamingClientSecret()`, and send the browser only `value` and `expiresAt`:
202
+
203
+ ```typescript
204
+ // Server route, after your own auth check.
205
+ import { createStreamingClientSecret } from '@happyvertical/speech';
206
+
207
+ const secret = await createStreamingClientSecret({
208
+ type: 'openai-realtime',
209
+ apiKey: process.env.OPENAI_API_KEY, // or HAVE_SPEECH_STREAMING_API_KEY
210
+ tenantId: tenant.id,
211
+ sessionId: dictation.id,
212
+ ttlSeconds: 60, // default; 10-7200
213
+ model: 'gpt-4o-transcribe',
214
+ language: 'en',
215
+ headers: { 'x-bf-vk': tenant.virtualKey }, // optional gateway attribution
216
+ });
217
+ await usageLedger.recordMint(secret.tenantId, secret.sessionId, secret.expiresAt);
218
+ return { clientSecret: secret.value, expiresAt: secret.expiresAt };
219
+ ```
220
+
221
+ ```typescript
222
+ // Browser: mint a fresh secret per session; it travels as the
223
+ // `openai-insecure-api-key.<secret>` WebSocket subprotocol.
224
+ const streaming = getStreamingTranscriber({
225
+ type: 'openai-realtime',
226
+ clientSecret: async () =>
227
+ (await (await fetch('/api/realtime-token')).json()).clientSecret,
228
+ });
229
+ ```
230
+
231
+ - **OpenAI.** The helper calls `POST /v1/realtime/client_secrets` with `expires_after: { anchor: 'created_at', seconds: ttlSeconds }` and a `session.type: "transcription"` config built from the same options as the adapter. The result is an `ek_…` secret bound to that config. The browser uses it to open the socket, so keep the TTL short: mint one per session, and a leaked secret expires quickly.
232
+ - **Attribution.** OpenAI's transcription client secrets have no tenant or metadata field. `tenantId`, `sessionId`, and `metadata` are echoed in the result for your own usage ledger and never sent to the provider. For provider-side attribution, mint with a per-tenant project key or a gateway virtual key in `headers`. Pair the mint record with the session's `onUsage` report.
233
+ - **Voxtral / vLLM.** vLLM has no ephemeral-token mechanism, so `createStreamingClientSecret({ type: 'voxtral-realtime' })` throws `SpeechConfigurationError` rather than exposing the server key. Browser Voxtral needs your own proxy or token gateway in front of vLLM: it validates the short-lived tokens it minted and connects upstream with the real key. The adapter sends a browser `clientSecret` as `['realtime', 'openai-insecure-api-key.<secret>']` subprotocols, so the gateway must read the token there and echo `realtime`. Without a `clientSecret`, no subprotocol is offered, as plain vLLM expects.
234
+ - `createStreamingClientSecret()` itself throws in a browser runtime.
235
+
236
+ ### Runtimes and custom sockets
237
+
238
+ The default socket is `globalThis.WebSocket` (Node 22+, browsers, Deno, Bun). Handshake headers are passed as `new WebSocket(url, { protocols, headers })`, which Node's built-in client supports. Inject another client with `WebSocket` (a constructor) or `createWebSocket(url, { protocols, headers })`, for example to use the `ws` package: `createWebSocket: (url, { protocols, headers }) => new WS(url, protocols, { headers })`.
239
+
240
+ ### Record-then-send callers
241
+
242
+ `getTranscriber({ type: 'openai-realtime' })` wraps the streaming adapter as a normal `Transcriber`: each `transcribe()` opens a session with `manual` turn detection, streams the recording in 64 KiB chunks, and returns `session.end()`. Use `wrapStreamingTranscriber(streaming, { chunkBytes, maxBytes, turnDetection })` to wrap one you built yourself. Streaming-only settings go in `streaming`:
243
+
244
+ ```typescript
245
+ const transcriber = await getTranscriber({
246
+ type: 'openai-realtime',
247
+ apiKey: process.env.OPENAI_API_KEY,
248
+ streaming: { connectTimeoutMs: 5000 },
249
+ });
250
+
251
+ await transcriber.transcribe({ audio: wavBytes, mimeType: 'audio/wav' });
252
+ ```
253
+
254
+ A clip longer than the adapter's `maxTurnSeconds` is split into rollover turns, or rejected with `SPEECH_TURN_TOO_LONG` before connecting when `rollover: false` (see [Turn limits and automatic rollover](#turn-limits-and-automatic-rollover)).
255
+
256
+ The input must be raw audio: a 16-bit PCM or G.711 WAV (the header sets the format), `audio/pcm` (little-endian) or `audio/L16` (big-endian per RFC 2586, byte-swapped before sending, so an odd byte length is rejected; both take optional `rate` and `channels` parameters, or `AudioInput.sampleRate`/`channels`), `audio/pcmu`, `audio/pcma`, or untyped bytes in the adapter default format. Compressed recordings such as `audio/webm` are rejected; send those to `openai-compatible` instead.
257
+
258
+ ### Streaming environment configuration
259
+
260
+ Explicit options win over these variables:
261
+
262
+ ```bash
263
+ HAVE_SPEECH_STREAMING_TYPE=openai-realtime # default; or voxtral-realtime
264
+ HAVE_SPEECH_STREAMING_BASE_URL=wss://api.openai.com/v1/realtime # or http://host:8000/v1
265
+ HAVE_SPEECH_STREAMING_MODEL=gpt-4o-transcribe
266
+ HAVE_SPEECH_STREAMING_API_KEY=sk-...
267
+ HAVE_SPEECH_STREAMING_LANGUAGE=en
268
+ HAVE_SPEECH_STREAMING_TURN_DETECTION=server_vad # server_vad | semantic_vad | manual
269
+ HAVE_SPEECH_STREAMING_TIMEOUT=30000 # ms of provider silence tolerated after end()
270
+ HAVE_SPEECH_STREAMING_CONNECT_TIMEOUT_MS=10000
271
+ HAVE_SPEECH_STREAMING_HEADERS='{"x-bf-vk":"vk-..."}'
272
+ HAVE_SPEECH_STREAMING_MAX_TURN_SECONDS=270 # per-turn cap; Infinity removes it (voxtral default 270)
273
+ ```
274
+
275
+ A base URL may be `ws(s)://` or `http(s)://`: a server root gets `/v1/realtime`, a base ending in a version segment gets `/realtime`, and for `openai-realtime` `intent=transcription` is added unless an `intent` is present. `voxtral-realtime` requires a base URL. `createStreamingClientSecret()` reads the same `TYPE`, `BASE_URL` (mapped to `…/v1/realtime/client_secrets`), `API_KEY`, `MODEL`, `LANGUAGE`, `TURN_DETECTION`, `TIMEOUT`, and `HEADERS` variables. `HAVE_SPEECH_TRANSCRIBER_TYPE=openai-realtime` also routes `getTranscriber()` to the wrapped streaming adapter, which then reads `HAVE_SPEECH_STREAMING_*`.
276
+
277
+ ## On-device Transcription
278
+
279
+ `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.
280
+
281
+ The runtime is an **optional peer dependency** behind the `@happyvertical/speech/local` subpath. The core `@happyvertical/speech` entry never imports it, so apps that do not use local transcription neither install nor bundle it. The build checks this guarantee: `scripts/check-core-isolation.mjs` fails if the core entry can reach `@huggingface/transformers`.
282
+
283
+ ```bash
284
+ pnpm add @happyvertical/speech @huggingface/transformers # tested with 4.3.0
285
+ ```
286
+
287
+ ```typescript
288
+ import { getTranscriber } from '@happyvertical/speech';
289
+ import '@happyvertical/speech/local'; // registers type: 'local'
290
+
291
+ const transcriber = await getTranscriber({
292
+ type: 'local',
293
+ model: 'onnx-community/whisper-base', // default
294
+ device: 'auto', // 'webgpu' | 'wasm' | 'cpu' | 'cuda' | 'dml' | 'coreml' | 'auto'
295
+ dtype: 'q8', // or { encoder_model: 'fp32', decoder_model_merged: 'q4' }
296
+ onProgress: (progress) => console.log(progress.status, progress.file, progress.progress),
297
+ });
298
+
299
+ const result = await transcriber.transcribe({
300
+ audio: recordingBlob, // Blob | Buffer | Uint8Array | ArrayBuffer | ReadableStream
301
+ mimeType: 'audio/webm;codecs=opus',
302
+ language: 'en',
303
+ timestampGranularities: ['segment'],
304
+ signal: abortController.signal,
305
+ });
306
+ result.usage; // { operation, provider: 'local', model, audioSeconds, bytes }
307
+ ```
308
+
309
+ 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.
310
+
311
+ - **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.
312
+ - **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.
313
+ - **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).
314
+ - **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:
315
+
316
+ ```typescript
317
+ decodeAudio: async ({ bytes }) => {
318
+ const pcm = await runFfmpeg(['-i', 'pipe:0', '-f', 'f32le', '-ac', '1', '-ar', '16000', 'pipe:1'], bytes);
319
+ return { samples: new Float32Array(pcm.buffer, pcm.byteOffset, pcm.byteLength / 4), sampleRate: 16000 };
320
+ }
321
+ ```
322
+
323
+ - **Model storage.** Browsers cache weights in Cache Storage. In Node, set `cacheDir` (default: transformers.js's cache inside `node_modules`). `modelHost` points downloads at self-hosted weights. It sets the process-wide `env.remoteHost`, which uses the Hugging Face `{model}/resolve/{revision}/` layout. `configureEnv(env)` adjusts any other transformers.js setting, for example `localModelPath`, `allowRemoteModels: false` for fully offline use, or WASM thread counts. `revision` pins a model revision.
324
+ - **Cancellation.** `signal` rejects immediately with the abort reason, including while the model is loading, and interrupts Whisper/Moonshine generation at the next token. Calls on one transcriber run one at a time. `dispose()` waits for in-flight inference, then releases the ONNX sessions.
325
+ - **Errors.** If the peer is missing, the adapter throws `SpeechConfigurationError` with an install hint. Calling `getTranscriber({ type: 'local' })` without importing the subpath also throws `SpeechConfigurationError`. Model load and inference failures throw `SpeechProviderError`.
326
+ - **Limits.** `maxBytes` defaults to 25 MB of encoded input. Uncompressed WAV fills that faster (about 13 minutes at 16 kHz 16-bit mono), so raise it for long WAV recordings. Request fields `prompt`, `temperature`, `responseFormat`, and `headers` do not apply to local models.
327
+
328
+ ### Web Worker
329
+
330
+ Inference can take seconds on long recordings, so in a browser run it in a worker to keep the UI responsive. The main-thread client transfers WAV and raw PCM bytes to the worker, which parses, downmixes, and resamples them there. `AudioContext` is not available in workers, so other formats (MediaRecorder WebM/Opus, Safari `audio/mp4`, MP3) are decoded on the main thread with `decodeAudioData` or the client's `decodeAudio` hook, and the decoded channels are transferred to the worker for downmixing and resampling. Without either, the bytes go to the worker's own `decodeAudio`:
331
+
332
+ ```typescript
333
+ // transcriber.worker.ts
334
+ import { serveLocalTranscriber } from '@happyvertical/speech/local';
335
+
336
+ serveLocalTranscriber({ model: 'onnx-community/whisper-base', device: 'auto' });
337
+ ```
338
+
339
+ ```typescript
340
+ // main thread
341
+ import { LocalTranscriberWorkerClient } from '@happyvertical/speech/local';
342
+
343
+ const worker = new Worker(new URL('./transcriber.worker.ts', import.meta.url), { type: 'module' });
344
+ const transcriber = new LocalTranscriberWorkerClient(worker, { onProgress, onUsage });
345
+
346
+ await transcriber.preload(); // optional: download the model before recording
347
+ const { text } = await transcriber.transcribe({ audio: recordingBlob, signal });
348
+ ```
349
+
350
+ Aborting a worker call rejects right away and interrupts generation inside the worker. `close()` rejects in-flight calls (including ones still decoding) and every later call, and aborts posted work in the worker; it does not terminate the worker. Progress events are forwarded to `onProgress`. Usage reports the original encoded byte count.
351
+
352
+ ### Local Environment Configuration
353
+
354
+ In Node, `getSpeech()`/`getTranscriber()` can select the local adapter from the environment once the subpath has been imported. Explicit options win:
355
+
356
+ ```bash
357
+ HAVE_SPEECH_TRANSCRIBER_TYPE=local
358
+ HAVE_SPEECH_TRANSCRIBER_MODEL=onnx-community/whisper-base
359
+ HAVE_SPEECH_TRANSCRIBER_DEVICE=cpu
360
+ HAVE_SPEECH_TRANSCRIBER_DTYPE=q8
361
+ HAVE_SPEECH_TRANSCRIBER_CACHE_DIR=/var/cache/hv-models
362
+ HAVE_SPEECH_TRANSCRIBER_MODEL_HOST=https://models.example.com/
363
+ ```
364
+
55
365
  ## Environment Configuration
56
366
 
367
+ Transcriber settings switch between cloud and self-hosted models without code changes. Explicit options win over the environment:
368
+
369
+ ```bash
370
+ HAVE_SPEECH_TRANSCRIBER_TYPE=openai-compatible
371
+ HAVE_SPEECH_TRANSCRIBER_BASE_URL=http://bifrost:8080/openai/v1
372
+ HAVE_SPEECH_TRANSCRIBER_MODEL=whisper-1
373
+ HAVE_SPEECH_TRANSCRIBER_API_KEY=sk-...
374
+ HAVE_SPEECH_TRANSCRIBER_TIMEOUT=60000 # milliseconds, per attempt
375
+ HAVE_SPEECH_TRANSCRIBER_MAX_BYTES=26214400 # optional
376
+ HAVE_SPEECH_TRANSCRIBER_HEADERS='{"x-bf-vk":"vk-..."}' # optional JSON object
377
+ ```
378
+
379
+ Headers merge in order environment, factory context, then explicit options (later wins per header). The `HAVE_SPEECH_STT_*` names below remain supported as fallbacks.
380
+
57
381
  SDK-style names are preferred:
58
382
 
59
383
  ```bash
@@ -74,11 +398,19 @@ TTS_ADAPTER=qwen3-tts
74
398
  TTS_BASE_URL=http://qwen3-tts.qwen3-tts.svc.cluster.local
75
399
  ```
76
400
 
77
- Optional overrides include `STT_PATH`, `TTS_PATH`, `STT_API_KEY`, `TTS_API_KEY`, `SPEECH_API_KEY`, `STT_TIMEOUT_MS`, and `TTS_TIMEOUT_MS`.
401
+ Optional overrides include `HAVE_SPEECH_STT_MODEL`, `STT_MODEL`, `STT_PATH`, `TTS_PATH`, `STT_API_KEY`, `TTS_API_KEY`, `SPEECH_API_KEY`, `STT_TIMEOUT_MS`, and `TTS_TIMEOUT_MS`.
78
402
 
79
403
  ## Testing
80
404
 
81
- Default tests use tiny in-process HTTP fixture services that mirror the Studio Server, Qwen3, and OpenAI-compatible request shapes. They validate field names, encodings, and response normalization without downloading production-scale models.
405
+ Default tests use tiny in-process HTTP fixture services or an injected `fetch` that mirror the Studio Server, Qwen3, and OpenAI-compatible request shapes, and an in-memory fake WebSocket for the realtime protocols (scripted to OpenAI's documented event sequence and to the sequence recorded from a live vLLM server). They validate field names, encodings, and response normalization without downloading production-scale models. Local transcriber tests inject a fake transformers.js module, so CI never downloads weights.
406
+
407
+ `src/__tests__/local-transcriber.smoke.test.ts` runs a real `onnx-community/whisper-tiny.en` model on onnxruntime-node. It is skipped unless `HV_SPEECH_MODEL_TESTS=1`:
408
+
409
+ ```bash
410
+ HV_SPEECH_MODEL_TESTS=1 pnpm --filter @happyvertical/speech test local-transcriber.smoke
411
+ ```
412
+
413
+ `src/__tests__/voxtral-realtime.live.test.ts` is an opt-in live check against a real vLLM server, skipped unless `HAVE_SPEECH_STREAMING_LIVE=1` and `HAVE_SPEECH_STREAMING_BASE_URL` are set. It also reads `HAVE_SPEECH_STREAMING_API_KEY`, `HAVE_SPEECH_STREAMING_MODEL`, and `HAVE_SPEECH_STREAMING_LIVE_AUDIO` (a 16 kHz mono PCM16 file containing speech).
82
414
 
83
415
  Docker or Testcontainers integration suites should run the fixture services or Studio Server with mock backends. Model-backed tests must remain opt-in, for example behind `HV_SPEECH_MODEL_TESTS=1`, so the normal SDK suite never downloads model weights.
84
416
 
@@ -0,0 +1,38 @@
1
+ /** Test doubles shared by the realtime adapter tests. */
2
+ type Listener = (event: unknown) => void;
3
+ export type Message = Record<string, unknown>;
4
+ /** In-memory WebSocket double. Tests drive the "server" side explicitly. */
5
+ export declare class FakeWebSocket {
6
+ readonly url: string;
7
+ readonly protocolsArg?: unknown | undefined;
8
+ static instances: FakeWebSocket[];
9
+ /** Called for every client message; lets a test script server replies. */
10
+ static onClientMessage?: (socket: FakeWebSocket, message: Message) => void;
11
+ /** Open automatically on the next macrotask. */
12
+ static autoOpen: boolean;
13
+ readyState: number;
14
+ bufferedAmount: number;
15
+ binaryType: string;
16
+ readonly sent: Message[];
17
+ readonly closeCalls: Array<number | undefined>;
18
+ private readonly listeners;
19
+ constructor(url: string, protocolsArg?: unknown | undefined);
20
+ addEventListener(type: string, listener: Listener): void;
21
+ send(data: string): void;
22
+ close(code?: number): void;
23
+ serverOpen(): void;
24
+ serverSend(message: Message): void;
25
+ serverDrop(code?: number, reason?: string): void;
26
+ types(): string[];
27
+ private dispatch;
28
+ }
29
+ export declare const lastSocket: () => FakeWebSocket;
30
+ /** A minimal PCM/G.711 WAV file filled with a constant sample byte. */
31
+ export declare function wav(samples: number, { sampleRate, channels, format, bits }?: {
32
+ sampleRate?: number | undefined;
33
+ channels?: number | undefined;
34
+ format?: number | undefined;
35
+ bits?: number | undefined;
36
+ }): Uint8Array;
37
+ export {};
38
+ //# sourceMappingURL=fake-websocket.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fake-websocket.d.ts","sourceRoot":"","sources":["../../src/__tests__/fake-websocket.ts"],"names":[],"mappings":"AAAA,yDAAyD;AAEzD,KAAK,QAAQ,GAAG,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;AACzC,MAAM,MAAM,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAE9C,4EAA4E;AAC5E,qBAAa,aAAa;IAetB,QAAQ,CAAC,GAAG,EAAE,MAAM;IACpB,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO;IAfjC,MAAM,CAAC,SAAS,EAAE,aAAa,EAAE,CAAM;IACvC,0EAA0E;IAC1E,MAAM,CAAC,eAAe,CAAC,EAAE,CAAC,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC3E,gDAAgD;IAChD,MAAM,CAAC,QAAQ,UAAQ;IAEvB,UAAU,SAAK;IACf,cAAc,SAAK;IACnB,UAAU,SAAU;IACpB,QAAQ,CAAC,IAAI,EAAE,OAAO,EAAE,CAAM;IAC9B,QAAQ,CAAC,UAAU,EAAE,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC,CAAM;IACpD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAiC;gBAGhD,GAAG,EAAE,MAAM,EACX,YAAY,CAAC,EAAE,OAAO,YAAA;IAQjC,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,GAAG,IAAI;IAMxD,IAAI,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IASxB,KAAK,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,IAAI;IAY1B,UAAU,IAAI,IAAI;IAQlB,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI;IAIlC,UAAU,CAAC,IAAI,SAAO,EAAE,MAAM,SAAS,GAAG,IAAI;IAK9C,KAAK,IAAI,MAAM,EAAE;IAIjB,OAAO,CAAC,QAAQ;CAKjB;AAED,eAAO,MAAM,UAAU,QAAO,aAM7B,CAAC;AAEF,uEAAuE;AACvE,wBAAgB,GAAG,CACjB,OAAO,EAAE,MAAM,EACf,EAAE,UAAmB,EAAE,QAAY,EAAE,MAAU,EAAE,IAAS,EAAE;;;;;CAAK,GAChE,UAAU,CAwBZ"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Rejects with the signal's reason as soon as it aborts, without waiting for
3
+ * `promise` (which keeps running; its eventual result is ignored).
4
+ */
5
+ export declare function raceAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T>;
6
+ //# sourceMappingURL=abort.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"abort.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/abort.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,wBAAgB,SAAS,CAAC,CAAC,EACzB,OAAO,EAAE,OAAO,CAAC,CAAC,CAAC,EACnB,MAAM,EAAE,WAAW,GAAG,SAAS,GAC9B,OAAO,CAAC,CAAC,CAAC,CAuBZ"}
@@ -0,0 +1,45 @@
1
+ import { DecodedAudio, LocalAudioDecoder } from './types.js';
2
+ /** Sample rate expected by Whisper and Moonshine feature extractors. */
3
+ export declare const LOCAL_SAMPLE_RATE = 16000;
4
+ export interface DecodeToPcmOptions {
5
+ /** Full MIME type including parameters (`audio/pcm;rate=16000`). */
6
+ mimeType: string;
7
+ /** Sample rate for raw PCM when the MIME type has no `rate` parameter. */
8
+ sampleRate?: number;
9
+ /** Channel count for raw PCM when the MIME type has no `channels` parameter. */
10
+ channels?: number;
11
+ decodeAudio?: LocalAudioDecoder;
12
+ signal?: AbortSignal;
13
+ }
14
+ /**
15
+ * Decodes `bytes` and returns 16 kHz mono Float32 samples.
16
+ *
17
+ * Raw PCM (`audio/pcm`) is little-endian signed 16-bit by default; pass
18
+ * `encoding=f32le` for Float32. The rate comes from the `rate` MIME parameter
19
+ * or `sampleRate`, the channel count from `channels` (default 1).
20
+ */
21
+ export declare function decodeToPcm16k(bytes: Uint8Array, options: DecodeToPcmOptions): Promise<Float32Array>;
22
+ /** Downmixes decoded audio to mono and resamples it to 16 kHz. */
23
+ export declare function toPcm16k(decoded: DecodedAudio): Float32Array;
24
+ /**
25
+ * True for WAV and raw PCM, which this module decodes itself in every
26
+ * runtime, including Web Workers.
27
+ */
28
+ export declare function isInProcessDecodable(bytes: Uint8Array, mimeType: string): boolean;
29
+ /**
30
+ * Decodes a format without an in-process decoder through the caller's
31
+ * `decodeAudio` hook or, in browsers, `OfflineAudioContext`. Resolves
32
+ * `undefined` when neither is available.
33
+ */
34
+ export declare function decodeExternally(bytes: Uint8Array, options: Omit<DecodeToPcmOptions, 'sampleRate' | 'channels'>): Promise<DecodedAudio | undefined>;
35
+ /** Parses a RIFF/WAVE file into per-channel Float32 samples. */
36
+ export declare function decodeWav(bytes: Uint8Array): DecodedAudio;
37
+ /** Averages channels into one mono track. */
38
+ export declare function downmix(samples: Float32Array | Float32Array[]): Float32Array;
39
+ /**
40
+ * Resamples mono audio with linear interpolation. When downsampling, a box
41
+ * filter one source period wide is applied first to limit aliasing; this is
42
+ * adequate for speech recognition, not for music playback.
43
+ */
44
+ export declare function resample(samples: Float32Array, fromRate: number, toRate: number): Float32Array;
45
+ //# sourceMappingURL=audio.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/audio.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAKH,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAElE,wEAAwE;AACxE,eAAO,MAAM,iBAAiB,QAAS,CAAC;AAYxC,MAAM,WAAW,kBAAkB;IACjC,oEAAoE;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,0EAA0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,iBAAiB,CAAC;IAChC,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;;;;GAMG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,UAAU,EACjB,OAAO,EAAE,kBAAkB,GAC1B,OAAO,CAAC,YAAY,CAAC,CAOvB;AAED,kEAAkE;AAClE,wBAAgB,QAAQ,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,CAM5D;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,KAAK,EAAE,UAAU,EACjB,QAAQ,EAAE,MAAM,GACf,OAAO,CAMT;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,OAAO,EAAE,IAAI,CAAC,kBAAkB,EAAE,YAAY,GAAG,UAAU,CAAC,GAC3D,OAAO,CAAC,YAAY,GAAG,SAAS,CAAC,CAgBnC;AAoCD,gEAAgE;AAChE,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,YAAY,CA8CzD;AA2ID,6CAA6C;AAC7C,wBAAgB,OAAO,CAAC,OAAO,EAAE,YAAY,GAAG,YAAY,EAAE,GAAG,YAAY,CAiB5E;AAED;;;;GAIG;AACH,wBAAgB,QAAQ,CACtB,OAAO,EAAE,YAAY,EACrB,QAAQ,EAAE,MAAM,EAChB,MAAM,EAAE,MAAM,GACb,YAAY,CAqBd"}
@@ -0,0 +1,15 @@
1
+ import { SpeechFactoryContext } from '../../shared/factory.js';
2
+ import { LocalTranscriberOptions } from './types.js';
3
+ /** Local-only environment variables, in priority order. */
4
+ export declare const LOCAL_TRANSCRIBER_ENV_KEYS: {
5
+ readonly device: readonly ["HAVE_SPEECH_TRANSCRIBER_DEVICE"];
6
+ readonly dtype: readonly ["HAVE_SPEECH_TRANSCRIBER_DTYPE"];
7
+ readonly cacheDir: readonly ["HAVE_SPEECH_TRANSCRIBER_CACHE_DIR"];
8
+ readonly modelHost: readonly ["HAVE_SPEECH_TRANSCRIBER_MODEL_HOST"];
9
+ };
10
+ /**
11
+ * Merges explicit options with the environment. `model` and `maxBytes` reuse
12
+ * the shared `HAVE_SPEECH_TRANSCRIBER_MODEL`/`_MAX_BYTES` keys.
13
+ */
14
+ export declare function resolveLocalTranscriberOptions(options?: LocalTranscriberOptions, context?: Pick<SpeechFactoryContext, 'env'>): LocalTranscriberOptions;
15
+ //# sourceMappingURL=env.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"env.d.ts","sourceRoot":"","sources":["../../../src/adapters/local/env.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AACpE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAE1D,2DAA2D;AAC3D,eAAO,MAAM,0BAA0B;;;;;CAK7B,CAAC;AAEX;;;GAGG;AACH,wBAAgB,8BAA8B,CAC5C,OAAO,GAAE,uBAA4B,EACrC,OAAO,GAAE,IAAI,CAAC,oBAAoB,EAAE,KAAK,CAAM,GAC9C,uBAAuB,CAqBzB"}
@@ -0,0 +1,58 @@
1
+ import { LocalTranscriberDevice, LocalTranscriberDtype, LocalTranscriberProgressCallback, LocalTransformersRuntime } from './types.js';
2
+ export declare const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
3
+ export declare const TRANSFORMERS_INSTALL_HINT: string;
4
+ /** One ASR result as returned by transformers.js. */
5
+ export interface AsrOutput {
6
+ text: string;
7
+ chunks?: Array<{
8
+ text: string;
9
+ timestamp: [number, number | null];
10
+ }>;
11
+ }
12
+ /** Callable `automatic-speech-recognition` pipeline (structural subset). */
13
+ export interface AsrPipeline {
14
+ (audio: Float32Array, options?: Record<string, unknown>): Promise<AsrOutput | AsrOutput[]>;
15
+ dispose?: () => Promise<void>;
16
+ model?: {
17
+ config?: {
18
+ model_type?: string;
19
+ };
20
+ };
21
+ }
22
+ /** The parts of the transformers.js module this adapter uses. */
23
+ export interface TransformersModule {
24
+ pipeline: (task: 'automatic-speech-recognition', model: string, options: Record<string, unknown>) => Promise<AsrPipeline>;
25
+ env?: Record<string, unknown>;
26
+ InterruptableStoppingCriteria?: new () => {
27
+ interrupt(): void;
28
+ };
29
+ }
30
+ /**
31
+ * Resolves the transformers.js module: an injected module or loader, else a
32
+ * dynamic import of the peer. A missing peer becomes a
33
+ * `SpeechConfigurationError` with an install hint.
34
+ */
35
+ export declare function loadTransformers(runtime: LocalTransformersRuntime | undefined): Promise<TransformersModule>;
36
+ /** Isolated so tests can observe whether the peer was ever imported. */
37
+ export declare function importTransformers(): Promise<unknown>;
38
+ export interface CreatePipelineOptions {
39
+ model: string;
40
+ device: LocalTranscriberDevice;
41
+ dtype?: LocalTranscriberDtype;
42
+ revision?: string;
43
+ cacheDir?: string;
44
+ onProgress?: LocalTranscriberProgressCallback;
45
+ }
46
+ /**
47
+ * Creates the ASR pipeline. `device: 'auto'` resolves to WebGPU when a
48
+ * browser adapter is available (retrying on WASM if WebGPU fails to
49
+ * initialise) and to CPU elsewhere.
50
+ */
51
+ export declare function createAsrPipeline(transformers: TransformersModule, options: CreatePipelineOptions): Promise<{
52
+ pipeline: AsrPipeline;
53
+ device: string;
54
+ }>;
55
+ /** `webgpu` when a browser exposes a usable adapter, `wasm` in other browsers, `cpu` in Node. */
56
+ export declare function detectAutoDevice(): Promise<string>;
57
+ export declare function isNodeRuntime(): boolean;
58
+ //# sourceMappingURL=runtime.d.ts.map