@framers/agentos-ext-voice-synthesis 2.0.0 → 2.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/LICENSE +96 -21
- package/README.md +66 -8
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +36 -7
- package/dist/index.js.map +1 -1
- package/dist/tools/speechToText.d.ts +77 -0
- package/dist/tools/speechToText.d.ts.map +1 -0
- package/dist/tools/speechToText.js +479 -0
- package/dist/tools/speechToText.js.map +1 -0
- package/dist/tools/textToSpeech.d.ts.map +1 -1
- package/dist/tools/textToSpeech.js +1 -0
- package/dist/tools/textToSpeech.js.map +1 -1
- package/manifest.json +20 -5
- package/package.json +21 -9
- package/src/index.ts +0 -61
- package/src/tools/textToSpeech.ts +0 -287
- package/test/textToSpeech.spec.ts +0 -189
- package/tsconfig.json +0 -22
- package/vitest.config.ts +0 -10
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@framers/agentos-ext-voice-synthesis",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Voice synthesis and transcription tools for AgentOS via OpenAI, ElevenLabs, Deepgram, and local Ollama/Whisper-compatible runtimes",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"type": "module",
|
|
@@ -18,10 +18,14 @@
|
|
|
18
18
|
"agentos",
|
|
19
19
|
"extension",
|
|
20
20
|
"tts",
|
|
21
|
+
"stt",
|
|
21
22
|
"voice",
|
|
23
|
+
"transcription",
|
|
22
24
|
"openai",
|
|
23
25
|
"elevenlabs",
|
|
26
|
+
"deepgram",
|
|
24
27
|
"ollama",
|
|
28
|
+
"whisper",
|
|
25
29
|
"speech",
|
|
26
30
|
"media"
|
|
27
31
|
],
|
|
@@ -40,29 +44,37 @@
|
|
|
40
44
|
"license": "MIT",
|
|
41
45
|
"repository": {
|
|
42
46
|
"type": "git",
|
|
43
|
-
"url": "https://github.com/
|
|
47
|
+
"url": "git+https://github.com/framerslab/agentos-extensions.git",
|
|
44
48
|
"directory": "registry/curated/media/voice-synthesis"
|
|
45
49
|
},
|
|
46
50
|
"publishConfig": {
|
|
47
51
|
"access": "public"
|
|
48
52
|
},
|
|
49
53
|
"peerDependencies": {
|
|
50
|
-
"@framers/agentos": "
|
|
54
|
+
"@framers/agentos": ">=0.10.40"
|
|
51
55
|
},
|
|
52
56
|
"devDependencies": {
|
|
53
|
-
"@framers/agentos": "^0.1.0",
|
|
54
57
|
"@types/node": "^20.12.12",
|
|
55
|
-
"@vitest/coverage-v8": "^
|
|
58
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
56
59
|
"rimraf": "^5.0.7",
|
|
57
60
|
"typescript": "^5.4.5",
|
|
58
|
-
"vitest": "^
|
|
61
|
+
"vitest": "^3.2.7",
|
|
62
|
+
"@framers/agentos": "^0.10.40"
|
|
59
63
|
},
|
|
64
|
+
"homepage": "https://github.com/framerslab/agentos-extensions/tree/master/registry/curated/media/voice-synthesis#readme",
|
|
65
|
+
"bugs": {
|
|
66
|
+
"url": "https://github.com/framerslab/agentos-extensions/issues"
|
|
67
|
+
},
|
|
68
|
+
"files": [
|
|
69
|
+
"dist",
|
|
70
|
+
"manifest.json",
|
|
71
|
+
"README.md"
|
|
72
|
+
],
|
|
60
73
|
"scripts": {
|
|
61
74
|
"build": "tsc",
|
|
62
|
-
"test": "vitest run",
|
|
75
|
+
"test": "vitest run --passWithNoTests",
|
|
63
76
|
"test:watch": "vitest",
|
|
64
77
|
"test:coverage": "vitest run --coverage",
|
|
65
|
-
"lint": "eslint src --ext .ts",
|
|
66
78
|
"typecheck": "tsc --noEmit",
|
|
67
79
|
"clean": "rimraf dist"
|
|
68
80
|
}
|
package/src/index.ts
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Voice Synthesis Extension Pack — multi-provider TTS for agents.
|
|
3
|
-
*
|
|
4
|
-
* Supports: OpenAI TTS, ElevenLabs, Ollama (local).
|
|
5
|
-
* Auto-detects available provider from API keys.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { TextToSpeechTool, type TTSConfig, type TTSProvider } from './tools/textToSpeech.js';
|
|
9
|
-
|
|
10
|
-
export interface VoiceSynthesisExtensionOptions {
|
|
11
|
-
elevenLabsApiKey?: string;
|
|
12
|
-
openaiApiKey?: string;
|
|
13
|
-
openaiBaseUrl?: string;
|
|
14
|
-
ollamaBaseUrl?: string;
|
|
15
|
-
defaultProvider?: TTSProvider;
|
|
16
|
-
priority?: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function createExtensionPack(context: any) {
|
|
20
|
-
const options = (context.options || {}) as VoiceSynthesisExtensionOptions;
|
|
21
|
-
|
|
22
|
-
const config: TTSConfig = {
|
|
23
|
-
openaiApiKey: options.openaiApiKey || context.getSecret?.('openai.apiKey') || process.env.OPENAI_API_KEY,
|
|
24
|
-
openaiBaseUrl: options.openaiBaseUrl || process.env.OPENAI_BASE_URL,
|
|
25
|
-
elevenLabsApiKey: options.elevenLabsApiKey || context.getSecret?.('elevenlabs.apiKey') || process.env.ELEVENLABS_API_KEY,
|
|
26
|
-
ollamaBaseUrl: options.ollamaBaseUrl || process.env.OLLAMA_BASE_URL,
|
|
27
|
-
defaultProvider: options.defaultProvider || (process.env.TTS_PROVIDER as TTSProvider) || 'auto',
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
const tool = new TextToSpeechTool(config);
|
|
31
|
-
|
|
32
|
-
// Determine which providers are available for the activation message
|
|
33
|
-
const providers: string[] = [];
|
|
34
|
-
if (config.openaiApiKey) providers.push('OpenAI');
|
|
35
|
-
if (config.elevenLabsApiKey) providers.push('ElevenLabs');
|
|
36
|
-
providers.push('Ollama (local fallback)');
|
|
37
|
-
|
|
38
|
-
return {
|
|
39
|
-
name: '@framers/agentos-ext-voice-synthesis',
|
|
40
|
-
version: '2.0.0',
|
|
41
|
-
descriptors: [
|
|
42
|
-
{
|
|
43
|
-
id: tool.name,
|
|
44
|
-
kind: 'tool' as const,
|
|
45
|
-
priority: options.priority || 50,
|
|
46
|
-
payload: tool,
|
|
47
|
-
requiredSecrets: [],
|
|
48
|
-
},
|
|
49
|
-
],
|
|
50
|
-
onActivate: async () => {
|
|
51
|
-
context.logger?.info?.(`Voice Synthesis activated — providers: ${providers.join(', ')}`);
|
|
52
|
-
},
|
|
53
|
-
onDeactivate: async () => {
|
|
54
|
-
context.logger?.info?.('Voice Synthesis deactivated');
|
|
55
|
-
},
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export { TextToSpeechTool };
|
|
60
|
-
export type { TTSInput, TTSOutput, TTSConfig, TTSProvider } from './tools/textToSpeech.js';
|
|
61
|
-
export default createExtensionPack;
|
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Multi-provider TTS Tool — text-to-speech synthesis.
|
|
3
|
-
*
|
|
4
|
-
* Supports: OpenAI TTS, ElevenLabs, Ollama (local), any OpenAI-compatible TTS API.
|
|
5
|
-
* Auto-detects available provider from API keys in environment.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import type { ITool, ToolExecutionContext, ToolExecutionResult, JSONSchemaObject } from '@framers/agentos';
|
|
9
|
-
|
|
10
|
-
export type TTSProvider = 'openai' | 'elevenlabs' | 'ollama' | 'auto';
|
|
11
|
-
|
|
12
|
-
export interface TTSInput {
|
|
13
|
-
text: string;
|
|
14
|
-
voice?: string;
|
|
15
|
-
model?: string;
|
|
16
|
-
provider?: TTSProvider;
|
|
17
|
-
/** ElevenLabs-specific */
|
|
18
|
-
stability?: number;
|
|
19
|
-
/** ElevenLabs-specific */
|
|
20
|
-
similarity_boost?: number;
|
|
21
|
-
/** OpenAI-specific: speed 0.25-4.0 */
|
|
22
|
-
speed?: number;
|
|
23
|
-
/** Output format: mp3, opus, aac, flac, wav */
|
|
24
|
-
format?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface TTSOutput {
|
|
28
|
-
text: string;
|
|
29
|
-
voice: string;
|
|
30
|
-
model: string;
|
|
31
|
-
provider: string;
|
|
32
|
-
audioBase64: string;
|
|
33
|
-
contentType: string;
|
|
34
|
-
durationEstimateMs: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
// ── ElevenLabs voice name → ID mapping ──
|
|
38
|
-
const ELEVENLABS_VOICES: Record<string, string> = {
|
|
39
|
-
rachel: '21m00Tcm4TlvDq8ikWAM',
|
|
40
|
-
domi: 'AZnzlk1XvdvUeBnXmlld',
|
|
41
|
-
bella: 'EXAVITQu4vr4xnSDxMaL',
|
|
42
|
-
antoni: 'ErXwobaYiN019PkySvjV',
|
|
43
|
-
josh: 'TxGEqnHWrfWFTfGW9XjX',
|
|
44
|
-
arnold: 'VR6AewLTigWG4xSOukaG',
|
|
45
|
-
adam: 'pNInz6obpgDQGcFmaJgB',
|
|
46
|
-
sam: 'yoZ06aMxZJJ28mfd3POQ',
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// ── OpenAI voice options ──
|
|
50
|
-
const OPENAI_VOICES = ['alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'];
|
|
51
|
-
|
|
52
|
-
export interface TTSConfig {
|
|
53
|
-
openaiApiKey?: string;
|
|
54
|
-
openaiBaseUrl?: string;
|
|
55
|
-
elevenLabsApiKey?: string;
|
|
56
|
-
ollamaBaseUrl?: string;
|
|
57
|
-
defaultProvider?: TTSProvider;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export class TextToSpeechTool implements ITool<TTSInput, TTSOutput> {
|
|
61
|
-
readonly id = 'tts-multi-provider-v1';
|
|
62
|
-
readonly name = 'text_to_speech';
|
|
63
|
-
readonly displayName = 'Text to Speech';
|
|
64
|
-
readonly description =
|
|
65
|
-
'Convert text to speech audio. Supports multiple providers: OpenAI TTS (alloy/echo/fable/onyx/nova/shimmer), ' +
|
|
66
|
-
'ElevenLabs (rachel/domi/bella/antoni/josh/arnold/adam/sam), or local Ollama TTS. ' +
|
|
67
|
-
'Auto-detects available provider from API keys. Returns base64-encoded audio.';
|
|
68
|
-
readonly category = 'media';
|
|
69
|
-
readonly version = '2.0.0';
|
|
70
|
-
readonly hasSideEffects = false;
|
|
71
|
-
|
|
72
|
-
readonly inputSchema: JSONSchemaObject = {
|
|
73
|
-
type: 'object',
|
|
74
|
-
properties: {
|
|
75
|
-
text: { type: 'string', description: 'Text to convert to speech. Max 5000 chars.' },
|
|
76
|
-
voice: {
|
|
77
|
-
type: 'string',
|
|
78
|
-
description:
|
|
79
|
-
'Voice name. OpenAI: alloy, echo, fable, onyx, nova (default), shimmer. ' +
|
|
80
|
-
'ElevenLabs: rachel (default), domi, bella, antoni, josh, arnold, adam, sam. ' +
|
|
81
|
-
'Or a custom voice ID.',
|
|
82
|
-
},
|
|
83
|
-
model: {
|
|
84
|
-
type: 'string',
|
|
85
|
-
description: 'TTS model. OpenAI: tts-1 (default), tts-1-hd. ElevenLabs: eleven_monolingual_v1 (default), eleven_multilingual_v2.',
|
|
86
|
-
},
|
|
87
|
-
provider: {
|
|
88
|
-
type: 'string',
|
|
89
|
-
enum: ['openai', 'elevenlabs', 'ollama', 'auto'],
|
|
90
|
-
description: 'TTS provider. Default: auto (detects from available API keys).',
|
|
91
|
-
},
|
|
92
|
-
speed: { type: 'number', minimum: 0.25, maximum: 4.0, description: 'OpenAI speed multiplier (0.25-4.0).' },
|
|
93
|
-
stability: { type: 'number', minimum: 0, maximum: 1, description: 'ElevenLabs voice stability (0-1).' },
|
|
94
|
-
similarity_boost: { type: 'number', minimum: 0, maximum: 1, description: 'ElevenLabs similarity boost (0-1).' },
|
|
95
|
-
format: { type: 'string', enum: ['mp3', 'opus', 'aac', 'flac', 'wav'], description: 'Output audio format.' },
|
|
96
|
-
},
|
|
97
|
-
required: ['text'],
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
readonly requiredCapabilities = ['capability:tts'];
|
|
101
|
-
|
|
102
|
-
private config: TTSConfig;
|
|
103
|
-
|
|
104
|
-
constructor(config?: TTSConfig) {
|
|
105
|
-
this.config = {
|
|
106
|
-
openaiApiKey: config?.openaiApiKey || process.env.OPENAI_API_KEY || '',
|
|
107
|
-
openaiBaseUrl: config?.openaiBaseUrl || process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1',
|
|
108
|
-
elevenLabsApiKey: config?.elevenLabsApiKey || process.env.ELEVENLABS_API_KEY || '',
|
|
109
|
-
ollamaBaseUrl: config?.ollamaBaseUrl || process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
|
|
110
|
-
defaultProvider: config?.defaultProvider || (process.env.TTS_PROVIDER as TTSProvider) || 'auto',
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
private resolveProvider(requested?: TTSProvider): TTSProvider | null {
|
|
115
|
-
const pref = requested || this.config.defaultProvider || 'auto';
|
|
116
|
-
if (pref !== 'auto') {
|
|
117
|
-
// Verify the requested provider has credentials
|
|
118
|
-
if (pref === 'openai' && this.config.openaiApiKey) return 'openai';
|
|
119
|
-
if (pref === 'elevenlabs' && this.config.elevenLabsApiKey) return 'elevenlabs';
|
|
120
|
-
if (pref === 'ollama') return 'ollama';
|
|
121
|
-
// Fall through to auto if requested provider isn't configured
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Auto-detect: prefer OpenAI (cheaper, faster), then ElevenLabs, then Ollama
|
|
125
|
-
if (this.config.openaiApiKey) return 'openai';
|
|
126
|
-
if (this.config.elevenLabsApiKey) return 'elevenlabs';
|
|
127
|
-
return 'ollama'; // Local fallback — may or may not have TTS model
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
async execute(args: TTSInput, _context: ToolExecutionContext): Promise<ToolExecutionResult<TTSOutput>> {
|
|
131
|
-
const text = args.text.slice(0, 5000);
|
|
132
|
-
const provider = this.resolveProvider(args.provider);
|
|
133
|
-
|
|
134
|
-
if (!provider) {
|
|
135
|
-
return {
|
|
136
|
-
success: false,
|
|
137
|
-
error:
|
|
138
|
-
'No TTS provider available. Set one of: OPENAI_API_KEY, ELEVENLABS_API_KEY, or configure Ollama with a TTS model. ' +
|
|
139
|
-
'Get an OpenAI key at https://platform.openai.com/api-keys or ElevenLabs at https://elevenlabs.io',
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
try {
|
|
144
|
-
switch (provider) {
|
|
145
|
-
case 'openai':
|
|
146
|
-
return await this.synthesizeOpenAI(text, args);
|
|
147
|
-
case 'elevenlabs':
|
|
148
|
-
return await this.synthesizeElevenLabs(text, args);
|
|
149
|
-
case 'ollama':
|
|
150
|
-
return await this.synthesizeOllama(text, args);
|
|
151
|
-
default:
|
|
152
|
-
return { success: false, error: `Unknown TTS provider: ${provider}` };
|
|
153
|
-
}
|
|
154
|
-
} catch (err: any) {
|
|
155
|
-
return { success: false, error: `TTS failed (${provider}): ${err.message}` };
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ── OpenAI TTS ──
|
|
160
|
-
|
|
161
|
-
private async synthesizeOpenAI(text: string, args: TTSInput): Promise<ToolExecutionResult<TTSOutput>> {
|
|
162
|
-
const voice = args.voice && OPENAI_VOICES.includes(args.voice) ? args.voice : 'nova';
|
|
163
|
-
const model = args.model || 'tts-1';
|
|
164
|
-
const format = args.format || 'mp3';
|
|
165
|
-
|
|
166
|
-
const response = await fetch(`${this.config.openaiBaseUrl}/audio/speech`, {
|
|
167
|
-
method: 'POST',
|
|
168
|
-
headers: {
|
|
169
|
-
Authorization: `Bearer ${this.config.openaiApiKey}`,
|
|
170
|
-
'Content-Type': 'application/json',
|
|
171
|
-
},
|
|
172
|
-
body: JSON.stringify({
|
|
173
|
-
model,
|
|
174
|
-
voice,
|
|
175
|
-
input: text,
|
|
176
|
-
response_format: format,
|
|
177
|
-
speed: args.speed,
|
|
178
|
-
}),
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
if (!response.ok) {
|
|
182
|
-
const err = await response.text();
|
|
183
|
-
return { success: false, error: `OpenAI TTS error (${response.status}): ${err.slice(0, 300)}` };
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const buf = await response.arrayBuffer();
|
|
187
|
-
const audioBase64 = Buffer.from(buf).toString('base64');
|
|
188
|
-
const contentType = format === 'opus' ? 'audio/opus' : format === 'wav' ? 'audio/wav' : 'audio/mpeg';
|
|
189
|
-
const durationEstimateMs = Math.round((text.split(/\s+/).length / 150) * 60 * 1000);
|
|
190
|
-
|
|
191
|
-
return {
|
|
192
|
-
success: true,
|
|
193
|
-
output: { text, voice, model, provider: 'openai', audioBase64, contentType, durationEstimateMs },
|
|
194
|
-
contentType,
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ── ElevenLabs TTS ──
|
|
199
|
-
|
|
200
|
-
private async synthesizeElevenLabs(text: string, args: TTSInput): Promise<ToolExecutionResult<TTSOutput>> {
|
|
201
|
-
const voiceId = ELEVENLABS_VOICES[(args.voice || 'rachel').toLowerCase()] || args.voice || ELEVENLABS_VOICES.rachel;
|
|
202
|
-
const model = args.model || 'eleven_monolingual_v1';
|
|
203
|
-
|
|
204
|
-
const response = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`, {
|
|
205
|
-
method: 'POST',
|
|
206
|
-
headers: {
|
|
207
|
-
'xi-api-key': this.config.elevenLabsApiKey!,
|
|
208
|
-
'Content-Type': 'application/json',
|
|
209
|
-
Accept: 'audio/mpeg',
|
|
210
|
-
},
|
|
211
|
-
body: JSON.stringify({
|
|
212
|
-
text,
|
|
213
|
-
model_id: model,
|
|
214
|
-
voice_settings: {
|
|
215
|
-
stability: args.stability ?? 0.5,
|
|
216
|
-
similarity_boost: args.similarity_boost ?? 0.75,
|
|
217
|
-
},
|
|
218
|
-
}),
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
if (!response.ok) {
|
|
222
|
-
const err = await response.text();
|
|
223
|
-
return { success: false, error: `ElevenLabs error (${response.status}): ${err.slice(0, 300)}` };
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
const buf = await response.arrayBuffer();
|
|
227
|
-
const audioBase64 = Buffer.from(buf).toString('base64');
|
|
228
|
-
const durationEstimateMs = Math.round((text.split(/\s+/).length / 150) * 60 * 1000);
|
|
229
|
-
|
|
230
|
-
return {
|
|
231
|
-
success: true,
|
|
232
|
-
output: {
|
|
233
|
-
text,
|
|
234
|
-
voice: args.voice || 'rachel',
|
|
235
|
-
model,
|
|
236
|
-
provider: 'elevenlabs',
|
|
237
|
-
audioBase64,
|
|
238
|
-
contentType: 'audio/mpeg',
|
|
239
|
-
durationEstimateMs,
|
|
240
|
-
},
|
|
241
|
-
contentType: 'audio/mpeg',
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
// ── Ollama TTS (local, experimental) ──
|
|
246
|
-
|
|
247
|
-
private async synthesizeOllama(text: string, args: TTSInput): Promise<ToolExecutionResult<TTSOutput>> {
|
|
248
|
-
// Ollama doesn't natively support TTS yet, but some models (e.g., bark, piper)
|
|
249
|
-
// can be served via OpenAI-compatible endpoints. Try the OpenAI-compat path.
|
|
250
|
-
const voice = args.voice || 'default';
|
|
251
|
-
const model = args.model || 'tts'; // User must have a TTS model loaded
|
|
252
|
-
|
|
253
|
-
try {
|
|
254
|
-
const response = await fetch(`${this.config.ollamaBaseUrl}/v1/audio/speech`, {
|
|
255
|
-
method: 'POST',
|
|
256
|
-
headers: { 'Content-Type': 'application/json' },
|
|
257
|
-
body: JSON.stringify({ model, voice, input: text }),
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
if (!response.ok) {
|
|
261
|
-
return {
|
|
262
|
-
success: false,
|
|
263
|
-
error:
|
|
264
|
-
`Ollama TTS not available (${response.status}). Ollama doesn't natively support TTS yet. ` +
|
|
265
|
-
'Set OPENAI_API_KEY or ELEVENLABS_API_KEY for cloud TTS, or use a dedicated local TTS server.',
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const buf = await response.arrayBuffer();
|
|
270
|
-
const audioBase64 = Buffer.from(buf).toString('base64');
|
|
271
|
-
const durationEstimateMs = Math.round((text.split(/\s+/).length / 150) * 60 * 1000);
|
|
272
|
-
|
|
273
|
-
return {
|
|
274
|
-
success: true,
|
|
275
|
-
output: { text, voice, model, provider: 'ollama', audioBase64, contentType: 'audio/mpeg', durationEstimateMs },
|
|
276
|
-
contentType: 'audio/mpeg',
|
|
277
|
-
};
|
|
278
|
-
} catch {
|
|
279
|
-
return {
|
|
280
|
-
success: false,
|
|
281
|
-
error:
|
|
282
|
-
'Ollama TTS endpoint not reachable. Ollama doesn\'t natively support TTS yet. ' +
|
|
283
|
-
'Set OPENAI_API_KEY or ELEVENLABS_API_KEY for cloud TTS.',
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
}
|
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
|
|
3
|
-
const mockFetch = vi.fn();
|
|
4
|
-
vi.stubGlobal('fetch', mockFetch);
|
|
5
|
-
|
|
6
|
-
// Clear env vars that affect provider detection
|
|
7
|
-
const savedEnv: Record<string, string | undefined> = {};
|
|
8
|
-
const envKeys = ['OPENAI_API_KEY', 'ELEVENLABS_API_KEY', 'OPENAI_BASE_URL', 'OLLAMA_BASE_URL', 'TTS_PROVIDER'];
|
|
9
|
-
|
|
10
|
-
const { TextToSpeechTool } = await import('../src/tools/textToSpeech.js');
|
|
11
|
-
const { createExtensionPack } = await import('../src/index.js');
|
|
12
|
-
|
|
13
|
-
describe('TextToSpeechTool', () => {
|
|
14
|
-
const ctx = {} as any;
|
|
15
|
-
|
|
16
|
-
beforeEach(() => {
|
|
17
|
-
vi.clearAllMocks();
|
|
18
|
-
// Save and clear env
|
|
19
|
-
for (const key of envKeys) {
|
|
20
|
-
savedEnv[key] = process.env[key];
|
|
21
|
-
delete process.env[key];
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
afterEach(() => {
|
|
26
|
-
// Restore env
|
|
27
|
-
for (const key of envKeys) {
|
|
28
|
-
if (savedEnv[key] !== undefined) process.env[key] = savedEnv[key];
|
|
29
|
-
else delete process.env[key];
|
|
30
|
-
}
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('metadata', () => {
|
|
34
|
-
it('has correct id and name', () => {
|
|
35
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test' });
|
|
36
|
-
expect(tool.id).toBe('tts-multi-provider-v1');
|
|
37
|
-
expect(tool.name).toBe('text_to_speech');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('has valid input schema with text required', () => {
|
|
41
|
-
const tool = new TextToSpeechTool({});
|
|
42
|
-
expect(tool.inputSchema.type).toBe('object');
|
|
43
|
-
expect(tool.inputSchema.required).toContain('text');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it('has no side effects', () => {
|
|
47
|
-
const tool = new TextToSpeechTool({});
|
|
48
|
-
expect(tool.hasSideEffects).toBe(false);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('describes multiple providers', () => {
|
|
52
|
-
const tool = new TextToSpeechTool({});
|
|
53
|
-
expect(tool.description).toContain('OpenAI');
|
|
54
|
-
expect(tool.description).toContain('ElevenLabs');
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
describe('provider resolution', () => {
|
|
59
|
-
it('falls back to Ollama when no API keys set', async () => {
|
|
60
|
-
const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: '' });
|
|
61
|
-
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
62
|
-
const result = await tool.execute({ text: 'Hello' }, ctx);
|
|
63
|
-
expect(result.success).toBe(false);
|
|
64
|
-
expect(result.error).toContain('Ollama');
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('auto-detects OpenAI when key provided', async () => {
|
|
68
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
|
|
69
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
|
|
70
|
-
const result = await tool.execute({ text: 'Hello' }, ctx);
|
|
71
|
-
expect(result.success).toBe(true);
|
|
72
|
-
expect(result.output!.provider).toBe('openai');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('auto-detects ElevenLabs when only that key set', async () => {
|
|
76
|
-
const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
|
|
77
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
|
|
78
|
-
const result = await tool.execute({ text: 'Hello' }, ctx);
|
|
79
|
-
expect(result.success).toBe(true);
|
|
80
|
-
expect(result.output!.provider).toBe('elevenlabs');
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it('respects explicit provider override', async () => {
|
|
84
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: 'el-test' });
|
|
85
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
|
|
86
|
-
const result = await tool.execute({ text: 'Hello', provider: 'elevenlabs' }, ctx);
|
|
87
|
-
expect(result.success).toBe(true);
|
|
88
|
-
expect(result.output!.provider).toBe('elevenlabs');
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
describe('OpenAI TTS', () => {
|
|
93
|
-
it('synthesizes with default voice (nova)', async () => {
|
|
94
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
|
|
95
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
|
|
96
|
-
const result = await tool.execute({ text: 'Hello world' }, ctx);
|
|
97
|
-
expect(result.success).toBe(true);
|
|
98
|
-
expect(result.output!.voice).toBe('nova');
|
|
99
|
-
expect(result.output!.provider).toBe('openai');
|
|
100
|
-
expect(result.output!.contentType).toBe('audio/mpeg');
|
|
101
|
-
expect(result.output!.audioBase64).toBeTruthy();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('sends correct request to OpenAI API', async () => {
|
|
105
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
|
|
106
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
|
|
107
|
-
await tool.execute({ text: 'Test', voice: 'shimmer', model: 'tts-1-hd' }, ctx);
|
|
108
|
-
const [url, opts] = mockFetch.mock.calls[0];
|
|
109
|
-
expect(url).toContain('/audio/speech');
|
|
110
|
-
const body = JSON.parse(opts.body);
|
|
111
|
-
expect(body.voice).toBe('shimmer');
|
|
112
|
-
expect(body.model).toBe('tts-1-hd');
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it('handles API errors', async () => {
|
|
116
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
|
|
117
|
-
mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'Unauthorized' });
|
|
118
|
-
const result = await tool.execute({ text: 'Test' }, ctx);
|
|
119
|
-
expect(result.success).toBe(false);
|
|
120
|
-
expect(result.error).toContain('401');
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
describe('ElevenLabs TTS', () => {
|
|
125
|
-
it('synthesizes with default voice (rachel)', async () => {
|
|
126
|
-
const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
|
|
127
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
|
|
128
|
-
const result = await tool.execute({ text: 'Hello world' }, ctx);
|
|
129
|
-
expect(result.success).toBe(true);
|
|
130
|
-
expect(result.output!.voice).toBe('rachel');
|
|
131
|
-
expect(result.output!.provider).toBe('elevenlabs');
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it('uses correct voice ID for named voice (josh)', async () => {
|
|
135
|
-
const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
|
|
136
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
|
|
137
|
-
await tool.execute({ text: 'Test', voice: 'josh' }, ctx);
|
|
138
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
139
|
-
expect.stringContaining('TxGEqnHWrfWFTfGW9XjX'),
|
|
140
|
-
expect.any(Object)
|
|
141
|
-
);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('sends ElevenLabs API key header', async () => {
|
|
145
|
-
const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
|
|
146
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
|
|
147
|
-
await tool.execute({ text: 'Test' }, ctx);
|
|
148
|
-
const [, opts] = mockFetch.mock.calls[0];
|
|
149
|
-
expect(opts.headers['xi-api-key']).toBe('el-test');
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe('common behavior', () => {
|
|
154
|
-
it('truncates text to 5000 chars', async () => {
|
|
155
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
|
|
156
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
|
|
157
|
-
const result = await tool.execute({ text: 'a'.repeat(6000) }, ctx);
|
|
158
|
-
expect(result.success).toBe(true);
|
|
159
|
-
expect(result.output!.text.length).toBe(5000);
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
it('estimates duration from word count', async () => {
|
|
163
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
|
|
164
|
-
mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
|
|
165
|
-
const result = await tool.execute({ text: 'one two three four five' }, ctx);
|
|
166
|
-
expect(result.success).toBe(true);
|
|
167
|
-
expect(result.output!.durationEstimateMs).toBeGreaterThan(0);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it('handles network errors gracefully', async () => {
|
|
171
|
-
const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
|
|
172
|
-
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
|
173
|
-
const result = await tool.execute({ text: 'Test' }, ctx);
|
|
174
|
-
expect(result.success).toBe(false);
|
|
175
|
-
expect(result.error).toContain('TTS failed');
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
describe('createExtensionPack', () => {
|
|
181
|
-
it('creates pack with correct metadata', () => {
|
|
182
|
-
const pack = createExtensionPack({ options: { elevenLabsApiKey: 'test' }, logger: { info: vi.fn() } });
|
|
183
|
-
expect(pack.name).toBe('@framers/agentos-ext-voice-synthesis');
|
|
184
|
-
expect(pack.version).toBe('2.0.0');
|
|
185
|
-
expect(pack.descriptors).toHaveLength(1);
|
|
186
|
-
expect(pack.descriptors[0].kind).toBe('tool');
|
|
187
|
-
expect(pack.descriptors[0].id).toBe('text_to_speech');
|
|
188
|
-
});
|
|
189
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "ESNext",
|
|
5
|
-
"lib": ["ES2022"],
|
|
6
|
-
"outDir": "./dist",
|
|
7
|
-
"rootDir": "./src",
|
|
8
|
-
"strict": true,
|
|
9
|
-
"esModuleInterop": true,
|
|
10
|
-
"skipLibCheck": true,
|
|
11
|
-
"noEmitOnError": false,
|
|
12
|
-
"forceConsistentCasingInFileNames": true,
|
|
13
|
-
"declaration": true,
|
|
14
|
-
"declarationMap": true,
|
|
15
|
-
"sourceMap": true,
|
|
16
|
-
"resolveJsonModule": true,
|
|
17
|
-
"moduleResolution": "Bundler",
|
|
18
|
-
"types": ["node"]
|
|
19
|
-
},
|
|
20
|
-
"include": ["src/**/*"],
|
|
21
|
-
"exclude": ["node_modules", "dist", "test"]
|
|
22
|
-
}
|