From 117eafe81e5f2c881e4182f34017594674f9bd08 Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Thu, 20 Feb 2025 17:44:25 -0600 Subject: [PATCH 01/10] add MastraVoice class --- packages/core/src/logger/index.ts | 1 + packages/core/src/voice/index.ts | 83 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 packages/core/src/voice/index.ts diff --git a/packages/core/src/logger/index.ts b/packages/core/src/logger/index.ts index a32185e580..f14e95a7eb 100644 --- a/packages/core/src/logger/index.ts +++ b/packages/core/src/logger/index.ts @@ -10,6 +10,7 @@ export const RegisteredLogger = { WORKFLOW: 'WORKFLOW', LLM: 'LLM', TTS: 'TTS', + VOICE: 'VOICE', VECTOR: 'VECTOR', BUNDLER: 'BUNDLER', DEPLOYER: 'DEPLOYER', diff --git a/packages/core/src/voice/index.ts b/packages/core/src/voice/index.ts new file mode 100644 index 0000000000..71edbf5eb4 --- /dev/null +++ b/packages/core/src/voice/index.ts @@ -0,0 +1,83 @@ +import { MastraBase } from '../base'; +import { InstrumentClass } from '../telemetry'; + +interface BuiltInModelConfig { + provider: string; + name: string; + apiKey?: string; +} + +export interface VoiceConfig { + listeningModel?: BuiltInModelConfig; + speechModel?: BuiltInModelConfig; + speaker?: string; +} + +@InstrumentClass({ + prefix: 'voice', + excludeMethods: ['__setTools', '__setLogger', '__setTelemetry', '#log'], +}) +export abstract class MastraVoice extends MastraBase { + protected listeningModel?: BuiltInModelConfig; + protected speechModel?: BuiltInModelConfig; + protected speaker?: string; + + constructor({ listeningModel, speechModel, speaker }: VoiceConfig) { + super({ + component: 'VOICE', + }); + this.listeningModel = listeningModel; + this.speechModel = speechModel; + this.speaker = speaker; + } + + traced(method: T, methodName: string): T { + return ( + this.telemetry?.traceMethod(method, { + spanName: `voice.${methodName}`, + attributes: { + 'voice.type': this.speechModel?.name || this.listeningModel?.name || 'unknown', + 'voice.provider': this.speechModel?.provider || this.listeningModel?.provider || 'unknown', + }, + }) ?? method + ); + } + + /** + * Convert text to speech + * @param input Text or text stream to convert to speech + * @param options Speech options including speaker and provider-specific options + * @returns Audio stream + */ + abstract speak( + input: string | NodeJS.ReadableStream, + options?: { + speaker?: string; + [key: string]: any; + }, + ): Promise; + + /** + * Convert speech to text + * @param audioStream Audio stream to transcribe + * @param options Provider-specific transcription options + * @returns Text or text stream + */ + abstract listen( + audioStream: NodeJS.ReadableStream, + options?: { + [key: string]: any; + }, + ): Promise; + + /** + * Get available speakers/voices + * @returns Array of available voice IDs and their metadata + */ + abstract getSpeakers(): Promise< + Array<{ + voiceId: string; + [key: string]: any; + }> + >; +} From ba7d0c8c003fd40ae8c1e4bf2e6bd586bcc8fa28 Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Thu, 20 Feb 2025 18:34:05 -0600 Subject: [PATCH 02/10] changeset --- .changeset/lovely-results-open.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/lovely-results-open.md diff --git a/.changeset/lovely-results-open.md b/.changeset/lovely-results-open.md new file mode 100644 index 0000000000..70d151d9cf --- /dev/null +++ b/.changeset/lovely-results-open.md @@ -0,0 +1,5 @@ +--- +'@mastra/core': minor +--- + +Add MastraVoice class From 246608017596b02061e9cf6e6e4d127705cadb6e Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Thu, 20 Feb 2025 18:45:45 -0600 Subject: [PATCH 03/10] remove provider attribute --- packages/core/src/voice/index.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/voice/index.ts b/packages/core/src/voice/index.ts index 71edbf5eb4..b690a2b252 100644 --- a/packages/core/src/voice/index.ts +++ b/packages/core/src/voice/index.ts @@ -2,7 +2,6 @@ import { MastraBase } from '../base'; import { InstrumentClass } from '../telemetry'; interface BuiltInModelConfig { - provider: string; name: string; apiKey?: string; } @@ -37,7 +36,6 @@ export abstract class MastraVoice extends MastraBase { spanName: `voice.${methodName}`, attributes: { 'voice.type': this.speechModel?.name || this.listeningModel?.name || 'unknown', - 'voice.provider': this.speechModel?.provider || this.listeningModel?.provider || 'unknown', }, }) ?? method ); From 9631dfdc6cd44bfc4006f9e50228b0afa8fa12b7 Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Thu, 20 Feb 2025 19:19:07 -0600 Subject: [PATCH 04/10] deprecate @mastra/speech-openai for @mastra/voice-openai --- speech/openai/README.md | 66 ++----- speech/openai/package.json | 8 +- speech/openai/src/index.test.ts | 114 ------------ speech/openai/src/index.ts | 4 + voice/openai/CHANGELOG.md | 8 + voice/openai/README.md | 74 ++++++++ voice/openai/__fixtures__/voice-test.m4a | Bin 0 -> 32731 bytes voice/openai/package.json | 35 ++++ voice/openai/src/index.test.ts | 166 ++++++++++++++++++ voice/openai/src/index.ts | 144 +++++++++++++++ .../test-outputs/speech-stream-input.mp3 | Bin 0 -> 20640 bytes .../test-outputs/speech-test-params.mp3 | Bin 0 -> 49920 bytes voice/openai/test-outputs/speech-test.mp3 | Bin 0 -> 14400 bytes voice/openai/tsconfig.json | 5 + {speech => voice}/openai/vitest.config.ts | 1 + 15 files changed, 452 insertions(+), 173 deletions(-) delete mode 100644 speech/openai/src/index.test.ts create mode 100644 voice/openai/CHANGELOG.md create mode 100644 voice/openai/README.md create mode 100644 voice/openai/__fixtures__/voice-test.m4a create mode 100644 voice/openai/package.json create mode 100644 voice/openai/src/index.test.ts create mode 100644 voice/openai/src/index.ts create mode 100644 voice/openai/test-outputs/speech-stream-input.mp3 create mode 100644 voice/openai/test-outputs/speech-test-params.mp3 create mode 100644 voice/openai/test-outputs/speech-test.mp3 create mode 100644 voice/openai/tsconfig.json rename {speech => voice}/openai/vitest.config.ts (81%) diff --git a/speech/openai/README.md b/speech/openai/README.md index 0891b8068f..d6e90171ea 100644 --- a/speech/openai/README.md +++ b/speech/openai/README.md @@ -1,67 +1,25 @@ -# @mastra/speech-openai +# @mastra/speech-openai (DEPRECATED) -OpenAI Speech integration for Mastra, providing Text-to-Speech (TTS) capabilities using OpenAI's advanced speech models. +⚠️ **This package is deprecated.** Please use [@mastra/voice-openai](https://github.com/mastra-ai/mastra/tree/main/voice/openai) instead. -## Installation +## Migration -```bash -npm install @mastra/speech-openai -``` - -## Configuration +The new package `@mastra/voice-openai` provides both Text-to-Speech and Speech-to-Text capabilities. To migrate: -The module requires the following environment variable: +1. Install the new package: ```bash -OPENAI_API_KEY=your_api_key +npm uninstall @mastra/speech-openai +npm install @mastra/voice-openai ``` -## Usage +2. Update your imports: ```typescript +// Old import { OpenAITTS } from '@mastra/speech-openai'; - -// Initialize with configuration -const tts = new OpenAITTS({ - model: { - name: 'alloy', // Default voice - apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var - }, -}); - -// List available voices -const voices = await tts.voices(); - -// Generate speech -const result = await tts.generate({ - voice: 'alloy', - text: 'Hello from Mastra!', -}); - -// Stream speech -const stream = await tts.stream({ - voice: 'alloy', - text: 'Hello from Mastra!', -}); +// New +import { OpenAIVoice } from '@mastra/voice-openai'; ``` -## Features - -- High-quality Text-to-Speech synthesis -- Multiple voice options -- Streaming support -- Natural and expressive speech output -- Fast generation times - -## Voice Options - -OpenAI provides several high-quality voices: - -- alloy (Neutral) -- echo (Male) -- fable (Male) -- onyx (Male) -- nova (Female) -- shimmer (Female) - -View the complete list in the `voices.ts` file or [OpenAI's documentation](https://platform.openai.com/docs/guides/text-to-speech). +For detailed migration instructions and new features, please refer to the [@mastra/voice-openai documentation](https://github.com/mastra-ai/mastra/tree/main/voice/openai). diff --git a/speech/openai/package.json b/speech/openai/package.json index 6bc23035d5..aa64b8b0d9 100644 --- a/speech/openai/package.json +++ b/speech/openai/package.json @@ -1,7 +1,7 @@ { "name": "@mastra/speech-openai", "version": "0.1.2", - "description": "Mastra OpenAI speech integration", + "description": "Mastra OpenAI speech integration (deprecated, please use @mastra/voice-openai instead)", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -16,8 +16,7 @@ }, "scripts": { "build": "tsup src/index.ts --format esm --experimental-dts --clean --treeshake", - "build:watch": "pnpm build --watch", - "test": "vitest run" + "test": "echo \"deprecated\"" }, "dependencies": { "@mastra/core": "workspace:^", @@ -28,7 +27,6 @@ "@microsoft/api-extractor": "^7.49.2", "@types/node": "^22.13.1", "tsup": "^8.0.1", - "typescript": "^5.7.3", - "vitest": "^2.1.8" + "typescript": "^5.7.3" } } diff --git a/speech/openai/src/index.test.ts b/speech/openai/src/index.test.ts deleted file mode 100644 index 2e7cfe279f..0000000000 --- a/speech/openai/src/index.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { createWriteStream, writeFileSync } from 'fs'; -import path from 'path'; - -import { OpenAITTS } from './index.js'; - -describe('OpenAITTS Integration Tests', () => { - let tts: OpenAITTS; - - beforeAll(() => { - tts = new OpenAITTS({ - model: { - name: 'tts-1', - }, - }); - }); - - describe('stream', () => { - it('should stream audio data to file', async () => { - const { audioResult } = await tts.stream({ - text: 'Test streaming', - voice: 'alloy', - }); - - return new Promise((resolve, reject) => { - const outputPath = path.join(process.cwd(), 'test-outputs/stream-test.mp3'); - const fileStream = createWriteStream(outputPath); - const chunks: Buffer[] = []; - - audioResult.on('data', (chunk: Buffer) => { - chunks.push(chunk); - }); - - audioResult.pipe(fileStream); - - fileStream.on('finish', () => { - expect(chunks.length).toBeGreaterThan(0); - resolve(undefined); - }); - - audioResult.on('error', reject); - fileStream.on('error', reject); - }); - }), - 50000; - - it('should stream with different parameters and save to file', async () => { - const { audioResult } = await tts.stream({ - text: 'Testing with different voice and speed', - voice: 'nova', - speed: 1.2, - }); - - return new Promise((resolve, reject) => { - const outputPath = path.join(process.cwd(), 'test-outputs/stream-test-params.mp3'); - const fileStream = createWriteStream(outputPath); - - audioResult.pipe(fileStream); - - fileStream.on('finish', resolve); - audioResult.on('error', reject); - fileStream.on('error', reject); - }); - }); - }); - - describe('generate', () => { - it('should return a complete audio buffer and save to file', async () => { - const { audioResult } = await tts.generate({ - text: 'Hello World', - voice: 'alloy', - }); - - expect(Buffer.isBuffer(audioResult)).toBeTruthy(); - expect(audioResult.length).toBeGreaterThan(0); - - const outputPath = path.join(process.cwd(), 'test-outputs/open-aigenerate-test.mp3'); - writeFileSync(outputPath, audioResult); - }); - - it('should work with different parameters and save to file', async () => { - const { audioResult } = await tts.generate({ - text: 'Test with parameters', - voice: 'nova', - speed: 1.5, - }); - - expect(Buffer.isBuffer(audioResult)).toBeTruthy(); - - const outputPath = path.join(process.cwd(), 'test-outputs/open-nova-aigenerate-test.mp3'); - writeFileSync(outputPath, audioResult); - }); - }); - - // Error cases - describe('error handling', () => { - it('should handle invalid voice names', async () => { - await expect( - tts.stream({ - text: 'Test', - voice: 'invalid_voice', - }), - ).rejects.toThrow(); - }); - - it('should handle empty text', async () => { - await expect( - tts.stream({ - text: '', - voice: 'alloy', - }), - ).rejects.toThrow(); - }); - }); -}); diff --git a/speech/openai/src/index.ts b/speech/openai/src/index.ts index e84512585a..96ca0c09cd 100644 --- a/speech/openai/src/index.ts +++ b/speech/openai/src/index.ts @@ -7,6 +7,10 @@ interface OpenAITTSConfig { apiKey?: string; } +throw new Error( + '@mastra/speech-openai is deprecated. Please use @mastra/voice-openai instead, which provides both Text-to-Speech and Speech-to-Text capabilities.', +); + export class OpenAITTS extends MastraTTS { client: OpenAI; constructor({ model }: { model: OpenAITTSConfig }) { diff --git a/voice/openai/CHANGELOG.md b/voice/openai/CHANGELOG.md new file mode 100644 index 0000000000..71691707c1 --- /dev/null +++ b/voice/openai/CHANGELOG.md @@ -0,0 +1,8 @@ +# @mastra/voice-openai + +## 0.1.0 + +### Changes + +- `@mastra/speech-openai` is now deprecated. Please use `@mastra/voice-openai` instead. +- This package provides both Text-to-Speech (TTS) and Speech-to-Text (STT) capabilities through OpenAI's API. diff --git a/voice/openai/README.md b/voice/openai/README.md new file mode 100644 index 0000000000..c9ff6bf87e --- /dev/null +++ b/voice/openai/README.md @@ -0,0 +1,74 @@ +# @mastra/voice-openai + +OpenAI Voice integration for Mastra, providing both Text-to-Speech (TTS) and Speech-to-Text (STT) capabilities using OpenAI's advanced models. + +## Installation + +```bash +npm install @mastra/voice-openai +``` + +## Configuration + +The module requires an OpenAI API key, which can be provided through environment variables or directly in the configuration: + +```bash +OPENAI_API_KEY=your_api_key +``` + +## Usage + +```typescript +import { OpenAIVoice } from '@mastra/voice-openai'; + +// Initialize with configuration +const voice = new OpenAIVoice({ + speechModel: { + name: 'tts-1', // or 'tts-1-hd' for higher quality + apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var + }, + listeningModel: { + name: 'whisper-1', + apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var + }, + speaker: 'alloy', // Default voice +}); + +// List available voices +const speakers = await voice.getSpeakers(); + +// Generate speech +const audioStream = await voice.speak('Hello from Mastra!', { + speaker: 'alloy', + speed: 1.0, +}); + +// Convert speech to text +const text = await voice.listen(audioStream, { + filetype: 'wav', +}); +``` + +## Features + +- High-quality Text-to-Speech synthesis +- Accurate Speech-to-Text transcription +- Multiple voice options +- Natural and expressive speech output +- Fast processing times + +## Voice Options + +OpenAI provides several high-quality voices: + +- alloy (Neutral) +- echo (Male) +- fable (Male) +- onyx (Male) +- nova (Female) +- shimmer (Female) +- ash (Male) +- coral (Female) +- sage (Male) + +View the complete list in OpenAI's [Text to Speech documentation](https://platform.openai.com/docs/guides/text-to-speech). diff --git a/voice/openai/__fixtures__/voice-test.m4a b/voice/openai/__fixtures__/voice-test.m4a new file mode 100644 index 0000000000000000000000000000000000000000..515a9a28ee2f7b074cc605ebc287be1f3faba725 GIT binary patch literal 32731 zcmW(+Wmp{T4&8;trP$(7+})+PyGx1RUeW3Lz{2pE34a4cbEDhQQJNH|*<&he{c_4+B@QFJ}zypvEMS~#>2 zkfe9`a4ZcXjpEPrW2`iGh!80-0m=N+QF!3C-H9EinX0SmgvxO^(+RHp3uHC3FudRY zrJm6;2_TQLqK{+vlz_P>L-~(OQqWhgKWxJ(MEg}@4|73qJob*`F)vxi$V{P^T*yQe z@vo>uI)e0nwpO&@GZv;nk;1DK!uNhTt(T)?TNWQot3*lav*b`|zC<~vU$Ib-e7G4J zR>e)KtQzQ=99 z+1cPkD~jJ%XzfN2A(>|bCfEAr&#YeDOBA!=`EM)9sD%CdqxIP5o0(d#?_J;V4|`7; zg>(gc?qZUcOh6t79iu1gO>td->Fx}!q|TLUpU$*BJZ^vQS39k46W*Dp3K#Efq@Ty5 zfkzzL2u$DR`F%7HxQ)n@(vA z({mD)!FxYZ{kp0`Izvwzx~gVskC7Cb-LS2LoKk+W^xkv516$j2$h&+^M%gtuK_`r- zJ!-L%DX$`|}Y7?-lkyCGSZJ~Ts!!qXu5FRBvdR#_r^tj2{#weYwxK7}XL zgZ%IeX?IynW<%QuV8CVMMZZlGm zE@Mk`NjTFJ7dh&cX;YSD_RRo%CC9n`hm!)n#J*?K8jO z`Y=lr_7m7!jDFHsowKQ( zWi`ZOq{I)J>AE;?egDSM^C>WU%sTou{>VXiO|z8;Ny0p~1tvs$DrPeI)MdkX#ezm6 zcs`To=_tg!x)~(hXs*oRT!cjr;m7!DZoy{mTXQ* z&-#PXVxep2?Z5Z&H{{2#d8Q!JGt8S0I!Z!udwFB$(nq8{_^9pj@~U;+Q4MdVW_mE7 zE$huvIY=DD%udE7xz45_fV1E`v89~)79J1mP)#S*s^TMNKIug5jcj3(ORkLe{!?XL z-LXB9{VL@zNYZ2akZ7jt!|Ls?Zy!8Dw3eithzVE4)8t?b@B(tky-8DmcF1h{V`x0I zqR7AK)9{f|&M#cr0pw)Ag?|`{4K6rqaEwXFuNO{-*#pDg7DO%M0%=DT;cHb$s!uLW z)R|+NyKswBHL(f@>auP8?Nmk`j@!;C4g!Cu)CNQ1iQ1ToEvj}ZeU|K##(RSL%VSu} zspDSwO>2Bd{f2?+`Ozh~pNJc%g}Nqgoy@sv1sMhg)2DMisK;>cmgS?|wKsoF5s_34 zi0&FbJb^16m#JCcO8pOK>DgjYVDI2DXg?DI{xdVPcnJYlJS-1MMmpbg3=k^jC;2;W zag)!}Q%hexc(PS}hnLxG{xM+XGS%|}V}xrx07QhS8975QAgcUeEk_^EYEv2JvZGJF zBqzvRk|S_w=Gt$vT~a;)F9>(MZ-*syPm#iwo*9`@ocUP)qu$P7=&=C+OH49YgnInN z#~zUtvA0DV^7^X+3R_Yujf7$ssm;Dd~h@vmoEp5xKtT1}& z!T>c1?m2#Q_m}Gaj1wq#gDvXo?iSUhO!vW+c}>i5PgsMUAT`w*HIWcxV_9R=gKMWn zG@QM-c%ym4`jfBUA;-AGdMn#mrTL*ZUS0VhEewfBCrO!3Rkp<9=|M2E24?fJKGk|mxWIGqq{ z-loaF-@T+tIPOOH@s|HlQyP|#!2aRu>?zslB#%883gr!sPya+Fs$|ycmrt<}-D#2f zU0bRw?u68$;l7z zm6Wa#P%^g3+#_te0M5c;J7*2Lf!)Dw(`p5rl`V2)VwoarmA|GOF#zNMC>%DZW5S@o zK%cPKJXX;>=6()LuN~Xb)xuQs5`RrIZMNg;6Y2NAo+DoE|GZx03YC)>-o|Yi&VLd0 z2^V1e`$gf0%fb5vu4V^&Kb;WB0xrLrM?!%$7LAl(quhxl@-S>{6GyGjg_c_{5?QTSt=(Gji)jf7N~GqLw?nZkcrj+SoAdfz55v+aiXqc2 zL!wfAv-b7xlt^z&+ovDMD=m1pzeYfstMr3}cc#6{pTNzQ|8j?gF>!r3vitUcY~b># zF-)|`0JMAwyI9u)9h*H{f4UTWuI}IP{^gIN6rGUJ=}iN=WvrE#r;@D`pS25H*CHqm zth%~J(M6f5LKZpkwxgF9M{M2TK9BfFkYWHk!cN9@QR&7pd`-vacEi}NI91nAH}7}$ z_Pflv&lsyqXjkxztRFJJfnY9sPd$5#ew3;cYT-^<6L_Or)4$LAbfaG&6;imM`Hpl%0Yde){(5 zoM|f{O}sp-YAQf?dNZaRZ9=Lv-A2BhiEhHMY3&;34g$c}0lGV_!))x?4ZR;JG_t9C z&(D7~elV0Rux_(JED^^z%OSJs&K}seVC-#Ol+tpRCd5?VyK92}$)2WH-QZX`1b_rB52|xLVm! zQU#T(vHQJ>$WW~q2b%FxErxz$N&rVjm!9BQS@V0(#~KKI`ldnVE_${VbFYBr6R8H4 z(1Q`r%l549>}-FfrLeg_WU1hB%!VkH=kyig30h3}{S|P{uN-aM!^8>>cKnC}q+A;b zEf6S5EAP*Wxl=cijagEK_HfI|?b@DgiP-dF38smUdDdVI$au7S@M*LvDl#_$c?aZi z_BcAFq}))|#s<7nW%Gv*U5y(5CI{xWop6M>KYiJymLJj_q1~2HMHw;s$y9H zyYfV*t2=Ws(uMANOYPG{==m?E!l4w>o5f+G%t-)xi=3MpKhJhQ7jbNPJRNw-QuY8y zU9V{u_;!u{oy=3Cr78(=tJSdZ{AYJ%I64Tb0;}_QW2IGg;l+l}JCmBxP%)4d*U^(K z-L6J;bScMYw2MTzNDC{nio?(@)j_8xlDp+{pAu)0CUeC7R_jxl{FJ1;y)`^y5iMS` zas3wAREGcWZL$o{DZGJqd-58O_50MGE$5|g8J1G3y;`j)4blD4(`(Ul##}j4=7$YH zP_qDUR--UulhfgoFRN_SWQ$R|#3KaYEQHEg@|S(%$?i98*{tm{2v&U_0Prs-{OAFY z$`wTTt=*IUd0(p6jqSUKYg=}C%QJX26}Ucqv(H52syX1w5VPdI{@Y<3`Ks`kd^`{5 zIq>f)-F}SRhm*#eVFUZ!&+w+@r6qdjr`Kt3ReoqwRXv&jDDx#mwr=I;Z$G`=G>4Tm zcORW+-`$XCaZ~wHB3E<6%>Fr49U7Q2Hc)VUTb;K!dRa?(J%jDK?q~Qb$YbB0k~$PF zO9G%Z)Ib9=e)yOKTs<>}*cNEz?Xe4+RP;^Tfq1GG*{J8JHVD^<4uPa=P|Ibm zg!AR4P?2b9;PP@+DrcBL4NN&8QU`!SfJ_|OyFWUU0P+}6ffV5CMX*Tyh2oe^*c@(* z6fct*?vkc|b@HWZ+wjJ&2`(k6v*~EQ~;n^TCA;{LeC%iPrrjKx1K%wx)Ngcw78n zxes{w;pM~=HuOMq5rm9`zjpSS=YzS7zC&Fj*!jWGG`r$pjUly~2Gh~Ve|!d3O>fz= zg%*5y+g1;M>~v5wZCsh-B2=t(QV~V<{sH`IDVWOm)-aM`#QeSyoo7#v-KZkz-JA#$ zwAU(|>=ykj4YF%13(|ppC04jzckJSaa${Kz~PJn zB%1tu(wr1&DF>{wT4`M)0Uek6ttp9ia7JjQPG@FPos`( zm1&knU#M8_tDkgfNTyb1IkwANjoEnMp(AvS z;n0kTN-k5hifKIvfQ1P2>FhUs6=@t$CNOJCJsIu`-2^QC|64MmcrU_MoaRPgYmv^TcZ( zjI!e5$P<<}yowzvF@b$R8gR9`yD@`g0A@>i;_~u$X&^E*31whHpCjl{a%iT)xi&u2 zbPH>Ke#SC*d8abbA4tren3B2`vW@VyZd(>+B8e2QdR|P6$JKQY3vRpn$Tm0D15^@i zbAXag{F~DaMe}sWh52g}FIAh;;H$&1uW?-~R=q_^YGMhQRLY-aV;4>@oJ;pqd&0ua zdcnIQ?;MTl)0pC&FeEx@h}Z6(coWYCY>3s<^t%V@jw~OjeicqN@uPEoG@LnCei#f1 zEGVqnk|23_L)h1GBf!r+=>)V9l_Xb@-z)bWigQ#fELg8Iybh7#$eLiAHNcnriqQ1gHwt$)wl4@A>a zSded8srft~197#oyDUj087U%BgU4ge{qP-8m7E>21up39vszxryK#g~Z$Pm818c4o zg2GWg45JItjRisHZt4*N-}AU2ss5i723M=B*3 z=#{KB1E1^Qg6e4v7?+0F&+|HKfh?X%rT+we?9n(xO9L1I1hqC(j^{{hFmd&cR z@^-bdgb+gHQE6s=n>GmE$p_p^T=^3Mi9g`EI~%TO6ed7RQzGVOJPOY#T1*(7+xj(J zWUCa%nc>D%SbqK*a(gigz$r;*9o*!kGFFe*v~a=eWbe1t`7`A} z)#lG^OrsF%IzS+W=#nga%;dv*02m+A*G@o;7A-_*<4^4V((paKA&Cn>iWLKkSW?nA zD(m>|_nD(HgAtSG1PITTL9ONzf^Rb!l+wI`jjiwA!8DI(z<%7igG>z(Qq|c4T8rhv0RWtph!*&d5fi8BN6E{ z^O~?EYYd5E0w!wd$5&;Kh+jHuI~eETE8Thty-;hxq+q?~eM1Y;HJZo=ldUk`z3=Q#_<7XHvW90Vx@1T3$c+%?tDSX9;l}-ZCC% zmuD4hG-|*G>k=vu7hF&s%o-m{285TcZdlQSCSyTFtO=3xV|q?}YqA3ioYNY1$a_^c zOnseB6nTunU1H(1V$)%3VK}kYyoPnsSsFwd`yQM!2VUUf7$yRne?h2(Kbh&W> ztQd{w=F4Dv26gh2-H;ebBBxrsQH}EFkYzUa<$a!4vZyhCIgNV+tUJ5L$I!_d+lYb~ z{ScMI;5gn{E*=lNpAo)@u?Bw0HNIk~56F_jGs|9~w|N$VXpel^oFr7NLf^xrBi9L2 z`&qkpZ?nR6uluOe^*25(>mNJ(q)~Z07i{H{R5R5{)WK}}D!vySZ1^M2kPry_VX&zY zhxD(s6el!Tm=zMBWY~@}eg}vSp3?!VZcqYu%=%l&^H?_hh}BL=L}z_#$a2|&&*Slo>n8HsA)^eg@5!!^U94(tJrRppI` z*ZMA3HtPU9_8he6ia(gSaCJ4zLAY_w5iMPfi6rcZ$F!ykmSQrZ6n)||KTCaXDX8LM zN*%)qfruZoO0o!#d^K?gk4UFI6%-=iOf?|1WPBf?+@bz)BKzUS;O=OZBS+uH+3!ml zoo(u;a+jOK7vZwK7soshT{TJCBo?{f-f{jH1F59M@qcpk=E_YSHi%`2lD+*D8TnGY z@q_rHrD*5_6fqKl<{2)L60zgCwlBgS4D842K~pD8luz3xP7hsBnpdb4UhP zOd!)Sc8dhJKT(ecYaAQ`mIxeSif7M4A+uo{Ly_K|r&pUOIhikFvfii)!be0cx>=3z zT23vF>aRxjKW^I0>3;^UxcW;YG5f{d4#8dUUnv!wOrmJGF8;9l0ziEsg$l*Dp{yFqx8MbAHFDxGqXa)x2p#Y+B|vxhkC_6GvczNJz>JL)!(z&t<0z zhKkqnpdsX!l!WcWB8N*s*OYu+3cMvP@{uESC0IgYe6qWU9y|TBsufP989;O$*6odf zQQ%NDj5a)~c9BGAE->B}bTd}qsc}TdK`kfE&7EcBQR&|_44kYL(}m7fn?_$q1Z+toX?n4OXF^IvW(dKbItah@qc1z`IHe`zF*>m6sFm)p zXKNC<2P%>W&`22uzDO=H5HbI=Bn14<_&Y@klO^R^L zpgrY>_Z{6teBq>jiZAhmB3f!=E69XUWOk^9*(sH+f#}=IOg)OBO}8~jpt4;j=_vx$|#3dUMq^})`^q5iKS{2+6GB>mDkXIeW!HddeJW3 zD>Het0G35L#L>%4^!l`RB|;1ibIlG`onb#B{_YFkVn8ufdRsXXDiAgOdC5aUhkWU4 zvAKlt#-}GA-tM71^euun=;<+{ZY0f9`%8Q3rJ2_8d2*=vm!8??49yt#JZbYYzdx`~ z>nPSo*mBAhHS``l!~rnL4GRHim_py|NsWaH5t@|rf9US|I-KCYx|{Ldp3%Y>471)M zKA&0?9v>?IxQH_v?IY9?vYS~bwVn{+esExcAgZuODY`;+yn?3Slu ziSNvspGu1!lzU1z^3t^sI_0Qve|k>3>SOuqT*zD28nrl8Ax=O1JQt&iDA5Fw-n~Ws z?sevgLfedc?-ta}vj-$wE~KDBq=}_MH`mo9s&LH5X8p#%r}joEQf)!~`+H4l%%zX6 zdlHSuIQuvr77#j$BS;qAx#0c&Wsc{lq&BXNjCmK^l%~ea4&Jg~|EA@S5g4CGvqK)d zWEW!{46i{E>d-Nh0Z(g?FgK{=J-3-l?AAIALm?WQ{SDqOoP*jooQ`w_9=lr}5J>pJFsTQ@z|(n3qgW0H{#1u0 zyR)Rn6%>MWD)b3TRt<+zoPgf>Ygc6%jo_|)ACYVG2A<8*HXVH)@GX)5(v&)wVx8}! zlsy+`Vr-c*HMaC{wcHt3);Thi>7^0JI`Gh%?w7ssTLk}xhMvJD%{p{PzHS;wi$Tgg z$(`>C3S)fFu}>h@Y|c%38Rl_~Yf)ghlSCEl(7LQ$%h~dbbjW16VyFWM%ut#Dcs^hz zMA;pmR5B3Ejh7j5OrbEGVH`RQ)mKW8YOcS#LJ%i?T+4DEb!xVC{`p30K@y~$;WgdI zHRKla8~eOoWzR$%E69gML_pJ3wLBuJmSI}HDV`AJw*S>1_Z{x?@7y2Z_D>yr7X@Mk zv(}e%lK3Ngo9taK=ILdM^w-xjZB412t)1I6W>CY)-MEU!7Z}HSyZG_|trPZ&kVob3CUw$O-%SgqsahNYVS)jgq7L;-YTf=?e{0g)tu72T4scsmS_!y}zxn&}9ef%C+s1Ka_k-_R;D*&Q> zy=IwDyHRA&f2u|$!dy_QswM+ZZ3u=tV5#^U@(Kp<@^SrA$GsNAN|oNLlBsrb=&WMm zwRDo(IgbSkqb4Ra&k>;v+fe`GIFOh4LE;0Xc;RF?A$BOPb^b}A%avpDVZtGo)4!y9 zzCRrgzOz&5k&!w@;v|M$XUBy@083!Oy35nOD+*C>lrp4e=G*)A$v~bI>p*68 zdbG^8mDR4(90z4TA7yb;ipW+gCuRaiH`}R6Zl4#Ctu^u*ruLGGI!xZR>04S%Z zgD||L^Q#Xi^)cX9yK=w;M;MVvi-rXu4315z0$GxbHLPnGk6hz{he2Mo<|HeEfqS*C z_TPV8e9oG79WJ|-Z_2T=6K0~%GnEXjps`~qgo4Fbf=$NGfVGsgp}?F;nQme}84uJ0 z`wHr(YSM!QH5X|YhZ|cei!F~;L0GIPHHJ_{CzYb&;UHu9%gh=>WvA&S-7ljlgA~og z6Y*|UHa&d}RLT|SOYaz8MaPtQ-zHkG0GJ*S<#NzLbq(M@Zmj(36%!F^Ft8rY8hhL_ zt*3P8zZ-~Blq;RI7iPX%?~kY#B5{Q1O-g@mI`cK1usm+GN|y)2;fG*g z<=l6#aErT9rWmuPF=DddkL`kC4EhLPCCy+F1vAw)b<%P%r8d^{HBLKaN#`lKb(A$1 zTZ+9ucKQ+{?ZXMP7}l3MQ)`^6b$fCc_$a1@(+lm@Fki9uWFt^rzZqy*{3gEnztXL=J-}j>R8w>mpvqx}~;2AS|U!5tFr_ z=9>;xITN>*b?@itcr5zW?AOpJg1Hv72`R1FQ{;+_C40n}4bO-T zsU-h^$IYG6aksE7ugJv#^{juF#pzo$O>H|czs!*~C`}mc$IIEp2$EU2Z8|Y8@ac;Z z2fk{mhkt*s{wdU&@OM;t{@L`2R)euacAY6>K0Vdg<>3Nv%rgh2p?Ebb(9^7tyrHurs$7bN@l`9WM4VM4+4O867COY* zvIo_91<8j4?kRkwsMkVM(YTtH6+2^ZyuOfdB~fvIK8mD3zZ1E-X;u?StlkGJn?IRM zPNVukyx1zaj@&NN?R+odIh2}ZSaU$D@9TU%$ys~aI$5z6V$|x#(LK-)wo=ZRuUuew2kuM zGG2iu(0K-n(MZ`RtYB_Qn#9V(mi>wQ%ZsoG!CavGh^@uhKB!pCxnatOP@kdM#U=_+LJ%uZ(R$;07@;h?*w%TJAQ1m3eFycQ>HUn z=8+phVHxm{u%B$O#;;(yhlz0MC|g{Cnjz=+Yu%bG0Gh(S5Qa1w!45&?=%;Ma;~#al z@3sco61=9-0)2Z5+ms2a#T8w+0USE7!yO8}~k&C+g zp?S!h{7$}7MJAcrZfhO{Skhms-8_3K8o){-lmQdy9{YZH{#4EhU^oNrFvMJ0yX+XI zMi!{FqPw}8QEnRLN}Pbaa-LMDI#~)8UN^eb z?FBhaCR8r=CppkC=vx7`jqwdh(#EXogn~eJG~RSo!_wV#9!lGdQ8q(U2z8<3WZ3JZ zrYwP{j_jY989w@X8m4k*dX~rO$EZ&NL zv&$P#Ujm**euEeeeTP{shbGAWFb&WpITRuu>oHXSwTtWT+_`o@-$yP~$Uu>SVks^8 z3mApl`U0R~168l`cja{gUv);V(t(jM>j6q0WZl!p+&T8CsOhDATJ-j0;pscCwRS(r z-jpE6l`x_*d+P>JU%67|PE-Y(*Fo97G&~V4{>&rIp;C8dOKxt3cZ#7TSovD7djxR& z!U7kepp(|&7N%|Iw77!fU~aQW#PqgNJ>$>o>oc)TxFLFwfw2_*2dX-l8ov)v_M!hl zZj!+rq85e}6p6u-iijxJJ5?fhbTQ0J%TIV7Wewk#DR z$?(Q%Xq#{!{3FAB@9V!}`*ySTcZ5HLQqjNh9nOF4n>2@U9mdHm{Hs!{s{~UzM1}7d=YhNNuw6dF1)*Rc=dw{|D-k~@o z0z_IsAvJnh5*;xp31mKq)VkI;luajQgu{^RQ<9VFyfxT7|CF}MQzwmtzci18>6!v)l6v=;Lf{JKyau{Rmo;Zg1TK1 z@rO2;%KXTlB^yv%y!%~oy0C#P%MvEtcr~Ig`;-esqVfBSemU6ICi-pf(tXKergr(T+njU(czWtMrvtZF>1Vmm?1wUd%Y`vn#O8e$ zry~kT7QyBn#Yff;!*y~%SD;nK+jVT@P8g=JaWLiMA|p%sUuv+z4A^X6B#(Qd(Md;W z`eW$-&&|bnj8IZ&g=8ULp+DM)wq@?(t>x8P6q@{*%cy?P`5?!OS)&98VFw~Dv|FMv z`2{bwO3E5kdLaqgDlG%kllU`+m=pVi1I$k&RXb2@80MOQkD*I5JdhVJB>d$5w7SYk z?vEb=Tz62h&cUMa#kt?(wcBAAC@_V`kJmi_`EOZ%yY-y;CNc@d_bh$5bk{Cr=xL)> zE|O6L-zzS$clv#WIPfmDhLTg#W*E)KkM=>WjD=!gWs*`(XO1NCAFu#Z^GRUmSrxY{ zhuzZY?p24JPt|wKj+JOut$U!`5(#BnvYIa7*Er>VNEM9o`qlbDJKbM~9{}Lh0!rS^ z94BbFzWP1yy0b_)A2)Q%0w`H$;6?IpM=5lsg$fjp)XDHO516g$8<7MxI!@hLAEmvZcjns|#^d z!~Li3vK=C&gpxq(S{Ei`O(7i52LKi4dw>6`;b?pX?_oD#$YAT?r&6IMF6ZZZzC<%GwHjFozd^28MW}2My(V19Kg_hjE6ud1 zI`u!hUr#xyubP=jH!K&g@qn><(m@Zg(Rp-Wi#O`n@~N(VZ86zgSYdUQ#zF9Ojoj{G zte#@|7WHK}EQ*~O9u_I8OU88K;!3qjh4Elx{Mmo+gLo@o7?O?;r~JZ)+^Dd&1{jWJ zfGKNTbUA@v8SHPDoG^K)AdJ?+VaN-Nfz+H_8YfBAh#0TLB!ei0m!$L9B6y7mj=5Uk&pV&iowLXiry5ka6za+<@loRLXF)dvLbqdFJQ~rLh4LsxMYzkwr`plg!&n2NGBPw z(@ytMer%or#~&6*uKZuAgV>tN@JQ}Q@rEREV=~BtjHxydnHoT)V(2`UzRL~ytV|}l zcC;R=+MNE``ToYwCSrK+#rt)&h!O$DA1*M*S;}RcE;KGXPSE1c9omr!(;3?TgOsnZ z4W^})r1I%g?9i1re&}xl3>E~v&YG`wZ`VYWb~{hlaaM$tu^Fj0W3|57b@scI=ffQK z*Wy!=BQP5AL@;uSV|%XyNOJ;C6Ho@cvkuEdJUoknwQdEPf8Cz`vmdXr_$2vuT5c*o zok0n|$&g<(V*)dA9!h8&`Zg1pB~8PXfEjcWf*H|c8SsHnV(>dP*0fVLt-NfAkZ~>v zoK_dFkdimyJOM zyJ%y*C03JK+Dk0)Dehn6Q*RwbNEFW0Cw3+GFA|cp^$1wjb&4Ibq{x|?%1Sp#Jb9Ss zIVRbja%QL%>G|b1N?)@2a#2MJ0(R9F@&Bf*rbv_fyP|*I4P%JNL#uXS*9~aVQ?KCs5)bj2KIfBK^0TUWZ}X6B9X)gu(i`GQAS3Yyh&sQeF#E_ zJq0FC*u3yUvd(#;f_9Y@4+nBsGUIT0mOV4af?pc*5e++_mT4zWXiE4W(^4KTqRKq; znMRn5Z%eNi4RmGq5Ew$2Kez1i?Ir`uaqvqg?MQe`kQ0iIiE_IQwA}9rx+09)o)*dO z0@DIc>d=SHZwB@F9WOUG;?Zd@*<0aj4nuiF3#yi$al3>imeiv!Hh0*4PW89UeolM# zP^vX@{&e^yr5d`981HBJe_Bg^2`wV9lng_}PGqaD9$7uENAIG@Qyw zrvC|X6Mm$f{IHt1paBU2-94X8Jl}`^<&-YL(rIZxJQ-IGG)C5b>gP>4~ z-K?_8fagt+w;$@1?9d z*E6&#`LLps^4f0ysvwU8;Y8_T={sjZg3=u3$ybe6vyMn*L4N9$0AM=mM+nNNvhy?ez5lRAyn_IO3g#W2B987X0IrrY7B~1uq07IfJF^B?- z(90E&@!c>On(!P4EvwH=gpCV!ucD^oKXq$`#?bi1ekt65)d7%b5wi+Xi6Z0tiPD#u zr4Z&*Y=$Ng-HJJNZUS_4Q5vMW=oLBl{89cC`;%>H&-?>q6f>g-P{5d&$u+Y+HKGreOVgy=L5RKI2Di`E1UAvb!}mqQ&TF4@i^#f03~2-f^{11KmEHS)F|F&yT$Zw2RY*7}xT~dhF&KJw!)KBz*TQIyCT<#=OCJT31`MIo zCL@v$pnxBy+&D6j`NF}~a$$bPjL;& zb(t(4Oy0)DcYc&VO^ z6NB;1kf$qTSDkstV0Bi>3{j=g8jOd=i%KSmqWk#>X?aZBG|g**-Rld<2Va))hC~sp z9Mv{WU(-@S~Wwofo zKWPD9@QnOc#-n8S8}^%baSb(&Y_98gu+C@&#^P=91RREFkbFT*dCM(C?&u;}8A)97 za#inM_XJ*W<6OF%R#KkpO7eHvbu8j&&f0m7<;}q#cMa=;w-LNTr9socL?3}<$vJ07 z+u1R{2dj-=HPNq1wUL{X&r$K0)TA<+g+DeM=v1>aUV((^H(%6XGR>4#6OBV6F)piQ z+aV!idpmjUr8(^_0Sk~7coWse z0MNF3N3CZuKT+^_x7}UmAD0S==p=D{RZ>lm8OO{U7D}GBP49sb)xgx?hnRTvEUKg} zlu-Bqv6c&LJ<$;YL#W_)S!OGl{PPQs`bZ<@;9zh)B9neDFnL90aIKiZfYm~Qk=aY^ zGU|LK;fFS}nlE$ZqNN$3Q*77Y8IE}-eGfT(V8*HWE*Y?@r}YWdG`@gd$>Yak&_wbe zloXG4MCYZRTO+k$u+KW|oQkrX0ovXTq&9>84f2?NCq#1)dT)r2h)>*}7Jq?7)WAc!rwMc%31SVLz6+SaCn zhh2MHG^*NuKTNBNQR0~KCiMMx+~3S=UlS$tc(SUH2nh{+2@Xj2p&83UMv)FvWswe*vZ8t8H19Fvb6MWU_$AUT5D=Er z**4qZL?zVyR53<>5$^hgA~%mP+YZbK3(PMPIHa-(qbrm53Snky^Bx*sGtPv_;EHZc zMjhi`X5Hlc=~nx@->KbyDQ(TvlWK5OZ>-6NB9v$wou~*Vf$7f4E;?5rpaQEGXL7cDeXM* zZJKT1YrZOD0R2$nEOQHUpi`iYA|E%lqdpxCQN}I(=-VTjQ&Mhae@{ZVG)^1gf1#L4 zCu5NA=OCv0EVG<@0x_!)?vkzSI>QfaVeltf@W2~3p=k*yRvP>~-})RjVccdYIwiM5 zVp&ld=(JD4ks0*d7*I3-UhcFeuE{6ez3g2M;73ZG8&bIAfi&cB><-f1JT(?*2l|Lk z^r&BI$KzS<9Jg&sF~K(6+TGom^eDlipR~peYlu!*89f-$NTZb?EiykdsdRq})oVd`k4mvblRU!=?VKR#y_(!5uUz%7*y#_2en zpJ&17lugU)m!itoXuNN!W6j2s%d8;RidRaVsg7e5hzh@sKk4QR$fGm$JWp@az-|Wo z*Nn@jmZUSFB+$FvW-_1kAQ^I2nUH-X^GKIDVh7(aoX2|g*Dy4Ep2n!BDIH3D36O?G z`E{xR55)QQZE`|gTs9i&ud8_ATJE5%En3d7FrVDYMHGEx5QhK5L+U*;a8lV*$>v2p zdHjljve^0YIV6Cp{EzJ(-!H zx_0MUJn&NN^)Diuu|JE5rMtq1_WA=ZPYwB(gxtd~W+C*1I@d+Q`asRyKQD+NI*ETD zv4R>^cZpm{jsS>npjwd?V-RYZ4}G>Ba6v=yU9&Xh_LI*kB3B(OZ@#aWOy0(JmfmZbZ7sKY+|hdE1u(Q8npihi&{Q4 zS+lym(t8#$L9BU!Vd9ZFY!JINjCIb?=CZ-=7xK)j*fKP+!^CNNAo_mx`2C|b3<*6e7aylQxHqBfE_neOq*6Tx zFjwH7O`3_F%PPsAg6JMT_MrI)^YYzKldZQu4G3L-RDAdk#}6REAohzSffW2hMyV|O z$Ls2&X963v_y4XgSMp7)&>IAY2qGW@K4Po z_gY&iziY8YJC?rb4tC3S`Uz6)EQ zm$?;92Y>+oUm8{?6To1Jv9aPgixV~KwkKqnP)cN}jg>=1w?gE#dUgoR;bdBf?qCK4 z+S_=vpOsA{aaH!Jl=T{7XQEJN^QYl)Lb#4Efq|@oiSaMTv3Y5~Y2|59JyHJrJ2^G! zmI2P9NQCN$|NPq&?r8YlA?THES}6GQ(REUj>z5S4syj_2H1Dl2`QShf+ZI2e7Q^i< z5hll5p)@poL57v#MRytYgq&7<>L`ZN*}ef>O#rWJf>dm8r9jtdJenN;#BXovl{1x* z6)UwKc5a8-5%WtL9(^FhVNbx4F~fLEy

WEVe1aa&1AFj`~`h__v`l@HdaCe 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 Dmg^t2 literal 0 HcmV?d00001 diff --git a/voice/openai/package.json b/voice/openai/package.json new file mode 100644 index 0000000000..c62ed49afa --- /dev/null +++ b/voice/openai/package.json @@ -0,0 +1,35 @@ +{ + "name": "@mastra/voice-openai", + "version": "0.1.0", + "description": "Mastra OpenAI speech integration", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./package.json": "./package.json" + }, + "scripts": { + "build": "tsup src/index.ts --format esm --experimental-dts --clean --treeshake", + "build:watch": "pnpm build --watch", + "test": "vitest run" + }, + "dependencies": { + "@mastra/core": "workspace:^", + "openai": "^4.28.0", + "zod": "^3.24.1" + }, + "devDependencies": { + "@microsoft/api-extractor": "^7.49.2", + "@types/node": "^22.13.1", + "dotenv": "^16.4.7", + "tsup": "^8.3.6", + "typescript": "^5.7.3", + "vitest": "^2.1.8" + } +} diff --git a/voice/openai/src/index.test.ts b/voice/openai/src/index.test.ts new file mode 100644 index 0000000000..a061a6b135 --- /dev/null +++ b/voice/openai/src/index.test.ts @@ -0,0 +1,166 @@ +import { createWriteStream, writeFileSync, mkdirSync, createReadStream } from 'fs'; +import path from 'path'; +import { PassThrough } from 'stream'; +import { describe, expect, it, beforeAll } from 'vitest'; + +import { OpenAIVoice } from './index.js'; + +describe('OpenAIVoice Integration Tests', () => { + let voice: OpenAIVoice; + const outputDir = path.join(process.cwd(), 'test-outputs'); + + beforeAll(() => { + // Create output directory if it doesn't exist + try { + mkdirSync(outputDir, { recursive: true }); + } catch (err) { + // Ignore if directory already exists + } + + voice = new OpenAIVoice({ + speechModel: { + name: 'tts-1', + }, + listeningModel: { + name: 'whisper-1', + }, + }); + }); + + describe('getSpeakers', () => { + it('should list available voices', async () => { + const speakers = await voice.getSpeakers(); + expect(speakers).toEqual([ + { voiceId: 'alloy' }, + { voiceId: 'echo' }, + { voiceId: 'fable' }, + { voiceId: 'onyx' }, + { voiceId: 'nova' }, + { voiceId: 'shimmer' }, + ]); + }); + }); + + describe('speak', () => { + it('should generate audio stream from text', async () => { + const audioStream = await voice.speak('Hello World', { + speaker: 'alloy', + }); + + const chunks: Buffer[] = []; + for await (const chunk of audioStream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const audioBuffer = Buffer.concat(chunks); + + expect(audioBuffer.length).toBeGreaterThan(0); + + const outputPath = path.join(outputDir, 'speech-test.mp3'); + writeFileSync(outputPath, audioBuffer); + }, 10000); + + it('should work with different parameters', async () => { + const audioStream = await voice.speak('Test with parameters', { + speaker: 'nova', + speed: 0.5, + }); + + const chunks: Buffer[] = []; + for await (const chunk of audioStream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const audioBuffer = Buffer.concat(chunks); + + expect(audioBuffer.length).toBeGreaterThan(0); + + const outputPath = path.join(outputDir, 'speech-test-params.mp3'); + writeFileSync(outputPath, audioBuffer); + }, 10000); + + it('should accept text stream as input', async () => { + const inputStream = new PassThrough(); + inputStream.end('Hello from stream'); + + const audioStream = await voice.speak(inputStream, { + speaker: 'alloy', + }); + + const chunks: Buffer[] = []; + for await (const chunk of audioStream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const audioBuffer = Buffer.concat(chunks); + + expect(audioBuffer.length).toBeGreaterThan(0); + + const outputPath = path.join(outputDir, 'speech-stream-input.mp3'); + writeFileSync(outputPath, audioBuffer); + }, 10000); + }); + + describe('listen', () => { + it('should transcribe audio from fixture file', async () => { + const fixturePath = path.join(process.cwd(), '__fixtures__', 'voice-test.m4a'); + const audioStream = createReadStream(fixturePath); + + const text = await voice.listen(audioStream, { + filetype: 'm4a', + }); + + expect(text).toBeTruthy(); + console.log(text); + expect(typeof text).toBe('string'); + expect(text.length).toBeGreaterThan(0); + }, 15000); + + it('should transcribe audio stream', async () => { + // First generate some test audio + const audioStream = await voice.speak('This is a test for transcription', { + speaker: 'alloy', + }); + + // Then transcribe it + const text = await voice.listen(audioStream, { + filetype: 'm4a', + }); + + expect(text).toBeTruthy(); + expect(typeof text).toBe('string'); + expect(text.toLowerCase()).toContain('test'); + }, 15000); + + it('should accept options', async () => { + const audioStream = await voice.speak('Test with language option', { + speaker: 'nova', + }); + + const text = await voice.listen(audioStream, { + language: 'en', + filetype: 'm4a', + }); + + expect(text).toBeTruthy(); + expect(typeof text).toBe('string'); + expect(text.toLowerCase()).toContain('test'); + }, 15000); + }); + + // Error cases + describe('error handling', () => { + it('should handle invalid speaker names', async () => { + await expect( + voice.speak('Test', { + speaker: 'invalid_voice', + }), + ).rejects.toThrow(); + }); + + it('should handle empty text', async () => { + await expect( + voice.speak('', { + speaker: 'alloy', + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/voice/openai/src/index.ts b/voice/openai/src/index.ts new file mode 100644 index 0000000000..e1cef064db --- /dev/null +++ b/voice/openai/src/index.ts @@ -0,0 +1,144 @@ +import { MastraVoice } from '@mastra/core/voice'; +import OpenAI from 'openai'; +import { PassThrough } from 'stream'; + +type OpenAIVoiceId = 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer' | 'ash' | 'coral' | 'sage'; +// file types mp3, mp4, mpeg, mpga, m4a, wav, and webm. + +interface OpenAIConfig { + name?: 'tts-1' | 'tts-1-hd' | 'whisper-1'; + apiKey?: string; +} + +export class OpenAIVoice extends MastraVoice { + speechClient?: OpenAI; + listeningClient?: OpenAI; + + constructor({ + listeningModel, + speechModel, + speaker, + }: { + listeningModel?: OpenAIConfig; + speechModel?: OpenAIConfig; + speaker?: string; + }) { + super({ + speechModel: speechModel && { + name: speechModel.name || 'tts-1', + apiKey: speechModel.apiKey, + }, + listeningModel: listeningModel && { + name: listeningModel.name || 'whisper-1', + apiKey: listeningModel.apiKey, + }, + speaker, + }); + + const defaultApiKey = process.env.OPENAI_API_KEY; + + if (speechModel || defaultApiKey) { + const speechApiKey = speechModel?.apiKey || defaultApiKey; + if (!speechApiKey) { + throw new Error('No API key provided for speech model'); + } + this.speechClient = new OpenAI({ apiKey: speechApiKey }); + } + + if (listeningModel || defaultApiKey) { + const listeningApiKey = listeningModel?.apiKey || defaultApiKey; + if (!listeningApiKey) { + throw new Error('No API key provided for listening model'); + } + this.listeningClient = new OpenAI({ apiKey: listeningApiKey }); + } + + if (!this.speechClient && !this.listeningClient) { + throw new Error('At least one of OPENAI_API_KEY, speechModel.apiKey, or listeningModel.apiKey must be set'); + } + } + + async getSpeakers() { + return this.traced( + async () => [ + { voiceId: 'alloy' }, + { voiceId: 'echo' }, + { voiceId: 'fable' }, + { voiceId: 'onyx' }, + { voiceId: 'nova' }, + { voiceId: 'shimmer' }, + ], + 'voice.openai.getSpeakers', + )(); + } + + async speak( + input: string | NodeJS.ReadableStream, + options?: { + speaker?: string; + speed?: number; + [key: string]: any; + }, + ): Promise { + if (!this.speechClient) { + throw new Error('Speech model not configured'); + } + + if (typeof input !== 'string') { + const chunks: Buffer[] = []; + for await (const chunk of input) { + chunks.push(Buffer.from(chunk)); + } + input = Buffer.concat(chunks).toString('utf-8'); + } + + const audio = await this.traced(async () => { + const response = await this.speechClient!.audio.speech.create({ + model: this.speechModel?.name || 'tts-1', + voice: (options?.speaker || 'alloy') as OpenAIVoiceId, + input, + speed: options?.speed || 1.0, + }); + + const passThrough = new PassThrough(); + const buffer = Buffer.from(await response.arrayBuffer()); + passThrough.end(buffer); + return passThrough; + }, 'voice.openai.speak')(); + + return audio; + } + + async listen( + audioStream: NodeJS.ReadableStream, + options: { + filetype: 'mp3' | 'mp4' | 'mpeg' | 'mpga' | 'm4a' | 'wav' | 'webm'; + [key: string]: any; + }, + ): Promise { + if (!this.listeningClient) { + throw new Error('Listening model not configured'); + } + + const chunks: Buffer[] = []; + for await (const chunk of audioStream) { + chunks.push(Buffer.from(chunk)); + } + const audioBuffer = Buffer.concat(chunks); + + const text = await this.traced(async () => { + const { filetype, ...otherOptions } = options || {}; + const file = new File([audioBuffer], `audio.${filetype}`); + + const response = await this.listeningClient!.audio.transcriptions.create({ + model: this.listeningModel?.name || 'whisper-1', + file: file as any, + ...otherOptions, + }); + + return response.text; + }, 'voice.openai.listen')(); + + return text; + } +} diff --git a/voice/openai/test-outputs/speech-stream-input.mp3 b/voice/openai/test-outputs/speech-stream-input.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a36ab1de83da4ca624fd85165f77abdb21dadff2 GIT binary patch literal 20640 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 W8se>e1O8w9E&gBpPX8Bw&i?^LhcB7{ literal 0 HcmV?d00001 diff --git a/voice/openai/test-outputs/speech-test.mp3 b/voice/openai/test-outputs/speech-test.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..78a5dd9e341c9c3347ffd57aa454981d9396cc42 GIT binary patch literal 14400 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 oPwdYb{U0aP{} Date: Thu, 20 Feb 2025 19:21:54 -0600 Subject: [PATCH 05/10] update pnpm file --- pnpm-lock.yaml | 35 ++++++++++++++++++++++++++++++----- pnpm-workspace.yaml | 1 + voice/openai/package.json | 1 - voice/openai/vitest.config.ts | 1 - 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b9e4822dc..44fff26549 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2076,7 +2076,7 @@ importers: version: 7.50.0(@types/node@22.13.4) '@rollup/plugin-image': specifier: ^3.0.3 - version: 3.0.3(rollup@4.34.8) + version: 3.0.3(rollup@3.29.5) '@size-limit/preset-small-lib': specifier: ^11.1.4 version: 11.2.0(size-limit@11.2.0) @@ -2137,7 +2137,7 @@ importers: version: 7.50.0(@types/node@22.13.4) '@rollup/plugin-image': specifier: ^3.0.3 - version: 3.0.3(rollup@3.29.5) + version: 3.0.3(rollup@4.34.8) '@size-limit/preset-small-lib': specifier: ^11.1.4 version: 11.2.0(size-limit@11.2.0) @@ -3452,9 +3452,6 @@ importers: typescript: specifier: ^5.7.3 version: 5.7.3 - vitest: - specifier: ^2.1.8 - version: 2.1.9(@edge-runtime/vm@3.2.0)(@types/node@22.13.4)(jsdom@20.0.3(bufferutil@4.0.9)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@6.0.5))(terser@5.39.0) speech/playai: dependencies: @@ -3943,6 +3940,34 @@ importers: specifier: ^5.7.3 version: 5.7.3 + voice/openai: + dependencies: + '@mastra/core': + specifier: workspace:^ + version: link:../../packages/core + openai: + specifier: ^4.28.0 + version: 4.85.2(encoding@0.1.13)(ws@8.18.0(bufferutil@4.0.9)(utf-8-validate@6.0.5))(zod@3.24.2) + zod: + specifier: ^3.24.1 + version: 3.24.2 + devDependencies: + '@microsoft/api-extractor': + specifier: ^7.49.2 + version: 7.50.0(@types/node@22.13.4) + '@types/node': + specifier: ^22.13.1 + version: 22.13.4 + tsup: + specifier: ^8.3.6 + version: 8.3.6(@microsoft/api-extractor@7.50.0(@types/node@22.13.4))(@swc/core@1.10.18(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.2)(tsx@4.19.3)(typescript@5.7.3)(yaml@2.7.0) + typescript: + specifier: ^5.7.3 + version: 5.7.3 + vitest: + specifier: ^2.1.8 + version: 2.1.9(@edge-runtime/vm@3.2.0)(@types/node@22.13.4)(jsdom@20.0.3(bufferutil@4.0.9)(canvas@2.11.2(encoding@0.1.13))(utf-8-validate@6.0.5))(terser@5.39.0) + packages: '@ai-sdk/anthropic@1.1.9': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 730506a27e..9b274d33d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - "vector-stores/*" - "stores/*" - "speech/*" + - "voice/*" - "client-sdks/*" - "!packages/cli/admin" - "integration-generator/*" diff --git a/voice/openai/package.json b/voice/openai/package.json index c62ed49afa..225ce29f62 100644 --- a/voice/openai/package.json +++ b/voice/openai/package.json @@ -27,7 +27,6 @@ "devDependencies": { "@microsoft/api-extractor": "^7.49.2", "@types/node": "^22.13.1", - "dotenv": "^16.4.7", "tsup": "^8.3.6", "typescript": "^5.7.3", "vitest": "^2.1.8" diff --git a/voice/openai/vitest.config.ts b/voice/openai/vitest.config.ts index 21f0835f12..5a4214273c 100644 --- a/voice/openai/vitest.config.ts +++ b/voice/openai/vitest.config.ts @@ -4,6 +4,5 @@ export default defineConfig({ test: { globals: true, include: ['src/**/*.test.ts'], - setupFiles: ['dotenv/config'], }, }); From 29eb499105a38275bf553a94894e9abe8feed3b1 Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Thu, 20 Feb 2025 19:23:30 -0600 Subject: [PATCH 06/10] changeset --- .changeset/thin-worlds-tie.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/thin-worlds-tie.md diff --git a/.changeset/thin-worlds-tie.md b/.changeset/thin-worlds-tie.md new file mode 100644 index 0000000000..5506452e80 --- /dev/null +++ b/.changeset/thin-worlds-tie.md @@ -0,0 +1,6 @@ +--- +'@mastra/speech-openai': minor +'@mastra/voice-openai': minor +--- + +Deprecate @mastra/speech-openai for @mastra/voice-openai From d6976d1b8ed2cb7f80520cad7a24269dd297689e Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Fri, 21 Feb 2025 13:22:00 -0600 Subject: [PATCH 07/10] add function factory for createOpenAIVoice --- .gitignore | 5 +- voice/openai/README.md | 56 ++++++++++++---- voice/openai/src/index.test.ts | 119 ++++++++++++++++++++++++++++++--- voice/openai/src/index.ts | 91 ++++++++++++++++++++----- 4 files changed, 232 insertions(+), 39 deletions(-) diff --git a/.gitignore b/.gitignore index 597ee2532c..a0d5f6dec1 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ openapi-ts-error* .secrets # Local Netlify folder .netlify -.npmrc \ No newline at end of file +.npmrc + +# Test output directories +voice/**/test-output*/ diff --git a/voice/openai/README.md b/voice/openai/README.md index c9ff6bf87e..47bb1c29b2 100644 --- a/voice/openai/README.md +++ b/voice/openai/README.md @@ -18,30 +18,44 @@ OPENAI_API_KEY=your_api_key ## Usage +### Using the Factory Function (Recommended) + ```typescript -import { OpenAIVoice } from '@mastra/voice-openai'; +import { createOpenAIVoice } from '@mastra/voice-openai'; -// Initialize with configuration -const voice = new OpenAIVoice({ - speechModel: { - name: 'tts-1', // or 'tts-1-hd' for higher quality +// Create voice with both speech and listening capabilities +const voice = createOpenAIVoice({ + speech: { + model: 'tts-1', // or 'tts-1-hd' for higher quality apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var + speaker: 'alloy', // Default voice }, - listeningModel: { - name: 'whisper-1', + listening: { + model: 'whisper-1', apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var }, - speaker: 'alloy', // Default voice +}); + +// Or create speech-only voice +const speechVoice = createOpenAIVoice({ + speech: { + model: 'tts-1', + speaker: 'nova', + }, +}); + +// Or create listening-only voice +const listeningVoice = createOpenAIVoice({ + listening: { + model: 'whisper-1', + }, }); // List available voices const speakers = await voice.getSpeakers(); // Generate speech -const audioStream = await voice.speak('Hello from Mastra!', { - speaker: 'alloy', - speed: 1.0, -}); +const audioStream = await voice.speak('Hello from Mastra!'); // Convert speech to text const text = await voice.listen(audioStream, { @@ -49,6 +63,24 @@ const text = await voice.listen(audioStream, { }); ``` +### Using the Class Directly + +```typescript +import { OpenAIVoice } from '@mastra/voice-openai'; + +const voice = new OpenAIVoice({ + speechModel: { + name: 'tts-1', + apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var + }, + listeningModel: { + name: 'whisper-1', + apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var + }, + speaker: 'alloy', // Default voice +}); +``` + ## Features - High-quality Text-to-Speech synthesis diff --git a/voice/openai/src/index.test.ts b/voice/openai/src/index.test.ts index a061a6b135..3e35f751aa 100644 --- a/voice/openai/src/index.test.ts +++ b/voice/openai/src/index.test.ts @@ -1,9 +1,112 @@ -import { createWriteStream, writeFileSync, mkdirSync, createReadStream } from 'fs'; +import { writeFileSync, mkdirSync, createReadStream } from 'fs'; import path from 'path'; import { PassThrough } from 'stream'; -import { describe, expect, it, beforeAll } from 'vitest'; +import { describe, expect, it, beforeAll, beforeEach } from 'vitest'; -import { OpenAIVoice } from './index.js'; +import { createOpenAIVoice, OpenAIVoice } from './index.js'; + +describe('createOpenAIVoice', () => { + const outputDir = path.join(process.cwd(), 'test-outputs'); + let apiKey: string; + + beforeAll(() => { + try { + mkdirSync(outputDir, { recursive: true }); + } catch (err) { + // Ignore if directory already exists + } + }); + + beforeEach(() => { + apiKey = process.env.OPENAI_API_KEY || ''; + }); + + it('should create voice with speech capabilities and generate audio', async () => { + const voice = createOpenAIVoice({ + speech: { + model: 'tts-1', + speaker: 'alloy', + }, + }); + + expect(voice.speak).toBeDefined(); + expect(voice.getSpeakers).toBeDefined(); + expect(voice.listen).toBeUndefined(); + + const audioStream = await voice.speak!('Testing speech capabilities'); + const chunks: Buffer[] = []; + for await (const chunk of audioStream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const audioBuffer = Buffer.concat(chunks); + + expect(audioBuffer.length).toBeGreaterThan(0); + writeFileSync(path.join(outputDir, 'factory-speech-test.mp3'), audioBuffer); + }, 10000); + + it('should create voice with listening capabilities and transcribe audio', async () => { + const speechVoice = createOpenAIVoice({ + speech: { model: 'tts-1' }, + }); + + const audioStream = await speechVoice.speak!('Testing transcription capabilities'); + const voice = createOpenAIVoice({ + listening: { + model: 'whisper-1', + }, + }); + + expect(voice.listen).toBeDefined(); + expect(voice.speak).toBeUndefined(); + + const text = await voice.listen!(audioStream, { filetype: 'mp3' }); + expect(text.toLowerCase()).toContain('testing'); + expect(text.toLowerCase()).toContain('transcription'); + }, 15000); + + it('should create voice with both capabilities and round-trip audio', async () => { + const voice = createOpenAIVoice({ + speech: { + model: 'tts-1', + speaker: 'alloy', + }, + listening: { + model: 'whisper-1', + }, + }); + + expect(voice.speak).toBeDefined(); + expect(voice.listen).toBeDefined(); + + const originalText = 'Testing both speech and listening capabilities'; + const audioStream = await voice.speak!(originalText); + const transcribedText = await voice.listen!(audioStream, { filetype: 'mp3' }); + + expect(transcribedText.toLowerCase()).toContain('testing'); + expect(transcribedText.toLowerCase()).toContain('capabilities'); + }, 20000); + + it('should list available speakers', async () => { + const voice = createOpenAIVoice({ + speech: { + model: 'tts-1', + }, + }); + + const speakers = await voice.getSpeakers!(); + expect(speakers).toContainEqual({ voiceId: 'alloy' }); + expect(speakers).toContainEqual({ voiceId: 'nova' }); + expect(speakers.length).toBeGreaterThan(0); + }); + + it('should create voice without any capabilities', () => { + const voice = createOpenAIVoice(); + + expect(voice.speak).toBeUndefined(); + expect(voice.listen).toBeUndefined(); + expect(voice.getSpeakers).toBeUndefined(); + }); +}); describe('OpenAIVoice Integration Tests', () => { let voice: OpenAIVoice; @@ -30,14 +133,8 @@ describe('OpenAIVoice Integration Tests', () => { describe('getSpeakers', () => { it('should list available voices', async () => { const speakers = await voice.getSpeakers(); - expect(speakers).toEqual([ - { voiceId: 'alloy' }, - { voiceId: 'echo' }, - { voiceId: 'fable' }, - { voiceId: 'onyx' }, - { voiceId: 'nova' }, - { voiceId: 'shimmer' }, - ]); + expect(speakers).toContainEqual({ voiceId: 'alloy' }); + expect(speakers).toContainEqual({ voiceId: 'nova' }); }); }); diff --git a/voice/openai/src/index.ts b/voice/openai/src/index.ts index e1cef064db..05a3b543e6 100644 --- a/voice/openai/src/index.ts +++ b/voice/openai/src/index.ts @@ -3,13 +3,66 @@ import OpenAI from 'openai'; import { PassThrough } from 'stream'; type OpenAIVoiceId = 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer' | 'ash' | 'coral' | 'sage'; -// file types mp3, mp4, mpeg, mpga, m4a, wav, and webm. +type OpenAIModel = 'tts-1' | 'tts-1-hd' | 'whisper-1'; +type OpenAIFileType = 'mp3' | 'mp4' | 'mpeg' | 'mpga' | 'm4a' | 'wav' | 'webm'; -interface OpenAIConfig { - name?: 'tts-1' | 'tts-1-hd' | 'whisper-1'; +export interface OpenAIConfig { + name?: OpenAIModel; apiKey?: string; } +export interface OpenAIVoiceConfig { + speech?: { + model: 'tts-1' | 'tts-1-hd'; + apiKey?: string; + speaker?: OpenAIVoiceId; + }; + listening?: { + model: 'whisper-1'; + apiKey?: string; + language?: string; + }; +} + +export interface OpenAIVoiceCapabilities { + speak?: ( + input: string | NodeJS.ReadableStream, + options?: { speaker?: string; speed?: number }, + ) => Promise; + listen?: ( + audioStream: NodeJS.ReadableStream, + options: { filetype: OpenAIFileType; [key: string]: any }, + ) => Promise; + getSpeakers?: () => Promise>; +} + +/** + * Creates OpenAI voice capabilities + */ +export function createOpenAIVoice(config?: OpenAIVoiceConfig): OpenAIVoiceCapabilities { + const provider = new OpenAIVoice({ + speechModel: config?.speech + ? { + name: config.speech.model, + apiKey: config.speech.apiKey, + } + : undefined, + listeningModel: config?.listening + ? { + name: config.listening.model, + apiKey: config.listening.apiKey, + } + : undefined, + speaker: config?.speech?.speaker, + }); + + return { + speak: config?.speech ? provider.speak.bind(provider) : undefined, + listen: config?.listening ? provider.listen.bind(provider) : undefined, + getSpeakers: config?.speech ? provider.getSpeakers.bind(provider) : undefined, + }; +} + export class OpenAIVoice extends MastraVoice { speechClient?: OpenAI; listeningClient?: OpenAI; @@ -58,18 +111,22 @@ export class OpenAIVoice extends MastraVoice { } } - async getSpeakers() { - return this.traced( - async () => [ - { voiceId: 'alloy' }, - { voiceId: 'echo' }, - { voiceId: 'fable' }, - { voiceId: 'onyx' }, - { voiceId: 'nova' }, - { voiceId: 'shimmer' }, - ], - 'voice.openai.getSpeakers', - )(); + async getSpeakers(): Promise> { + if (!this.speechModel) { + throw new Error('Speech model not configured'); + } + + return [ + { voiceId: 'alloy' }, + { voiceId: 'echo' }, + { voiceId: 'fable' }, + { voiceId: 'onyx' }, + { voiceId: 'nova' }, + { voiceId: 'shimmer' }, + { voiceId: 'ash' }, + { voiceId: 'coral' }, + { voiceId: 'sage' }, + ]; } async speak( @@ -92,6 +149,10 @@ export class OpenAIVoice extends MastraVoice { input = Buffer.concat(chunks).toString('utf-8'); } + if (input.trim().length === 0) { + throw new Error('Input text is empty'); + } + const audio = await this.traced(async () => { const response = await this.speechClient!.audio.speech.create({ model: this.speechModel?.name || 'tts-1', From 8cb225e99a6d71c1f206c49ec7610b17fecdf425 Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Fri, 21 Feb 2025 17:29:13 -0600 Subject: [PATCH 08/10] remove factory function --- voice/openai/src/index.test.ts | 139 ++++++++------------------------- voice/openai/src/index.ts | 135 ++++++++++++++++---------------- 2 files changed, 100 insertions(+), 174 deletions(-) diff --git a/voice/openai/src/index.test.ts b/voice/openai/src/index.test.ts index 3e35f751aa..e0a4aa9a54 100644 --- a/voice/openai/src/index.test.ts +++ b/voice/openai/src/index.test.ts @@ -1,119 +1,15 @@ import { writeFileSync, mkdirSync, createReadStream } from 'fs'; import path from 'path'; import { PassThrough } from 'stream'; -import { describe, expect, it, beforeAll, beforeEach } from 'vitest'; +import { describe, expect, it, beforeAll } from 'vitest'; -import { createOpenAIVoice, OpenAIVoice } from './index.js'; - -describe('createOpenAIVoice', () => { - const outputDir = path.join(process.cwd(), 'test-outputs'); - let apiKey: string; - - beforeAll(() => { - try { - mkdirSync(outputDir, { recursive: true }); - } catch (err) { - // Ignore if directory already exists - } - }); - - beforeEach(() => { - apiKey = process.env.OPENAI_API_KEY || ''; - }); - - it('should create voice with speech capabilities and generate audio', async () => { - const voice = createOpenAIVoice({ - speech: { - model: 'tts-1', - speaker: 'alloy', - }, - }); - - expect(voice.speak).toBeDefined(); - expect(voice.getSpeakers).toBeDefined(); - expect(voice.listen).toBeUndefined(); - - const audioStream = await voice.speak!('Testing speech capabilities'); - const chunks: Buffer[] = []; - for await (const chunk of audioStream) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - const audioBuffer = Buffer.concat(chunks); - - expect(audioBuffer.length).toBeGreaterThan(0); - writeFileSync(path.join(outputDir, 'factory-speech-test.mp3'), audioBuffer); - }, 10000); - - it('should create voice with listening capabilities and transcribe audio', async () => { - const speechVoice = createOpenAIVoice({ - speech: { model: 'tts-1' }, - }); - - const audioStream = await speechVoice.speak!('Testing transcription capabilities'); - const voice = createOpenAIVoice({ - listening: { - model: 'whisper-1', - }, - }); - - expect(voice.listen).toBeDefined(); - expect(voice.speak).toBeUndefined(); - - const text = await voice.listen!(audioStream, { filetype: 'mp3' }); - expect(text.toLowerCase()).toContain('testing'); - expect(text.toLowerCase()).toContain('transcription'); - }, 15000); - - it('should create voice with both capabilities and round-trip audio', async () => { - const voice = createOpenAIVoice({ - speech: { - model: 'tts-1', - speaker: 'alloy', - }, - listening: { - model: 'whisper-1', - }, - }); - - expect(voice.speak).toBeDefined(); - expect(voice.listen).toBeDefined(); - - const originalText = 'Testing both speech and listening capabilities'; - const audioStream = await voice.speak!(originalText); - const transcribedText = await voice.listen!(audioStream, { filetype: 'mp3' }); - - expect(transcribedText.toLowerCase()).toContain('testing'); - expect(transcribedText.toLowerCase()).toContain('capabilities'); - }, 20000); - - it('should list available speakers', async () => { - const voice = createOpenAIVoice({ - speech: { - model: 'tts-1', - }, - }); - - const speakers = await voice.getSpeakers!(); - expect(speakers).toContainEqual({ voiceId: 'alloy' }); - expect(speakers).toContainEqual({ voiceId: 'nova' }); - expect(speakers.length).toBeGreaterThan(0); - }); - - it('should create voice without any capabilities', () => { - const voice = createOpenAIVoice(); - - expect(voice.speak).toBeUndefined(); - expect(voice.listen).toBeUndefined(); - expect(voice.getSpeakers).toBeUndefined(); - }); -}); +import { OpenAIVoice } from './index.js'; describe('OpenAIVoice Integration Tests', () => { let voice: OpenAIVoice; const outputDir = path.join(process.cwd(), 'test-outputs'); beforeAll(() => { - // Create output directory if it doesn't exist try { mkdirSync(outputDir, { recursive: true }); } catch (err) { @@ -138,7 +34,27 @@ describe('OpenAIVoice Integration Tests', () => { }); }); + it('should initialize with default parameters', async () => { + const defaultVoice = new OpenAIVoice(); + const speakers = await defaultVoice.getSpeakers(); + expect(speakers).toBeInstanceOf(Array); + expect(speakers.length).toBeGreaterThan(0); + }); + describe('speak', () => { + it('should speak with default parameters', async () => { + const defaultVoice = new OpenAIVoice(); + const audioStream = await defaultVoice.speak('Hello with defaults'); + + const chunks: Buffer[] = []; + for await (const chunk of audioStream) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const audioBuffer = Buffer.concat(chunks); + + expect(audioBuffer.length).toBeGreaterThan(0); + }); + it('should generate audio stream from text', async () => { const audioStream = await voice.speak('Hello World', { speaker: 'alloy', @@ -196,6 +112,17 @@ describe('OpenAIVoice Integration Tests', () => { }); describe('listen', () => { + it('should listen with default parameters', async () => { + const defaultVoice = new OpenAIVoice(); + const audioStream = await defaultVoice.speak('Listening test with defaults'); + + const text = await defaultVoice.listen(audioStream); + + expect(text).toBeTruthy(); + expect(typeof text).toBe('string'); + expect(text.toLowerCase()).toContain('listening test'); + }); + it('should transcribe audio from fixture file', async () => { const fixturePath = path.join(process.cwd(), '__fixtures__', 'voice-test.m4a'); const audioStream = createReadStream(fixturePath); diff --git a/voice/openai/src/index.ts b/voice/openai/src/index.ts index 05a3b543e6..70ce916afc 100644 --- a/voice/openai/src/index.ts +++ b/voice/openai/src/index.ts @@ -4,7 +4,6 @@ import { PassThrough } from 'stream'; type OpenAIVoiceId = 'alloy' | 'echo' | 'fable' | 'onyx' | 'nova' | 'shimmer' | 'ash' | 'coral' | 'sage'; type OpenAIModel = 'tts-1' | 'tts-1-hd' | 'whisper-1'; -type OpenAIFileType = 'mp3' | 'mp4' | 'mpeg' | 'mpga' | 'm4a' | 'wav' | 'webm'; export interface OpenAIConfig { name?: OpenAIModel; @@ -20,46 +19,6 @@ export interface OpenAIVoiceConfig { listening?: { model: 'whisper-1'; apiKey?: string; - language?: string; - }; -} - -export interface OpenAIVoiceCapabilities { - speak?: ( - input: string | NodeJS.ReadableStream, - options?: { speaker?: string; speed?: number }, - ) => Promise; - listen?: ( - audioStream: NodeJS.ReadableStream, - options: { filetype: OpenAIFileType; [key: string]: any }, - ) => Promise; - getSpeakers?: () => Promise>; -} - -/** - * Creates OpenAI voice capabilities - */ -export function createOpenAIVoice(config?: OpenAIVoiceConfig): OpenAIVoiceCapabilities { - const provider = new OpenAIVoice({ - speechModel: config?.speech - ? { - name: config.speech.model, - apiKey: config.speech.apiKey, - } - : undefined, - listeningModel: config?.listening - ? { - name: config.listening.model, - apiKey: config.listening.apiKey, - } - : undefined, - speaker: config?.speech?.speaker, - }); - - return { - speak: config?.speech ? provider.speak.bind(provider) : undefined, - listen: config?.listening ? provider.listen.bind(provider) : undefined, - getSpeakers: config?.speech ? provider.getSpeakers.bind(provider) : undefined, }; } @@ -67,6 +26,15 @@ export class OpenAIVoice extends MastraVoice { speechClient?: OpenAI; listeningClient?: OpenAI; + /** + * Constructs an instance of OpenAIVoice with optional configurations for speech and listening models. + * + * @param {Object} [config] - Configuration options for the OpenAIVoice instance. + * @param {OpenAIConfig} [config.listeningModel] - Configuration for the listening model, including model name and API key. + * @param {OpenAIConfig} [config.speechModel] - Configuration for the speech model, including model name and API key. + * @param {string} [config.speaker] - The default speaker's voice to use for speech synthesis. + * @throws {Error} - Throws an error if no API key is provided for either the speech or listening model. + */ constructor({ listeningModel, speechModel, @@ -75,42 +43,53 @@ export class OpenAIVoice extends MastraVoice { listeningModel?: OpenAIConfig; speechModel?: OpenAIConfig; speaker?: string; - }) { + } = {}) { + const defaultApiKey = process.env.OPENAI_API_KEY; + const defaultSpeechModel = { + name: 'tts-1', + apiKey: defaultApiKey, + }; + const defaultListeningModel = { + name: 'whisper-1', + apiKey: defaultApiKey, + }; + super({ - speechModel: speechModel && { - name: speechModel.name || 'tts-1', - apiKey: speechModel.apiKey, + speechModel: { + name: speechModel?.name ?? defaultSpeechModel.name, + apiKey: speechModel?.apiKey ?? defaultSpeechModel.apiKey, }, - listeningModel: listeningModel && { - name: listeningModel.name || 'whisper-1', - apiKey: listeningModel.apiKey, + listeningModel: { + name: listeningModel?.name ?? defaultListeningModel.name, + apiKey: listeningModel?.apiKey ?? defaultListeningModel.apiKey, }, - speaker, + speaker: speaker ?? 'alloy', }); - const defaultApiKey = process.env.OPENAI_API_KEY; - - if (speechModel || defaultApiKey) { - const speechApiKey = speechModel?.apiKey || defaultApiKey; - if (!speechApiKey) { - throw new Error('No API key provided for speech model'); - } - this.speechClient = new OpenAI({ apiKey: speechApiKey }); + const speechApiKey = speechModel?.apiKey || defaultApiKey; + if (!speechApiKey) { + throw new Error('No API key provided for speech model'); } + this.speechClient = new OpenAI({ apiKey: speechApiKey }); - if (listeningModel || defaultApiKey) { - const listeningApiKey = listeningModel?.apiKey || defaultApiKey; - if (!listeningApiKey) { - throw new Error('No API key provided for listening model'); - } - this.listeningClient = new OpenAI({ apiKey: listeningApiKey }); + const listeningApiKey = listeningModel?.apiKey || defaultApiKey; + if (!listeningApiKey) { + throw new Error('No API key provided for listening model'); } + this.listeningClient = new OpenAI({ apiKey: listeningApiKey }); if (!this.speechClient && !this.listeningClient) { throw new Error('At least one of OPENAI_API_KEY, speechModel.apiKey, or listeningModel.apiKey must be set'); } } + /** + * Retrieves a list of available speakers for the speech model. + * + * @returns {Promise>} - A promise that resolves to an array of objects, + * each containing a `voiceId` representing an available speaker. + * @throws {Error} - Throws an error if the speech model is not configured. + */ async getSpeakers(): Promise> { if (!this.speechModel) { throw new Error('Speech model not configured'); @@ -129,6 +108,16 @@ export class OpenAIVoice extends MastraVoice { ]; } + /** + * Converts text or audio input into speech using the configured speech model. + * + * @param {string | NodeJS.ReadableStream} input - The text or audio stream to be converted into speech. + * @param {Object} [options] - Optional parameters for the speech synthesis. + * @param {string} [options.speaker] - The speaker's voice to use for the speech synthesis. + * @param {number} [options.speed] - The speed at which the speech should be synthesized. + * @returns {Promise} - A promise that resolves to a readable stream of the synthesized audio. + * @throws {Error} - Throws an error if the speech model is not configured or if the input text is empty. + */ async speak( input: string | NodeJS.ReadableStream, options?: { @@ -155,8 +144,8 @@ export class OpenAIVoice extends MastraVoice { const audio = await this.traced(async () => { const response = await this.speechClient!.audio.speech.create({ - model: this.speechModel?.name || 'tts-1', - voice: (options?.speaker || 'alloy') as OpenAIVoiceId, + model: this.speechModel?.name ?? 'tts-1', + voice: (options?.speaker ?? this.speaker) as OpenAIVoiceId, input, speed: options?.speed || 1.0, }); @@ -170,10 +159,20 @@ export class OpenAIVoice extends MastraVoice { return audio; } + /** + * Transcribes audio from a given stream using the configured listening model. + * + * @param {NodeJS.ReadableStream} audioStream - The audio stream to be transcribed. + * @param {Object} [options] - Optional parameters for the transcription. + * @param {string} [options.filetype] - The file type of the audio stream. + * Supported types include 'mp3', 'mp4', 'mpeg', 'mpga', 'm4a', 'wav', 'webm'. + * @returns {Promise} - A promise that resolves to the transcribed text. + * @throws {Error} - Throws an error if the listening model is not configured. + */ async listen( audioStream: NodeJS.ReadableStream, - options: { - filetype: 'mp3' | 'mp4' | 'mpeg' | 'mpga' | 'm4a' | 'wav' | 'webm'; + options?: { + filetype?: 'mp3' | 'mp4' | 'mpeg' | 'mpga' | 'm4a' | 'wav' | 'webm'; [key: string]: any; }, ): Promise { @@ -189,7 +188,7 @@ export class OpenAIVoice extends MastraVoice { const text = await this.traced(async () => { const { filetype, ...otherOptions } = options || {}; - const file = new File([audioBuffer], `audio.${filetype}`); + const file = new File([audioBuffer], `audio.${filetype || 'mp3'}`); const response = await this.listeningClient!.audio.transcriptions.create({ model: this.listeningModel?.name || 'whisper-1', From 9a9b11660d08236d009158aa3e9a72c697f76637 Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Fri, 21 Feb 2025 17:31:47 -0600 Subject: [PATCH 09/10] update README --- voice/openai/README.md | 55 +++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 35 deletions(-) diff --git a/voice/openai/README.md b/voice/openai/README.md index 47bb1c29b2..29637ae231 100644 --- a/voice/openai/README.md +++ b/voice/openai/README.md @@ -18,36 +18,36 @@ OPENAI_API_KEY=your_api_key ## Usage -### Using the Factory Function (Recommended) - ```typescript -import { createOpenAIVoice } from '@mastra/voice-openai'; +import { OpenAIVoice } from '@mastra/voice-openai'; // Create voice with both speech and listening capabilities -const voice = createOpenAIVoice({ - speech: { - model: 'tts-1', // or 'tts-1-hd' for higher quality +const voice = new OpenAIVoice({ + speechModel: { + name: 'tts-1', // or 'tts-1-hd' for higher quality apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var - speaker: 'alloy', // Default voice }, - listening: { - model: 'whisper-1', + listeningModel: { + name: 'whisper-1', apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var }, + speaker: 'alloy', // Default voice }); // Or create speech-only voice -const speechVoice = createOpenAIVoice({ - speech: { - model: 'tts-1', - speaker: 'nova', +const speechVoice = new OpenAIVoice({ + speechModel: { + name: 'tts-1', + apiKey: 'your-api-key', }, + speaker: 'nova', }); // Or create listening-only voice -const listeningVoice = createOpenAIVoice({ - listening: { - model: 'whisper-1', +const listeningVoice = new OpenAIVoice({ + listeningModel: { + name: 'whisper-1', + apiKey: 'your-api-key', }, }); @@ -55,7 +55,10 @@ const listeningVoice = createOpenAIVoice({ const speakers = await voice.getSpeakers(); // Generate speech -const audioStream = await voice.speak('Hello from Mastra!'); +const audioStream = await voice.speak('Hello from Mastra!', { + speaker: 'nova', // Optional: override default speaker + speed: 1.0, // Optional: adjust speech speed +}); // Convert speech to text const text = await voice.listen(audioStream, { @@ -63,24 +66,6 @@ const text = await voice.listen(audioStream, { }); ``` -### Using the Class Directly - -```typescript -import { OpenAIVoice } from '@mastra/voice-openai'; - -const voice = new OpenAIVoice({ - speechModel: { - name: 'tts-1', - apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var - }, - listeningModel: { - name: 'whisper-1', - apiKey: 'your-api-key', // Optional, can use OPENAI_API_KEY env var - }, - speaker: 'alloy', // Default voice -}); -``` - ## Features - High-quality Text-to-Speech synthesis From d998176d3d2918e98064ebc7032a7311778a2823 Mon Sep 17 00:00:00 2001 From: Yujohn Nattrass Date: Fri, 21 Feb 2025 17:32:06 -0600 Subject: [PATCH 10/10] change package version --- voice/openai/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/voice/openai/package.json b/voice/openai/package.json index 225ce29f62..ce540f9443 100644 --- a/voice/openai/package.json +++ b/voice/openai/package.json @@ -1,6 +1,6 @@ { "name": "@mastra/voice-openai", - "version": "0.1.0", + "version": "0.1.0-alpha.1", "description": "Mastra OpenAI speech integration", "type": "module", "main": "dist/index.js",