@framers/agentos-ext-voice-synthesis 2.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.
- package/README.md +66 -8
- package/dist/index.d.ts +17 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +35 -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/manifest.json +20 -5
- package/package.json +6 -2
- package/src/index.ts +38 -7
- package/src/tools/speechToText.ts +650 -0
- package/test/textToSpeech.spec.ts +211 -2
|
@@ -5,9 +5,20 @@ vi.stubGlobal('fetch', mockFetch);
|
|
|
5
5
|
|
|
6
6
|
// Clear env vars that affect provider detection
|
|
7
7
|
const savedEnv: Record<string, string | undefined> = {};
|
|
8
|
-
const envKeys = [
|
|
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
|
+
];
|
|
9
19
|
|
|
10
20
|
const { TextToSpeechTool } = await import('../src/tools/textToSpeech.js');
|
|
21
|
+
const { SpeechToTextTool } = await import('../src/tools/speechToText.js');
|
|
11
22
|
const { createExtensionPack } = await import('../src/index.js');
|
|
12
23
|
|
|
13
24
|
describe('TextToSpeechTool', () => {
|
|
@@ -182,8 +193,206 @@ describe('createExtensionPack', () => {
|
|
|
182
193
|
const pack = createExtensionPack({ options: { elevenLabsApiKey: 'test' }, logger: { info: vi.fn() } });
|
|
183
194
|
expect(pack.name).toBe('@framers/agentos-ext-voice-synthesis');
|
|
184
195
|
expect(pack.version).toBe('2.0.0');
|
|
185
|
-
expect(pack.descriptors).toHaveLength(
|
|
196
|
+
expect(pack.descriptors).toHaveLength(2);
|
|
186
197
|
expect(pack.descriptors[0].kind).toBe('tool');
|
|
187
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');
|
|
188
397
|
});
|
|
189
398
|
});
|