@moxxy/plugin-tts-openai 0.27.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/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/openai-tts.d.ts +76 -0
- package/dist/openai-tts.d.ts.map +1 -0
- package/dist/openai-tts.js +187 -0
- package/dist/openai-tts.js.map +1 -0
- package/package.json +77 -0
- package/src/index.ts +86 -0
- package/src/openai-tts.test.ts +269 -0
- package/src/openai-tts.ts +233 -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.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import { type OpenAiSynthesizerOptions } from './openai-tts.js';
|
|
3
|
+
export { OpenAiSynthesizer, createOpenAiSynthesizer, capInput, clampSpeed, type OpenAiSynthesizerOptions, type OpenAiTtsFormat, type FetchLike, } from './openai-tts.js';
|
|
4
|
+
/** The single registered synthesizer name — surfaces show this in `set_voice`. */
|
|
5
|
+
export declare const OPENAI_TTS_SYNTHESIZER_NAME = "openai-tts";
|
|
6
|
+
export interface BuildOpenAiTtsPluginOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Build-time defaults baked into the synthesizer def. Per-activation config
|
|
9
|
+
* (`session.synthesizers.setActive(name, config)`) still overrides `model` /
|
|
10
|
+
* `voice` / `format`. Mainly a seam for tests to inject `fetchImpl` / `apiKey`.
|
|
11
|
+
*/
|
|
12
|
+
readonly defaults?: Omit<OpenAiSynthesizerOptions, 'getSecret'>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Build the @moxxy/plugin-tts-openai plugin. Registers exactly one synthesizer,
|
|
16
|
+
* `openai-tts`, backed by OpenAI's `/v1/audio/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 (this one, on a fresh install) becomes active on read without any
|
|
21
|
+
* activation step here; the agent switches voices via the `set_voice` tool.
|
|
22
|
+
*/
|
|
23
|
+
export declare function buildOpenAiTtsPlugin(opts?: BuildOpenAiTtsPluginOptions): 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,wBAAwB,EAE9B,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,QAAQ,EACR,UAAU,EACV,KAAK,wBAAwB,EAC7B,KAAK,eAAe,EACpB,KAAK,SAAS,GACf,MAAM,iBAAiB,CAAC;AAEzB,kFAAkF;AAClF,eAAO,MAAM,2BAA2B,eAAe,CAAC;AAExD,MAAM,WAAW,2BAA2B;IAC1C;;;;OAIG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,IAAI,CAAC,wBAAwB,EAAE,WAAW,CAAC,CAAC;CACjE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,GAAE,2BAAgC,GAAG,MAAM,CAoBnF;;AA2BD,wBAAsC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { definePlugin, defineSynthesizer } from '@moxxy/sdk';
|
|
2
|
+
import { OpenAiSynthesizer, } from './openai-tts.js';
|
|
3
|
+
export { OpenAiSynthesizer, createOpenAiSynthesizer, capInput, clampSpeed, } from './openai-tts.js';
|
|
4
|
+
/** The single registered synthesizer name — surfaces show this in `set_voice`. */
|
|
5
|
+
export const OPENAI_TTS_SYNTHESIZER_NAME = 'openai-tts';
|
|
6
|
+
/**
|
|
7
|
+
* Build the @moxxy/plugin-tts-openai plugin. Registers exactly one synthesizer,
|
|
8
|
+
* `openai-tts`, backed by OpenAI's `/v1/audio/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 (this one, on a fresh install) becomes active on read without any
|
|
13
|
+
* activation step here; the agent switches voices via the `set_voice` tool.
|
|
14
|
+
*/
|
|
15
|
+
export function buildOpenAiTtsPlugin(opts = {}) {
|
|
16
|
+
const defaults = opts.defaults ?? {};
|
|
17
|
+
return definePlugin({
|
|
18
|
+
name: '@moxxy/plugin-tts-openai',
|
|
19
|
+
version: '0.0.0',
|
|
20
|
+
synthesizers: [
|
|
21
|
+
defineSynthesizer({
|
|
22
|
+
name: OPENAI_TTS_SYNTHESIZER_NAME,
|
|
23
|
+
displayName: 'OpenAI TTS',
|
|
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 OpenAiSynthesizer({
|
|
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` / `voice` / `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.voice === 'string' && config.voice)
|
|
43
|
+
out.voice = config.voice;
|
|
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 = ['mp3', 'opus', 'wav', 'aac'];
|
|
54
|
+
/** Accept only the exact supported format strings (guards against inherited
|
|
55
|
+
* keys like `constructor` slipping through an `in` check). */
|
|
56
|
+
function normalizeFormat(value) {
|
|
57
|
+
return typeof value === 'string' && KNOWN_FORMATS.includes(value)
|
|
58
|
+
? value
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
61
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
62
|
+
export default buildOpenAiTtsPlugin();
|
|
63
|
+
//# 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,iBAAiB,GAGlB,MAAM,iBAAiB,CAAC;AAEzB,OAAO,EACL,iBAAiB,EACjB,uBAAuB,EACvB,QAAQ,EACR,UAAU,GAIX,MAAM,iBAAiB,CAAC;AAEzB,kFAAkF;AAClF,MAAM,CAAC,MAAM,2BAA2B,GAAG,YAAY,CAAC;AAWxD;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,OAAoC,EAAE;IACzE,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,EAAE,CAAC;IACrC,OAAO,YAAY,CAAC;QAClB,IAAI,EAAE,0BAA0B;QAChC,OAAO,EAAE,OAAO;QAChB,YAAY,EAAE;YACZ,iBAAiB,CAAC;gBAChB,IAAI,EAAE,2BAA2B;gBACjC,WAAW,EAAE,YAAY;gBACzB,wEAAwE;gBACxE,6DAA6D;gBAC7D,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE,CACd,IAAI,iBAAiB,CAAC;oBACpB,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,GAAsF,EAAE,CAAC;IAClG,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,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,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,GAAmC,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,CAAC,CAAC;AAEpF;+DAC+D;AAC/D,SAAS,eAAe,CAAC,KAAc;IACrC,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAK,aAAuC,CAAC,QAAQ,CAAC,KAAK,CAAC;QAC1F,CAAC,CAAE,KAAyB;QAC5B,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED,0EAA0E;AAC1E,eAAe,oBAAoB,EAAE,CAAC"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type Synthesizer, type SynthesizeOptions, type SynthesisResult } from '@moxxy/sdk';
|
|
2
|
+
/** OpenAI `/v1/audio/speech` response formats we expose, mapped to the MIME
|
|
3
|
+
* type the desktop/channels play the returned bytes as. Kept a small closed
|
|
4
|
+
* map (OpenAI also serves flac/pcm — not surfaced here). */
|
|
5
|
+
declare const MIME_BY_FORMAT: {
|
|
6
|
+
readonly mp3: "audio/mpeg";
|
|
7
|
+
readonly opus: "audio/ogg";
|
|
8
|
+
readonly wav: "audio/wav";
|
|
9
|
+
readonly aac: "audio/aac";
|
|
10
|
+
};
|
|
11
|
+
export type OpenAiTtsFormat = keyof typeof MIME_BY_FORMAT;
|
|
12
|
+
/** Injectable `fetch` — the default is the global; tests pass a stub. Widened
|
|
13
|
+
* from the global signature so a plain `(url, init) => Response` stub fits. */
|
|
14
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
15
|
+
export interface OpenAiSynthesizerOptions {
|
|
16
|
+
/** Explicit API key. Normally omitted — the key is read via `getSecret`. */
|
|
17
|
+
readonly apiKey?: string;
|
|
18
|
+
/** Vault-backed secret resolver handed in by `SynthesizerCreateContext`. */
|
|
19
|
+
readonly getSecret?: (name: string) => Promise<string | null>;
|
|
20
|
+
/** API base, default `https://api.openai.com/v1`. Trailing slashes trimmed. */
|
|
21
|
+
readonly baseURL?: string;
|
|
22
|
+
/** TTS model, default `gpt-4o-mini-tts`. */
|
|
23
|
+
readonly model?: string;
|
|
24
|
+
/** Default voice, default `alloy`. Overridden per-call by `opts.voice`. */
|
|
25
|
+
readonly voice?: string;
|
|
26
|
+
/** Output container, default `mp3`. Determines the returned `mimeType`. */
|
|
27
|
+
readonly format?: OpenAiTtsFormat;
|
|
28
|
+
/** Injected `fetch` (tests). Defaults to the global. */
|
|
29
|
+
readonly fetchImpl?: FetchLike;
|
|
30
|
+
/** Per-request timeout in ms. Default 60000. */
|
|
31
|
+
readonly timeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* `Synthesizer` backed by OpenAI's `POST /v1/audio/speech` endpoint — one JSON
|
|
35
|
+
* POST returning raw audio bytes, so there's no need for the `openai` SDK. The
|
|
36
|
+
* API key rides the vault (`ctx.getSecret('OPENAI_API_KEY')`, the same key the
|
|
37
|
+
* OpenAI provider uses) with a `process.env.OPENAI_API_KEY` fallback, resolved
|
|
38
|
+
* lazily on the first `synthesize` and cached so `create()` stays cheap.
|
|
39
|
+
*/
|
|
40
|
+
export declare class OpenAiSynthesizer implements Synthesizer {
|
|
41
|
+
readonly name = "openai-tts";
|
|
42
|
+
/** MIME type of the bytes this instance returns (derived from `format`). */
|
|
43
|
+
readonly mimeType: string;
|
|
44
|
+
private readonly explicitKey;
|
|
45
|
+
private readonly getSecret;
|
|
46
|
+
private readonly baseURL;
|
|
47
|
+
private readonly model;
|
|
48
|
+
private readonly voice;
|
|
49
|
+
private readonly format;
|
|
50
|
+
private readonly fetchImpl;
|
|
51
|
+
private readonly timeoutMs;
|
|
52
|
+
private key;
|
|
53
|
+
constructor(opts?: OpenAiSynthesizerOptions);
|
|
54
|
+
synthesize(text: string, opts?: SynthesizeOptions): Promise<SynthesisResult>;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve the API key lazily: explicit option → vault (`OPENAI_API_KEY`) →
|
|
57
|
+
* `process.env.OPENAI_API_KEY`. Cache only a successful resolution so a key
|
|
58
|
+
* added after a first failed attempt is still picked up on retry. A missing
|
|
59
|
+
* key becomes a classified, actionable `MoxxyError`.
|
|
60
|
+
*/
|
|
61
|
+
private resolveKey;
|
|
62
|
+
}
|
|
63
|
+
/** Map a `SynthesizeOptions.rate` multiplier onto OpenAI's `speed`, clamped to
|
|
64
|
+
* the accepted 0.25–4.0 range. Non-finite / absent rates yield `undefined` so
|
|
65
|
+
* the field is omitted and OpenAI uses its default. */
|
|
66
|
+
export declare function clampSpeed(rate: number | undefined): number | undefined;
|
|
67
|
+
/**
|
|
68
|
+
* Cap `text` to OpenAI's 4096-char `input` limit. When over, truncate at the
|
|
69
|
+
* last sentence boundary (`. `, `! `, `? `, newline) inside the budget — as
|
|
70
|
+
* long as that keeps most of the text — else hard-slice, and append a single
|
|
71
|
+
* ellipsis so the result is always ≤ 4096 characters.
|
|
72
|
+
*/
|
|
73
|
+
export declare function capInput(text: string): string;
|
|
74
|
+
export declare function createOpenAiSynthesizer(opts?: OpenAiSynthesizerOptions): OpenAiSynthesizer;
|
|
75
|
+
export {};
|
|
76
|
+
//# sourceMappingURL=openai-tts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openai-tts.d.ts","sourceRoot":"","sources":["../src/openai-tts.ts"],"names":[],"mappings":"AAAA,OAAO,EAIL,KAAK,WAAW,EAChB,KAAK,iBAAiB,EACtB,KAAK,eAAe,EACrB,MAAM,YAAY,CAAC;AAKpB;;6DAE6D;AAC7D,QAAA,MAAM,cAAc;;;;;CAKV,CAAC;AAEX,MAAM,MAAM,eAAe,GAAG,MAAM,OAAO,cAAc,CAAC;AAe1D;gFACgF;AAChF,MAAM,MAAM,SAAS,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAEhF,MAAM,WAAW,wBAAwB;IACvC,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,+EAA+E;IAC/E,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,4CAA4C;IAC5C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,MAAM,CAAC,EAAE,eAAe,CAAC;IAClC,wDAAwD;IACxD,QAAQ,CAAC,SAAS,CAAC,EAAE,SAAS,CAAC;IAC/B,gDAAgD;IAChD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED;;;;;;GAMG;AACH,qBAAa,iBAAkB,YAAW,WAAW;IACnD,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,KAAK,CAAS;IAC/B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkB;IACzC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAY;IACtC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,GAAG,CAAuB;gBAEtB,IAAI,GAAE,wBAA6B;IAYzC,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,GAAE,iBAAsB,GAAG,OAAO,CAAC,eAAe,CAAC;IA+EtF;;;;;OAKG;YACW,UAAU;CAmBzB;AAED;;wDAEwD;AACxD,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAGvE;AAED;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAc7C;AAED,wBAAgB,uBAAuB,CAAC,IAAI,GAAE,wBAA6B,GAAG,iBAAiB,CAE9F"}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { classifyHttpStatus, classifyNetworkError, MoxxyError, } from '@moxxy/sdk';
|
|
2
|
+
/** Provider tag attached to classified errors for logs/debug context. */
|
|
3
|
+
const OPENAI_TTS_PROVIDER_ID = 'openai';
|
|
4
|
+
/** OpenAI `/v1/audio/speech` response formats we expose, mapped to the MIME
|
|
5
|
+
* type the desktop/channels play the returned bytes as. Kept a small closed
|
|
6
|
+
* map (OpenAI also serves flac/pcm — not surfaced here). */
|
|
7
|
+
const MIME_BY_FORMAT = {
|
|
8
|
+
mp3: 'audio/mpeg',
|
|
9
|
+
opus: 'audio/ogg',
|
|
10
|
+
wav: 'audio/wav',
|
|
11
|
+
aac: 'audio/aac',
|
|
12
|
+
};
|
|
13
|
+
/** OpenAI rejects `input` longer than 4096 characters. Channels routinely send
|
|
14
|
+
* long replies, so we truncate at a sentence boundary rather than let the API
|
|
15
|
+
* 400 the whole read-aloud. */
|
|
16
|
+
const MAX_INPUT_CHARS = 4096;
|
|
17
|
+
/** OpenAI clamps `speed` to this inclusive range; anything outside 400s. */
|
|
18
|
+
const MIN_SPEED = 0.25;
|
|
19
|
+
const MAX_SPEED = 4.0;
|
|
20
|
+
/** Default per-request deadline. Read-aloud is cancellable, but a hung socket
|
|
21
|
+
* should still free itself so callers can fall back to the OS voice. */
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
23
|
+
/**
|
|
24
|
+
* `Synthesizer` backed by OpenAI's `POST /v1/audio/speech` endpoint — one JSON
|
|
25
|
+
* POST returning raw audio bytes, so there's no need for the `openai` SDK. The
|
|
26
|
+
* API key rides the vault (`ctx.getSecret('OPENAI_API_KEY')`, the same key the
|
|
27
|
+
* OpenAI provider uses) with a `process.env.OPENAI_API_KEY` fallback, resolved
|
|
28
|
+
* lazily on the first `synthesize` and cached so `create()` stays cheap.
|
|
29
|
+
*/
|
|
30
|
+
export class OpenAiSynthesizer {
|
|
31
|
+
name = 'openai-tts';
|
|
32
|
+
/** MIME type of the bytes this instance returns (derived from `format`). */
|
|
33
|
+
mimeType;
|
|
34
|
+
explicitKey;
|
|
35
|
+
getSecret;
|
|
36
|
+
baseURL;
|
|
37
|
+
model;
|
|
38
|
+
voice;
|
|
39
|
+
format;
|
|
40
|
+
fetchImpl;
|
|
41
|
+
timeoutMs;
|
|
42
|
+
key = null;
|
|
43
|
+
constructor(opts = {}) {
|
|
44
|
+
this.explicitKey = opts.apiKey;
|
|
45
|
+
this.getSecret = opts.getSecret;
|
|
46
|
+
this.baseURL = (opts.baseURL ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
47
|
+
this.model = opts.model ?? 'gpt-4o-mini-tts';
|
|
48
|
+
this.voice = opts.voice ?? 'alloy';
|
|
49
|
+
this.format = opts.format ?? 'mp3';
|
|
50
|
+
this.mimeType = MIME_BY_FORMAT[this.format];
|
|
51
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
52
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
53
|
+
}
|
|
54
|
+
async synthesize(text, opts = {}) {
|
|
55
|
+
// Fast-path a caller who cancelled before we started — propagate their reason.
|
|
56
|
+
opts.signal?.throwIfAborted();
|
|
57
|
+
const key = await this.resolveKey();
|
|
58
|
+
const input = capInput(text);
|
|
59
|
+
const voice = opts.voice ?? this.voice;
|
|
60
|
+
const speed = clampSpeed(opts.rate);
|
|
61
|
+
const body = {
|
|
62
|
+
model: this.model,
|
|
63
|
+
voice,
|
|
64
|
+
input,
|
|
65
|
+
response_format: this.format,
|
|
66
|
+
...(speed !== undefined ? { speed } : {}),
|
|
67
|
+
};
|
|
68
|
+
const url = `${this.baseURL}/audio/speech`;
|
|
69
|
+
// Chain the caller's abort signal with our own timeout onto one controller
|
|
70
|
+
// passed to fetch, so either cancels the request and frees the socket.
|
|
71
|
+
const controller = new AbortController();
|
|
72
|
+
let timedOut = false;
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
timedOut = true;
|
|
75
|
+
controller.abort();
|
|
76
|
+
}, this.timeoutMs);
|
|
77
|
+
const forward = () => controller.abort();
|
|
78
|
+
if (opts.signal) {
|
|
79
|
+
// The signal may have fired during key resolution above; a listener added
|
|
80
|
+
// to an already-aborted signal is never called, so abort directly here.
|
|
81
|
+
if (opts.signal.aborted)
|
|
82
|
+
controller.abort();
|
|
83
|
+
else
|
|
84
|
+
opts.signal.addEventListener('abort', forward, { once: true });
|
|
85
|
+
}
|
|
86
|
+
let res;
|
|
87
|
+
try {
|
|
88
|
+
res = await this.fetchImpl(url, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
91
|
+
body: JSON.stringify(body),
|
|
92
|
+
signal: controller.signal,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// Caller-initiated cancellation: re-throw their reason unchanged so a
|
|
97
|
+
// stopped read-aloud isn't masked as a provider/network error.
|
|
98
|
+
if (opts.signal?.aborted && !timedOut)
|
|
99
|
+
throw opts.signal.reason ?? err;
|
|
100
|
+
if (timedOut) {
|
|
101
|
+
throw new MoxxyError({
|
|
102
|
+
code: 'NETWORK_TIMEOUT',
|
|
103
|
+
message: `OpenAI text-to-speech request timed out after ${this.timeoutMs} ms.`,
|
|
104
|
+
hint: 'Retry, or shorten the text being read aloud.',
|
|
105
|
+
context: { provider: OPENAI_TTS_PROVIDER_ID, url },
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const network = classifyNetworkError(err, { provider: OPENAI_TTS_PROVIDER_ID, url });
|
|
109
|
+
if (network)
|
|
110
|
+
throw network;
|
|
111
|
+
throw err;
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
opts.signal?.removeEventListener('abort', forward);
|
|
116
|
+
}
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
const classified = classifyHttpStatus(res.status, {
|
|
119
|
+
provider: OPENAI_TTS_PROVIDER_ID,
|
|
120
|
+
url,
|
|
121
|
+
body: await res.text().catch(() => ''),
|
|
122
|
+
});
|
|
123
|
+
if (classified)
|
|
124
|
+
throw classified;
|
|
125
|
+
throw new MoxxyError({
|
|
126
|
+
code: 'PROVIDER_BAD_REQUEST',
|
|
127
|
+
message: `OpenAI text-to-speech returned HTTP ${res.status}.`,
|
|
128
|
+
context: { provider: OPENAI_TTS_PROVIDER_ID, url, status: res.status },
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const audio = new Uint8Array(await res.arrayBuffer());
|
|
132
|
+
return { audio, mimeType: this.mimeType };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Resolve the API key lazily: explicit option → vault (`OPENAI_API_KEY`) →
|
|
136
|
+
* `process.env.OPENAI_API_KEY`. Cache only a successful resolution so a key
|
|
137
|
+
* added after a first failed attempt is still picked up on retry. A missing
|
|
138
|
+
* key becomes a classified, actionable `MoxxyError`.
|
|
139
|
+
*/
|
|
140
|
+
async resolveKey() {
|
|
141
|
+
if (this.key)
|
|
142
|
+
return this.key;
|
|
143
|
+
const resolved = this.explicitKey ??
|
|
144
|
+
(await this.getSecret?.('OPENAI_API_KEY')) ??
|
|
145
|
+
process.env.OPENAI_API_KEY ??
|
|
146
|
+
null;
|
|
147
|
+
if (!resolved) {
|
|
148
|
+
throw new MoxxyError({
|
|
149
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
150
|
+
message: 'No OpenAI API key for text-to-speech. It rides the same OPENAI_API_KEY as the OpenAI provider.',
|
|
151
|
+
hint: 'Run `moxxy init` (stores it in the vault) or set OPENAI_API_KEY.',
|
|
152
|
+
context: { provider: OPENAI_TTS_PROVIDER_ID },
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
this.key = resolved;
|
|
156
|
+
return resolved;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/** Map a `SynthesizeOptions.rate` multiplier onto OpenAI's `speed`, clamped to
|
|
160
|
+
* the accepted 0.25–4.0 range. Non-finite / absent rates yield `undefined` so
|
|
161
|
+
* the field is omitted and OpenAI uses its default. */
|
|
162
|
+
export function clampSpeed(rate) {
|
|
163
|
+
if (rate === undefined || !Number.isFinite(rate))
|
|
164
|
+
return undefined;
|
|
165
|
+
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, rate));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Cap `text` to OpenAI's 4096-char `input` limit. When over, truncate at the
|
|
169
|
+
* last sentence boundary (`. `, `! `, `? `, newline) inside the budget — as
|
|
170
|
+
* long as that keeps most of the text — else hard-slice, and append a single
|
|
171
|
+
* ellipsis so the result is always ≤ 4096 characters.
|
|
172
|
+
*/
|
|
173
|
+
export function capInput(text) {
|
|
174
|
+
if (text.length <= MAX_INPUT_CHARS)
|
|
175
|
+
return text;
|
|
176
|
+
const budget = MAX_INPUT_CHARS - 1; // leave room for the ellipsis
|
|
177
|
+
const slice = text.slice(0, budget);
|
|
178
|
+
const boundary = Math.max(slice.lastIndexOf('. '), slice.lastIndexOf('! '), slice.lastIndexOf('? '), slice.lastIndexOf('\n'));
|
|
179
|
+
// Only honor a boundary that doesn't discard more than half the budget,
|
|
180
|
+
// otherwise a single very long sentence would be cut to almost nothing.
|
|
181
|
+
const cut = boundary > budget * 0.5 ? boundary + 1 : budget;
|
|
182
|
+
return `${slice.slice(0, cut).trimEnd()}…`;
|
|
183
|
+
}
|
|
184
|
+
export function createOpenAiSynthesizer(opts = {}) {
|
|
185
|
+
return new OpenAiSynthesizer(opts);
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=openai-tts.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"openai-tts.js","sourceRoot":"","sources":["../src/openai-tts.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@moxxy/plugin-tts-openai",
|
|
3
|
+
"version": "0.27.0",
|
|
4
|
+
"description": "OpenAI text-to-speech Synthesizer for moxxy. Text → spoken audio bytes via POST /v1/audio/speech. Plugs into the session's SynthesizerRegistry; read-aloud surfaces (desktop 'Read aloud', a voice channel) consume it transparently. The first shipped Synthesizer backend.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"moxxy",
|
|
7
|
+
"agent",
|
|
8
|
+
"tts",
|
|
9
|
+
"text-to-speech",
|
|
10
|
+
"openai",
|
|
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-openai"
|
|
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": "OpenAI text-to-speech",
|
|
47
|
+
"description": "Read-aloud through OpenAI's /v1/audio/speech. Uses the same OPENAI_API_KEY as the OpenAI provider — if you already added that (via `moxxy init` or the OpenAI provider), you can skip this; leave it blank to reuse the existing key.",
|
|
48
|
+
"fields": [
|
|
49
|
+
{
|
|
50
|
+
"key": "apiKey",
|
|
51
|
+
"label": "OpenAI API key",
|
|
52
|
+
"kind": "secret",
|
|
53
|
+
"vaultKey": "OPENAI_API_KEY",
|
|
54
|
+
"required": false,
|
|
55
|
+
"placeholder": "sk-…",
|
|
56
|
+
"description": "Reuses the OpenAI provider key. Only needed if you haven't already stored OPENAI_API_KEY."
|
|
57
|
+
}
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"@moxxy/sdk": "0.27.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
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { definePlugin, defineSynthesizer, type Plugin } from '@moxxy/sdk';
|
|
2
|
+
import {
|
|
3
|
+
OpenAiSynthesizer,
|
|
4
|
+
type OpenAiSynthesizerOptions,
|
|
5
|
+
type OpenAiTtsFormat,
|
|
6
|
+
} from './openai-tts.js';
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
OpenAiSynthesizer,
|
|
10
|
+
createOpenAiSynthesizer,
|
|
11
|
+
capInput,
|
|
12
|
+
clampSpeed,
|
|
13
|
+
type OpenAiSynthesizerOptions,
|
|
14
|
+
type OpenAiTtsFormat,
|
|
15
|
+
type FetchLike,
|
|
16
|
+
} from './openai-tts.js';
|
|
17
|
+
|
|
18
|
+
/** The single registered synthesizer name — surfaces show this in `set_voice`. */
|
|
19
|
+
export const OPENAI_TTS_SYNTHESIZER_NAME = 'openai-tts';
|
|
20
|
+
|
|
21
|
+
export interface BuildOpenAiTtsPluginOptions {
|
|
22
|
+
/**
|
|
23
|
+
* Build-time defaults baked into the synthesizer def. Per-activation config
|
|
24
|
+
* (`session.synthesizers.setActive(name, config)`) still overrides `model` /
|
|
25
|
+
* `voice` / `format`. Mainly a seam for tests to inject `fetchImpl` / `apiKey`.
|
|
26
|
+
*/
|
|
27
|
+
readonly defaults?: Omit<OpenAiSynthesizerOptions, 'getSecret'>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Build the @moxxy/plugin-tts-openai plugin. Registers exactly one synthesizer,
|
|
32
|
+
* `openai-tts`, backed by OpenAI's `/v1/audio/speech`.
|
|
33
|
+
*
|
|
34
|
+
* The plugin is intentionally side-effect free — it never calls `setActive`.
|
|
35
|
+
* The `SynthesizerRegistry` is `autoAdoptFirst`, so the first synthesizer
|
|
36
|
+
* registered (this one, on a fresh install) becomes active on read without any
|
|
37
|
+
* activation step here; the agent switches voices via the `set_voice` tool.
|
|
38
|
+
*/
|
|
39
|
+
export function buildOpenAiTtsPlugin(opts: BuildOpenAiTtsPluginOptions = {}): Plugin {
|
|
40
|
+
const defaults = opts.defaults ?? {};
|
|
41
|
+
return definePlugin({
|
|
42
|
+
name: '@moxxy/plugin-tts-openai',
|
|
43
|
+
version: '0.0.0',
|
|
44
|
+
synthesizers: [
|
|
45
|
+
defineSynthesizer({
|
|
46
|
+
name: OPENAI_TTS_SYNTHESIZER_NAME,
|
|
47
|
+
displayName: 'OpenAI TTS',
|
|
48
|
+
// `create` runs lazily and may re-run (buildOnRead) — keep it cheap and
|
|
49
|
+
// side-effect free; the key is resolved inside `synthesize`.
|
|
50
|
+
create: (ctx) =>
|
|
51
|
+
new OpenAiSynthesizer({
|
|
52
|
+
...defaults,
|
|
53
|
+
...configToOptions(ctx.config),
|
|
54
|
+
...(ctx.getSecret ? { getSecret: ctx.getSecret } : {}),
|
|
55
|
+
}),
|
|
56
|
+
}),
|
|
57
|
+
],
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Narrow the untrusted per-activation config record into typed options. Only
|
|
62
|
+
* string `model` / `voice` / `baseURL` / `apiKey` and a known `format` are
|
|
63
|
+
* honored; anything else is ignored so a malformed config can't break create. */
|
|
64
|
+
function configToOptions(config: Record<string, unknown>): Partial<OpenAiSynthesizerOptions> {
|
|
65
|
+
const out: { -readonly [K in keyof OpenAiSynthesizerOptions]?: OpenAiSynthesizerOptions[K] } = {};
|
|
66
|
+
if (typeof config.model === 'string' && config.model) out.model = config.model;
|
|
67
|
+
if (typeof config.voice === 'string' && config.voice) out.voice = config.voice;
|
|
68
|
+
if (typeof config.baseURL === 'string' && config.baseURL) out.baseURL = config.baseURL;
|
|
69
|
+
if (typeof config.apiKey === 'string' && config.apiKey) out.apiKey = config.apiKey;
|
|
70
|
+
const format = normalizeFormat(config.format);
|
|
71
|
+
if (format) out.format = format;
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const KNOWN_FORMATS: ReadonlyArray<OpenAiTtsFormat> = ['mp3', 'opus', 'wav', 'aac'];
|
|
76
|
+
|
|
77
|
+
/** Accept only the exact supported format strings (guards against inherited
|
|
78
|
+
* keys like `constructor` slipping through an `in` check). */
|
|
79
|
+
function normalizeFormat(value: unknown): OpenAiTtsFormat | undefined {
|
|
80
|
+
return typeof value === 'string' && (KNOWN_FORMATS as ReadonlyArray<string>).includes(value)
|
|
81
|
+
? (value as OpenAiTtsFormat)
|
|
82
|
+
: undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Discovery entry: `createPluginLoader` requires a default Plugin export.
|
|
86
|
+
export default buildOpenAiTtsPlugin();
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { OpenAiSynthesizer, capInput, clampSpeed, type FetchLike } from './openai-tts.js';
|
|
3
|
+
import { buildOpenAiTtsPlugin, OPENAI_TTS_SYNTHESIZER_NAME } from './index.js';
|
|
4
|
+
|
|
5
|
+
interface Captured {
|
|
6
|
+
url: string;
|
|
7
|
+
init: RequestInit;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** A stub `fetch` that records the request and returns fixed audio bytes. */
|
|
11
|
+
function okFetch(
|
|
12
|
+
bytes: Uint8Array = new Uint8Array([1, 2, 3, 4]),
|
|
13
|
+
status = 200,
|
|
14
|
+
): { fetchImpl: FetchLike; calls: Captured[] } {
|
|
15
|
+
const calls: Captured[] = [];
|
|
16
|
+
const fetchImpl: FetchLike = async (url, init) => {
|
|
17
|
+
calls.push({ url, init });
|
|
18
|
+
return new Response(bytes, { status });
|
|
19
|
+
};
|
|
20
|
+
return { fetchImpl, calls };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A stub `fetch` that returns an error status with a body. */
|
|
24
|
+
function statusFetch(status: number, body = 'boom'): FetchLike {
|
|
25
|
+
return async () => new Response(body, { status });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A `fetch` that never resolves and only rejects when its signal aborts —
|
|
29
|
+
* checking `aborted` synchronously (as real fetch does) so it works even when
|
|
30
|
+
* the signal fired before the request was issued. Drives abort + timeout. */
|
|
31
|
+
const abortAwareFetch: FetchLike = (_url, init) =>
|
|
32
|
+
new Promise((_resolve, reject) => {
|
|
33
|
+
const signal = init.signal;
|
|
34
|
+
const fail = (): void => reject(new DOMException('Aborted', 'AbortError'));
|
|
35
|
+
if (signal?.aborted) fail();
|
|
36
|
+
else signal?.addEventListener('abort', fail, { once: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Parse the JSON body a captured request carried. */
|
|
40
|
+
function bodyOf(calls: Captured[]): Record<string, unknown> {
|
|
41
|
+
return JSON.parse(calls[0]!.init.body as string) as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('OpenAiSynthesizer.synthesize', () => {
|
|
45
|
+
it('returns audio bytes and the mp3 mimeType by default', async () => {
|
|
46
|
+
const { fetchImpl, calls } = okFetch(new Uint8Array([9, 8, 7]));
|
|
47
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
48
|
+
const out = await synth.synthesize('hello world');
|
|
49
|
+
|
|
50
|
+
expect(Array.from(out.audio)).toEqual([9, 8, 7]);
|
|
51
|
+
expect(out.mimeType).toBe('audio/mpeg');
|
|
52
|
+
expect(calls[0]!.url).toBe('https://api.openai.com/v1/audio/speech');
|
|
53
|
+
expect(calls[0]!.init.method).toBe('POST');
|
|
54
|
+
const headers = calls[0]!.init.headers as Record<string, string>;
|
|
55
|
+
expect(headers.authorization).toBe('Bearer sk-test');
|
|
56
|
+
expect(headers['content-type']).toBe('application/json');
|
|
57
|
+
const body = bodyOf(calls);
|
|
58
|
+
expect(body.model).toBe('gpt-4o-mini-tts');
|
|
59
|
+
expect(body.voice).toBe('alloy');
|
|
60
|
+
expect(body.input).toBe('hello world');
|
|
61
|
+
expect(body.response_format).toBe('mp3');
|
|
62
|
+
expect(body.speed).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it.each([
|
|
66
|
+
['mp3', 'audio/mpeg'],
|
|
67
|
+
['opus', 'audio/ogg'],
|
|
68
|
+
['wav', 'audio/wav'],
|
|
69
|
+
['aac', 'audio/aac'],
|
|
70
|
+
] as const)('maps format %s to mimeType %s', async (format, mimeType) => {
|
|
71
|
+
const { fetchImpl, calls } = okFetch();
|
|
72
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl, format });
|
|
73
|
+
const out = await synth.synthesize('hi');
|
|
74
|
+
expect(out.mimeType).toBe(mimeType);
|
|
75
|
+
expect(bodyOf(calls).response_format).toBe(format);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('lets opts.voice override the configured voice', async () => {
|
|
79
|
+
const { fetchImpl, calls } = okFetch();
|
|
80
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl, voice: 'alloy' });
|
|
81
|
+
await synth.synthesize('hi', { voice: 'nova' });
|
|
82
|
+
expect(bodyOf(calls).voice).toBe('nova');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('maps rate to speed', async () => {
|
|
86
|
+
const { fetchImpl, calls } = okFetch();
|
|
87
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
88
|
+
await synth.synthesize('hi', { rate: 1.5 });
|
|
89
|
+
expect(bodyOf(calls).speed).toBe(1.5);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it.each([
|
|
93
|
+
[10, 4.0],
|
|
94
|
+
[0.01, 0.25],
|
|
95
|
+
[Number.NaN, undefined],
|
|
96
|
+
])('clamps rate %s to speed %s', async (rate, expected) => {
|
|
97
|
+
const { fetchImpl, calls } = okFetch();
|
|
98
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
99
|
+
await synth.synthesize('hi', { rate });
|
|
100
|
+
expect(bodyOf(calls).speed).toBe(expected);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('truncates input over 4096 chars at a sentence boundary with an ellipsis', async () => {
|
|
104
|
+
const { fetchImpl, calls } = okFetch();
|
|
105
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
106
|
+
// 'a'*4000 + '. ' then more — the sentence boundary at index 4000 is the
|
|
107
|
+
// last one inside the 4095-char budget.
|
|
108
|
+
const long = `${'a'.repeat(4000)}. ${'b'.repeat(200)}. ${'c'.repeat(200)}`;
|
|
109
|
+
await synth.synthesize(long);
|
|
110
|
+
const input = bodyOf(calls).input as string;
|
|
111
|
+
expect(input.length).toBeLessThanOrEqual(4096);
|
|
112
|
+
expect(input.length).toBeLessThan(long.length);
|
|
113
|
+
expect(input.endsWith('…')).toBe(true);
|
|
114
|
+
// Cut fell right after the sentence-ending period, not mid-word.
|
|
115
|
+
expect(input.endsWith('.…')).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('leaves short input untouched (no ellipsis)', async () => {
|
|
119
|
+
const { fetchImpl, calls } = okFetch();
|
|
120
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
121
|
+
await synth.synthesize('a short reply.');
|
|
122
|
+
expect(bodyOf(calls).input).toBe('a short reply.');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('throws AUTH_NO_CREDENTIALS when no key is available anywhere', async () => {
|
|
126
|
+
const prev = process.env.OPENAI_API_KEY;
|
|
127
|
+
delete process.env.OPENAI_API_KEY;
|
|
128
|
+
try {
|
|
129
|
+
const { fetchImpl } = okFetch();
|
|
130
|
+
const synth = new OpenAiSynthesizer({ getSecret: async () => null, fetchImpl });
|
|
131
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({
|
|
132
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
133
|
+
});
|
|
134
|
+
} finally {
|
|
135
|
+
if (prev !== undefined) process.env.OPENAI_API_KEY = prev;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('reads the key from getSecret (OPENAI_API_KEY) when no explicit key', async () => {
|
|
140
|
+
const { fetchImpl, calls } = okFetch();
|
|
141
|
+
const synth = new OpenAiSynthesizer({
|
|
142
|
+
getSecret: async (name) => (name === 'OPENAI_API_KEY' ? 'sk-vault' : null),
|
|
143
|
+
fetchImpl,
|
|
144
|
+
});
|
|
145
|
+
await synth.synthesize('hi');
|
|
146
|
+
const headers = calls[0]!.init.headers as Record<string, string>;
|
|
147
|
+
expect(headers.authorization).toBe('Bearer sk-vault');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it.each([
|
|
151
|
+
[401, 'AUTH_INVALID'],
|
|
152
|
+
[403, 'AUTH_DENIED'],
|
|
153
|
+
[429, 'PROVIDER_RATE_LIMITED'],
|
|
154
|
+
[500, 'PROVIDER_SERVER_ERROR'],
|
|
155
|
+
[503, 'PROVIDER_SERVER_ERROR'],
|
|
156
|
+
])('maps HTTP %s to %s', async (status, code) => {
|
|
157
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl: statusFetch(status) });
|
|
158
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({ code });
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('falls back to PROVIDER_BAD_REQUEST for an unmapped status', async () => {
|
|
162
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl: statusFetch(418) });
|
|
163
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({
|
|
164
|
+
code: 'PROVIDER_BAD_REQUEST',
|
|
165
|
+
context: { status: 418 },
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('classifies a network failure', async () => {
|
|
170
|
+
const fetchImpl: FetchLike = async () => {
|
|
171
|
+
throw new Error('fetch failed');
|
|
172
|
+
};
|
|
173
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
174
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({
|
|
175
|
+
code: 'NETWORK_UNREACHABLE',
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('honors an already-aborted signal, propagating its reason', async () => {
|
|
180
|
+
const { fetchImpl } = okFetch();
|
|
181
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl });
|
|
182
|
+
const ac = new AbortController();
|
|
183
|
+
const reason = new Error('user stopped');
|
|
184
|
+
ac.abort(reason);
|
|
185
|
+
await expect(synth.synthesize('hi', { signal: ac.signal })).rejects.toBe(reason);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('honors an in-flight abort, propagating the caller reason', async () => {
|
|
189
|
+
const synth = new OpenAiSynthesizer({ apiKey: 'sk-test', fetchImpl: abortAwareFetch });
|
|
190
|
+
const ac = new AbortController();
|
|
191
|
+
const reason = new Error('cancelled mid-flight');
|
|
192
|
+
const p = synth.synthesize('hi', { signal: ac.signal });
|
|
193
|
+
ac.abort(reason);
|
|
194
|
+
await expect(p).rejects.toBe(reason);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('times out a hung request as NETWORK_TIMEOUT', async () => {
|
|
198
|
+
const synth = new OpenAiSynthesizer({
|
|
199
|
+
apiKey: 'sk-test',
|
|
200
|
+
fetchImpl: abortAwareFetch,
|
|
201
|
+
timeoutMs: 10,
|
|
202
|
+
});
|
|
203
|
+
await expect(synth.synthesize('hi')).rejects.toMatchObject({ code: 'NETWORK_TIMEOUT' });
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe('capInput / clampSpeed helpers', () => {
|
|
208
|
+
it('capInput passes through under the limit', () => {
|
|
209
|
+
expect(capInput('short')).toBe('short');
|
|
210
|
+
expect(capInput('a'.repeat(4096))).toBe('a'.repeat(4096));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('capInput hard-slices a single boundary-less sentence', () => {
|
|
214
|
+
const out = capInput('x'.repeat(5000));
|
|
215
|
+
expect(out.length).toBe(4096); // 4095 chars + the ellipsis
|
|
216
|
+
expect(out.endsWith('…')).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('clampSpeed clamps and drops non-finite', () => {
|
|
220
|
+
expect(clampSpeed(undefined)).toBeUndefined();
|
|
221
|
+
expect(clampSpeed(Number.POSITIVE_INFINITY)).toBeUndefined();
|
|
222
|
+
expect(clampSpeed(1)).toBe(1);
|
|
223
|
+
expect(clampSpeed(9)).toBe(4);
|
|
224
|
+
expect(clampSpeed(0)).toBe(0.25);
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('buildOpenAiTtsPlugin', () => {
|
|
229
|
+
it('registers exactly one synthesizer named openai-tts', () => {
|
|
230
|
+
const plugin = buildOpenAiTtsPlugin();
|
|
231
|
+
expect(plugin.synthesizers).toHaveLength(1);
|
|
232
|
+
const def = plugin.synthesizers![0]!;
|
|
233
|
+
expect(def.name).toBe(OPENAI_TTS_SYNTHESIZER_NAME);
|
|
234
|
+
expect(def.displayName).toBe('OpenAI TTS');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('create() yields a synthesizer whose config voice/format flow through', async () => {
|
|
238
|
+
const { fetchImpl, calls } = okFetch();
|
|
239
|
+
const plugin = buildOpenAiTtsPlugin({ defaults: { fetchImpl, apiKey: 'sk-test' } });
|
|
240
|
+
const def = plugin.synthesizers![0]!;
|
|
241
|
+
const inst = def.create({ config: { voice: 'nova', format: 'opus' } });
|
|
242
|
+
expect(inst.name).toBe('openai-tts');
|
|
243
|
+
const out = await inst.synthesize('hi');
|
|
244
|
+
expect(out.mimeType).toBe('audio/ogg');
|
|
245
|
+
expect(bodyOf(calls).voice).toBe('nova');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('create() wires ctx.getSecret through for key resolution', async () => {
|
|
249
|
+
const { fetchImpl, calls } = okFetch();
|
|
250
|
+
const plugin = buildOpenAiTtsPlugin({ defaults: { fetchImpl } });
|
|
251
|
+
const def = plugin.synthesizers![0]!;
|
|
252
|
+
const inst = def.create({
|
|
253
|
+
config: {},
|
|
254
|
+
getSecret: async (name) => (name === 'OPENAI_API_KEY' ? 'sk-ctx' : null),
|
|
255
|
+
});
|
|
256
|
+
await inst.synthesize('hi');
|
|
257
|
+
const headers = calls[0]!.init.headers as Record<string, string>;
|
|
258
|
+
expect(headers.authorization).toBe('Bearer sk-ctx');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('create() ignores an unknown config format', async () => {
|
|
262
|
+
const { fetchImpl } = okFetch();
|
|
263
|
+
const plugin = buildOpenAiTtsPlugin({ defaults: { fetchImpl, apiKey: 'sk-test' } });
|
|
264
|
+
const inst = plugin.synthesizers![0]!.create({ config: { format: 'flac' } });
|
|
265
|
+
const out = await inst.synthesize('hi');
|
|
266
|
+
// Unknown format ignored → default mp3.
|
|
267
|
+
expect(out.mimeType).toBe('audio/mpeg');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -0,0 +1,233 @@
|
|
|
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 OPENAI_TTS_PROVIDER_ID = 'openai';
|
|
12
|
+
|
|
13
|
+
/** OpenAI `/v1/audio/speech` response formats we expose, mapped to the MIME
|
|
14
|
+
* type the desktop/channels play the returned bytes as. Kept a small closed
|
|
15
|
+
* map (OpenAI also serves flac/pcm — not surfaced here). */
|
|
16
|
+
const MIME_BY_FORMAT = {
|
|
17
|
+
mp3: 'audio/mpeg',
|
|
18
|
+
opus: 'audio/ogg',
|
|
19
|
+
wav: 'audio/wav',
|
|
20
|
+
aac: 'audio/aac',
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
export type OpenAiTtsFormat = keyof typeof MIME_BY_FORMAT;
|
|
24
|
+
|
|
25
|
+
/** OpenAI rejects `input` longer than 4096 characters. Channels routinely send
|
|
26
|
+
* long replies, so we truncate at a sentence boundary rather than let the API
|
|
27
|
+
* 400 the whole read-aloud. */
|
|
28
|
+
const MAX_INPUT_CHARS = 4096;
|
|
29
|
+
|
|
30
|
+
/** OpenAI clamps `speed` to this inclusive range; anything outside 400s. */
|
|
31
|
+
const MIN_SPEED = 0.25;
|
|
32
|
+
const MAX_SPEED = 4.0;
|
|
33
|
+
|
|
34
|
+
/** Default per-request deadline. Read-aloud is cancellable, but a hung socket
|
|
35
|
+
* should still free itself so callers can fall back to the OS voice. */
|
|
36
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
/** Injectable `fetch` — the default is the global; tests pass a stub. Widened
|
|
39
|
+
* from the global signature so a plain `(url, init) => Response` stub fits. */
|
|
40
|
+
export type FetchLike = (input: string, init: RequestInit) => Promise<Response>;
|
|
41
|
+
|
|
42
|
+
export interface OpenAiSynthesizerOptions {
|
|
43
|
+
/** Explicit API key. Normally omitted — the key is read via `getSecret`. */
|
|
44
|
+
readonly apiKey?: string;
|
|
45
|
+
/** Vault-backed secret resolver handed in by `SynthesizerCreateContext`. */
|
|
46
|
+
readonly getSecret?: (name: string) => Promise<string | null>;
|
|
47
|
+
/** API base, default `https://api.openai.com/v1`. Trailing slashes trimmed. */
|
|
48
|
+
readonly baseURL?: string;
|
|
49
|
+
/** TTS model, default `gpt-4o-mini-tts`. */
|
|
50
|
+
readonly model?: string;
|
|
51
|
+
/** Default voice, default `alloy`. Overridden per-call by `opts.voice`. */
|
|
52
|
+
readonly voice?: string;
|
|
53
|
+
/** Output container, default `mp3`. Determines the returned `mimeType`. */
|
|
54
|
+
readonly format?: OpenAiTtsFormat;
|
|
55
|
+
/** Injected `fetch` (tests). Defaults to the global. */
|
|
56
|
+
readonly fetchImpl?: FetchLike;
|
|
57
|
+
/** Per-request timeout in ms. Default 60000. */
|
|
58
|
+
readonly timeoutMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `Synthesizer` backed by OpenAI's `POST /v1/audio/speech` endpoint — one JSON
|
|
63
|
+
* POST returning raw audio bytes, so there's no need for the `openai` SDK. The
|
|
64
|
+
* API key rides the vault (`ctx.getSecret('OPENAI_API_KEY')`, the same key the
|
|
65
|
+
* OpenAI provider uses) with a `process.env.OPENAI_API_KEY` fallback, resolved
|
|
66
|
+
* lazily on the first `synthesize` and cached so `create()` stays cheap.
|
|
67
|
+
*/
|
|
68
|
+
export class OpenAiSynthesizer implements Synthesizer {
|
|
69
|
+
readonly name = 'openai-tts';
|
|
70
|
+
/** MIME type of the bytes this instance returns (derived from `format`). */
|
|
71
|
+
readonly mimeType: string;
|
|
72
|
+
|
|
73
|
+
private readonly explicitKey: string | undefined;
|
|
74
|
+
private readonly getSecret: ((name: string) => Promise<string | null>) | undefined;
|
|
75
|
+
private readonly baseURL: string;
|
|
76
|
+
private readonly model: string;
|
|
77
|
+
private readonly voice: string;
|
|
78
|
+
private readonly format: OpenAiTtsFormat;
|
|
79
|
+
private readonly fetchImpl: FetchLike;
|
|
80
|
+
private readonly timeoutMs: number;
|
|
81
|
+
private key: string | null = null;
|
|
82
|
+
|
|
83
|
+
constructor(opts: OpenAiSynthesizerOptions = {}) {
|
|
84
|
+
this.explicitKey = opts.apiKey;
|
|
85
|
+
this.getSecret = opts.getSecret;
|
|
86
|
+
this.baseURL = (opts.baseURL ?? 'https://api.openai.com/v1').replace(/\/+$/, '');
|
|
87
|
+
this.model = opts.model ?? 'gpt-4o-mini-tts';
|
|
88
|
+
this.voice = opts.voice ?? 'alloy';
|
|
89
|
+
this.format = opts.format ?? 'mp3';
|
|
90
|
+
this.mimeType = MIME_BY_FORMAT[this.format];
|
|
91
|
+
this.fetchImpl = opts.fetchImpl ?? (globalThis.fetch as FetchLike);
|
|
92
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async synthesize(text: string, opts: SynthesizeOptions = {}): Promise<SynthesisResult> {
|
|
96
|
+
// Fast-path a caller who cancelled before we started — propagate their reason.
|
|
97
|
+
opts.signal?.throwIfAborted();
|
|
98
|
+
|
|
99
|
+
const key = await this.resolveKey();
|
|
100
|
+
const input = capInput(text);
|
|
101
|
+
const voice = opts.voice ?? this.voice;
|
|
102
|
+
const speed = clampSpeed(opts.rate);
|
|
103
|
+
const body = {
|
|
104
|
+
model: this.model,
|
|
105
|
+
voice,
|
|
106
|
+
input,
|
|
107
|
+
response_format: this.format,
|
|
108
|
+
...(speed !== undefined ? { speed } : {}),
|
|
109
|
+
};
|
|
110
|
+
const url = `${this.baseURL}/audio/speech`;
|
|
111
|
+
|
|
112
|
+
// Chain the caller's abort signal with our own timeout onto one controller
|
|
113
|
+
// passed to fetch, so either cancels the request and frees the socket.
|
|
114
|
+
const controller = new AbortController();
|
|
115
|
+
let timedOut = false;
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
timedOut = true;
|
|
118
|
+
controller.abort();
|
|
119
|
+
}, this.timeoutMs);
|
|
120
|
+
const forward = (): void => controller.abort();
|
|
121
|
+
if (opts.signal) {
|
|
122
|
+
// The signal may have fired during key resolution above; a listener added
|
|
123
|
+
// to an already-aborted signal is never called, so abort directly here.
|
|
124
|
+
if (opts.signal.aborted) controller.abort();
|
|
125
|
+
else opts.signal.addEventListener('abort', forward, { once: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let res: Response;
|
|
129
|
+
try {
|
|
130
|
+
res = await this.fetchImpl(url, {
|
|
131
|
+
method: 'POST',
|
|
132
|
+
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
133
|
+
body: JSON.stringify(body),
|
|
134
|
+
signal: controller.signal,
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
// Caller-initiated cancellation: re-throw their reason unchanged so a
|
|
138
|
+
// stopped read-aloud isn't masked as a provider/network error.
|
|
139
|
+
if (opts.signal?.aborted && !timedOut) throw opts.signal.reason ?? err;
|
|
140
|
+
if (timedOut) {
|
|
141
|
+
throw new MoxxyError({
|
|
142
|
+
code: 'NETWORK_TIMEOUT',
|
|
143
|
+
message: `OpenAI text-to-speech request timed out after ${this.timeoutMs} ms.`,
|
|
144
|
+
hint: 'Retry, or shorten the text being read aloud.',
|
|
145
|
+
context: { provider: OPENAI_TTS_PROVIDER_ID, url },
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
const network = classifyNetworkError(err, { provider: OPENAI_TTS_PROVIDER_ID, url });
|
|
149
|
+
if (network) throw network;
|
|
150
|
+
throw err;
|
|
151
|
+
} finally {
|
|
152
|
+
clearTimeout(timer);
|
|
153
|
+
opts.signal?.removeEventListener('abort', forward);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const classified = classifyHttpStatus(res.status, {
|
|
158
|
+
provider: OPENAI_TTS_PROVIDER_ID,
|
|
159
|
+
url,
|
|
160
|
+
body: await res.text().catch(() => ''),
|
|
161
|
+
});
|
|
162
|
+
if (classified) throw classified;
|
|
163
|
+
throw new MoxxyError({
|
|
164
|
+
code: 'PROVIDER_BAD_REQUEST',
|
|
165
|
+
message: `OpenAI text-to-speech returned HTTP ${res.status}.`,
|
|
166
|
+
context: { provider: OPENAI_TTS_PROVIDER_ID, url, status: res.status },
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const audio = new Uint8Array(await res.arrayBuffer());
|
|
171
|
+
return { audio, mimeType: this.mimeType };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Resolve the API key lazily: explicit option → vault (`OPENAI_API_KEY`) →
|
|
176
|
+
* `process.env.OPENAI_API_KEY`. Cache only a successful resolution so a key
|
|
177
|
+
* added after a first failed attempt is still picked up on retry. A missing
|
|
178
|
+
* key becomes a classified, actionable `MoxxyError`.
|
|
179
|
+
*/
|
|
180
|
+
private async resolveKey(): Promise<string> {
|
|
181
|
+
if (this.key) return this.key;
|
|
182
|
+
const resolved =
|
|
183
|
+
this.explicitKey ??
|
|
184
|
+
(await this.getSecret?.('OPENAI_API_KEY')) ??
|
|
185
|
+
process.env.OPENAI_API_KEY ??
|
|
186
|
+
null;
|
|
187
|
+
if (!resolved) {
|
|
188
|
+
throw new MoxxyError({
|
|
189
|
+
code: 'AUTH_NO_CREDENTIALS',
|
|
190
|
+
message:
|
|
191
|
+
'No OpenAI API key for text-to-speech. It rides the same OPENAI_API_KEY as the OpenAI provider.',
|
|
192
|
+
hint: 'Run `moxxy init` (stores it in the vault) or set OPENAI_API_KEY.',
|
|
193
|
+
context: { provider: OPENAI_TTS_PROVIDER_ID },
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
this.key = resolved;
|
|
197
|
+
return resolved;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Map a `SynthesizeOptions.rate` multiplier onto OpenAI's `speed`, clamped to
|
|
202
|
+
* the accepted 0.25–4.0 range. Non-finite / absent rates yield `undefined` so
|
|
203
|
+
* the field is omitted and OpenAI uses its default. */
|
|
204
|
+
export function clampSpeed(rate: number | undefined): number | undefined {
|
|
205
|
+
if (rate === undefined || !Number.isFinite(rate)) return undefined;
|
|
206
|
+
return Math.min(MAX_SPEED, Math.max(MIN_SPEED, rate));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Cap `text` to OpenAI's 4096-char `input` limit. When over, truncate at the
|
|
211
|
+
* last sentence boundary (`. `, `! `, `? `, newline) inside the budget — as
|
|
212
|
+
* long as that keeps most of the text — else hard-slice, and append a single
|
|
213
|
+
* ellipsis so the result is always ≤ 4096 characters.
|
|
214
|
+
*/
|
|
215
|
+
export function capInput(text: string): string {
|
|
216
|
+
if (text.length <= MAX_INPUT_CHARS) return text;
|
|
217
|
+
const budget = MAX_INPUT_CHARS - 1; // leave room for the ellipsis
|
|
218
|
+
const slice = text.slice(0, budget);
|
|
219
|
+
const boundary = Math.max(
|
|
220
|
+
slice.lastIndexOf('. '),
|
|
221
|
+
slice.lastIndexOf('! '),
|
|
222
|
+
slice.lastIndexOf('? '),
|
|
223
|
+
slice.lastIndexOf('\n'),
|
|
224
|
+
);
|
|
225
|
+
// Only honor a boundary that doesn't discard more than half the budget,
|
|
226
|
+
// otherwise a single very long sentence would be cut to almost nothing.
|
|
227
|
+
const cut = boundary > budget * 0.5 ? boundary + 1 : budget;
|
|
228
|
+
return `${slice.slice(0, cut).trimEnd()}…`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function createOpenAiSynthesizer(opts: OpenAiSynthesizerOptions = {}): OpenAiSynthesizer {
|
|
232
|
+
return new OpenAiSynthesizer(opts);
|
|
233
|
+
}
|