@nkirit/ai-voice 0.1.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/README.md ADDED
@@ -0,0 +1,124 @@
1
+ # @neelkirit/ai-voice
2
+
3
+ Isomorphic TypeScript TTS library for OpenAI, ElevenLabs, and Google Cloud TTS. **BYOK** — bring your own API key. Works in Node 18+ and modern browsers (no vendor SDKs, just `fetch`).
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @neelkirit/ai-voice
9
+ ```
10
+
11
+ ## Quick start
12
+
13
+ ```ts
14
+ import { synthesize } from "@neelkirit/ai-voice";
15
+
16
+ const bytes = await synthesize({
17
+ provider: "openai",
18
+ apiKey: "sk-...",
19
+ text: "Hello, world!",
20
+ });
21
+ // bytes is a Uint8Array of MP3 audio
22
+ ```
23
+
24
+ ## Streaming
25
+
26
+ ```ts
27
+ import { synthesizeStream } from "@neelkirit/ai-voice";
28
+
29
+ const stream = await synthesizeStream({
30
+ provider: "elevenlabs",
31
+ apiKey: "xi-...",
32
+ text: "Hello from ElevenLabs!",
33
+ });
34
+ // stream is a ReadableStream<Uint8Array>
35
+ ```
36
+
37
+ ## Providers
38
+
39
+ ### OpenAI
40
+
41
+ ```ts
42
+ await synthesize({
43
+ provider: "openai",
44
+ apiKey: "sk-...", // sk-...
45
+ text: "Hello",
46
+ voice: {
47
+ gender: "female", // male | female | neutral (default)
48
+ speed: 1.2, // 0.25–4.0
49
+ voiceId: "shimmer", // overrides gender
50
+ },
51
+ });
52
+ ```
53
+
54
+ Default voices: `male → onyx`, `female → nova`, `neutral → alloy`.
55
+
56
+ ### ElevenLabs
57
+
58
+ ```ts
59
+ await synthesize({
60
+ provider: "elevenlabs",
61
+ apiKey: "xi-...",
62
+ text: "Hello",
63
+ voice: {
64
+ gender: "female",
65
+ speed: 1.0, // 0.7–1.2 (clamped per ElevenLabs limits)
66
+ voiceId: "EXAVITQu4vr4xnSDxMaL", // overrides gender
67
+ },
68
+ });
69
+ ```
70
+
71
+ Default voices: `male → Josh`, `female → Bella`, `neutral → Rachel`.
72
+
73
+ ### Google Cloud TTS
74
+
75
+ ```ts
76
+ await synthesize({
77
+ provider: "google",
78
+ apiKey: "AIza...", // Google API key (must have Cloud TTS enabled)
79
+ text: "Hello",
80
+ voice: {
81
+ language: "en-US", // BCP-47 tag
82
+ gender: "female",
83
+ speed: 1.0, // 0.25–4.0
84
+ pitch: 2.0, // -20.0 to +20.0 semitones
85
+ voiceId: "en-US-Neural2-A", // overrides gender (sets voice.name)
86
+ },
87
+ });
88
+ ```
89
+
90
+ ## Voice parameters by provider
91
+
92
+ | Param | OpenAI | ElevenLabs | Google |
93
+ |---|---|---|---|
94
+ | `voiceId` | ✓ | ✓ | ✓ (mapped to `name`) |
95
+ | `gender` | ✓ (default) | ✓ (default) | ✓ (default) |
96
+ | `speed` | ✓ 0.25–4.0 | ✓ 0.7–1.2 | ✓ 0.25–4.0 |
97
+ | `pitch` | — | — | ✓ -20–+20 |
98
+ | `language` | — | — | ✓ |
99
+
100
+ ## Error handling
101
+
102
+ ```ts
103
+ import { synthesize, InvalidApiKeyError, ProviderError, VoiceAppError } from "@neelkirit/ai-voice";
104
+
105
+ try {
106
+ const bytes = await synthesize({ provider: "openai", apiKey: "bad", text: "hi" });
107
+ } catch (e) {
108
+ if (e instanceof InvalidApiKeyError) {
109
+ console.error("Check your API key for:", e.provider);
110
+ } else if (e instanceof ProviderError) {
111
+ console.error("Provider returned an error:", e.message);
112
+ } else if (e instanceof VoiceAppError) {
113
+ console.error("Library error:", e.message);
114
+ }
115
+ }
116
+ ```
117
+
118
+ ## Security note
119
+
120
+ This is a BYOK library — API keys are used directly in HTTP calls from wherever the library runs. In browser contexts (e.g. the demo site), the key is visible in DevTools network requests. **Do not use production keys in browser demos.** For server-side use (Node.js), keys stay server-side and are safe.
121
+
122
+ ## License
123
+
124
+ MIT
@@ -0,0 +1,12 @@
1
+ export declare class VoiceAppError extends Error {
2
+ readonly statusCode: number;
3
+ constructor(message: string, statusCode?: number);
4
+ }
5
+ export declare class InvalidApiKeyError extends VoiceAppError {
6
+ readonly provider: string;
7
+ constructor(provider: string);
8
+ }
9
+ export declare class ProviderError extends VoiceAppError {
10
+ constructor(provider: string, cause: unknown, statusCode?: number);
11
+ }
12
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,qBAAa,aAAc,SAAQ,KAAK;aACO,UAAU,EAAE,MAAM;gBAAnD,OAAO,EAAE,MAAM,EAAkB,UAAU,GAAE,MAAY;CAItE;AAED,qBAAa,kBAAmB,SAAQ,aAAa;aACvB,QAAQ,EAAE,MAAM;gBAAhB,QAAQ,EAAE,MAAM;CAI7C;AAED,qBAAa,aAAc,SAAQ,aAAa;gBAClC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,SAAM;CAK/D"}
package/dist/errors.js ADDED
@@ -0,0 +1,22 @@
1
+ export class VoiceAppError extends Error {
2
+ constructor(message, statusCode = 500) {
3
+ super(message);
4
+ this.statusCode = statusCode;
5
+ this.name = "VoiceAppError";
6
+ }
7
+ }
8
+ export class InvalidApiKeyError extends VoiceAppError {
9
+ constructor(provider) {
10
+ super(`Invalid or unauthorized API key for provider: ${provider}`, 401);
11
+ this.provider = provider;
12
+ this.name = "InvalidApiKeyError";
13
+ }
14
+ }
15
+ export class ProviderError extends VoiceAppError {
16
+ constructor(provider, cause, statusCode = 502) {
17
+ const detail = cause instanceof Error ? cause.message : String(cause);
18
+ super(`${provider} provider error: ${detail}`, statusCode);
19
+ this.name = "ProviderError";
20
+ }
21
+ }
22
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,aAAc,SAAQ,KAAK;IACtC,YAAY,OAAe,EAAkB,aAAqB,GAAG;QACnE,KAAK,CAAC,OAAO,CAAC,CAAC;QAD4B,eAAU,GAAV,UAAU,CAAc;QAEnE,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF;AAED,MAAM,OAAO,kBAAmB,SAAQ,aAAa;IACnD,YAA4B,QAAgB;QAC1C,KAAK,CAAC,iDAAiD,QAAQ,EAAE,EAAE,GAAG,CAAC,CAAC;QAD9C,aAAQ,GAAR,QAAQ,CAAQ;QAE1C,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;IACnC,CAAC;CACF;AAED,MAAM,OAAO,aAAc,SAAQ,aAAa;IAC9C,YAAY,QAAgB,EAAE,KAAc,EAAE,UAAU,GAAG,GAAG;QAC5D,MAAM,MAAM,GAAG,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;QACtE,KAAK,CAAC,GAAG,QAAQ,oBAAoB,MAAM,EAAE,EAAE,UAAU,CAAC,CAAC;QAC3D,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC;IAC9B,CAAC;CACF"}
@@ -0,0 +1,4 @@
1
+ export { synthesize, synthesizeStream } from "./synthesize.js";
2
+ export type { Provider, VoiceParams, SynthesizeOptions } from "./types.js";
3
+ export { VoiceAppError, InvalidApiKeyError, ProviderError } from "./errors.js";
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAC/D,YAAY,EAAE,QAAQ,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAC3E,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { synthesize, synthesizeStream } from "./synthesize.js";
2
+ export { VoiceAppError, InvalidApiKeyError, ProviderError } from "./errors.js";
3
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAE/D,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { SynthesizeOptions } from "../types.js";
2
+ export declare function elevenlabsStream(opts: SynthesizeOptions): Promise<ReadableStream<Uint8Array>>;
3
+ //# sourceMappingURL=elevenlabs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elevenlabs.d.ts","sourceRoot":"","sources":["../../src/providers/elevenlabs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAUrD,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CA6BnG"}
@@ -0,0 +1,47 @@
1
+ import { InvalidApiKeyError, ProviderError } from "../errors.js";
2
+ import { clamp } from "../util/clamp.js";
3
+ const DEFAULT_VOICES = {
4
+ male: "TxGEqnHWrfWFTfGW9XjX", // Josh
5
+ female: "EXAVITQu4vr4xnSDxMaL", // Bella
6
+ neutral: "21m00Tcm4TlvDq8ikWAM", // Rachel
7
+ };
8
+ export async function elevenlabsStream(opts) {
9
+ const f = opts.fetch ?? fetch;
10
+ const v = opts.voice ?? {};
11
+ const voiceId = v.voiceId ?? DEFAULT_VOICES[v.gender ?? "neutral"];
12
+ const url = `https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voiceId)}/stream?output_format=mp3_44100_128`;
13
+ const res = await f(url, {
14
+ method: "POST",
15
+ headers: {
16
+ "xi-api-key": opts.apiKey,
17
+ "Content-Type": "application/json",
18
+ Accept: "audio/mpeg",
19
+ },
20
+ body: JSON.stringify({
21
+ text: opts.text,
22
+ model_id: "eleven_turbo_v2_5",
23
+ voice_settings: {
24
+ stability: 0.5,
25
+ similarity_boost: 0.75,
26
+ speed: clamp(v.speed ?? 1.0, 0.7, 1.2),
27
+ },
28
+ }),
29
+ signal: opts.signal,
30
+ });
31
+ if (res.status === 401 || res.status === 403)
32
+ throw new InvalidApiKeyError("elevenlabs");
33
+ if (!res.ok)
34
+ throw new ProviderError("elevenlabs", new Error(`HTTP ${res.status}: ${await safeText(res)}`));
35
+ if (!res.body)
36
+ throw new ProviderError("elevenlabs", new Error("empty response body"));
37
+ return res.body;
38
+ }
39
+ async function safeText(res) {
40
+ try {
41
+ return await res.text();
42
+ }
43
+ catch {
44
+ return res.statusText;
45
+ }
46
+ }
47
+ //# sourceMappingURL=elevenlabs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elevenlabs.js","sourceRoot":"","sources":["../../src/providers/elevenlabs.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAEzC,MAAM,cAAc,GAAG;IACrB,IAAI,EAAK,sBAAsB,EAAE,OAAO;IACxC,MAAM,EAAG,sBAAsB,EAAE,QAAQ;IACzC,OAAO,EAAE,sBAAsB,EAAE,SAAS;CAClC,CAAC;AAEX,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,IAAuB;IAC5D,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC;IAC9B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;IAC3B,MAAM,OAAO,GAAG,CAAC,CAAC,OAAO,IAAI,cAAc,CAAC,CAAC,CAAC,MAAM,IAAI,SAAS,CAAC,CAAC;IACnE,MAAM,GAAG,GAAG,+CAA+C,kBAAkB,CAAC,OAAO,CAAC,qCAAqC,CAAC;IAE5H,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,GAAG,EAAE;QACvB,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,YAAY,EAAE,IAAI,CAAC,MAAM;YACzB,cAAc,EAAE,kBAAkB;YAClC,MAAM,EAAE,YAAY;SACrB;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,QAAQ,EAAE,mBAAmB;YAC7B,cAAc,EAAE;gBACd,SAAS,EAAE,GAAG;gBACd,gBAAgB,EAAE,IAAI;gBACtB,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC;aACvC;SACF,CAAC;QACF,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC,CAAC;IAEH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;QAAE,MAAM,IAAI,kBAAkB,CAAC,YAAY,CAAC,CAAC;IACzF,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,aAAa,CAAC,YAAY,EAAE,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,KAAK,MAAM,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAC5G,IAAI,CAAC,GAAG,CAAC,IAAI;QAAE,MAAM,IAAI,aAAa,CAAC,YAAY,EAAE,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC;IACvF,OAAO,GAAG,CAAC,IAAI,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,GAAa;IACnC,IAAI,CAAC;QAAC,OAAO,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,GAAG,CAAC,UAAU,CAAC;IAAC,CAAC;AACnE,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { SynthesizeOptions } from "../types.js";
2
+ export declare function googleStream(opts: SynthesizeOptions): Promise<ReadableStream<Uint8Array>>;
3
+ //# sourceMappingURL=google.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google.d.ts","sourceRoot":"","sources":["../../src/providers/google.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAOrD,wBAAsB,YAAY,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAsC/F"}
@@ -0,0 +1,51 @@
1
+ import { InvalidApiKeyError, ProviderError } from "../errors.js";
2
+ import { clamp } from "../util/clamp.js";
3
+ import { base64ToBytes } from "../util/base64.js";
4
+ const GENDER_MAP = { male: "MALE", female: "FEMALE", neutral: "NEUTRAL" };
5
+ export async function googleStream(opts) {
6
+ const f = opts.fetch ?? fetch;
7
+ const v = opts.voice ?? {};
8
+ const language = v.language ?? "en-US";
9
+ const ssmlGender = GENDER_MAP[v.gender ?? "neutral"];
10
+ const url = `https://texttospeech.googleapis.com/v1/text:synthesize?key=${encodeURIComponent(opts.apiKey)}`;
11
+ const res = await f(url, {
12
+ method: "POST",
13
+ headers: { "Content-Type": "application/json" },
14
+ body: JSON.stringify({
15
+ input: { text: opts.text },
16
+ voice: {
17
+ languageCode: language,
18
+ ...(v.voiceId ? { name: v.voiceId } : { ssmlGender }),
19
+ },
20
+ audioConfig: {
21
+ audioEncoding: "MP3",
22
+ speakingRate: clamp(v.speed ?? 1.0, 0.25, 4.0),
23
+ pitch: clamp(v.pitch ?? 0.0, -20, 20),
24
+ },
25
+ }),
26
+ signal: opts.signal,
27
+ });
28
+ if (res.status === 401 || res.status === 403)
29
+ throw new InvalidApiKeyError("google");
30
+ if (!res.ok)
31
+ throw new ProviderError("google", new Error(`HTTP ${res.status}: ${await safeText(res)}`));
32
+ const json = (await res.json());
33
+ if (!json.audioContent)
34
+ throw new ProviderError("google", new Error("missing audioContent in response"));
35
+ const bytes = base64ToBytes(json.audioContent);
36
+ return new ReadableStream({
37
+ start(controller) {
38
+ controller.enqueue(bytes);
39
+ controller.close();
40
+ },
41
+ });
42
+ }
43
+ async function safeText(res) {
44
+ try {
45
+ return await res.text();
46
+ }
47
+ catch {
48
+ return res.statusText;
49
+ }
50
+ }
51
+ //# sourceMappingURL=google.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google.js","sourceRoot":"","sources":["../../src/providers/google.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AACzC,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAElD,MAAM,UAAU,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,EAAE,SAAS,EAAW,CAAC;AAEnF,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAuB;IACxD,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC;IAC9B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;IAC3B,MAAM,QAAQ,GAAG,CAAC,CAAC,QAAQ,IAAI,OAAO,CAAC;IACvC,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,CAAC,MAAM,IAAI,SAAS,CAAC,CAAC;IACrD,MAAM,GAAG,GAAG,8DAA8D,kBAAkB,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC;IAE5G,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,GAAG,EAAE;QACvB,MAAM,EAAE,MAAM;QACd,OAAO,EAAE,EAAE,cAAc,EAAE,kBAAkB,EAAE;QAC/C,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,KAAK,EAAE,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE;YAC1B,KAAK,EAAE;gBACL,YAAY,EAAE,QAAQ;gBACtB,GAAG,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,CAAC,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,EAAE,UAAU,EAAE,CAAC;aACtD;YACD,WAAW,EAAE;gBACX,aAAa,EAAE,KAAK;gBACpB,YAAY,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC;gBAC9C,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,EAAE,CAAC,EAAE,EAAE,EAAE,CAAC;aACtC;SACF,CAAC;QACF,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC,CAAC;IAEH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;QAAE,MAAM,IAAI,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACrF,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,aAAa,CAAC,QAAQ,EAAE,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,KAAK,MAAM,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IAExG,MAAM,IAAI,GAAG,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAA8B,CAAC;IAC7D,IAAI,CAAC,IAAI,CAAC,YAAY;QAAE,MAAM,IAAI,aAAa,CAAC,QAAQ,EAAE,IAAI,KAAK,CAAC,kCAAkC,CAAC,CAAC,CAAC;IACzG,MAAM,KAAK,GAAG,aAAa,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;IAE/C,OAAO,IAAI,cAAc,CAAa;QACpC,KAAK,CAAC,UAAU;YACd,UAAU,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC;YAC1B,UAAU,CAAC,KAAK,EAAE,CAAC;QACrB,CAAC;KACF,CAAC,CAAC;AACL,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,GAAa;IACnC,IAAI,CAAC;QAAC,OAAO,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,GAAG,CAAC,UAAU,CAAC;IAAC,CAAC;AACnE,CAAC"}
@@ -0,0 +1,3 @@
1
+ import type { SynthesizeOptions } from "../types.js";
2
+ export declare function openaiStream(opts: SynthesizeOptions): Promise<ReadableStream<Uint8Array>>;
3
+ //# sourceMappingURL=openai.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai.d.ts","sourceRoot":"","sources":["../../src/providers/openai.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAOrD,wBAAsB,YAAY,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CA0B/F"}
@@ -0,0 +1,41 @@
1
+ import { InvalidApiKeyError, ProviderError } from "../errors.js";
2
+ import { clamp } from "../util/clamp.js";
3
+ const VOICES = { male: "onyx", female: "nova", neutral: "alloy" };
4
+ const TTS_URL = "https://api.openai.com/v1/audio/speech";
5
+ export async function openaiStream(opts) {
6
+ const f = opts.fetch ?? fetch;
7
+ const v = opts.voice ?? {};
8
+ const voice = v.voiceId ?? VOICES[v.gender ?? "neutral"];
9
+ const res = await f(TTS_URL, {
10
+ method: "POST",
11
+ headers: {
12
+ Authorization: `Bearer ${opts.apiKey}`,
13
+ "Content-Type": "application/json",
14
+ Accept: "audio/mpeg",
15
+ },
16
+ body: JSON.stringify({
17
+ model: "tts-1-hd",
18
+ voice,
19
+ input: opts.text,
20
+ response_format: "mp3",
21
+ speed: clamp(v.speed ?? 1.0, 0.25, 4.0),
22
+ }),
23
+ signal: opts.signal,
24
+ });
25
+ if (res.status === 401 || res.status === 403)
26
+ throw new InvalidApiKeyError("openai");
27
+ if (!res.ok)
28
+ throw new ProviderError("openai", new Error(`HTTP ${res.status}: ${await safeText(res)}`));
29
+ if (!res.body)
30
+ throw new ProviderError("openai", new Error("empty response body"));
31
+ return res.body;
32
+ }
33
+ async function safeText(res) {
34
+ try {
35
+ return await res.text();
36
+ }
37
+ catch {
38
+ return res.statusText;
39
+ }
40
+ }
41
+ //# sourceMappingURL=openai.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"openai.js","sourceRoot":"","sources":["../../src/providers/openai.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AACjE,OAAO,EAAE,KAAK,EAAE,MAAM,kBAAkB,CAAC;AAEzC,MAAM,MAAM,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAW,CAAC;AAC3E,MAAM,OAAO,GAAG,wCAAwC,CAAC;AAEzD,MAAM,CAAC,KAAK,UAAU,YAAY,CAAC,IAAuB;IACxD,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC;IAC9B,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;IAC3B,MAAM,KAAK,GAAG,CAAC,CAAC,OAAO,IAAI,MAAM,CAAC,CAAC,CAAC,MAAM,IAAI,SAAS,CAAC,CAAC;IAEzD,MAAM,GAAG,GAAG,MAAM,CAAC,CAAC,OAAO,EAAE;QAC3B,MAAM,EAAE,MAAM;QACd,OAAO,EAAE;YACP,aAAa,EAAE,UAAU,IAAI,CAAC,MAAM,EAAE;YACtC,cAAc,EAAE,kBAAkB;YAClC,MAAM,EAAE,YAAY;SACrB;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC;YACnB,KAAK,EAAE,UAAU;YACjB,KAAK;YACL,KAAK,EAAE,IAAI,CAAC,IAAI;YAChB,eAAe,EAAE,KAAK;YACtB,KAAK,EAAE,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,EAAE,IAAI,EAAE,GAAG,CAAC;SACxC,CAAC;QACF,MAAM,EAAE,IAAI,CAAC,MAAM;KACpB,CAAC,CAAC;IAEH,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG,IAAI,GAAG,CAAC,MAAM,KAAK,GAAG;QAAE,MAAM,IAAI,kBAAkB,CAAC,QAAQ,CAAC,CAAC;IACrF,IAAI,CAAC,GAAG,CAAC,EAAE;QAAE,MAAM,IAAI,aAAa,CAAC,QAAQ,EAAE,IAAI,KAAK,CAAC,QAAQ,GAAG,CAAC,MAAM,KAAK,MAAM,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;IACxG,IAAI,CAAC,GAAG,CAAC,IAAI;QAAE,MAAM,IAAI,aAAa,CAAC,QAAQ,EAAE,IAAI,KAAK,CAAC,qBAAqB,CAAC,CAAC,CAAC;IACnF,OAAO,GAAG,CAAC,IAAI,CAAC;AAClB,CAAC;AAED,KAAK,UAAU,QAAQ,CAAC,GAAa;IACnC,IAAI,CAAC;QAAC,OAAO,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC;IAAC,CAAC;IAAC,MAAM,CAAC;QAAC,OAAO,GAAG,CAAC,UAAU,CAAC;IAAC,CAAC;AACnE,CAAC"}
@@ -0,0 +1,4 @@
1
+ import type { SynthesizeOptions } from "./types.js";
2
+ export declare function synthesizeStream(opts: SynthesizeOptions): Promise<ReadableStream<Uint8Array>>;
3
+ export declare function synthesize(opts: SynthesizeOptions): Promise<Uint8Array<ArrayBuffer>>;
4
+ //# sourceMappingURL=synthesize.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"synthesize.d.ts","sourceRoot":"","sources":["../src/synthesize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAY,MAAM,YAAY,CAAC;AAQ9D,wBAAsB,gBAAgB,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC,UAAU,CAAC,CAAC,CAOnG;AAED,wBAAsB,UAAU,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAG1F"}
@@ -0,0 +1,48 @@
1
+ import { VoiceAppError } from "./errors.js";
2
+ import { openaiStream } from "./providers/openai.js";
3
+ import { elevenlabsStream } from "./providers/elevenlabs.js";
4
+ import { googleStream } from "./providers/google.js";
5
+ const SUPPORTED = ["openai", "elevenlabs", "google"];
6
+ export async function synthesizeStream(opts) {
7
+ validate(opts);
8
+ switch (opts.provider) {
9
+ case "openai": return openaiStream(opts);
10
+ case "elevenlabs": return elevenlabsStream(opts);
11
+ case "google": return googleStream(opts);
12
+ }
13
+ }
14
+ export async function synthesize(opts) {
15
+ const stream = await synthesizeStream(opts);
16
+ return collect(stream);
17
+ }
18
+ function validate(opts) {
19
+ if (!opts.text?.trim())
20
+ throw new VoiceAppError("text is required and must be non-empty", 400);
21
+ if (!SUPPORTED.includes(opts.provider)) {
22
+ throw new VoiceAppError(`provider must be one of: ${SUPPORTED.join(", ")}`, 400);
23
+ }
24
+ if (!opts.apiKey)
25
+ throw new VoiceAppError("apiKey is required", 400);
26
+ }
27
+ async function collect(stream) {
28
+ const reader = stream.getReader();
29
+ const chunks = [];
30
+ let total = 0;
31
+ for (;;) {
32
+ const { value, done } = await reader.read();
33
+ if (done)
34
+ break;
35
+ if (value) {
36
+ chunks.push(value);
37
+ total += value.byteLength;
38
+ }
39
+ }
40
+ const out = new Uint8Array(total);
41
+ let off = 0;
42
+ for (const c of chunks) {
43
+ out.set(c, off);
44
+ off += c.byteLength;
45
+ }
46
+ return out;
47
+ }
48
+ //# sourceMappingURL=synthesize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"synthesize.js","sourceRoot":"","sources":["../src/synthesize.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,EAAE,gBAAgB,EAAE,MAAM,2BAA2B,CAAC;AAC7D,OAAO,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAErD,MAAM,SAAS,GAAwB,CAAC,QAAQ,EAAE,YAAY,EAAE,QAAQ,CAAC,CAAC;AAE1E,MAAM,CAAC,KAAK,UAAU,gBAAgB,CAAC,IAAuB;IAC5D,QAAQ,CAAC,IAAI,CAAC,CAAC;IACf,QAAQ,IAAI,CAAC,QAAQ,EAAE,CAAC;QACtB,KAAK,QAAQ,CAAC,CAAK,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC;QAC7C,KAAK,YAAY,CAAC,CAAC,OAAO,gBAAgB,CAAC,IAAI,CAAC,CAAC;QACjD,KAAK,QAAQ,CAAC,CAAK,OAAO,YAAY,CAAC,IAAI,CAAC,CAAC;IAC/C,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,IAAuB;IACtD,MAAM,MAAM,GAAG,MAAM,gBAAgB,CAAC,IAAI,CAAC,CAAC;IAC5C,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC;AACzB,CAAC;AAED,SAAS,QAAQ,CAAC,IAAuB;IACvC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE;QAAE,MAAM,IAAI,aAAa,CAAC,wCAAwC,EAAE,GAAG,CAAC,CAAC;IAC/F,IAAI,CAAE,SAA+B,CAAC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,EAAE,CAAC;QAC9D,MAAM,IAAI,aAAa,CAAC,4BAA4B,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,EAAE,GAAG,CAAC,CAAC;IACnF,CAAC;IACD,IAAI,CAAC,IAAI,CAAC,MAAM;QAAE,MAAM,IAAI,aAAa,CAAC,oBAAoB,EAAE,GAAG,CAAC,CAAC;AACvE,CAAC;AAED,KAAK,UAAU,OAAO,CAAC,MAAkC;IACvD,MAAM,MAAM,GAAG,MAAM,CAAC,SAAS,EAAE,CAAC;IAClC,MAAM,MAAM,GAAiB,EAAE,CAAC;IAChC,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,SAAS,CAAC;QACR,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,MAAM,MAAM,CAAC,IAAI,EAAE,CAAC;QAC5C,IAAI,IAAI;YAAE,MAAM;QAChB,IAAI,KAAK,EAAE,CAAC;YAAC,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAAC,KAAK,IAAI,KAAK,CAAC,UAAU,CAAC;QAAC,CAAC;IAC/D,CAAC;IACD,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,KAAK,CAA4B,CAAC;IAC7D,IAAI,GAAG,GAAG,CAAC,CAAC;IACZ,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC;QAAC,GAAG,IAAI,CAAC,CAAC,UAAU,CAAC;IAAC,CAAC;IACjE,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,27 @@
1
+ export type Provider = "openai" | "elevenlabs" | "google";
2
+ export interface VoiceParams {
3
+ /** Provider-specific voice identifier. If omitted, a gender-based default is used. */
4
+ voiceId?: string;
5
+ /** Playback rate. Clamped per-provider; canonical input range 0.25–4.0. */
6
+ speed?: number;
7
+ /** Semitones; Google only. -20.0 to +20.0. Silently ignored by OpenAI and ElevenLabs. */
8
+ pitch?: number;
9
+ /** BCP-47 language tag (e.g. "en-US"). Google uses this; OpenAI/ElevenLabs ignore it. */
10
+ language?: string;
11
+ /** Fallback for default voice selection when voiceId is absent. */
12
+ gender?: "male" | "female" | "neutral";
13
+ }
14
+ export interface SynthesizeOptions {
15
+ provider: Provider;
16
+ /** User-supplied provider API key (sk-... for OpenAI, xi-... for ElevenLabs, AIza... for Google). */
17
+ apiKey: string;
18
+ /** Text to synthesize. Must be non-empty after trim. */
19
+ text: string;
20
+ /** Voice configuration. Provider-specific defaults applied when omitted. */
21
+ voice?: VoiceParams;
22
+ /** AbortSignal for cancellation. */
23
+ signal?: AbortSignal;
24
+ /** Override the fetch implementation (useful for testing or custom runtimes). */
25
+ fetch?: typeof fetch;
26
+ }
27
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,QAAQ,GAAG,QAAQ,GAAG,YAAY,GAAG,QAAQ,CAAC;AAE1D,MAAM,WAAW,WAAW;IAC1B,sFAAsF;IACtF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,2EAA2E;IAC3E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yFAAyF;IACzF,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yFAAyF;IACzF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mEAAmE;IACnE,MAAM,CAAC,EAAE,MAAM,GAAG,QAAQ,GAAG,SAAS,CAAC;CACxC;AAED,MAAM,WAAW,iBAAiB;IAChC,QAAQ,EAAE,QAAQ,CAAC;IACnB,qGAAqG;IACrG,MAAM,EAAE,MAAM,CAAC;IACf,wDAAwD;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,KAAK,CAAC,EAAE,WAAW,CAAC;IACpB,oCAAoC;IACpC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,iFAAiF;IACjF,KAAK,CAAC,EAAE,OAAO,KAAK,CAAC;CACtB"}
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,3 @@
1
+ /** Decode a base64 string to Uint8Array — works in Node 18+ (atob is global) and browsers. */
2
+ export declare function base64ToBytes(b64: string): Uint8Array;
3
+ //# sourceMappingURL=base64.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.d.ts","sourceRoot":"","sources":["../../src/util/base64.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,wBAAgB,aAAa,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAKrD"}
@@ -0,0 +1,9 @@
1
+ /** Decode a base64 string to Uint8Array — works in Node 18+ (atob is global) and browsers. */
2
+ export function base64ToBytes(b64) {
3
+ const bin = atob(b64);
4
+ const out = new Uint8Array(bin.length);
5
+ for (let i = 0; i < bin.length; i++)
6
+ out[i] = bin.charCodeAt(i);
7
+ return out;
8
+ }
9
+ //# sourceMappingURL=base64.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"base64.js","sourceRoot":"","sources":["../../src/util/base64.ts"],"names":[],"mappings":"AAAA,8FAA8F;AAC9F,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC;IACtB,MAAM,GAAG,GAAG,IAAI,UAAU,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;IACvC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC,EAAE;QAAE,GAAG,CAAC,CAAC,CAAC,GAAG,GAAG,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC;IAChE,OAAO,GAAG,CAAC;AACb,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare const clamp: (v: number, min: number, max: number) => number;
2
+ //# sourceMappingURL=clamp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clamp.d.ts","sourceRoot":"","sources":["../../src/util/clamp.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,KAAK,GAAI,GAAG,MAAM,EAAE,KAAK,MAAM,EAAE,KAAK,MAAM,KAAG,MAC3B,CAAC"}
@@ -0,0 +1,2 @@
1
+ export const clamp = (v, min, max) => Math.min(Math.max(v, min), max);
2
+ //# sourceMappingURL=clamp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clamp.js","sourceRoot":"","sources":["../../src/util/clamp.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,KAAK,GAAG,CAAC,CAAS,EAAE,GAAW,EAAE,GAAW,EAAU,EAAE,CACnE,IAAI,CAAC,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC"}
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@nkirit/ai-voice",
3
+ "version": "0.1.0",
4
+ "description": "Isomorphic TypeScript TTS client for OpenAI, ElevenLabs, and Google Cloud TTS. BYOK.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js",
13
+ "default": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": ["dist", "README.md", "LICENSE"],
17
+ "sideEffects": false,
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.6.0",
25
+ "vitest": "^2.1.0"
26
+ },
27
+ "dependencies": {},
28
+ "peerDependencies": {},
29
+ "engines": { "node": ">=18.18" },
30
+ "publishConfig": { "access": "public" },
31
+ "keywords": ["tts", "openai", "elevenlabs", "google-cloud-tts", "isomorphic", "byok"],
32
+ "repository": { "type": "git", "url": "https://github.com/neelkirit/ai-voice" },
33
+ "homepage": "https://github.com/neelkirit/ai-voice#readme"
34
+ }