@framers/agentos-ext-voice-synthesis 1.0.0 → 2.0.1

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.
@@ -1,61 +1,149 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
 
3
3
  const mockFetch = vi.fn();
4
4
  vi.stubGlobal('fetch', mockFetch);
5
5
 
6
+ // Clear env vars that affect provider detection
7
+ const savedEnv: Record<string, string | undefined> = {};
8
+ const envKeys = [
9
+ 'OPENAI_API_KEY',
10
+ 'ELEVENLABS_API_KEY',
11
+ 'OPENAI_BASE_URL',
12
+ 'OLLAMA_BASE_URL',
13
+ 'TTS_PROVIDER',
14
+ 'DEEPGRAM_API_KEY',
15
+ 'DEEPGRAM_BASE_URL',
16
+ 'WHISPER_LOCAL_BASE_URL',
17
+ 'STT_PROVIDER',
18
+ ];
19
+
6
20
  const { TextToSpeechTool } = await import('../src/tools/textToSpeech.js');
21
+ const { SpeechToTextTool } = await import('../src/tools/speechToText.js');
7
22
  const { createExtensionPack } = await import('../src/index.js');
8
23
 
9
24
  describe('TextToSpeechTool', () => {
10
- let tool: InstanceType<typeof TextToSpeechTool>;
25
+ const ctx = {} as any;
11
26
 
12
27
  beforeEach(() => {
13
28
  vi.clearAllMocks();
14
- tool = new TextToSpeechTool('test-api-key');
29
+ // Save and clear env
30
+ for (const key of envKeys) {
31
+ savedEnv[key] = process.env[key];
32
+ delete process.env[key];
33
+ }
34
+ });
35
+
36
+ afterEach(() => {
37
+ // Restore env
38
+ for (const key of envKeys) {
39
+ if (savedEnv[key] !== undefined) process.env[key] = savedEnv[key];
40
+ else delete process.env[key];
41
+ }
15
42
  });
16
43
 
17
44
  describe('metadata', () => {
18
45
  it('has correct id and name', () => {
19
- expect(tool.id).toBe('elevenlabs-tts-v1');
46
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test' });
47
+ expect(tool.id).toBe('tts-multi-provider-v1');
20
48
  expect(tool.name).toBe('text_to_speech');
21
49
  });
22
50
 
23
- it('has valid input schema', () => {
51
+ it('has valid input schema with text required', () => {
52
+ const tool = new TextToSpeechTool({});
24
53
  expect(tool.inputSchema.type).toBe('object');
25
54
  expect(tool.inputSchema.required).toContain('text');
26
55
  });
27
56
 
28
57
  it('has no side effects', () => {
58
+ const tool = new TextToSpeechTool({});
29
59
  expect(tool.hasSideEffects).toBe(false);
30
60
  });
31
- });
32
61
 
33
- describe('execute', () => {
34
- const ctx = {} as any;
62
+ it('describes multiple providers', () => {
63
+ const tool = new TextToSpeechTool({});
64
+ expect(tool.description).toContain('OpenAI');
65
+ expect(tool.description).toContain('ElevenLabs');
66
+ });
67
+ });
35
68
 
36
- it('returns error when API key is missing', async () => {
37
- const noKeyTool = new TextToSpeechTool('');
38
- const result = await noKeyTool.execute({ text: 'Hello' }, ctx);
69
+ describe('provider resolution', () => {
70
+ it('falls back to Ollama when no API keys set', async () => {
71
+ const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: '' });
72
+ mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
73
+ const result = await tool.execute({ text: 'Hello' }, ctx);
39
74
  expect(result.success).toBe(false);
40
- expect(result.error).toContain('ELEVENLABS_API_KEY');
75
+ expect(result.error).toContain('Ollama');
41
76
  });
42
77
 
43
- it('synthesizes speech successfully', async () => {
44
- const mockAudio = new ArrayBuffer(100);
45
- mockFetch.mockResolvedValueOnce({
46
- ok: true,
47
- arrayBuffer: async () => mockAudio,
48
- });
78
+ it('auto-detects OpenAI when key provided', async () => {
79
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
80
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
81
+ const result = await tool.execute({ text: 'Hello' }, ctx);
82
+ expect(result.success).toBe(true);
83
+ expect(result.output!.provider).toBe('openai');
84
+ });
85
+
86
+ it('auto-detects ElevenLabs when only that key set', async () => {
87
+ const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
88
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
89
+ const result = await tool.execute({ text: 'Hello' }, ctx);
90
+ expect(result.success).toBe(true);
91
+ expect(result.output!.provider).toBe('elevenlabs');
92
+ });
93
+
94
+ it('respects explicit provider override', async () => {
95
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: 'el-test' });
96
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
97
+ const result = await tool.execute({ text: 'Hello', provider: 'elevenlabs' }, ctx);
98
+ expect(result.success).toBe(true);
99
+ expect(result.output!.provider).toBe('elevenlabs');
100
+ });
101
+ });
49
102
 
103
+ describe('OpenAI TTS', () => {
104
+ it('synthesizes with default voice (nova)', async () => {
105
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
106
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
50
107
  const result = await tool.execute({ text: 'Hello world' }, ctx);
51
108
  expect(result.success).toBe(true);
52
- expect(result.output!.voice).toBe('rachel');
109
+ expect(result.output!.voice).toBe('nova');
110
+ expect(result.output!.provider).toBe('openai');
53
111
  expect(result.output!.contentType).toBe('audio/mpeg');
54
112
  expect(result.output!.audioBase64).toBeTruthy();
55
- expect(result.output!.durationEstimateMs).toBeGreaterThan(0);
56
113
  });
57
114
 
58
- it('uses correct voice ID for named voice', async () => {
115
+ it('sends correct request to OpenAI API', async () => {
116
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
117
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
118
+ await tool.execute({ text: 'Test', voice: 'shimmer', model: 'tts-1-hd' }, ctx);
119
+ const [url, opts] = mockFetch.mock.calls[0];
120
+ expect(url).toContain('/audio/speech');
121
+ const body = JSON.parse(opts.body);
122
+ expect(body.voice).toBe('shimmer');
123
+ expect(body.model).toBe('tts-1-hd');
124
+ });
125
+
126
+ it('handles API errors', async () => {
127
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
128
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'Unauthorized' });
129
+ const result = await tool.execute({ text: 'Test' }, ctx);
130
+ expect(result.success).toBe(false);
131
+ expect(result.error).toContain('401');
132
+ });
133
+ });
134
+
135
+ describe('ElevenLabs TTS', () => {
136
+ it('synthesizes with default voice (rachel)', async () => {
137
+ const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
138
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(100) });
139
+ const result = await tool.execute({ text: 'Hello world' }, ctx);
140
+ expect(result.success).toBe(true);
141
+ expect(result.output!.voice).toBe('rachel');
142
+ expect(result.output!.provider).toBe('elevenlabs');
143
+ });
144
+
145
+ it('uses correct voice ID for named voice (josh)', async () => {
146
+ const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
59
147
  mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
60
148
  await tool.execute({ text: 'Test', voice: 'josh' }, ctx);
61
149
  expect(mockFetch).toHaveBeenCalledWith(
@@ -64,26 +152,38 @@ describe('TextToSpeechTool', () => {
64
152
  );
65
153
  });
66
154
 
155
+ it('sends ElevenLabs API key header', async () => {
156
+ const tool = new TextToSpeechTool({ openaiApiKey: '', elevenLabsApiKey: 'el-test' });
157
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
158
+ await tool.execute({ text: 'Test' }, ctx);
159
+ const [, opts] = mockFetch.mock.calls[0];
160
+ expect(opts.headers['xi-api-key']).toBe('el-test');
161
+ });
162
+ });
163
+
164
+ describe('common behavior', () => {
67
165
  it('truncates text to 5000 chars', async () => {
166
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
68
167
  mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
69
- const longText = 'a'.repeat(6000);
70
- const result = await tool.execute({ text: longText }, ctx);
168
+ const result = await tool.execute({ text: 'a'.repeat(6000) }, ctx);
71
169
  expect(result.success).toBe(true);
72
170
  expect(result.output!.text.length).toBe(5000);
73
171
  });
74
172
 
75
- it('handles API errors', async () => {
76
- mockFetch.mockResolvedValueOnce({ ok: false, status: 401, text: async () => 'Unauthorized' });
77
- const result = await tool.execute({ text: 'Test' }, ctx);
78
- expect(result.success).toBe(false);
79
- expect(result.error).toContain('401');
173
+ it('estimates duration from word count', async () => {
174
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
175
+ mockFetch.mockResolvedValueOnce({ ok: true, arrayBuffer: async () => new ArrayBuffer(10) });
176
+ const result = await tool.execute({ text: 'one two three four five' }, ctx);
177
+ expect(result.success).toBe(true);
178
+ expect(result.output!.durationEstimateMs).toBeGreaterThan(0);
80
179
  });
81
180
 
82
- it('handles network errors', async () => {
83
- mockFetch.mockRejectedValueOnce(new Error('Timeout'));
181
+ it('handles network errors gracefully', async () => {
182
+ const tool = new TextToSpeechTool({ openaiApiKey: 'sk-test', elevenLabsApiKey: '' });
183
+ mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
84
184
  const result = await tool.execute({ text: 'Test' }, ctx);
85
185
  expect(result.success).toBe(false);
86
- expect(result.error).toContain('Timeout');
186
+ expect(result.error).toContain('TTS failed');
87
187
  });
88
188
  });
89
189
  });
@@ -92,6 +192,207 @@ describe('createExtensionPack', () => {
92
192
  it('creates pack with correct metadata', () => {
93
193
  const pack = createExtensionPack({ options: { elevenLabsApiKey: 'test' }, logger: { info: vi.fn() } });
94
194
  expect(pack.name).toBe('@framers/agentos-ext-voice-synthesis');
95
- expect(pack.descriptors).toHaveLength(1);
195
+ expect(pack.version).toBe('2.0.0');
196
+ expect(pack.descriptors).toHaveLength(2);
197
+ expect(pack.descriptors[0].kind).toBe('tool');
198
+ expect(pack.descriptors[0].id).toBe('text_to_speech');
199
+ expect(pack.descriptors[1].id).toBe('speech_to_text');
200
+ });
201
+ });
202
+
203
+ describe('SpeechToTextTool', () => {
204
+ const ctx = {} as any;
205
+
206
+ beforeEach(() => {
207
+ vi.clearAllMocks();
208
+ for (const key of envKeys) {
209
+ savedEnv[key] = process.env[key];
210
+ delete process.env[key];
211
+ }
212
+ });
213
+
214
+ afterEach(() => {
215
+ for (const key of envKeys) {
216
+ if (savedEnv[key] !== undefined) process.env[key] = savedEnv[key];
217
+ else delete process.env[key];
218
+ }
219
+ });
220
+
221
+ it('has correct id and name', () => {
222
+ const tool = new SpeechToTextTool({ openaiApiKey: 'sk-test' });
223
+ expect(tool.id).toBe('stt-multi-provider-v1');
224
+ expect(tool.name).toBe('speech_to_text');
225
+ expect(tool.hasSideEffects).toBe(false);
226
+ });
227
+
228
+ it('fails when no provider is configured', async () => {
229
+ const tool = new SpeechToTextTool({ openaiApiKey: '' });
230
+ const result = await tool.execute({ audioBase64: Buffer.from('wav').toString('base64') }, ctx);
231
+ expect(result.success).toBe(false);
232
+ expect(result.error).toContain('OPENAI_API_KEY');
233
+ expect(result.error).toContain('DEEPGRAM_API_KEY');
234
+ });
235
+
236
+ it('fails when no audio input is provided', async () => {
237
+ const tool = new SpeechToTextTool({ openaiApiKey: 'sk-test' });
238
+ const result = await tool.execute({}, ctx);
239
+ expect(result.success).toBe(false);
240
+ expect(result.error).toContain('Provide either audioBase64 or audioUrl');
241
+ });
242
+
243
+ it('transcribes base64 audio through OpenAI Whisper', async () => {
244
+ const tool = new SpeechToTextTool({ openaiApiKey: 'sk-test' });
245
+ mockFetch.mockResolvedValueOnce({
246
+ ok: true,
247
+ headers: { get: vi.fn().mockReturnValue('application/json') },
248
+ json: async () => ({ text: 'hello world', language: 'en', duration: 1.5, segments: [{ text: 'hello world', start: 0, end: 1.5 }] }),
249
+ });
250
+
251
+ const result = await tool.execute(
252
+ {
253
+ audioBase64: 'data:audio/wav;base64,' + Buffer.from('fake-audio').toString('base64'),
254
+ responseFormat: 'verbose_json',
255
+ },
256
+ ctx,
257
+ );
258
+
259
+ expect(result.success).toBe(true);
260
+ expect(result.output!.text).toBe('hello world');
261
+ expect(result.output!.language).toBe('en');
262
+ expect(result.output!.provider).toBe('openai');
263
+ expect(result.output!.segments).toHaveLength(1);
264
+ const [url, opts] = mockFetch.mock.calls[0];
265
+ expect(url).toContain('/audio/transcriptions');
266
+ expect(opts.headers.Authorization).toBe('Bearer sk-test');
267
+ });
268
+
269
+ it('auto-detects Deepgram when OpenAI is unavailable', async () => {
270
+ const tool = new SpeechToTextTool({ openaiApiKey: '', deepgramApiKey: 'dg-test' });
271
+ mockFetch.mockResolvedValueOnce({
272
+ ok: true,
273
+ json: async () => ({
274
+ metadata: { duration: 2.1 },
275
+ results: {
276
+ utterances: [
277
+ {
278
+ transcript: 'hello from deepgram',
279
+ start: 0,
280
+ end: 2.1,
281
+ confidence: 0.98,
282
+ speaker: 0,
283
+ words: [{ word: 'hello', start: 0, end: 0.5, confidence: 0.9 }],
284
+ },
285
+ ],
286
+ channels: [
287
+ {
288
+ alternatives: [
289
+ {
290
+ transcript: 'hello from deepgram',
291
+ confidence: 0.98,
292
+ detected_language: 'en',
293
+ },
294
+ ],
295
+ },
296
+ ],
297
+ },
298
+ }),
299
+ });
300
+
301
+ const result = await tool.execute(
302
+ {
303
+ audioBase64: Buffer.from('fake-audio').toString('base64'),
304
+ },
305
+ ctx,
306
+ );
307
+
308
+ expect(result.success).toBe(true);
309
+ expect(result.output!.provider).toBe('deepgram');
310
+ expect(result.output!.language).toBe('en');
311
+ expect(result.output!.segments).toHaveLength(1);
312
+ const [url, opts] = mockFetch.mock.calls[0];
313
+ expect(url).toContain('/listen?');
314
+ expect(opts.headers.Authorization).toBe('Token dg-test');
315
+ });
316
+
317
+ it('uses Whisper-local when explicitly requested', async () => {
318
+ const tool = new SpeechToTextTool({ whisperLocalBaseUrl: 'http://127.0.0.1:9000/v1' });
319
+ mockFetch.mockResolvedValueOnce({
320
+ ok: true,
321
+ headers: { get: vi.fn().mockReturnValue('application/json') },
322
+ json: async () => ({
323
+ text: 'local transcript',
324
+ language: 'en',
325
+ duration: 1.2,
326
+ }),
327
+ });
328
+
329
+ const result = await tool.execute(
330
+ {
331
+ provider: 'whisper-local',
332
+ audioBase64: Buffer.from('fake-audio').toString('base64'),
333
+ },
334
+ ctx,
335
+ );
336
+
337
+ expect(result.success).toBe(true);
338
+ expect(result.output!.provider).toBe('whisper-local');
339
+ expect(result.output!.text).toBe('local transcript');
340
+ expect(mockFetch.mock.calls[0][0]).toContain('127.0.0.1:9000/v1/audio/transcriptions');
341
+ });
342
+
343
+ it('downloads audio from a URL before transcribing', async () => {
344
+ const tool = new SpeechToTextTool({ openaiApiKey: 'sk-test' });
345
+ mockFetch
346
+ .mockResolvedValueOnce({
347
+ ok: true,
348
+ headers: { get: vi.fn().mockReturnValue('audio/mpeg') },
349
+ arrayBuffer: async () => new ArrayBuffer(12),
350
+ })
351
+ .mockResolvedValueOnce({
352
+ ok: true,
353
+ headers: { get: vi.fn().mockReturnValue('text/plain') },
354
+ text: async () => 'remote transcript',
355
+ });
356
+
357
+ const result = await tool.execute({ audioUrl: 'https://example.com/audio.mp3', responseFormat: 'text' }, ctx);
358
+
359
+ expect(result.success).toBe(true);
360
+ expect(result.output!.text).toBe('remote transcript');
361
+ expect(mockFetch).toHaveBeenNthCalledWith(1, 'https://example.com/audio.mp3');
362
+ expect(mockFetch.mock.calls[1][0]).toContain('/audio/transcriptions');
363
+ });
364
+
365
+ it('surfaces download failures clearly', async () => {
366
+ const tool = new SpeechToTextTool({ openaiApiKey: 'sk-test' });
367
+ mockFetch.mockResolvedValueOnce({
368
+ ok: false,
369
+ status: 404,
370
+ headers: { get: vi.fn().mockReturnValue(null) },
371
+ });
372
+
373
+ const result = await tool.execute({ audioUrl: 'https://example.com/missing.wav' }, ctx);
374
+
375
+ expect(result.success).toBe(false);
376
+ expect(result.error).toContain('Audio download failed (404)');
377
+ });
378
+
379
+ it('respects STT_PROVIDER from the environment', async () => {
380
+ process.env.STT_PROVIDER = 'deepgram';
381
+ process.env.DEEPGRAM_API_KEY = 'dg-env';
382
+ const tool = new SpeechToTextTool({});
383
+ mockFetch.mockResolvedValueOnce({
384
+ ok: true,
385
+ json: async () => ({
386
+ metadata: { duration: 0.8 },
387
+ results: {
388
+ channels: [{ alternatives: [{ transcript: 'env transcript', confidence: 0.91 }] }],
389
+ },
390
+ }),
391
+ });
392
+
393
+ const result = await tool.execute({ audioBase64: Buffer.from('fake-audio').toString('base64') }, ctx);
394
+
395
+ expect(result.success).toBe(true);
396
+ expect(result.output!.provider).toBe('deepgram');
96
397
  });
97
398
  });