@framers/agentos-ext-streaming-tts-elevenlabs 0.2.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 +23 -0
- package/SKILL.md +65 -0
- package/dist/ElevenLabsStreamingTTS.d.ts +64 -0
- package/dist/ElevenLabsStreamingTTS.d.ts.map +1 -0
- package/dist/ElevenLabsStreamingTTS.js +91 -0
- package/dist/ElevenLabsStreamingTTS.js.map +1 -0
- package/dist/ElevenLabsTTSSession.d.ts +147 -0
- package/dist/ElevenLabsTTSSession.d.ts.map +1 -0
- package/dist/ElevenLabsTTSSession.js +334 -0
- package/dist/ElevenLabsTTSSession.js.map +1 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +88 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +120 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +11 -0
- package/dist/types.js.map +1 -0
- package/manifest.json +8 -0
- package/package.json +45 -0
- package/src/ElevenLabsStreamingTTS.ts +100 -0
- package/src/ElevenLabsTTSSession.ts +385 -0
- package/src/index.ts +125 -0
- package/src/types.ts +132 -0
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@framers/agentos-ext-streaming-tts-elevenlabs",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Streaming TTS via ElevenLabs WebSocket API for AgentOS voice pipeline",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"src",
|
|
17
|
+
"SKILL.md",
|
|
18
|
+
"manifest.json"
|
|
19
|
+
],
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@framers/agentos": "^0.1.0"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"ws": "^8.18.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/ws": "^8.5.13",
|
|
28
|
+
"typescript": "^5.5.0",
|
|
29
|
+
"vitest": "^1.6.0"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"author": "Frame.dev",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "https://github.com/framersai/agentos-extensions.git",
|
|
36
|
+
"directory": "registry/curated/voice/streaming-tts-elevenlabs"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "tsc -p tsconfig.json",
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file ElevenLabsStreamingTTS.ts
|
|
3
|
+
* @description {@link IStreamingTTS}-compatible factory for ElevenLabs TTS sessions.
|
|
4
|
+
*
|
|
5
|
+
* {@link ElevenLabsStreamingTTS} is a thin factory that creates
|
|
6
|
+
* {@link ElevenLabsTTSSession} instances on demand. It holds no mutable state
|
|
7
|
+
* other than an active-session reference count used to implement {@link isStreaming}.
|
|
8
|
+
*
|
|
9
|
+
* @module streaming-tts-elevenlabs/ElevenLabsStreamingTTS
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { ElevenLabsTTSSession } from './ElevenLabsTTSSession.js';
|
|
13
|
+
import type { ElevenLabsStreamingTTSConfig, StreamingTTSConfig } from './types.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Main class
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Factory for ElevenLabs-backed streaming TTS sessions.
|
|
21
|
+
*
|
|
22
|
+
* Instantiate once per agent and reuse across voice turns. Each call to
|
|
23
|
+
* {@link startSession} creates an independent {@link ElevenLabsTTSSession} that
|
|
24
|
+
* owns its own WebSocket connection lifecycle.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```ts
|
|
28
|
+
* const tts = new ElevenLabsStreamingTTS({ apiKey: process.env.ELEVENLABS_API_KEY! });
|
|
29
|
+
* const session = await tts.startSession();
|
|
30
|
+
*
|
|
31
|
+
* session.on('audio_chunk', (chunk) => audioPlayer.enqueue(chunk.audio));
|
|
32
|
+
*
|
|
33
|
+
* llm.on('token', (tok) => session.pushTokens(tok));
|
|
34
|
+
* llm.on('end', () => session.flush());
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class ElevenLabsStreamingTTS {
|
|
38
|
+
/**
|
|
39
|
+
* Stable provider identifier used by the voice pipeline to select between
|
|
40
|
+
* registered TTS implementations.
|
|
41
|
+
*/
|
|
42
|
+
readonly providerId = 'elevenlabs-streaming-tts';
|
|
43
|
+
|
|
44
|
+
/** `true` while at least one session is open and not yet closed. */
|
|
45
|
+
get isStreaming(): boolean {
|
|
46
|
+
return this._activeSessions > 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Count of sessions opened but not yet closed or cancelled. */
|
|
50
|
+
private _activeSessions = 0;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param config - ElevenLabs provider configuration. `apiKey` is required;
|
|
54
|
+
* all other fields have sensible defaults.
|
|
55
|
+
*/
|
|
56
|
+
constructor(private readonly config: ElevenLabsStreamingTTSConfig) {}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Open a new streaming TTS session backed by a fresh WebSocket connection.
|
|
60
|
+
*
|
|
61
|
+
* Provider-specific options can be forwarded via `config.providerOptions`
|
|
62
|
+
* under the following keys:
|
|
63
|
+
* - `voiceId` — ElevenLabs voice ID (default Rachel).
|
|
64
|
+
* - `modelId` — Model ID (default `'eleven_turbo_v2'`).
|
|
65
|
+
* - `stability` — Voice stability 0.0–1.0 (default `0.5`).
|
|
66
|
+
* - `similarityBoost`— Similarity boost 0.0–1.0 (default `0.75`).
|
|
67
|
+
* - `style` — Style exaggeration 0.0–1.0 (default `0.0`).
|
|
68
|
+
* - `useSpeakerBoost`— Speaker boost enabled (default `true`).
|
|
69
|
+
*
|
|
70
|
+
* @param config - Generic session config merged with provider defaults.
|
|
71
|
+
* @returns A ready-to-use {@link ElevenLabsTTSSession}.
|
|
72
|
+
*/
|
|
73
|
+
async startSession(config?: StreamingTTSConfig): Promise<ElevenLabsTTSSession> {
|
|
74
|
+
const provOpts = (config?.providerOptions ?? {}) as Partial<ElevenLabsStreamingTTSConfig>;
|
|
75
|
+
|
|
76
|
+
// Session-level overrides take precedence over factory-level config.
|
|
77
|
+
const sessionProviderConfig: ElevenLabsStreamingTTSConfig = {
|
|
78
|
+
...this.config,
|
|
79
|
+
voiceId: provOpts.voiceId ?? this.config.voiceId,
|
|
80
|
+
modelId: provOpts.modelId ?? this.config.modelId,
|
|
81
|
+
stability: provOpts.stability ?? this.config.stability,
|
|
82
|
+
similarityBoost:provOpts.similarityBoost ?? this.config.similarityBoost,
|
|
83
|
+
style: provOpts.style ?? this.config.style,
|
|
84
|
+
useSpeakerBoost:provOpts.useSpeakerBoost ?? this.config.useSpeakerBoost,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const session = new ElevenLabsTTSSession(sessionProviderConfig, config ?? {});
|
|
88
|
+
|
|
89
|
+
this._activeSessions++;
|
|
90
|
+
|
|
91
|
+
// Decrement the counter when the session terminates for any reason.
|
|
92
|
+
const onDone = (): void => {
|
|
93
|
+
this._activeSessions = Math.max(0, this._activeSessions - 1);
|
|
94
|
+
};
|
|
95
|
+
session.once('close', onDone);
|
|
96
|
+
session.once('cancelled', onDone);
|
|
97
|
+
|
|
98
|
+
return session;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file ElevenLabsTTSSession.ts
|
|
3
|
+
* @description Active TTS session backed by the ElevenLabs WebSocket Streaming API.
|
|
4
|
+
*
|
|
5
|
+
* {@link ElevenLabsTTSSession} implements the `StreamingTTSSession` interface
|
|
6
|
+
* (EventEmitter) and provides the following events:
|
|
7
|
+
*
|
|
8
|
+
* | Event | Payload | Description |
|
|
9
|
+
* |----------------------|----------------------------|------------------------------------------------------|
|
|
10
|
+
* | `'audio_chunk'` | `EncodedAudioChunk` | MP3 audio buffer from ElevenLabs, ready for playback |
|
|
11
|
+
* | `'utterance_complete'`| `{ text, durationMs }` | ElevenLabs signalled end of audio generation |
|
|
12
|
+
* | `'cancelled'` | `{ remaining: string }` | Session cancelled; remaining text not rendered |
|
|
13
|
+
* | `'error'` | `Error` | WebSocket or protocol error |
|
|
14
|
+
* | `'close'` | — | Session fully terminated |
|
|
15
|
+
*
|
|
16
|
+
* ### Protocol Overview
|
|
17
|
+
*
|
|
18
|
+
* The ElevenLabs WebSocket streaming protocol works as follows:
|
|
19
|
+
*
|
|
20
|
+
* 1. Open WSS connection to the voice/model endpoint.
|
|
21
|
+
* 2. Send a BOS (Beginning-Of-Stream) message: `{ text: " ", voice_settings: {...}, xi_api_key }`.
|
|
22
|
+
* 3. Stream text tokens as `{ text: "..." }` JSON messages.
|
|
23
|
+
* 4. To generate audio for accumulated text, include `flush: true` in the text message.
|
|
24
|
+
* 5. To end the stream, send an EOS (End-Of-Stream) message: `{ text: "" }`.
|
|
25
|
+
* 6. The server returns binary messages (MP3 audio chunks) and JSON status messages.
|
|
26
|
+
* 7. When all audio has been generated the server sends `{ isFinal: true }`.
|
|
27
|
+
*
|
|
28
|
+
* @module streaming-tts-elevenlabs/ElevenLabsTTSSession
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { EventEmitter } from 'node:events';
|
|
32
|
+
import WebSocket from 'ws';
|
|
33
|
+
import type { ElevenLabsStreamingTTSConfig, StreamingTTSConfig, EncodedAudioChunk } from './types.js';
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Constants
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
/** ElevenLabs WebSocket API base URL. */
|
|
40
|
+
const ELEVENLABS_WS_BASE = 'wss://api.elevenlabs.io';
|
|
41
|
+
|
|
42
|
+
/** Default voice ID — Rachel (a neutral, clear English voice). */
|
|
43
|
+
const DEFAULT_VOICE_ID = '21m00Tcm4TlvDq8ikWAM';
|
|
44
|
+
|
|
45
|
+
/** Default model — ElevenLabs Turbo v2, optimized for low latency. */
|
|
46
|
+
const DEFAULT_MODEL_ID = 'eleven_turbo_v2';
|
|
47
|
+
|
|
48
|
+
/** MP3 output format at 44.1 kHz / 128 kbps. */
|
|
49
|
+
const OUTPUT_FORMAT = 'mp3_44100_128';
|
|
50
|
+
|
|
51
|
+
/** Sample rate that matches the OUTPUT_FORMAT specifier. */
|
|
52
|
+
const SAMPLE_RATE_HZ = 44_100;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Rough estimate of spoken word rate used to compute {@link EncodedAudioChunk.durationMs}.
|
|
56
|
+
* 150 words per minute.
|
|
57
|
+
*/
|
|
58
|
+
const WORDS_PER_MS = 150 / 60_000;
|
|
59
|
+
|
|
60
|
+
/** Regex matching sentence-final punctuation. */
|
|
61
|
+
const SENTENCE_BOUNDARY_RE = /[.?!]/;
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Helpers
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Estimate the spoken duration in milliseconds for a piece of text.
|
|
69
|
+
*
|
|
70
|
+
* Uses a simple word-count heuristic (150 WPM). Callers should treat the result
|
|
71
|
+
* as a hint rather than a precise measurement.
|
|
72
|
+
*
|
|
73
|
+
* @param text - The text to estimate duration for.
|
|
74
|
+
* @returns Estimated playback duration in milliseconds (minimum 200 ms).
|
|
75
|
+
*/
|
|
76
|
+
function estimateDurationMs(text: string): number {
|
|
77
|
+
const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
|
|
78
|
+
return Math.max(200, Math.round(wordCount / WORDS_PER_MS));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Main class
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Active streaming TTS session backed by the ElevenLabs WebSocket Streaming API.
|
|
87
|
+
*
|
|
88
|
+
* Construct via {@link ElevenLabsStreamingTTS.startSession} rather than directly.
|
|
89
|
+
*
|
|
90
|
+
* @example
|
|
91
|
+
* ```ts
|
|
92
|
+
* const session = new ElevenLabsTTSSession(
|
|
93
|
+
* { apiKey: process.env.ELEVENLABS_API_KEY! },
|
|
94
|
+
* {},
|
|
95
|
+
* );
|
|
96
|
+
*
|
|
97
|
+
* session.on('audio_chunk', (chunk) => audioPlayer.enqueue(chunk.audio));
|
|
98
|
+
* session.on('utterance_complete', ({ text, durationMs }) => {
|
|
99
|
+
* console.log(`Synthesis complete ~${durationMs} ms`);
|
|
100
|
+
* });
|
|
101
|
+
*
|
|
102
|
+
* llm.on('token', (tok) => session.pushTokens(tok));
|
|
103
|
+
* llm.on('end', () => session.flush());
|
|
104
|
+
* ```
|
|
105
|
+
*/
|
|
106
|
+
export class ElevenLabsTTSSession extends EventEmitter {
|
|
107
|
+
// -------------------------------------------------------------------------
|
|
108
|
+
// Resolved config
|
|
109
|
+
// -------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/** ElevenLabs voice ID. */
|
|
112
|
+
private readonly voiceId: string;
|
|
113
|
+
|
|
114
|
+
/** ElevenLabs model ID. */
|
|
115
|
+
private readonly modelId: string;
|
|
116
|
+
|
|
117
|
+
/** Voice stability (0.0 – 1.0). */
|
|
118
|
+
private readonly stability: number;
|
|
119
|
+
|
|
120
|
+
/** Similarity boost (0.0 – 1.0). */
|
|
121
|
+
private readonly similarityBoost: number;
|
|
122
|
+
|
|
123
|
+
/** Style exaggeration (0.0 – 1.0). */
|
|
124
|
+
private readonly style: number;
|
|
125
|
+
|
|
126
|
+
/** Whether to enable speaker boost. */
|
|
127
|
+
private readonly useSpeakerBoost: boolean;
|
|
128
|
+
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
// Internal state
|
|
131
|
+
// -------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/** Live WebSocket connection to the ElevenLabs streaming endpoint. */
|
|
134
|
+
private readonly ws: WebSocket;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Text that has been pushed since the last sentence-boundary flush.
|
|
138
|
+
*
|
|
139
|
+
* Accumulated so that `cancel()` can report the remaining un-synthesised text.
|
|
140
|
+
*/
|
|
141
|
+
private pendingText = '';
|
|
142
|
+
|
|
143
|
+
/** `true` after {@link close} or {@link cancel} is called. */
|
|
144
|
+
private closed = false;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Whether the BOS (beginning-of-stream) message has been sent.
|
|
148
|
+
*
|
|
149
|
+
* The BOS is sent once on `ws.open`; subsequent messages are plain text sends.
|
|
150
|
+
*/
|
|
151
|
+
private bosAcknowledged = false;
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Queue of text segments that need to be sent once the WebSocket opens.
|
|
155
|
+
*
|
|
156
|
+
* If `pushTokens` or `flush` is called before the socket is open, messages
|
|
157
|
+
* are queued here and drained on `ws.open`.
|
|
158
|
+
*/
|
|
159
|
+
private readonly sendQueue: Array<object> = [];
|
|
160
|
+
|
|
161
|
+
// -------------------------------------------------------------------------
|
|
162
|
+
// Constructor
|
|
163
|
+
// -------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* @param providerConfig - ElevenLabs-specific configuration (apiKey required).
|
|
167
|
+
* @param _sessionConfig - Generic voice-pipeline session config (reserved for
|
|
168
|
+
* future `language` forwarding).
|
|
169
|
+
*/
|
|
170
|
+
constructor(
|
|
171
|
+
private readonly providerConfig: ElevenLabsStreamingTTSConfig,
|
|
172
|
+
_sessionConfig: StreamingTTSConfig,
|
|
173
|
+
) {
|
|
174
|
+
super();
|
|
175
|
+
|
|
176
|
+
// Apply defaults.
|
|
177
|
+
this.voiceId = providerConfig.voiceId ?? DEFAULT_VOICE_ID;
|
|
178
|
+
this.modelId = providerConfig.modelId ?? DEFAULT_MODEL_ID;
|
|
179
|
+
this.stability = providerConfig.stability ?? 0.5;
|
|
180
|
+
this.similarityBoost= providerConfig.similarityBoost ?? 0.75;
|
|
181
|
+
this.style = providerConfig.style ?? 0.0;
|
|
182
|
+
this.useSpeakerBoost= providerConfig.useSpeakerBoost ?? true;
|
|
183
|
+
|
|
184
|
+
// Build the WebSocket endpoint URL.
|
|
185
|
+
const wsUrl =
|
|
186
|
+
`${ELEVENLABS_WS_BASE}/v1/text-to-speech/${this.voiceId}/stream-input` +
|
|
187
|
+
`?model_id=${this.modelId}&output_format=${OUTPUT_FORMAT}`;
|
|
188
|
+
|
|
189
|
+
this.ws = new WebSocket(wsUrl);
|
|
190
|
+
|
|
191
|
+
this._wireWebSocket();
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
// StreamingTTSSession interface
|
|
196
|
+
// -------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Push LLM output token(s) into the stream.
|
|
200
|
+
*
|
|
201
|
+
* Text is accumulated in an internal buffer. When a sentence boundary
|
|
202
|
+
* (`.`, `?`, `!`) is detected the accumulated segment is sent to ElevenLabs
|
|
203
|
+
* with `flush: true` so the server generates audio immediately. Mid-sentence
|
|
204
|
+
* tokens are sent with a trailing space continuation to avoid falling intonation
|
|
205
|
+
* on partial phrases.
|
|
206
|
+
*
|
|
207
|
+
* @param text - One or more LLM token characters to append.
|
|
208
|
+
*/
|
|
209
|
+
pushTokens(text: string): void {
|
|
210
|
+
if (this.closed) return;
|
|
211
|
+
|
|
212
|
+
this.pendingText += text;
|
|
213
|
+
|
|
214
|
+
// Check for sentence-boundary punctuation.
|
|
215
|
+
const hasBoundary = SENTENCE_BOUNDARY_RE.test(text);
|
|
216
|
+
|
|
217
|
+
if (hasBoundary) {
|
|
218
|
+
// Send the accumulated text with flush: true so ElevenLabs generates audio.
|
|
219
|
+
const segment = this.pendingText;
|
|
220
|
+
this.pendingText = '';
|
|
221
|
+
this._send({ text: segment, flush: true });
|
|
222
|
+
} else {
|
|
223
|
+
// Send the token chunk without flush; include a space continuation to hint
|
|
224
|
+
// that the phrase is mid-sentence (prevents falling prosody on partial text).
|
|
225
|
+
this._send({ text });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Flush any remaining buffered text and signal EOS to ElevenLabs.
|
|
231
|
+
*
|
|
232
|
+
* Call this when the LLM stream has ended. Sends an EOS (end-of-stream)
|
|
233
|
+
* message (`{ text: "" }`) which instructs ElevenLabs to finalise audio
|
|
234
|
+
* generation for all remaining buffered text and close the stream.
|
|
235
|
+
*
|
|
236
|
+
* @returns Promise that resolves when the EOS message is queued for sending.
|
|
237
|
+
*/
|
|
238
|
+
async flush(): Promise<void> {
|
|
239
|
+
if (this.closed) return;
|
|
240
|
+
|
|
241
|
+
// If there is buffered text that has not been sent, send it with flush: true first.
|
|
242
|
+
if (this.pendingText.trim().length > 0) {
|
|
243
|
+
const segment = this.pendingText;
|
|
244
|
+
this.pendingText = '';
|
|
245
|
+
this._send({ text: segment, flush: true });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Send EOS — empty string signals end of stream to ElevenLabs.
|
|
249
|
+
this._send({ text: '' });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Cancel the session immediately.
|
|
254
|
+
*
|
|
255
|
+
* Closes the WebSocket, discards any unsent text, and emits a `'cancelled'`
|
|
256
|
+
* event carrying the remaining un-synthesised text.
|
|
257
|
+
*/
|
|
258
|
+
cancel(): void {
|
|
259
|
+
if (this.closed) return;
|
|
260
|
+
this.closed = true;
|
|
261
|
+
|
|
262
|
+
const remaining = this.pendingText;
|
|
263
|
+
this.pendingText = '';
|
|
264
|
+
|
|
265
|
+
this.ws.close();
|
|
266
|
+
this.emit('cancelled', { remaining });
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Close the session cleanly.
|
|
271
|
+
*
|
|
272
|
+
* Sends a WebSocket close frame and emits a `'close'` event.
|
|
273
|
+
* Idempotent — safe to call multiple times.
|
|
274
|
+
*/
|
|
275
|
+
close(): void {
|
|
276
|
+
if (this.closed) return;
|
|
277
|
+
this.closed = true;
|
|
278
|
+
|
|
279
|
+
this.ws.close();
|
|
280
|
+
this.emit('close');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// -------------------------------------------------------------------------
|
|
284
|
+
// Internal — WebSocket wiring
|
|
285
|
+
// -------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Attach event listeners to the WebSocket instance and handle the full
|
|
289
|
+
* message lifecycle.
|
|
290
|
+
*/
|
|
291
|
+
private _wireWebSocket(): void {
|
|
292
|
+
this.ws.on('open', () => {
|
|
293
|
+
// Send BOS message immediately on connection open.
|
|
294
|
+
const bosMessage = {
|
|
295
|
+
text: ' ',
|
|
296
|
+
voice_settings: {
|
|
297
|
+
stability: this.stability,
|
|
298
|
+
similarity_boost: this.similarityBoost,
|
|
299
|
+
style: this.style,
|
|
300
|
+
use_speaker_boost: this.useSpeakerBoost,
|
|
301
|
+
},
|
|
302
|
+
xi_api_key: this.providerConfig.apiKey,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Use the raw WS send to bypass the queue (BOS must be first).
|
|
306
|
+
this.ws.send(JSON.stringify(bosMessage));
|
|
307
|
+
this.bosAcknowledged = true;
|
|
308
|
+
|
|
309
|
+
// Drain any messages queued before the socket opened.
|
|
310
|
+
for (const queued of this.sendQueue) {
|
|
311
|
+
this.ws.send(JSON.stringify(queued));
|
|
312
|
+
}
|
|
313
|
+
this.sendQueue.length = 0;
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
this.ws.on('message', (data: WebSocket.RawData, isBinary: boolean) => {
|
|
317
|
+
if (this.closed) return;
|
|
318
|
+
|
|
319
|
+
if (isBinary) {
|
|
320
|
+
// Binary message: MP3 audio chunk.
|
|
321
|
+
const audio = Buffer.isBuffer(data)
|
|
322
|
+
? data
|
|
323
|
+
: Buffer.from(data as ArrayBuffer);
|
|
324
|
+
|
|
325
|
+
const durationMs = estimateDurationMs(this.pendingText);
|
|
326
|
+
|
|
327
|
+
const chunk: EncodedAudioChunk = {
|
|
328
|
+
audio,
|
|
329
|
+
format: 'mp3',
|
|
330
|
+
sampleRate: SAMPLE_RATE_HZ,
|
|
331
|
+
durationMs,
|
|
332
|
+
text: '',
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
this.emit('audio_chunk', chunk);
|
|
336
|
+
} else {
|
|
337
|
+
// Text (JSON) message: parse for protocol signals.
|
|
338
|
+
try {
|
|
339
|
+
const msg = JSON.parse(data.toString()) as Record<string, unknown>;
|
|
340
|
+
|
|
341
|
+
if (msg['isFinal'] === true) {
|
|
342
|
+
// ElevenLabs has finished generating all audio for this stream.
|
|
343
|
+
const durationMs = estimateDurationMs(this.pendingText);
|
|
344
|
+
this.emit('utterance_complete', { text: this.pendingText, durationMs });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Other JSON messages (e.g. alignment data) are silently ignored for now.
|
|
348
|
+
} catch {
|
|
349
|
+
// Malformed JSON from the server — emit a non-fatal error.
|
|
350
|
+
this.emit('error', new Error(`ElevenLabs: unexpected non-JSON text message: ${String(data)}`));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
this.ws.on('error', (err: Error) => {
|
|
356
|
+
if (!this.closed) {
|
|
357
|
+
this.emit('error', err);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
this.ws.on('close', () => {
|
|
362
|
+
if (!this.closed) {
|
|
363
|
+
this.closed = true;
|
|
364
|
+
this.emit('close');
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Send a JSON message over the WebSocket.
|
|
371
|
+
*
|
|
372
|
+
* If the socket is not yet open, the message is queued and will be sent once
|
|
373
|
+
* the `open` event fires (after the BOS message is dispatched).
|
|
374
|
+
*
|
|
375
|
+
* @param msg - The message object to serialise and send.
|
|
376
|
+
*/
|
|
377
|
+
private _send(msg: object): void {
|
|
378
|
+
if (this.bosAcknowledged && this.ws.readyState === WebSocket.OPEN) {
|
|
379
|
+
this.ws.send(JSON.stringify(msg));
|
|
380
|
+
} else {
|
|
381
|
+
// Queue for post-open delivery.
|
|
382
|
+
this.sendQueue.push(msg);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file index.ts
|
|
3
|
+
* @description Pack factory for the ElevenLabs Streaming TTS extension pack.
|
|
4
|
+
*
|
|
5
|
+
* Exports the main {@link createElevenLabsStreamingTTS} factory function and the
|
|
6
|
+
* {@link createExtensionPack} bridge function conforming to the AgentOS manifest
|
|
7
|
+
* factory convention.
|
|
8
|
+
*
|
|
9
|
+
* ### Usage (direct)
|
|
10
|
+
* ```ts
|
|
11
|
+
* import { createElevenLabsStreamingTTS } from '@framers/agentos-ext-streaming-tts-elevenlabs';
|
|
12
|
+
*
|
|
13
|
+
* const tts = createElevenLabsStreamingTTS(process.env.ELEVENLABS_API_KEY!);
|
|
14
|
+
* const session = await tts.startSession();
|
|
15
|
+
*
|
|
16
|
+
* session.on('audio_chunk', (chunk) => audioPlayer.enqueue(chunk.audio));
|
|
17
|
+
* llm.on('token', (tok) => session.pushTokens(tok));
|
|
18
|
+
* llm.on('end', () => session.flush());
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* ### Usage (manifest-driven)
|
|
22
|
+
* ```json
|
|
23
|
+
* { "packs": [{ "module": "@framers/agentos-ext-streaming-tts-elevenlabs" }] }
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @module streaming-tts-elevenlabs
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { ElevenLabsStreamingTTS } from './ElevenLabsStreamingTTS.js';
|
|
30
|
+
import type { ElevenLabsStreamingTTSConfig } from './types.js';
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Local interface mirrors — avoids a hard runtime dep on @framers/agentos
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
/** Subset of ExtensionDescriptor required by this pack. */
|
|
37
|
+
interface ExtensionDescriptor {
|
|
38
|
+
id: string;
|
|
39
|
+
kind: string;
|
|
40
|
+
payload: unknown;
|
|
41
|
+
enableByDefault?: boolean;
|
|
42
|
+
metadata?: Record<string, unknown>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Subset of ExtensionPack required by this pack. */
|
|
46
|
+
interface ExtensionPack {
|
|
47
|
+
id: string;
|
|
48
|
+
descriptors: ExtensionDescriptor[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subset of ExtensionPackContext required by this pack. */
|
|
52
|
+
interface ExtensionPackContext {
|
|
53
|
+
getSecret?: (id: string) => string | undefined;
|
|
54
|
+
options?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Kind constant matching packages/agentos/src/extensions/types.ts. */
|
|
58
|
+
const EXTENSION_KIND_STREAMING_TTS = 'streaming-tts-provider';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Factories
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create a standalone {@link ElevenLabsStreamingTTS} instance.
|
|
66
|
+
*
|
|
67
|
+
* Use this when composing the provider programmatically outside of the
|
|
68
|
+
* AgentOS extension system.
|
|
69
|
+
*
|
|
70
|
+
* @param apiKey - ElevenLabs API key.
|
|
71
|
+
* @param options - Optional provider configuration overrides.
|
|
72
|
+
* @returns Configured {@link ElevenLabsStreamingTTS}.
|
|
73
|
+
*/
|
|
74
|
+
export function createElevenLabsStreamingTTS(
|
|
75
|
+
apiKey: string,
|
|
76
|
+
options?: Omit<ElevenLabsStreamingTTSConfig, 'apiKey'>,
|
|
77
|
+
): ElevenLabsStreamingTTS {
|
|
78
|
+
return new ElevenLabsStreamingTTS({ apiKey, ...options });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* AgentOS manifest factory function.
|
|
83
|
+
*
|
|
84
|
+
* Reads the `ELEVENLABS_API_KEY` secret from the context and returns an
|
|
85
|
+
* {@link ExtensionPack} containing a single `streaming-tts-provider`
|
|
86
|
+
* descriptor backed by {@link ElevenLabsStreamingTTS}.
|
|
87
|
+
*
|
|
88
|
+
* Provider-level defaults can be overridden via `context.options`:
|
|
89
|
+
* - `voiceId` — ElevenLabs voice ID.
|
|
90
|
+
* - `modelId` — Model ID (default `'eleven_turbo_v2'`).
|
|
91
|
+
* - `stability` — Voice stability 0.0–1.0.
|
|
92
|
+
* - `similarityBoost`— Similarity boost 0.0–1.0.
|
|
93
|
+
* - `style` — Style exaggeration 0.0–1.0.
|
|
94
|
+
* - `useSpeakerBoost`— Speaker boost.
|
|
95
|
+
*
|
|
96
|
+
* @param context - Pack context supplied by the extension manager.
|
|
97
|
+
* @returns A fully configured {@link ExtensionPack}.
|
|
98
|
+
*/
|
|
99
|
+
export function createExtensionPack(context: ExtensionPackContext): ExtensionPack {
|
|
100
|
+
const apiKey = context.getSecret?.('ELEVENLABS_API_KEY') ?? '';
|
|
101
|
+
const opts = (context.options ?? {}) as Omit<ElevenLabsStreamingTTSConfig, 'apiKey'>;
|
|
102
|
+
|
|
103
|
+
const tts = new ElevenLabsStreamingTTS({ apiKey, ...opts });
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
id: 'streaming-tts-elevenlabs',
|
|
107
|
+
descriptors: [
|
|
108
|
+
{
|
|
109
|
+
id: 'elevenlabs-streaming-tts',
|
|
110
|
+
kind: EXTENSION_KIND_STREAMING_TTS,
|
|
111
|
+
payload: tts,
|
|
112
|
+
enableByDefault: true,
|
|
113
|
+
metadata: { providerId: 'elevenlabs-streaming-tts' },
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Re-exports
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export * from './types.js';
|
|
124
|
+
export { ElevenLabsStreamingTTS } from './ElevenLabsStreamingTTS.js';
|
|
125
|
+
export { ElevenLabsTTSSession } from './ElevenLabsTTSSession.js';
|