@kuralle-syrinx/elevenlabs 4.3.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.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kuralle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @kuralle-syrinx/elevenlabs
2
+
3
+ ElevenLabs voice provider for Syrinx — multi-context WebSocket streaming TTS and Scribe v2
4
+ Realtime streaming STT.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pnpm add @kuralle-syrinx/elevenlabs
10
+ ```
11
+
12
+ ## Auth
13
+
14
+ Pass `api_key` (and `voice_id` for TTS) in plugin config. For local smokes, set `ELEVENLABS_API_KEY`
15
+ in the repo-root `.env` (the live smoke reads `EL_VOICE_ID` for the TTS voice, defaulting to a
16
+ free-accessible premade voice).
17
+
18
+ ## Streaming TTS — `ElevenLabsTTSPlugin`
19
+
20
+ ```typescript
21
+ import { ElevenLabsTTSPlugin } from "@kuralle-syrinx/elevenlabs";
22
+ import { createNodeWsSocket } from "@kuralle-syrinx/ws/node";
23
+
24
+ const tts = new ElevenLabsTTSPlugin(createNodeWsSocket);
25
+ await tts.initialize(bus, {
26
+ api_key: process.env.ELEVENLABS_API_KEY!,
27
+ voice_id: process.env.ELEVENLABS_VOICE_ID,
28
+ model_id: "eleven_flash_v2_5",
29
+ sample_rate: 16000,
30
+ });
31
+ ```
32
+
33
+ Connects to `wss://api.elevenlabs.io/v1/text-to-speech/<voice_id>/multi-stream-input`
34
+ (`multi-stream-input`, concurrent contexts keyed by `context_id`) via the shared `tts-core`
35
+ deep module. Sends the required `InitializeConnectionMulti`-style context-init frame
36
+ (`voice_settings` + optional `generation_config`) before a context's first text. `output_format`
37
+ and `generation_config` are dev-configurable, not pinned. Default voice
38
+ (`EXAVITQu4vr4xnSDxMaL`) is a premade voice accessible to free API accounts — library voices
39
+ (e.g. Rachel) require a paid plan; override with `voice_id`.
40
+
41
+ Usage billing (`usage.recorded{stage:"tts", characters}`) fires on **audio received**, once per
42
+ context — not on `isFinal` (ElevenLabs streams audio with `isFinal:null`, and a rejected
43
+ generation returns an empty final that must not be billed).
44
+
45
+ ## Streaming STT — `ElevenLabsSTTPlugin`
46
+
47
+ ```typescript
48
+ import { ElevenLabsSTTPlugin } from "@kuralle-syrinx/elevenlabs";
49
+ import { createNodeWsSocket } from "@kuralle-syrinx/ws/node";
50
+
51
+ const stt = new ElevenLabsSTTPlugin(createNodeWsSocket);
52
+ await stt.initialize(bus, {
53
+ api_key: process.env.ELEVENLABS_API_KEY!,
54
+ sample_rate: 16000,
55
+ });
56
+ ```
57
+
58
+ Connects to `wss://api.elevenlabs.io/v1/speech-to-text/realtime` (Scribe v2 Realtime, model
59
+ `scribe_v2_realtime` by default) via the shared `@kuralle-syrinx/stt-core` session. Provider
60
+ `partial_transcript` → `stt.interim`, `committed_transcript` → `stt.result` (`speechFinal: true`).
61
+ Usage billing (`usage.recorded{stage:"stt", audioSeconds}`) fires at the final-transcript funnel
62
+ with delta-billing. Owns endpointing (`endpointingCapability.owner: "provider_stt"`).
63
+
64
+ ## Pricing
65
+
66
+ Cited in `@kuralle-syrinx/core`'s `DEFAULT_PRICE_CATALOG`: Scribe v2 STT $0.39/hr;
67
+ Flash/Turbo TTS $50/1M characters, Multilingual TTS $100/1M characters.
68
+
69
+ ## Status
70
+
71
+ Live-verified end-to-end (TTS audio + usage, STT transcript + usage).
72
+
73
+ ## Live smoke
74
+
75
+ From `examples/02-hello-voice-headless` (requires `ELEVENLABS_API_KEY`; optional `EL_VOICE_ID`):
76
+
77
+ ```bash
78
+ pnpm -C examples/02-hello-voice-headless exec tsx scripts/spike-elevenlabs.ts
79
+ ```
80
+
81
+ Exercises both TTS (multi-context WS) and STT (Scribe v2 realtime WS) against the live API,
82
+ including `usage.recorded` on both stages.
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@kuralle-syrinx/elevenlabs",
3
+ "version": "4.3.0",
4
+ "private": false,
5
+ "description": "ElevenLabs adapters for Syrinx — multi-context WebSocket TTS and Scribe v2 Realtime STT",
6
+ "keywords": [
7
+ "voice",
8
+ "voice-agent",
9
+ "speech",
10
+ "syrinx",
11
+ "elevenlabs",
12
+ "speech-to-text",
13
+ "text-to-speech"
14
+ ],
15
+ "license": "MIT",
16
+ "homepage": "https://github.com/kuralle/syrinx#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/kuralle/syrinx.git",
20
+ "directory": "packages/elevenlabs"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/kuralle/syrinx/issues"
24
+ },
25
+ "type": "module",
26
+ "main": "./src/index.ts",
27
+ "types": "./src/index.ts",
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./stt": "./src/stt.ts",
31
+ "./tts": "./src/tts.ts"
32
+ },
33
+ "dependencies": {
34
+ "@kuralle-syrinx/core": "4.3.0",
35
+ "@kuralle-syrinx/stt-core": "4.3.0",
36
+ "@kuralle-syrinx/tts-core": "4.3.0",
37
+ "@kuralle-syrinx/ws": "4.3.0"
38
+ },
39
+ "devDependencies": {
40
+ "@types/ws": "^8.5.0",
41
+ "typescript": "^5.7.0",
42
+ "vitest": "^3.2.6",
43
+ "ws": "^8.21.0"
44
+ },
45
+ "scripts": {
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export { ElevenLabsTTSPlugin, ElevenLabsWireProtocol } from "./tts.js";
4
+ export { ElevenLabsSTTPlugin } from "./stt.js";
package/src/stt.ts ADDED
@@ -0,0 +1,219 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // ElevenLabs Scribe v2 Realtime STT.
4
+ // Wire protocol pinned from:
5
+ // https://elevenlabs.io/docs/api-reference/speech-to-text/v-1-speech-to-text-realtime
6
+ // Session lifecycle lives in @kuralle-syrinx/stt-core.
7
+
8
+ import {
9
+ assertAudioFormat,
10
+ optionalStringConfig,
11
+ readProviderRetryConfig,
12
+ requireStringConfig,
13
+ type AudioFormat,
14
+ type PipelineBus,
15
+ type PluginConfig,
16
+ type VoicePlugin,
17
+ } from "@kuralle-syrinx/core";
18
+ import {
19
+ defaultNodeSocketFactory,
20
+ startStreamingSttSession,
21
+ type SttEvent,
22
+ type SttWireProtocol,
23
+ type StreamingSttSession,
24
+ } from "@kuralle-syrinx/stt-core";
25
+ import type { SocketData, SocketFactory } from "@kuralle-syrinx/ws";
26
+
27
+ const DEFAULT_MODEL_ID = "scribe_v2_realtime";
28
+ const DEFAULT_ENDPOINT = "wss://api.elevenlabs.io/v1/speech-to-text/realtime";
29
+
30
+ class ElevenLabsSttWireProtocol implements SttWireProtocol {
31
+ constructor(
32
+ private readonly sampleRate: number,
33
+ private readonly commitStrategy: "manual" | "vad",
34
+ private readonly language: string,
35
+ ) {}
36
+
37
+ isReady(): boolean {
38
+ // Session is usable as soon as the socket is up; session_started is informative.
39
+ return true;
40
+ }
41
+
42
+ encodeAudio(audio: Uint8Array): readonly SocketData[] {
43
+ return [
44
+ JSON.stringify({
45
+ message_type: "input_audio_chunk",
46
+ audio_base_64: Buffer.from(audio).toString("base64"),
47
+ commit: false,
48
+ sample_rate: this.sampleRate,
49
+ }),
50
+ ];
51
+ }
52
+
53
+ encodeFinalize(_contextId: string): readonly SocketData[] {
54
+ if (this.commitStrategy !== "manual") return [];
55
+ return [
56
+ JSON.stringify({
57
+ message_type: "input_audio_chunk",
58
+ audio_base_64: "",
59
+ commit: true,
60
+ sample_rate: this.sampleRate,
61
+ }),
62
+ ];
63
+ }
64
+
65
+ decode(data: SocketData, _isBinary: boolean): readonly SttEvent[] {
66
+ if (typeof data !== "string") return [];
67
+ let msg: Record<string, unknown>;
68
+ try {
69
+ msg = JSON.parse(data) as Record<string, unknown>;
70
+ } catch (err) {
71
+ return [
72
+ {
73
+ type: "error",
74
+ error: new Error(
75
+ `ElevenLabs STT provider sent malformed JSON: ${err instanceof Error ? err.message : String(err)}`,
76
+ ),
77
+ },
78
+ ];
79
+ }
80
+
81
+ const messageType = typeof msg["message_type"] === "string" ? msg["message_type"] : "";
82
+ switch (messageType) {
83
+ case "session_started":
84
+ return [];
85
+ case "partial_transcript": {
86
+ const text = typeof msg["text"] === "string" ? msg["text"].trim() : "";
87
+ if (!text) return [];
88
+ return [{ type: "interim", contextId: "", text }];
89
+ }
90
+ case "committed_transcript":
91
+ case "committed_transcript_with_timestamps":
92
+ case "final_transcript":
93
+ case "final_transcript_with_timestamps": {
94
+ const text = typeof msg["text"] === "string" ? msg["text"].trim() : "";
95
+ if (!text) return [];
96
+ const language =
97
+ typeof msg["language_code"] === "string" && msg["language_code"]
98
+ ? msg["language_code"]
99
+ : this.language || "en";
100
+ return [
101
+ {
102
+ type: "final",
103
+ contextId: "",
104
+ text,
105
+ confidence: 1,
106
+ language,
107
+ speechFinal: true,
108
+ provider: { words: msg["words"] },
109
+ },
110
+ ];
111
+ }
112
+ case "error":
113
+ case "auth_error":
114
+ case "quota_exceeded":
115
+ case "commit_throttled":
116
+ case "unaccepted_terms":
117
+ case "rate_limited":
118
+ case "queue_overflow":
119
+ case "resource_exhausted":
120
+ case "session_time_limit_exceeded":
121
+ case "input_error":
122
+ case "chunk_size_exceeded":
123
+ case "insufficient_audio_activity":
124
+ case "transcriber_error":
125
+ return [{ type: "error", error: elevenLabsSttError(msg, messageType) }];
126
+ default:
127
+ return [];
128
+ }
129
+ }
130
+ }
131
+
132
+ export class ElevenLabsSTTPlugin implements VoicePlugin {
133
+ readonly endpointingCapability = {
134
+ owner: "provider_stt" as const,
135
+ disableConfig: {
136
+ emit_eos_on_final: false,
137
+ },
138
+ };
139
+
140
+ constructor(private readonly socketFactory?: SocketFactory) {}
141
+
142
+ private session: StreamingSttSession | null = null;
143
+
144
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
145
+ const apiKey = requireStringConfig(config, "api_key");
146
+ const sampleRate = (config["sample_rate"] as number) ?? 16000;
147
+ const model =
148
+ optionalStringConfig(config, "model") ??
149
+ optionalStringConfig(config, "model_id") ??
150
+ DEFAULT_MODEL_ID;
151
+ const language =
152
+ optionalStringConfig(config, "language") ??
153
+ optionalStringConfig(config, "language_code") ??
154
+ "";
155
+ const endpointUrl = optionalStringConfig(config, "endpoint_url") ?? DEFAULT_ENDPOINT;
156
+ const strategy = optionalStringConfig(config, "commit_strategy");
157
+ const commitStrategy: "manual" | "vad" = strategy === "vad" ? "vad" : "manual";
158
+ const emitEosOnFinal = (config["emit_eos_on_final"] as boolean) ?? true;
159
+ const audioFormat: AudioFormat = {
160
+ encoding: "pcm_s16le",
161
+ sampleRateHz: sampleRate,
162
+ channels: 1,
163
+ };
164
+ assertAudioFormat(audioFormat);
165
+ const audioFormatParam = pcmAudioFormatParam(sampleRate);
166
+
167
+ this.session = await startStreamingSttSession(bus, {
168
+ protocol: new ElevenLabsSttWireProtocol(sampleRate, commitStrategy, language),
169
+ provider: { name: "elevenlabs", model, region: "global" },
170
+ format: audioFormat,
171
+ language: language || "en",
172
+ emitEosOnFinal,
173
+ url: () => {
174
+ const params = new URLSearchParams({
175
+ model_id: model,
176
+ audio_format: audioFormatParam,
177
+ commit_strategy: commitStrategy,
178
+ });
179
+ if (language) params.set("language_code", language);
180
+ const separator = endpointUrl.includes("?") ? "&" : "?";
181
+ return `${endpointUrl}${separator}${params.toString()}`;
182
+ },
183
+ headers: { "xi-api-key": apiKey },
184
+ socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
185
+ retry: readProviderRetryConfig(config),
186
+ replayBufferSize: (config["replay_buffer_size"] as number) ?? 64,
187
+ metricPrefix: "stt.elevenlabs",
188
+ });
189
+ }
190
+
191
+ async close(): Promise<void> {
192
+ await this.session?.dispose();
193
+ this.session = null;
194
+ }
195
+ }
196
+
197
+ function pcmAudioFormatParam(sampleRate: number): string {
198
+ switch (sampleRate) {
199
+ case 8000:
200
+ return "pcm_8000";
201
+ case 16000:
202
+ return "pcm_16000";
203
+ case 22050:
204
+ return "pcm_22050";
205
+ case 24000:
206
+ return "pcm_24000";
207
+ case 44100:
208
+ return "pcm_44100";
209
+ case 48000:
210
+ return "pcm_48000";
211
+ default:
212
+ return `pcm_${sampleRate}`;
213
+ }
214
+ }
215
+
216
+ function elevenLabsSttError(msg: Record<string, unknown>, messageType: string): Error {
217
+ const detail = typeof msg["error"] === "string" ? msg["error"] : messageType;
218
+ return new Error(`ElevenLabs STT provider error (${messageType}): ${detail}`);
219
+ }
package/src/tts.ts ADDED
@@ -0,0 +1,258 @@
1
+ // SPDX-License-Identifier: MIT
2
+ //
3
+ // ElevenLabs multi-context WebSocket TTS.
4
+ // Wire protocol pinned from:
5
+ // https://elevenlabs.io/docs/api-reference/text-to-speech/v-1-text-to-speech-voice-id-multi-stream-input
6
+ // Lifecycle (reconnect/keepalive/finish-timeout) lives in tts-core.
7
+
8
+ import {
9
+ Route,
10
+ assertAudioFormat,
11
+ assertAudioPayload,
12
+ optionalStringConfig,
13
+ readProviderRetryConfig,
14
+ requireStringConfig,
15
+ type AudioFormat,
16
+ type PipelineBus,
17
+ type PluginConfig,
18
+ type VoicePlugin,
19
+ } from "@kuralle-syrinx/core";
20
+ import {
21
+ attributionKey,
22
+ defaultNodeSocketFactory,
23
+ startStreamingTtsSession,
24
+ type AttributionKey,
25
+ type StreamingTtsSession,
26
+ type WireEvent,
27
+ type WireProtocol,
28
+ } from "@kuralle-syrinx/tts-core";
29
+ import type { SocketData, SocketFactory } from "@kuralle-syrinx/ws";
30
+
31
+ const KEEP_ALIVE_INTERVAL_MS = 10_000;
32
+ // A premade voice in the default set — accessible to free API accounts. (Library voices like
33
+ // Rachel 21m00Tcm4TlvDq8ikWAM require a paid plan: "cannot use library voices via the API".)
34
+ const DEFAULT_VOICE_ID = "EXAVITQu4vr4xnSDxMaL";
35
+ const DEFAULT_MODEL_ID = "eleven_flash_v2_5";
36
+ const DEFAULT_VOICE_SETTINGS: Record<string, unknown> = { stability: 0.5, similarity_boost: 0.75 };
37
+
38
+ interface ElevenLabsWireConfig {
39
+ readonly modelId: string;
40
+ readonly audioFormat: AudioFormat;
41
+ readonly voiceSettings: Record<string, unknown>;
42
+ readonly generationConfig?: Record<string, unknown>;
43
+ }
44
+
45
+ /** Exported for unit tests that exercise encode/decode without a live socket. */
46
+ export class ElevenLabsWireProtocol implements WireProtocol {
47
+ private readonly charactersByKey = new Map<AttributionKey, number>();
48
+ private readonly initializedKeys = new Set<AttributionKey>();
49
+ private readonly billedKeys = new Set<AttributionKey>();
50
+
51
+ constructor(private readonly cfg: ElevenLabsWireConfig) {}
52
+
53
+ attributionFor(contextId: string): { key: AttributionKey; contextId: string } {
54
+ return { key: attributionKey(contextId), contextId };
55
+ }
56
+
57
+ encodeText(key: AttributionKey, text: string): SocketData[] {
58
+ this.charactersByKey.set(key, (this.charactersByKey.get(key) ?? 0) + text.length);
59
+ const frames: SocketData[] = [];
60
+ // Multi-stream requires initializing a context (voice_settings) before its first text —
61
+ // without it ElevenLabs accepts the text but generates NO audio (a final with no audio frames).
62
+ if (!this.initializedKeys.has(key)) {
63
+ this.initializedKeys.add(key);
64
+ const init: Record<string, unknown> = { text: " ", context_id: key, voice_settings: this.cfg.voiceSettings };
65
+ if (this.cfg.generationConfig) init["generation_config"] = this.cfg.generationConfig;
66
+ frames.push(JSON.stringify(init));
67
+ }
68
+ frames.push(JSON.stringify({ text, context_id: key }));
69
+ return frames;
70
+ }
71
+
72
+ encodeFinish(contextId: string, _activeKeys: readonly AttributionKey[]): SocketData[] {
73
+ return [JSON.stringify({ context_id: contextId, flush: true })];
74
+ }
75
+
76
+ encodeCancel(key: AttributionKey, _contextId: string): SocketData[] {
77
+ this.charactersByKey.delete(key);
78
+ this.initializedKeys.delete(key);
79
+ this.billedKeys.delete(key);
80
+ return [JSON.stringify({ context_id: key, close_context: true })];
81
+ }
82
+
83
+ encodeClose(): SocketData[] {
84
+ return [JSON.stringify({ close_socket: true })];
85
+ }
86
+
87
+ decode(data: SocketData, _isBinary: boolean): readonly WireEvent[] {
88
+ if (typeof data !== "string") return [];
89
+ const msg = JSON.parse(data) as Record<string, unknown>;
90
+ const contextId = readContextId(msg);
91
+ const key = attributionKey(contextId || "");
92
+
93
+ if (isErrorFrame(msg)) {
94
+ return [{ type: "error", key: contextId ? key : null, error: elevenLabsProviderError(msg), endsContext: isFinalFlag(msg) }];
95
+ }
96
+
97
+ const events: WireEvent[] = [];
98
+ const audioB64 = typeof msg["audio"] === "string" ? msg["audio"] : "";
99
+ if (audioB64.length > 0) {
100
+ if (!contextId) {
101
+ events.push({ type: "error", key: null, error: new Error("ElevenLabs TTS audio frame missing context_id") });
102
+ } else {
103
+ try {
104
+ const bytes = new Uint8Array(decodeStrictBase64(audioB64, "ElevenLabs TTS provider audio"));
105
+ assertAudioPayload(this.cfg.audioFormat, bytes);
106
+ events.push({ type: "audio", key, pcm: bytes });
107
+ // Bill on real audio, once per context — NOT on isFinal. EL streams audio with
108
+ // isFinal:null and may not send a separate isFinal:true final (the socket close is the
109
+ // real end), and a rejected generation returns an empty final with no audio that must
110
+ // NOT be billed. Audio-received is the correct "synthesis happened" signal.
111
+ const characters = this.charactersByKey.get(key) ?? 0;
112
+ if (!this.billedKeys.has(key) && characters > 0) {
113
+ this.billedKeys.add(key);
114
+ const modelId = this.cfg.modelId;
115
+ events.push({
116
+ type: "sideband",
117
+ key,
118
+ route: Route.Background,
119
+ build: (ctxId, timestampMs) => ({
120
+ kind: "usage.recorded",
121
+ contextId: ctxId,
122
+ timestampMs,
123
+ stage: "tts" as const,
124
+ provider: "elevenlabs",
125
+ model: modelId,
126
+ characters,
127
+ }),
128
+ });
129
+ }
130
+ } catch (err) {
131
+ events.push({ type: "error", key, error: err instanceof Error ? err : new Error(String(err)) });
132
+ }
133
+ }
134
+ }
135
+
136
+ if (isFinalFlag(msg) && contextId) {
137
+ events.push({ type: "context_end", key });
138
+ this.charactersByKey.delete(key);
139
+ this.billedKeys.delete(key);
140
+ this.initializedKeys.delete(key);
141
+ }
142
+ return events;
143
+ }
144
+ }
145
+
146
+ export class ElevenLabsTTSPlugin implements VoicePlugin {
147
+ constructor(private readonly socketFactory?: SocketFactory) {}
148
+
149
+ private session: StreamingTtsSession | null = null;
150
+
151
+ async initialize(bus: PipelineBus, config: PluginConfig): Promise<void> {
152
+ const apiKey = requireStringConfig(config, "api_key");
153
+ const voiceId = optionalStringConfig(config, "voice_id") ?? DEFAULT_VOICE_ID;
154
+ const modelId = optionalStringConfig(config, "model_id") ?? DEFAULT_MODEL_ID;
155
+ const sampleRate = (config["sample_rate"] as number) ?? 16000;
156
+ const voiceSettings = (config["voice_settings"] as Record<string, unknown> | undefined) ?? DEFAULT_VOICE_SETTINGS;
157
+ const languageCode = optionalStringConfig(config, "language_code") ?? optionalStringConfig(config, "language");
158
+ const baseUrl =
159
+ optionalStringConfig(config, "endpoint_url") ??
160
+ `wss://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}/multi-stream-input`;
161
+ const audioFormat: AudioFormat = { encoding: "pcm_s16le", sampleRateHz: sampleRate, channels: 1 };
162
+ assertAudioFormat(audioFormat);
163
+ // Derive a sensible default from sample_rate, but let the dev override (mp3, ulaw_8000 for
164
+ // telephony, etc.) — never hard-pin the provider's own format knob.
165
+ const outputFormat = optionalStringConfig(config, "output_format") ?? pcmOutputFormat(sampleRate);
166
+ // Optional provider-specific passthrough for the multi-stream context-init frame.
167
+ const generationConfig = config["generation_config"] as Record<string, unknown> | undefined;
168
+
169
+ this.session = await startStreamingTtsSession(bus, {
170
+ protocol: new ElevenLabsWireProtocol({ modelId, audioFormat, voiceSettings, generationConfig }),
171
+ provider: { name: "elevenlabs", model: modelId, region: "global" },
172
+ format: audioFormat,
173
+ sampleRateHz: sampleRate,
174
+ url: () => {
175
+ const params = new URLSearchParams({
176
+ model_id: modelId,
177
+ output_format: outputFormat,
178
+ });
179
+ if (languageCode) params.set("language_code", languageCode);
180
+ const separator = baseUrl.includes("?") ? "&" : "?";
181
+ return `${baseUrl}${separator}${params.toString()}`;
182
+ },
183
+ headers: { "xi-api-key": apiKey },
184
+ retry: readProviderRetryConfig(config),
185
+ finishTimeoutMs: readNonNegativeInteger(config["finish_timeout_ms"], 2000),
186
+ metricPrefix: "tts.elevenlabs",
187
+ socketFactory: this.socketFactory ?? (await defaultNodeSocketFactory()),
188
+ replayBufferSize: (config["replay_buffer_size"] as number) ?? 32,
189
+ keepAliveIntervalMs: KEEP_ALIVE_INTERVAL_MS,
190
+ });
191
+ }
192
+
193
+ async close(): Promise<void> {
194
+ await this.session?.dispose();
195
+ this.session = null;
196
+ }
197
+ }
198
+
199
+ function pcmOutputFormat(sampleRate: number): string {
200
+ switch (sampleRate) {
201
+ case 8000:
202
+ return "pcm_8000";
203
+ case 16000:
204
+ return "pcm_16000";
205
+ case 22050:
206
+ return "pcm_22050";
207
+ case 24000:
208
+ return "pcm_24000";
209
+ case 44100:
210
+ return "pcm_44100";
211
+ default:
212
+ return `pcm_${sampleRate}`;
213
+ }
214
+ }
215
+
216
+ function readContextId(msg: Record<string, unknown>): string {
217
+ if (typeof msg["context_id"] === "string") return msg["context_id"];
218
+ if (typeof msg["contextId"] === "string") return msg["contextId"];
219
+ return "";
220
+ }
221
+
222
+ function isFinalFlag(msg: Record<string, unknown>): boolean {
223
+ return msg["is_final"] === true || msg["isFinal"] === true;
224
+ }
225
+
226
+ function isErrorFrame(msg: Record<string, unknown>): boolean {
227
+ const type = typeof msg["type"] === "string" ? msg["type"].toLowerCase() : "";
228
+ const messageType = typeof msg["message_type"] === "string" ? msg["message_type"].toLowerCase() : "";
229
+ return type === "error" || messageType === "error" || typeof msg["error"] === "string";
230
+ }
231
+
232
+ function elevenLabsProviderError(msg: Record<string, unknown>): Error {
233
+ const err =
234
+ typeof msg["error"] === "string"
235
+ ? msg["error"]
236
+ : typeof msg["message"] === "string"
237
+ ? msg["message"]
238
+ : "ElevenLabs TTS provider error";
239
+ return new Error(err);
240
+ }
241
+
242
+ function decodeStrictBase64(value: string, name: string): Buffer {
243
+ const normalized = value.replace(/\s+/g, "");
244
+ if (normalized.length === 0 || normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
245
+ throw new Error(`${name} must be valid base64`);
246
+ }
247
+ const decoded = Buffer.from(normalized, "base64");
248
+ if (decoded.toString("base64") !== normalized) {
249
+ throw new Error(`${name} must be valid base64`);
250
+ }
251
+ return decoded;
252
+ }
253
+
254
+ function readNonNegativeInteger(value: unknown, fallback: number): number {
255
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
256
+ const integer = Math.floor(value);
257
+ return integer >= 0 ? integer : fallback;
258
+ }