@moxxy/plugin-tts-elevenlabs 0.28.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 +21 -0
- package/dist/elevenlabs-tts.d.ts +84 -0
- package/dist/elevenlabs-tts.d.ts.map +1 -0
- package/dist/elevenlabs-tts.js +191 -0
- package/dist/elevenlabs-tts.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +67 -0
- package/dist/index.js.map +1 -0
- package/package.json +77 -0
- package/src/elevenlabs-tts.test.ts +276 -0
- package/src/elevenlabs-tts.ts +239 -0
- package/src/index.ts +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Moxxy (moxxy.ai)
|
|
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.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { type Synthesizer, type SynthesizeOptions, type SynthesisResult } from '@moxxy/sdk';
|
|
2
|
+
/** ElevenLabs `output_format` query values we expose, mapped to the MIME type
|
|
3
|
+
* the desktop/channels play the returned bytes as. Deliberately a small closed
|
|
4
|
+
* map of only the mp3 variants — every one is a self-contained, playable
|
|
5
|
+
* container that maps cleanly to `audio/mpeg`. `mp3_44100_128` is the default
|
|
6
|
+
* (44.1 kHz / 128 kbps).
|
|
7
|
+
*
|
|
8
|
+
* Formats intentionally NOT surfaced, and why:
|
|
9
|
+
* - `pcm_*` (raw 16-bit LE PCM): headerless — there is no container, so the
|
|
10
|
+
* honest options are to WAV-wrap the bytes or omit them. We omit rather than
|
|
11
|
+
* hand a caller unplayable bytes under a bogus `audio/wav`/`audio/pcm` label.
|
|
12
|
+
* - `ulaw_8000` / `alaw_8000`: telephony codecs, likewise headerless.
|
|
13
|
+
* - opus: some newer API versions document an `opus_48000_*` family, but we
|
|
14
|
+
* are not confident of the exact stable token on this endpoint, and inventing
|
|
15
|
+
* a query value would 4xx the request — so it is left out rather than guessed. */
|
|
16
|
+
declare const MIME_BY_FORMAT: {
|
|
17
|
+
readonly mp3_44100_128: "audio/mpeg";
|
|
18
|
+
readonly mp3_44100_64: "audio/mpeg";
|
|
19
|
+
readonly mp3_22050_32: "audio/mpeg";
|
|
20
|
+
};
|
|
21
|
+
export type ElevenLabsTtsFormat = keyof typeof MIME_BY_FORMAT;
|
|
22
|
+
/** Injectable `fetch` — the default is the global; tests pass a stub. Widened
|
|
23
|
+
* from the global signature so a plain `(url, init) => Response` stub fits. */
|
|
24
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
25
|
+
export interface ElevenLabsSynthesizerOptions {
|
|
26
|
+
/** Explicit API key. Normally omitted — the key is read via `getSecret`. */
|
|
27
|
+
readonly apiKey?: string;
|
|
28
|
+
/** Vault-backed secret resolver handed in by `SynthesizerCreateContext`. */
|
|
29
|
+
readonly getSecret?: (name: string) => Promise<string | null>;
|
|
30
|
+
/** API base, default `https://api.elevenlabs.io/v1`. Trailing slashes trimmed. */
|
|
31
|
+
readonly baseURL?: string;
|
|
32
|
+
/** TTS model, default `eleven_multilingual_v2`. */
|
|
33
|
+
readonly model?: string;
|
|
34
|
+
/** Default voice id, default Rachel (`21m00Tcm4TlvDq8ikWAM`). Overridden
|
|
35
|
+
* per-call by `opts.voice`. */
|
|
36
|
+
readonly voiceId?: string;
|
|
37
|
+
/** Output container, default `mp3_44100_128`. Determines the returned `mimeType`. */
|
|
38
|
+
readonly format?: ElevenLabsTtsFormat;
|
|
39
|
+
/** Injected `fetch` (tests). Defaults to the global. */
|
|
40
|
+
readonly fetchImpl?: FetchLike;
|
|
41
|
+
/** Per-request timeout in ms. Default 60000. */
|
|
42
|
+
readonly timeoutMs?: number;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* `Synthesizer` backed by ElevenLabs' `POST /v1/text-to-speech/{voiceId}`
|
|
46
|
+
* endpoint — one JSON POST returning raw audio bytes, so there's no need for a
|
|
47
|
+
* vendor SDK. The API key rides the vault (`ctx.getSecret('ELEVENLABS_API_KEY')`)
|
|
48
|
+
* with a `process.env.ELEVENLABS_API_KEY` fallback, resolved lazily on the first
|
|
49
|
+
* `synthesize` and cached so `create()` stays cheap. This is the "quality"
|
|
50
|
+
* Synthesizer option alongside the OpenAI backend.
|
|
51
|
+
*/
|
|
52
|
+
export declare class ElevenLabsSynthesizer implements Synthesizer {
|
|
53
|
+
readonly name = "elevenlabs";
|
|
54
|
+
/** MIME type of the bytes this instance returns (derived from `format`). */
|
|
55
|
+
readonly mimeType: string;
|
|
56
|
+
private readonly explicitKey;
|
|
57
|
+
private readonly getSecret;
|
|
58
|
+
private readonly baseURL;
|
|
59
|
+
private readonly model;
|
|
60
|
+
private readonly voiceId;
|
|
61
|
+
private readonly format;
|
|
62
|
+
private readonly fetchImpl;
|
|
63
|
+
private readonly timeoutMs;
|
|
64
|
+
private key;
|
|
65
|
+
constructor(opts?: ElevenLabsSynthesizerOptions);
|
|
66
|
+
synthesize(text: string, opts?: SynthesizeOptions): Promise<SynthesisResult>;
|
|
67
|
+
/**
|
|
68
|
+
* Resolve the API key lazily: explicit option → vault (`ELEVENLABS_API_KEY`) →
|
|
69
|
+
* `process.env.ELEVENLABS_API_KEY`. Cache only a successful resolution so a key
|
|
70
|
+
* added after a first failed attempt is still picked up on retry. A missing
|
|
71
|
+
* key becomes a classified, actionable `MoxxyError`.
|
|
72
|
+
*/
|
|
73
|
+
private resolveKey;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Cap `text` to ElevenLabs' input limit. When over, truncate at the last
|
|
77
|
+
* sentence boundary (`. `, `! `, `? `, newline) inside the budget — as long as
|
|
78
|
+
* that keeps most of the text — else hard-slice, and append a single ellipsis so
|
|
79
|
+
* the result is always ≤ {@link MAX_INPUT_CHARS} characters.
|
|
80
|
+
*/
|
|
81
|
+
export declare function capInput(text: string): string;
|
|
82
|
+
export declare function createElevenLabsSynthesizer(opts?: ElevenLabsSynthesizerOptions): ElevenLabsSynthesizer;
|
|
83
|
+
export {};
|
|
84
|
+
//# sourceMappingURL=elevenlabs-tts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"elevenlabs-tts.d.ts","sourceRoot":"","sources":["../src/elevenlabs-tts.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,YAAY,CAAC;AAKpB;;;;;;;;;;;;;sFAasF;AACtF,QAAA,MAAM,cAAc;;;;CAIV,CAAC;AAEX,MAAM,MAAM,mBAAmB,GAAG,MAAM,OAAO,cAAc,CAAC;AAe9D;gFACgF;AAChF,MAAM,MAAM,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEhF,MAAM,WAAW,4BAA4B;IAC3C,4EAA4E;IAC5E,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,4EAA4E;IAC5E,QAAQ,CAAC,SAAS,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC9D,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,mDAAmD;IACnD,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;oCACgC;IAChC,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,qFAAqF;IACrF,QAAQ,CAAC,MAAM,CAAC,EAAE,mBAAmB,CAAC;IACtC,wDAAwD;IACxD,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,gDAAgD;IAChD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;;GAOG;AACH,qBAAa,qBAAsB,YAAW,WAAW;IACvD,QAAQ,CAAC,IAAI,gBAAgB;IAC7B,4EAA4E;IAC5E,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAqB;IACjD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyD;IACnF,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAsB;IAC7C,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,GAAG,CAAuB;gBAEtB,IAAI,GAAE,4BAAiC;IAY7C,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,iBAAsB,GAAG,OAAO,CAAC,eAAe,CAAC;IAgFtF;;;;;OAKG;YACW,UAAU;CAkBzB;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAc7C;AAED,wBAAgB,2BAA2B,CACzC,IAAI,GAAE,4BAAiC,GACtC,qBAAqB,CAEvB"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { classifyHttpStatus, classifyNetworkError, MoxxyError, } from '@moxxy/sdk';
|
|
2
|
+
/** Provider tag attached to classified errors for logs/debug context. */
|
|
3
|
+
const ELEVENLABS_TTS_PROVIDER_ID = 'elevenlabs';
|
|
4
|
+
/** ElevenLabs `output_format` query values we expose, mapped to the MIME type
|
|
5
|
+
* the desktop/channels play the returned bytes as. Deliberately a small closed
|
|
6
|
+
* map of only the mp3 variants — every one is a self-contained, playable
|
|
7
|
+
* container that maps cleanly to `audio/mpeg`. `mp3_44100_128` is the default
|
|
8
|
+
* (44.1 kHz / 128 kbps).
|
|
9
|
+
*
|
|
10
|
+
* Formats intentionally NOT surfaced, and why:
|
|
11
|
+
* - `pcm_*` (raw 16-bit LE PCM): headerless — there is no container, so the
|
|
12
|
+
* honest options are to WAV-wrap the bytes or omit them. We omit rather than
|
|
13
|
+
* hand a caller unplayable bytes under a bogus `audio/wav`/`audio/pcm` label.
|
|
14
|
+
* - `ulaw_8000` / `alaw_8000`: telephony codecs, likewise headerless.
|
|
15
|
+
* - opus: some newer API versions document an `opus_48000_*` family, but we
|
|
16
|
+
* are not confident of the exact stable token on this endpoint, and inventing
|
|
17
|
+
* a query value would 4xx the request — so it is left out rather than guessed. */
|
|
18
|
+
const MIME_BY_FORMAT = {
|
|
19
|
+
mp3_44100_128: 'audio/mpeg',
|
|
20
|
+
mp3_44100_64: 'audio/mpeg',
|
|
21
|
+
mp3_22050_32: 'audio/mpeg',
|
|
22
|
+
};
|
|
23
|
+
/** Rachel — a stock ElevenLabs voice present on every account. */
|
|
24
|
+
const DEFAULT_VOICE_ID = '21m00Tcm4TlvDq8ikWAM';
|
|
25
|
+
/** ElevenLabs' input length caps vary by plan/model (roughly 2500–5000 chars).
|
|
26
|
+
* We truncate at the conservative 2500 so a long channel reply never 4xx's the
|
|
27
|
+
* whole read-aloud regardless of the caller's plan; the cut lands on a sentence
|
|
28
|
+
* boundary, same mechanism as the OpenAI sibling. */
|
|
29
|
+
const MAX_INPUT_CHARS = 2500;
|
|
30
|
+
/** Default per-request deadline. Read-aloud is cancellable, but a hung socket
|
|
31
|
+
* should still free itself so callers can fall back to the OS voice. */
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
33
|
+
/**
|
|
34
|
+
* `Synthesizer` backed by ElevenLabs' `POST /v1/text-to-speech/{voiceId}`
|
|
35
|
+
* endpoint — one JSON POST returning raw audio bytes, so there's no need for a
|
|
36
|
+
* vendor SDK. The API key rides the vault (`ctx.getSecret('ELEVENLABS_API_KEY')`)
|
|
37
|
+
* with a `process.env.ELEVENLABS_API_KEY` fallback, resolved lazily on the first
|
|
38
|
+
* `synthesize` and cached so `create()` stays cheap. This is the "quality"
|
|
39
|
+
* Synthesizer option alongside the OpenAI backend.
|
|
40
|
+
*/
|
|
41
|
+
export class ElevenLabsSynthesizer {
|
|
42
|
+
name = 'elevenlabs';
|
|
43
|
+
/** MIME type of the bytes this instance returns (derived from `format`). */
|
|
44
|
+
mimeType;
|
|
45
|
+
explicitKey;
|
|
46
|
+
getSecret;
|
|
47
|
+
baseURL;
|
|
48
|
+
model;
|
|
49
|
+
voiceId;
|
|
50
|
+
format;
|
|
51
|
+
fetchImpl;
|
|
52
|
+
timeoutMs;
|
|
53
|
+
key = null;
|
|
54
|
+
constructor(opts = {}) {
|
|
55
|
+
this.explicitKey = opts.apiKey;
|
|
56
|
+
this.getSecret = opts.getSecret;
|
|
57
|
+
this.baseURL = (opts.baseURL ?? 'https://api.elevenlabs.io/v1').replace(/\/+$/, '');
|
|
58
|
+
this.model = opts.model ?? 'eleven_multilingual_v2';
|
|
59
|
+
this.voiceId = opts.voiceId ?? DEFAULT_VOICE_ID;
|
|
60
|
+
this.format = opts.format ?? 'mp3_44100_128';
|
|
61
|
+
this.mimeType = MIME_BY_FORMAT[this.format];
|
|
62
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
63
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
64
|
+
}
|
|
65
|
+
async synthesize(text, opts = {}) {
|
|
66
|
+
// Fast-path a caller who cancelled before we started — propagate their reason.
|
|
67
|
+
opts.signal?.throwIfAborted();
|
|
68
|
+
const key = await this.resolveKey();
|
|
69
|
+
const input = capInput(text);
|
|
70
|
+
const voiceId = opts.voice ?? this.voiceId;
|
|
71
|
+
// `opts.rate` is intentionally ignored: ElevenLabs has no reliable, model-
|
|
72
|
+
// agnostic speaking-rate parameter (`voice_settings` covers stability /
|
|
73
|
+
// similarity / style, not speed), so mapping it would be guesswork. We omit
|
|
74
|
+
// `voice_settings` entirely and let the model use its own defaults.
|
|
75
|
+
const body = {
|
|
76
|
+
text: input,
|
|
77
|
+
model_id: this.model,
|
|
78
|
+
};
|
|
79
|
+
// `output_format` is a QUERY parameter on this endpoint, not a body field.
|
|
80
|
+
const url = `${this.baseURL}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${this.format}`;
|
|
81
|
+
// Chain the caller's abort signal with our own timeout onto one controller
|
|
82
|
+
// passed to fetch, so either cancels the request and frees the socket.
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
let timedOut = false;
|
|
85
|
+
const timer = setTimeout(() => {
|
|
86
|
+
timedOut = true;
|
|
87
|
+
controller.abort();
|
|
88
|
+
}, this.timeoutMs);
|
|
89
|
+
const forward = () => controller.abort();
|
|
90
|
+
if (opts.signal) {
|
|
91
|
+
// The signal may have fired during key resolution above; a listener added
|
|
92
|
+
// to an already-aborted signal is never called, so abort directly here.
|
|
93
|
+
if (opts.signal.aborted)
|
|
94
|
+
controller.abort();
|
|
95
|
+
else
|
|
96
|
+
opts.signal.addEventListener('abort', forward, { once: true });
|
|
97
|
+
}
|
|
98
|
+
let res;
|
|
99
|
+
try {
|
|
100
|
+
res = await this.fetchImpl(url, {
|
|
101
|
+
method: 'POST',
|
|
102
|
+
headers: { 'xi-api-key': key, 'content-type': 'application/json' },
|
|
103
|
+
body: JSON.stringify(body),
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
// Caller-initiated cancellation: re-throw their reason unchanged so a
|
|
109
|
+
// stopped read-aloud isn't masked as a provider/network error.
|
|
110
|
+
if (opts.signal?.aborted && !timedOut)
|
|
111
|
+
throw opts.signal.reason ?? err;
|
|
112
|
+
if (timedOut) {
|
|
113
|
+
throw new MoxxyError({
|
|
114
|
+
code: 'NETWORK_TIMEOUT',
|
|
115
|
+
message: `ElevenLabs text-to-speech request timed out after ${this.timeoutMs} ms.`,
|
|
116
|
+
hint: 'Retry, or shorten the text being read aloud.',
|
|
117
|
+
context: { provider: ELEVENLABS_TTS_PROVIDER_ID, url },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const network = classifyNetworkError(err, { provider: ELEVENLABS_TTS_PROVIDER_ID, url });
|
|
121
|
+
if (network)
|
|
122
|
+
throw network;
|
|
123
|
+
throw err;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
opts.signal?.removeEventListener('abort', forward);
|
|
128
|
+
}
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
const classified = classifyHttpStatus(res.status, {
|
|
131
|
+
provider: ELEVENLABS_TTS_PROVIDER_ID,
|
|
132
|
+
url,
|
|
133
|
+
body: await res.text().catch(() => ''),
|
|
134
|
+
});
|
|
135
|
+
if (classified)
|
|
136
|
+
throw classified;
|
|
137
|
+
throw new MoxxyError({
|
|
138
|
+
code: 'PROVIDER_BAD_REQUEST',
|
|
139
|
+
message: `ElevenLabs text-to-speech returned HTTP ${res.status}.`,
|
|
140
|
+
context: { provider: ELEVENLABS_TTS_PROVIDER_ID, url, status: res.status },
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const audio = new Uint8Array(await res.arrayBuffer());
|
|
144
|
+
return { audio, mimeType: this.mimeType };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Resolve the API key lazily: explicit option → vault (`ELEVENLABS_API_KEY`) →
|
|
148
|
+
* `process.env.ELEVENLABS_API_KEY`. Cache only a successful resolution so a key
|
|
149
|
+
* added after a first failed attempt is still picked up on retry. A missing
|
|
150
|
+
* key becomes a classified, actionable `MoxxyError`.
|
|
151
|
+
*/
|
|
152
|
+
async resolveKey() {
|
|
153
|
+
if (this.key)
|
|
154
|
+
return this.key;
|
|
155
|
+
const resolved = this.explicitKey ??
|
|
156
|
+
(await this.getSecret?.('ELEVENLABS_API_KEY')) ??
|
|
157
|
+
process.env.ELEVENLABS_API_KEY ??
|
|
158
|
+
null;
|
|
159
|
+
if (!resolved) {
|
|
160
|
+
throw new MoxxyError({
|
|
161
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
162
|
+
message: 'No ElevenLabs API key for text-to-speech.',
|
|
163
|
+
hint: 'Run `moxxy init` (stores it in the vault) or set ELEVENLABS_API_KEY.',
|
|
164
|
+
context: { provider: ELEVENLABS_TTS_PROVIDER_ID },
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
this.key = resolved;
|
|
168
|
+
return resolved;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Cap `text` to ElevenLabs' input limit. When over, truncate at the last
|
|
173
|
+
* sentence boundary (`. `, `! `, `? `, newline) inside the budget — as long as
|
|
174
|
+
* that keeps most of the text — else hard-slice, and append a single ellipsis so
|
|
175
|
+
* the result is always ≤ {@link MAX_INPUT_CHARS} characters.
|
|
176
|
+
*/
|
|
177
|
+
export function capInput(text) {
|
|
178
|
+
if (text.length <= MAX_INPUT_CHARS)
|
|
179
|
+
return text;
|
|
180
|
+
const budget = MAX_INPUT_CHARS - 1; // leave room for the ellipsis
|
|
181
|
+
const slice = text.slice(0, budget);
|
|
182
|
+
const boundary = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('! '), slice.lastIndexOf('? '), slice.lastIndexOf('\n'));
|
|
183
|
+
// Only honor a boundary that doesn't discard more than half the budget,
|
|
184
|
+
// otherwise a single very long sentence would be cut to almost nothing.
|
|
185
|
+
const cut = boundary > budget * 0.5 ? boundary + 1 : budget;
|
|
186
|
+
return `${slice.slice(0, cut).trimEnd()}…`;
|
|
187
|
+
}
|
|
188
|
+
export function createElevenLabsSynthesizer(opts = {}) {
|
|
189
|
+
return new ElevenLabsSynthesizer(opts);
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=elevenlabs-tts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"elevenlabs-tts.js","sourceRoot":"","sources":["../src/elevenlabs-tts.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import { type ElevenLabsSynthesizerOptions } from './elevenlabs-tts.js';
|
|
3
|
+
export { ElevenLabsSynthesizer, createElevenLabsSynthesizer, capInput, type ElevenLabsSynthesizerOptions, type ElevenLabsTtsFormat, type FetchLike, } from './elevenlabs-tts.js';
|
|
4
|
+
/** The single registered synthesizer name — surfaces show this in `set_voice`. */
|
|
5
|
+
export declare const ELEVENLABS_TTS_SYNTHESIZER_NAME = "elevenlabs";
|
|
6
|
+
export interface BuildElevenLabsTtsPluginOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Build-time defaults baked into the synthesizer def. Per-activation config
|
|
9
|
+
* (`session.synthesizers.setActive(name, config)`) still overrides `model` /
|
|
10
|
+
* `voiceId` / `format`. Mainly a seam for tests to inject `fetchImpl` / `apiKey`.
|
|
11
|
+
*/
|
|
12
|
+
readonly defaults?: Omit<ElevenLabsSynthesizerOptions, 'getSecret'>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build the @moxxy/plugin-tts-elevenlabs plugin. Registers exactly one
|
|
16
|
+
* synthesizer, `elevenlabs`, backed by ElevenLabs' `POST /v1/text-to-speech`.
|
|
17
|
+
*
|
|
18
|
+
* The plugin is intentionally side-effect free — it never calls `setActive`.
|
|
19
|
+
* The `SynthesizerRegistry` is `autoAdoptFirst`, so the first synthesizer
|
|
20
|
+
* registered becomes active on read without any activation step here; the agent
|
|
21
|
+
* switches voices via the `set_voice` tool.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildElevenLabsTtsPlugin(opts?: BuildElevenLabsTtsPluginOptions): Plugin;
|
|
24
|
+
declare const _default: Plugin;
|
|
25
|
+
export default _default;
|
|
26
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAmC,KAAK,MAAM,EAAE,MAAM,YAAY,CAAC;AAC1E,OAAO,EAEL,KAAK,4BAA4B,EAElC,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,qBAAqB,EACrB,2BAA2B,EAC3B,QAAQ,EACR,KAAK,4BAA4B,EACjC,KAAK,mBAAmB,EACxB,KAAK,SAAS,GACf,MAAM,qBAAqB,CAAC;AAE7B,kFAAkF;AAClF,eAAO,MAAM,+BAA+B,eAAe,CAAC;AAE5D,MAAM,WAAW,+BAA+B;IAC9C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,4BAA4B,EAAE,WAAW,CAAC,CAAC;CACrE;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,CAAC,IAAI,GAAE,+BAAoC,GAAG,MAAM,CAoB3F;;AAiCD,wBAA0C"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { definePlugin, defineSynthesizer } from '@moxxy/sdk';
|
|
2
|
+
import { ElevenLabsSynthesizer, } from './elevenlabs-tts.js';
|
|
3
|
+
export { ElevenLabsSynthesizer, createElevenLabsSynthesizer, capInput, } from './elevenlabs-tts.js';
|
|
4
|
+
/** The single registered synthesizer name — surfaces show this in `set_voice`. */
|
|
5
|
+
export const ELEVENLABS_TTS_SYNTHESIZER_NAME = 'elevenlabs';
|
|
6
|
+
/**
|
|
7
|
+
* Build the @moxxy/plugin-tts-elevenlabs plugin. Registers exactly one
|
|
8
|
+
* synthesizer, `elevenlabs`, backed by ElevenLabs' `POST /v1/text-to-speech`.
|
|
9
|
+
*
|
|
10
|
+
* The plugin is intentionally side-effect free — it never calls `setActive`.
|
|
11
|
+
* The `SynthesizerRegistry` is `autoAdoptFirst`, so the first synthesizer
|
|
12
|
+
* registered becomes active on read without any activation step here; the agent
|
|
13
|
+
* switches voices via the `set_voice` tool.
|
|
14
|
+
*/
|
|
15
|
+
export function buildElevenLabsTtsPlugin(opts = {}) {
|
|
16
|
+
const defaults = opts.defaults ?? {};
|
|
17
|
+
return definePlugin({
|
|
18
|
+
name: '@moxxy/plugin-tts-elevenlabs',
|
|
19
|
+
version: '0.0.0',
|
|
20
|
+
synthesizers: [
|
|
21
|
+
defineSynthesizer({
|
|
22
|
+
name: ELEVENLABS_TTS_SYNTHESIZER_NAME,
|
|
23
|
+
displayName: 'ElevenLabs',
|
|
24
|
+
// `create` runs lazily and may re-run (buildOnRead) — keep it cheap and
|
|
25
|
+
// side-effect free; the key is resolved inside `synthesize`.
|
|
26
|
+
create: (ctx) => new ElevenLabsSynthesizer({
|
|
27
|
+
...defaults,
|
|
28
|
+
...configToOptions(ctx.config),
|
|
29
|
+
...(ctx.getSecret ? { getSecret: ctx.getSecret } : {}),
|
|
30
|
+
}),
|
|
31
|
+
}),
|
|
32
|
+
],
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Narrow the untrusted per-activation config record into typed options. Only
|
|
36
|
+
* string `model` / `voiceId` / `baseURL` / `apiKey` and a known `format` are
|
|
37
|
+
* honored; anything else is ignored so a malformed config can't break create. */
|
|
38
|
+
function configToOptions(config) {
|
|
39
|
+
const out = {};
|
|
40
|
+
if (typeof config.model === 'string' && config.model)
|
|
41
|
+
out.model = config.model;
|
|
42
|
+
if (typeof config.voiceId === 'string' && config.voiceId)
|
|
43
|
+
out.voiceId = config.voiceId;
|
|
44
|
+
if (typeof config.baseURL === 'string' && config.baseURL)
|
|
45
|
+
out.baseURL = config.baseURL;
|
|
46
|
+
if (typeof config.apiKey === 'string' && config.apiKey)
|
|
47
|
+
out.apiKey = config.apiKey;
|
|
48
|
+
const format = normalizeFormat(config.format);
|
|
49
|
+
if (format)
|
|
50
|
+
out.format = format;
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
const KNOWN_FORMATS = [
|
|
54
|
+
'mp3_44100_128',
|
|
55
|
+
'mp3_44100_64',
|
|
56
|
+
'mp3_22050_32',
|
|
57
|
+
];
|
|
58
|
+
/** Accept only the exact supported format strings (guards against inherited
|
|
59
|
+
* keys like `constructor` slipping through an `in` check). */
|
|
60
|
+
function normalizeFormat(value) {
|
|
61
|
+
return typeof value === 'string' && KNOWN_FORMATS.includes(value)
|
|
62
|
+
? value
|
|
63
|
+
: undefined;
|
|
64
|
+
}
|
|
65
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
66
|
+
export default buildElevenLabsTtsPlugin();
|
|
67
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAe,MAAM,YAAY,CAAC;AAC1E,OAAO,EACL,qBAAqB,GAGtB,MAAM,qBAAqB,CAAC;AAE7B,OAAO,EACL,qBAAqB,EACrB,2BAA2B,EAC3B,QAAQ,GAIT,MAAM,qBAAqB,CAAC;AAE7B,kFAAkF;AAClF,MAAM,CAAC,MAAM,+BAA+B,GAAG,YAAY,CAAC;AAW5D;;;;;;;;GAQG;AACH,MAAM,UAAU,wBAAwB,CAAC,OAAwC,EAAE;IACjF,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC;IACrC,OAAO,YAAY,CAAC;QAClB,IAAI,EAAE,8BAA8B;QACpC,OAAO,EAAE,OAAO;QAChB,YAAY,EAAE;YACZ,iBAAiB,CAAC;gBAChB,IAAI,EAAE,+BAA+B;gBACrC,WAAW,EAAE,YAAY;gBACzB,wEAAwE;gBACxE,6DAA6D;gBAC7D,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,CACd,IAAI,qBAAqB,CAAC;oBACxB,GAAG,QAAQ;oBACX,GAAG,eAAe,CAAC,GAAG,CAAC,MAAM,CAAC;oBAC9B,GAAG,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC,CAAC,EAAE,SAAS,EAAE,GAAG,CAAC,SAAS,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;iBACvD,CAAC;aACL,CAAC;SACH;KACF,CAAC,CAAC;AACL,CAAC;AAED;;kFAEkF;AAClF,SAAS,eAAe,CAAC,MAA+B;IACtD,MAAM,GAAG,GAEL,EAAE,CAAC;IACP,IAAI,OAAO,MAAM,CAAC,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK;QAAE,GAAG,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;IAC/E,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,IAAI,MAAM,CAAC,OAAO;QAAE,GAAG,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IACvF,IAAI,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,IAAI,MAAM,CAAC,OAAO;QAAE,GAAG,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC;IACvF,IAAI,OAAO,MAAM,CAAC,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,MAAM;QAAE,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;IACnF,MAAM,MAAM,GAAG,eAAe,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IAC9C,IAAI,MAAM;QAAE,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,aAAa,GAAuC;IACxD,eAAe;IACf,cAAc;IACd,cAAc;CACf,CAAC;AAEF;+DAC+D;AAC/D,SAAS,eAAe,CAAC,KAAc;IACrC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,aAAuC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAC1F,CAAC,CAAE,KAA6B;QAChC,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED,0EAA0E;AAC1E,eAAe,wBAAwB,EAAE,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-tts-elevenlabs",
|
|
3
|
+
"version": "0.28.0",
|
|
4
|
+
"description": "ElevenLabs text-to-speech Synthesizer for moxxy. Text → spoken audio bytes via POST /v1/text-to-speech/{voiceId}. Plugs into the session's SynthesizerRegistry; read-aloud surfaces (desktop 'Read aloud', a voice channel) consume it transparently. The higher-quality Synthesizer backend alongside OpenAI TTS.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"tts",
|
|
9
|
+
"text-to-speech",
|
|
10
|
+
"elevenlabs",
|
|
11
|
+
"synthesizer"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://moxxy.ai",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/moxxy-ai/moxxy/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/moxxy-ai/moxxy.git",
|
|
20
|
+
"directory": "packages/plugin-tts-elevenlabs"
|
|
21
|
+
},
|
|
22
|
+
"author": "Michal Makowski <michal.makowski97@gmail.com>",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"main": "./dist/index.js",
|
|
29
|
+
"types": "./dist/index.d.ts",
|
|
30
|
+
"exports": {
|
|
31
|
+
".": {
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"import": "./dist/index.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist",
|
|
38
|
+
"src"
|
|
39
|
+
],
|
|
40
|
+
"moxxy": {
|
|
41
|
+
"plugin": {
|
|
42
|
+
"entry": "./dist/index.js",
|
|
43
|
+
"kind": "synthesizer"
|
|
44
|
+
},
|
|
45
|
+
"setup": {
|
|
46
|
+
"title": "ElevenLabs text-to-speech",
|
|
47
|
+
"description": "Read-aloud through ElevenLabs' /v1/text-to-speech. Needs an ElevenLabs API key — the plugin does nothing without one, so skipping this leaves the package disabled.",
|
|
48
|
+
"fields": [
|
|
49
|
+
{
|
|
50
|
+
"key": "apiKey",
|
|
51
|
+
"label": "ElevenLabs API key",
|
|
52
|
+
"kind": "secret",
|
|
53
|
+
"vaultKey": "ELEVENLABS_API_KEY",
|
|
54
|
+
"required": true,
|
|
55
|
+
"placeholder": "sk_…",
|
|
56
|
+
"description": "Your ElevenLabs API key, from https://elevenlabs.io → Profile → API Keys. Stored in the vault as ELEVENLABS_API_KEY; the model never sees it."
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@moxxy/sdk": "0.28.0"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@types/node": "^22.10.0",
|
|
66
|
+
"typescript": "^5.7.3",
|
|
67
|
+
"vitest": "^2.1.8",
|
|
68
|
+
"@moxxy/vitest-preset": "0.0.0",
|
|
69
|
+
"@moxxy/tsconfig": "0.0.0"
|
|
70
|
+
},
|
|
71
|
+
"scripts": {
|
|
72
|
+
"build": "tsc -p tsconfig.json",
|
|
73
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
74
|
+
"test": "vitest run",
|
|
75
|
+
"clean": "rm -rf dist .turbo"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ElevenLabsSynthesizer, capInput, type FetchLike } from './elevenlabs-tts.js';
|
|
3
|
+
import { buildElevenLabsTtsPlugin, ELEVENLABS_TTS_SYNTHESIZER_NAME } from './index.js';
|
|
4
|
+
|
|
5
|
+
interface Captured {
|
|
6
|
+
url: string;
|
|
7
|
+
init: RequestInit;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const RACHEL = '21m00Tcm4TlvDq8ikWAM';
|
|
11
|
+
|
|
12
|
+
/** A stub `fetch` that records the request and returns fixed audio bytes. */
|
|
13
|
+
function okFetch(
|
|
14
|
+
bytes: Uint8Array = new Uint8Array([1, 2, 3, 4]),
|
|
15
|
+
status = 200,
|
|
16
|
+
): { fetchImpl: FetchLike; calls: Captured[] } {
|
|
17
|
+
const calls: Captured[] = [];
|
|
18
|
+
const fetchImpl: FetchLike = async (url, init) => {
|
|
19
|
+
calls.push({ url, init });
|
|
20
|
+
return new Response(bytes, { status });
|
|
21
|
+
};
|
|
22
|
+
return { fetchImpl, calls };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A stub `fetch` that returns an error status with a body. */
|
|
26
|
+
function statusFetch(status: number, body = 'boom'): FetchLike {
|
|
27
|
+
return async () => new Response(body, { status });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A `fetch` that never resolves and only rejects when its signal aborts —
|
|
31
|
+
* checking `aborted` synchronously (as real fetch does) so it works even when
|
|
32
|
+
* the signal fired before the request was issued. Drives abort + timeout. */
|
|
33
|
+
const abortAwareFetch: FetchLike = (_url, init) =>
|
|
34
|
+
new Promise((_resolve, reject) => {
|
|
35
|
+
const signal = init.signal;
|
|
36
|
+
const fail = (): void => reject(new DOMException('Aborted', 'AbortError'));
|
|
37
|
+
if (signal?.aborted) fail();
|
|
38
|
+
else signal?.addEventListener('abort', fail, { once: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Parse the JSON body a captured request carried. */
|
|
42
|
+
function bodyOf(calls: Captured[]): Record<string, unknown> {
|
|
43
|
+
return JSON.parse(calls[0]!.init.body as string) as Record<string, unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe('ElevenLabsSynthesizer.synthesize', () => {
|
|
47
|
+
it('returns audio bytes and the mp3 mimeType by default', async () => {
|
|
48
|
+
const { fetchImpl, calls } = okFetch(new Uint8Array([9, 8, 7]));
|
|
49
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
50
|
+
const out = await synth.synthesize('hello world');
|
|
51
|
+
|
|
52
|
+
expect(Array.from(out.audio)).toEqual([9, 8, 7]);
|
|
53
|
+
expect(out.mimeType).toBe('audio/mpeg');
|
|
54
|
+
expect(calls[0]!.url).toBe(
|
|
55
|
+
`https://api.elevenlabs.io/v1/text-to-speech/${RACHEL}?output_format=mp3_44100_128`,
|
|
56
|
+
);
|
|
57
|
+
expect(calls[0]!.init.method).toBe('POST');
|
|
58
|
+
const headers = calls[0]!.init.headers as Record<string, string>;
|
|
59
|
+
expect(headers['xi-api-key']).toBe('sk-test');
|
|
60
|
+
expect(headers['content-type']).toBe('application/json');
|
|
61
|
+
const body = bodyOf(calls);
|
|
62
|
+
expect(body.text).toBe('hello world');
|
|
63
|
+
expect(body.model_id).toBe('eleven_multilingual_v2');
|
|
64
|
+
// No speaking-rate/voice_settings field is ever sent (see `rate` below).
|
|
65
|
+
expect(Object.keys(body).sort()).toEqual(['model_id', 'text']);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
['mp3_44100_128', 'audio/mpeg'],
|
|
70
|
+
['mp3_44100_64', 'audio/mpeg'],
|
|
71
|
+
['mp3_22050_32', 'audio/mpeg'],
|
|
72
|
+
] as const)('maps format %s to mimeType %s (as an output_format query)', async (format, mimeType) => {
|
|
73
|
+
const { fetchImpl, calls } = okFetch();
|
|
74
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl, format });
|
|
75
|
+
const out = await synth.synthesize('hi');
|
|
76
|
+
expect(out.mimeType).toBe(mimeType);
|
|
77
|
+
expect(calls[0]!.url).toContain(`output_format=${format}`);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('lets opts.voice override the configured voiceId (in the URL path)', async () => {
|
|
81
|
+
const { fetchImpl, calls } = okFetch();
|
|
82
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl, voiceId: RACHEL });
|
|
83
|
+
await synth.synthesize('hi', { voice: 'AZnzlk1XvdvUeBnXmlld' });
|
|
84
|
+
expect(calls[0]!.url).toBe(
|
|
85
|
+
'https://api.elevenlabs.io/v1/text-to-speech/AZnzlk1XvdvUeBnXmlld?output_format=mp3_44100_128',
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('ignores opts.rate (no speaking-rate parameter is sent)', async () => {
|
|
90
|
+
const { fetchImpl, calls } = okFetch();
|
|
91
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
92
|
+
await synth.synthesize('hi', { rate: 1.5 });
|
|
93
|
+
const body = bodyOf(calls);
|
|
94
|
+
expect(body.speed).toBeUndefined();
|
|
95
|
+
expect(body.voice_settings).toBeUndefined();
|
|
96
|
+
expect(Object.keys(body).sort()).toEqual(['model_id', 'text']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('truncates input over 2500 chars at a sentence boundary with an ellipsis', async () => {
|
|
100
|
+
const { fetchImpl, calls } = okFetch();
|
|
101
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
102
|
+
// 'a'*2400 + '. ' then more — the sentence boundary at index 2400 is the
|
|
103
|
+
// last one inside the 2499-char budget.
|
|
104
|
+
const long = `${'a'.repeat(2400)}. ${'b'.repeat(200)}. ${'c'.repeat(200)}`;
|
|
105
|
+
await synth.synthesize(long);
|
|
106
|
+
const input = bodyOf(calls).text as string;
|
|
107
|
+
expect(input.length).toBeLessThanOrEqual(2500);
|
|
108
|
+
expect(input.length).toBeLessThan(long.length);
|
|
109
|
+
expect(input.endsWith('…')).toBe(true);
|
|
110
|
+
// Cut fell right after the sentence-ending period, not mid-word.
|
|
111
|
+
expect(input.endsWith('.…')).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('leaves short input untouched (no ellipsis)', async () => {
|
|
115
|
+
const { fetchImpl, calls } = okFetch();
|
|
116
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
117
|
+
await synth.synthesize('a short reply.');
|
|
118
|
+
expect(bodyOf(calls).text).toBe('a short reply.');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('throws AUTH_NO_CREDENTIALS when no key is available anywhere', async () => {
|
|
122
|
+
const prev = process.env.ELEVENLABS_API_KEY;
|
|
123
|
+
delete process.env.ELEVENLABS_API_KEY;
|
|
124
|
+
try {
|
|
125
|
+
const { fetchImpl } = okFetch();
|
|
126
|
+
const synth = new ElevenLabsSynthesizer({ getSecret: async () => null, fetchImpl });
|
|
127
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({
|
|
128
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
if (prev !== undefined) process.env.ELEVENLABS_API_KEY = prev;
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('reads the key from getSecret (ELEVENLABS_API_KEY) when no explicit key', async () => {
|
|
136
|
+
const { fetchImpl, calls } = okFetch();
|
|
137
|
+
const synth = new ElevenLabsSynthesizer({
|
|
138
|
+
getSecret: async (name) => (name === 'ELEVENLABS_API_KEY' ? 'sk-vault' : null),
|
|
139
|
+
fetchImpl,
|
|
140
|
+
});
|
|
141
|
+
await synth.synthesize('hi');
|
|
142
|
+
const headers = calls[0]!.init.headers as Record<string, string>;
|
|
143
|
+
expect(headers['xi-api-key']).toBe('sk-vault');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it.each([
|
|
147
|
+
[401, 'AUTH_INVALID'],
|
|
148
|
+
[403, 'AUTH_DENIED'],
|
|
149
|
+
[429, 'PROVIDER_RATE_LIMITED'],
|
|
150
|
+
[500, 'PROVIDER_SERVER_ERROR'],
|
|
151
|
+
[503, 'PROVIDER_SERVER_ERROR'],
|
|
152
|
+
])('maps HTTP %s to %s', async (status, code) => {
|
|
153
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl: statusFetch(status) });
|
|
154
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({ code });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('falls back to PROVIDER_BAD_REQUEST for an unmapped status', async () => {
|
|
158
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl: statusFetch(418) });
|
|
159
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({
|
|
160
|
+
code: 'PROVIDER_BAD_REQUEST',
|
|
161
|
+
context: { status: 418 },
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('classifies a network failure', async () => {
|
|
166
|
+
const fetchImpl: FetchLike = async () => {
|
|
167
|
+
throw new Error('fetch failed');
|
|
168
|
+
};
|
|
169
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
170
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({
|
|
171
|
+
code: 'NETWORK_UNREACHABLE',
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('honors an already-aborted signal, propagating its reason', async () => {
|
|
176
|
+
const { fetchImpl } = okFetch();
|
|
177
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
178
|
+
const ac = new AbortController();
|
|
179
|
+
const reason = new Error('user stopped');
|
|
180
|
+
ac.abort(reason);
|
|
181
|
+
await expect(synth.synthesize('hi', { signal: ac.signal })).rejects.toBe(reason);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('honors an in-flight abort, propagating the caller reason', async () => {
|
|
185
|
+
const synth = new ElevenLabsSynthesizer({ apiKey: 'sk-test', fetchImpl: abortAwareFetch });
|
|
186
|
+
const ac = new AbortController();
|
|
187
|
+
const reason = new Error('cancelled mid-flight');
|
|
188
|
+
const p = synth.synthesize('hi', { signal: ac.signal });
|
|
189
|
+
ac.abort(reason);
|
|
190
|
+
await expect(p).rejects.toBe(reason);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('times out a hung request as NETWORK_TIMEOUT', async () => {
|
|
194
|
+
const synth = new ElevenLabsSynthesizer({
|
|
195
|
+
apiKey: 'sk-test',
|
|
196
|
+
fetchImpl: abortAwareFetch,
|
|
197
|
+
timeoutMs: 10,
|
|
198
|
+
});
|
|
199
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('capInput helper', () => {
|
|
204
|
+
it('passes through under the limit', () => {
|
|
205
|
+
expect(capInput('short')).toBe('short');
|
|
206
|
+
expect(capInput('a'.repeat(2500))).toBe('a'.repeat(2500));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('hard-slices a single boundary-less sentence', () => {
|
|
210
|
+
const out = capInput('x'.repeat(4000));
|
|
211
|
+
expect(out.length).toBe(2500); // 2499 chars + the ellipsis
|
|
212
|
+
expect(out.endsWith('…')).toBe(true);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('buildElevenLabsTtsPlugin', () => {
|
|
217
|
+
it('registers exactly one synthesizer named elevenlabs', () => {
|
|
218
|
+
const plugin = buildElevenLabsTtsPlugin();
|
|
219
|
+
expect(plugin.synthesizers).toHaveLength(1);
|
|
220
|
+
const def = plugin.synthesizers![0]!;
|
|
221
|
+
expect(def.name).toBe(ELEVENLABS_TTS_SYNTHESIZER_NAME);
|
|
222
|
+
expect(def.displayName).toBe('ElevenLabs');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('create() yields a synthesizer whose config voiceId/format flow through', async () => {
|
|
226
|
+
const { fetchImpl, calls } = okFetch();
|
|
227
|
+
const plugin = buildElevenLabsTtsPlugin({ defaults: { fetchImpl, apiKey: 'sk-test' } });
|
|
228
|
+
const def = plugin.synthesizers![0]!;
|
|
229
|
+
const inst = def.create({ config: { voiceId: 'AZnzlk1XvdvUeBnXmlld', format: 'mp3_22050_32' } });
|
|
230
|
+
expect(inst.name).toBe('elevenlabs');
|
|
231
|
+
const out = await inst.synthesize('hi');
|
|
232
|
+
expect(out.mimeType).toBe('audio/mpeg');
|
|
233
|
+
expect(calls[0]!.url).toBe(
|
|
234
|
+
'https://api.elevenlabs.io/v1/text-to-speech/AZnzlk1XvdvUeBnXmlld?output_format=mp3_22050_32',
|
|
235
|
+
);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('create() wires ctx.getSecret through for key resolution', async () => {
|
|
239
|
+
const { fetchImpl, calls } = okFetch();
|
|
240
|
+
const plugin = buildElevenLabsTtsPlugin({ defaults: { fetchImpl } });
|
|
241
|
+
const def = plugin.synthesizers![0]!;
|
|
242
|
+
const inst = def.create({
|
|
243
|
+
config: {},
|
|
244
|
+
getSecret: async (name) => (name === 'ELEVENLABS_API_KEY' ? 'sk-ctx' : null),
|
|
245
|
+
});
|
|
246
|
+
await inst.synthesize('hi');
|
|
247
|
+
const headers = calls[0]!.init.headers as Record<string, string>;
|
|
248
|
+
expect(headers['xi-api-key']).toBe('sk-ctx');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('create() ignores an unknown config format', async () => {
|
|
252
|
+
const { fetchImpl } = okFetch();
|
|
253
|
+
const plugin = buildElevenLabsTtsPlugin({ defaults: { fetchImpl, apiKey: 'sk-test' } });
|
|
254
|
+
const inst = plugin.synthesizers![0]!.create({ config: { format: 'pcm_24000' } });
|
|
255
|
+
const out = await inst.synthesize('hi');
|
|
256
|
+
// Unknown/unsupported format ignored → default mp3_44100_128.
|
|
257
|
+
expect(out.mimeType).toBe('audio/mpeg');
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it('default export registers and builds a synthesizer with a fake getSecret', async () => {
|
|
261
|
+
// Registry-level smoke: the default export is what `createPluginLoader`
|
|
262
|
+
// discovers. It must register exactly one synthesizer whose `create`, given a
|
|
263
|
+
// vault-backed `getSecret`, builds a ready instance without touching the
|
|
264
|
+
// network or a live key.
|
|
265
|
+
const { default: plugin } = await import('./index.js');
|
|
266
|
+
expect(plugin.name).toBe('@moxxy/plugin-tts-elevenlabs');
|
|
267
|
+
expect(plugin.synthesizers).toHaveLength(1);
|
|
268
|
+
const def = plugin.synthesizers![0]!;
|
|
269
|
+
const inst = def.create({
|
|
270
|
+
config: {},
|
|
271
|
+
getSecret: async (name) => (name === 'ELEVENLABS_API_KEY' ? 'sk-smoke' : null),
|
|
272
|
+
});
|
|
273
|
+
expect(inst.name).toBe('elevenlabs');
|
|
274
|
+
expect(inst.mimeType).toBe('audio/mpeg');
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {
|
|
2
|
+
classifyHttpStatus,
|
|
3
|
+
classifyNetworkError,
|
|
4
|
+
MoxxyError,
|
|
5
|
+
type Synthesizer,
|
|
6
|
+
type SynthesizeOptions,
|
|
7
|
+
type SynthesisResult,
|
|
8
|
+
} from '@moxxy/sdk';
|
|
9
|
+
|
|
10
|
+
/** Provider tag attached to classified errors for logs/debug context. */
|
|
11
|
+
const ELEVENLABS_TTS_PROVIDER_ID = 'elevenlabs';
|
|
12
|
+
|
|
13
|
+
/** ElevenLabs `output_format` query values we expose, mapped to the MIME type
|
|
14
|
+
* the desktop/channels play the returned bytes as. Deliberately a small closed
|
|
15
|
+
* map of only the mp3 variants — every one is a self-contained, playable
|
|
16
|
+
* container that maps cleanly to `audio/mpeg`. `mp3_44100_128` is the default
|
|
17
|
+
* (44.1 kHz / 128 kbps).
|
|
18
|
+
*
|
|
19
|
+
* Formats intentionally NOT surfaced, and why:
|
|
20
|
+
* - `pcm_*` (raw 16-bit LE PCM): headerless — there is no container, so the
|
|
21
|
+
* honest options are to WAV-wrap the bytes or omit them. We omit rather than
|
|
22
|
+
* hand a caller unplayable bytes under a bogus `audio/wav`/`audio/pcm` label.
|
|
23
|
+
* - `ulaw_8000` / `alaw_8000`: telephony codecs, likewise headerless.
|
|
24
|
+
* - opus: some newer API versions document an `opus_48000_*` family, but we
|
|
25
|
+
* are not confident of the exact stable token on this endpoint, and inventing
|
|
26
|
+
* a query value would 4xx the request — so it is left out rather than guessed. */
|
|
27
|
+
const MIME_BY_FORMAT = {
|
|
28
|
+
mp3_44100_128: 'audio/mpeg',
|
|
29
|
+
mp3_44100_64: 'audio/mpeg',
|
|
30
|
+
mp3_22050_32: 'audio/mpeg',
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export type ElevenLabsTtsFormat = keyof typeof MIME_BY_FORMAT;
|
|
34
|
+
|
|
35
|
+
/** Rachel — a stock ElevenLabs voice present on every account. */
|
|
36
|
+
const DEFAULT_VOICE_ID = '21m00Tcm4TlvDq8ikWAM';
|
|
37
|
+
|
|
38
|
+
/** ElevenLabs' input length caps vary by plan/model (roughly 2500–5000 chars).
|
|
39
|
+
* We truncate at the conservative 2500 so a long channel reply never 4xx's the
|
|
40
|
+
* whole read-aloud regardless of the caller's plan; the cut lands on a sentence
|
|
41
|
+
* boundary, same mechanism as the OpenAI sibling. */
|
|
42
|
+
const MAX_INPUT_CHARS = 2500;
|
|
43
|
+
|
|
44
|
+
/** Default per-request deadline. Read-aloud is cancellable, but a hung socket
|
|
45
|
+
* should still free itself so callers can fall back to the OS voice. */
|
|
46
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
47
|
+
|
|
48
|
+
/** Injectable `fetch` — the default is the global; tests pass a stub. Widened
|
|
49
|
+
* from the global signature so a plain `(url, init) => Response` stub fits. */
|
|
50
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
51
|
+
|
|
52
|
+
export interface ElevenLabsSynthesizerOptions {
|
|
53
|
+
/** Explicit API key. Normally omitted — the key is read via `getSecret`. */
|
|
54
|
+
readonly apiKey?: string;
|
|
55
|
+
/** Vault-backed secret resolver handed in by `SynthesizerCreateContext`. */
|
|
56
|
+
readonly getSecret?: (name: string) => Promise<string | null>;
|
|
57
|
+
/** API base, default `https://api.elevenlabs.io/v1`. Trailing slashes trimmed. */
|
|
58
|
+
readonly baseURL?: string;
|
|
59
|
+
/** TTS model, default `eleven_multilingual_v2`. */
|
|
60
|
+
readonly model?: string;
|
|
61
|
+
/** Default voice id, default Rachel (`21m00Tcm4TlvDq8ikWAM`). Overridden
|
|
62
|
+
* per-call by `opts.voice`. */
|
|
63
|
+
readonly voiceId?: string;
|
|
64
|
+
/** Output container, default `mp3_44100_128`. Determines the returned `mimeType`. */
|
|
65
|
+
readonly format?: ElevenLabsTtsFormat;
|
|
66
|
+
/** Injected `fetch` (tests). Defaults to the global. */
|
|
67
|
+
readonly fetchImpl?: FetchLike;
|
|
68
|
+
/** Per-request timeout in ms. Default 60000. */
|
|
69
|
+
readonly timeoutMs?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `Synthesizer` backed by ElevenLabs' `POST /v1/text-to-speech/{voiceId}`
|
|
74
|
+
* endpoint — one JSON POST returning raw audio bytes, so there's no need for a
|
|
75
|
+
* vendor SDK. The API key rides the vault (`ctx.getSecret('ELEVENLABS_API_KEY')`)
|
|
76
|
+
* with a `process.env.ELEVENLABS_API_KEY` fallback, resolved lazily on the first
|
|
77
|
+
* `synthesize` and cached so `create()` stays cheap. This is the "quality"
|
|
78
|
+
* Synthesizer option alongside the OpenAI backend.
|
|
79
|
+
*/
|
|
80
|
+
export class ElevenLabsSynthesizer implements Synthesizer {
|
|
81
|
+
readonly name = 'elevenlabs';
|
|
82
|
+
/** MIME type of the bytes this instance returns (derived from `format`). */
|
|
83
|
+
readonly mimeType: string;
|
|
84
|
+
|
|
85
|
+
private readonly explicitKey: string | undefined;
|
|
86
|
+
private readonly getSecret: ((name: string) => Promise<string | null>) | undefined;
|
|
87
|
+
private readonly baseURL: string;
|
|
88
|
+
private readonly model: string;
|
|
89
|
+
private readonly voiceId: string;
|
|
90
|
+
private readonly format: ElevenLabsTtsFormat;
|
|
91
|
+
private readonly fetchImpl: FetchLike;
|
|
92
|
+
private readonly timeoutMs: number;
|
|
93
|
+
private key: string | null = null;
|
|
94
|
+
|
|
95
|
+
constructor(opts: ElevenLabsSynthesizerOptions = {}) {
|
|
96
|
+
this.explicitKey = opts.apiKey;
|
|
97
|
+
this.getSecret = opts.getSecret;
|
|
98
|
+
this.baseURL = (opts.baseURL ?? 'https://api.elevenlabs.io/v1').replace(/\/+$/, '');
|
|
99
|
+
this.model = opts.model ?? 'eleven_multilingual_v2';
|
|
100
|
+
this.voiceId = opts.voiceId ?? DEFAULT_VOICE_ID;
|
|
101
|
+
this.format = opts.format ?? 'mp3_44100_128';
|
|
102
|
+
this.mimeType = MIME_BY_FORMAT[this.format];
|
|
103
|
+
this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as FetchLike);
|
|
104
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async synthesize(text: string, opts: SynthesizeOptions = {}): Promise<SynthesisResult> {
|
|
108
|
+
// Fast-path a caller who cancelled before we started — propagate their reason.
|
|
109
|
+
opts.signal?.throwIfAborted();
|
|
110
|
+
|
|
111
|
+
const key = await this.resolveKey();
|
|
112
|
+
const input = capInput(text);
|
|
113
|
+
const voiceId = opts.voice ?? this.voiceId;
|
|
114
|
+
// `opts.rate` is intentionally ignored: ElevenLabs has no reliable, model-
|
|
115
|
+
// agnostic speaking-rate parameter (`voice_settings` covers stability /
|
|
116
|
+
// similarity / style, not speed), so mapping it would be guesswork. We omit
|
|
117
|
+
// `voice_settings` entirely and let the model use its own defaults.
|
|
118
|
+
const body = {
|
|
119
|
+
text: input,
|
|
120
|
+
model_id: this.model,
|
|
121
|
+
};
|
|
122
|
+
// `output_format` is a QUERY parameter on this endpoint, not a body field.
|
|
123
|
+
const url = `${this.baseURL}/text-to-speech/${encodeURIComponent(voiceId)}?output_format=${this.format}`;
|
|
124
|
+
|
|
125
|
+
// Chain the caller's abort signal with our own timeout onto one controller
|
|
126
|
+
// passed to fetch, so either cancels the request and frees the socket.
|
|
127
|
+
const controller = new AbortController();
|
|
128
|
+
let timedOut = false;
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
timedOut = true;
|
|
131
|
+
controller.abort();
|
|
132
|
+
}, this.timeoutMs);
|
|
133
|
+
const forward = (): void => controller.abort();
|
|
134
|
+
if (opts.signal) {
|
|
135
|
+
// The signal may have fired during key resolution above; a listener added
|
|
136
|
+
// to an already-aborted signal is never called, so abort directly here.
|
|
137
|
+
if (opts.signal.aborted) controller.abort();
|
|
138
|
+
else opts.signal.addEventListener('abort', forward, { once: true });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let res: Response;
|
|
142
|
+
try {
|
|
143
|
+
res = await this.fetchImpl(url, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'xi-api-key': key, 'content-type': 'application/json' },
|
|
146
|
+
body: JSON.stringify(body),
|
|
147
|
+
signal: controller.signal,
|
|
148
|
+
});
|
|
149
|
+
} catch (err) {
|
|
150
|
+
// Caller-initiated cancellation: re-throw their reason unchanged so a
|
|
151
|
+
// stopped read-aloud isn't masked as a provider/network error.
|
|
152
|
+
if (opts.signal?.aborted && !timedOut) throw opts.signal.reason ?? err;
|
|
153
|
+
if (timedOut) {
|
|
154
|
+
throw new MoxxyError({
|
|
155
|
+
code: 'NETWORK_TIMEOUT',
|
|
156
|
+
message: `ElevenLabs text-to-speech request timed out after ${this.timeoutMs} ms.`,
|
|
157
|
+
hint: 'Retry, or shorten the text being read aloud.',
|
|
158
|
+
context: { provider: ELEVENLABS_TTS_PROVIDER_ID, url },
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
const network = classifyNetworkError(err, { provider: ELEVENLABS_TTS_PROVIDER_ID, url });
|
|
162
|
+
if (network) throw network;
|
|
163
|
+
throw err;
|
|
164
|
+
} finally {
|
|
165
|
+
clearTimeout(timer);
|
|
166
|
+
opts.signal?.removeEventListener('abort', forward);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
const classified = classifyHttpStatus(res.status, {
|
|
171
|
+
provider: ELEVENLABS_TTS_PROVIDER_ID,
|
|
172
|
+
url,
|
|
173
|
+
body: await res.text().catch(() => ''),
|
|
174
|
+
});
|
|
175
|
+
if (classified) throw classified;
|
|
176
|
+
throw new MoxxyError({
|
|
177
|
+
code: 'PROVIDER_BAD_REQUEST',
|
|
178
|
+
message: `ElevenLabs text-to-speech returned HTTP ${res.status}.`,
|
|
179
|
+
context: { provider: ELEVENLABS_TTS_PROVIDER_ID, url, status: res.status },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const audio = new Uint8Array(await res.arrayBuffer());
|
|
184
|
+
return { audio, mimeType: this.mimeType };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Resolve the API key lazily: explicit option → vault (`ELEVENLABS_API_KEY`) →
|
|
189
|
+
* `process.env.ELEVENLABS_API_KEY`. Cache only a successful resolution so a key
|
|
190
|
+
* added after a first failed attempt is still picked up on retry. A missing
|
|
191
|
+
* key becomes a classified, actionable `MoxxyError`.
|
|
192
|
+
*/
|
|
193
|
+
private async resolveKey(): Promise<string> {
|
|
194
|
+
if (this.key) return this.key;
|
|
195
|
+
const resolved =
|
|
196
|
+
this.explicitKey ??
|
|
197
|
+
(await this.getSecret?.('ELEVENLABS_API_KEY')) ??
|
|
198
|
+
process.env.ELEVENLABS_API_KEY ??
|
|
199
|
+
null;
|
|
200
|
+
if (!resolved) {
|
|
201
|
+
throw new MoxxyError({
|
|
202
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
203
|
+
message: 'No ElevenLabs API key for text-to-speech.',
|
|
204
|
+
hint: 'Run `moxxy init` (stores it in the vault) or set ELEVENLABS_API_KEY.',
|
|
205
|
+
context: { provider: ELEVENLABS_TTS_PROVIDER_ID },
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
this.key = resolved;
|
|
209
|
+
return resolved;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Cap `text` to ElevenLabs' input limit. When over, truncate at the last
|
|
215
|
+
* sentence boundary (`. `, `! `, `? `, newline) inside the budget — as long as
|
|
216
|
+
* that keeps most of the text — else hard-slice, and append a single ellipsis so
|
|
217
|
+
* the result is always ≤ {@link MAX_INPUT_CHARS} characters.
|
|
218
|
+
*/
|
|
219
|
+
export function capInput(text: string): string {
|
|
220
|
+
if (text.length <= MAX_INPUT_CHARS) return text;
|
|
221
|
+
const budget = MAX_INPUT_CHARS - 1; // leave room for the ellipsis
|
|
222
|
+
const slice = text.slice(0, budget);
|
|
223
|
+
const boundary = Math.max(
|
|
224
|
+
slice.lastIndexOf('. '),
|
|
225
|
+
slice.lastIndexOf('! '),
|
|
226
|
+
slice.lastIndexOf('? '),
|
|
227
|
+
slice.lastIndexOf('\n'),
|
|
228
|
+
);
|
|
229
|
+
// Only honor a boundary that doesn't discard more than half the budget,
|
|
230
|
+
// otherwise a single very long sentence would be cut to almost nothing.
|
|
231
|
+
const cut = boundary > budget * 0.5 ? boundary + 1 : budget;
|
|
232
|
+
return `${slice.slice(0, cut).trimEnd()}…`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function createElevenLabsSynthesizer(
|
|
236
|
+
opts: ElevenLabsSynthesizerOptions = {},
|
|
237
|
+
): ElevenLabsSynthesizer {
|
|
238
|
+
return new ElevenLabsSynthesizer(opts);
|
|
239
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { definePlugin, defineSynthesizer, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import {
|
|
3
|
+
ElevenLabsSynthesizer,
|
|
4
|
+
type ElevenLabsSynthesizerOptions,
|
|
5
|
+
type ElevenLabsTtsFormat,
|
|
6
|
+
} from './elevenlabs-tts.js';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
ElevenLabsSynthesizer,
|
|
10
|
+
createElevenLabsSynthesizer,
|
|
11
|
+
capInput,
|
|
12
|
+
type ElevenLabsSynthesizerOptions,
|
|
13
|
+
type ElevenLabsTtsFormat,
|
|
14
|
+
type FetchLike,
|
|
15
|
+
} from './elevenlabs-tts.js';
|
|
16
|
+
|
|
17
|
+
/** The single registered synthesizer name — surfaces show this in `set_voice`. */
|
|
18
|
+
export const ELEVENLABS_TTS_SYNTHESIZER_NAME = 'elevenlabs';
|
|
19
|
+
|
|
20
|
+
export interface BuildElevenLabsTtsPluginOptions {
|
|
21
|
+
/**
|
|
22
|
+
* Build-time defaults baked into the synthesizer def. Per-activation config
|
|
23
|
+
* (`session.synthesizers.setActive(name, config)`) still overrides `model` /
|
|
24
|
+
* `voiceId` / `format`. Mainly a seam for tests to inject `fetchImpl` / `apiKey`.
|
|
25
|
+
*/
|
|
26
|
+
readonly defaults?: Omit<ElevenLabsSynthesizerOptions, 'getSecret'>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build the @moxxy/plugin-tts-elevenlabs plugin. Registers exactly one
|
|
31
|
+
* synthesizer, `elevenlabs`, backed by ElevenLabs' `POST /v1/text-to-speech`.
|
|
32
|
+
*
|
|
33
|
+
* The plugin is intentionally side-effect free — it never calls `setActive`.
|
|
34
|
+
* The `SynthesizerRegistry` is `autoAdoptFirst`, so the first synthesizer
|
|
35
|
+
* registered becomes active on read without any activation step here; the agent
|
|
36
|
+
* switches voices via the `set_voice` tool.
|
|
37
|
+
*/
|
|
38
|
+
export function buildElevenLabsTtsPlugin(opts: BuildElevenLabsTtsPluginOptions = {}): Plugin {
|
|
39
|
+
const defaults = opts.defaults ?? {};
|
|
40
|
+
return definePlugin({
|
|
41
|
+
name: '@moxxy/plugin-tts-elevenlabs',
|
|
42
|
+
version: '0.0.0',
|
|
43
|
+
synthesizers: [
|
|
44
|
+
defineSynthesizer({
|
|
45
|
+
name: ELEVENLABS_TTS_SYNTHESIZER_NAME,
|
|
46
|
+
displayName: 'ElevenLabs',
|
|
47
|
+
// `create` runs lazily and may re-run (buildOnRead) — keep it cheap and
|
|
48
|
+
// side-effect free; the key is resolved inside `synthesize`.
|
|
49
|
+
create: (ctx) =>
|
|
50
|
+
new ElevenLabsSynthesizer({
|
|
51
|
+
...defaults,
|
|
52
|
+
...configToOptions(ctx.config),
|
|
53
|
+
...(ctx.getSecret ? { getSecret: ctx.getSecret } : {}),
|
|
54
|
+
}),
|
|
55
|
+
}),
|
|
56
|
+
],
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Narrow the untrusted per-activation config record into typed options. Only
|
|
61
|
+
* string `model` / `voiceId` / `baseURL` / `apiKey` and a known `format` are
|
|
62
|
+
* honored; anything else is ignored so a malformed config can't break create. */
|
|
63
|
+
function configToOptions(config: Record<string, unknown>): Partial<ElevenLabsSynthesizerOptions> {
|
|
64
|
+
const out: {
|
|
65
|
+
-readonly [K in keyof ElevenLabsSynthesizerOptions]?: ElevenLabsSynthesizerOptions[K];
|
|
66
|
+
} = {};
|
|
67
|
+
if (typeof config.model === 'string' && config.model) out.model = config.model;
|
|
68
|
+
if (typeof config.voiceId === 'string' && config.voiceId) out.voiceId = config.voiceId;
|
|
69
|
+
if (typeof config.baseURL === 'string' && config.baseURL) out.baseURL = config.baseURL;
|
|
70
|
+
if (typeof config.apiKey === 'string' && config.apiKey) out.apiKey = config.apiKey;
|
|
71
|
+
const format = normalizeFormat(config.format);
|
|
72
|
+
if (format) out.format = format;
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const KNOWN_FORMATS: ReadonlyArray<ElevenLabsTtsFormat> = [
|
|
77
|
+
'mp3_44100_128',
|
|
78
|
+
'mp3_44100_64',
|
|
79
|
+
'mp3_22050_32',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Accept only the exact supported format strings (guards against inherited
|
|
83
|
+
* keys like `constructor` slipping through an `in` check). */
|
|
84
|
+
function normalizeFormat(value: unknown): ElevenLabsTtsFormat | undefined {
|
|
85
|
+
return typeof value === 'string' && (KNOWN_FORMATS as ReadonlyArray<string>).includes(value)
|
|
86
|
+
? (value as ElevenLabsTtsFormat)
|
|
87
|
+
: undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
91
|
+
export default buildElevenLabsTtsPlugin();
|