@mastra/voice-azure 0.1.0-alpha.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/.turbo/turbo-build.log +23 -0
- package/CHANGELOG.md +9 -0
- package/LICENSE +44 -0
- package/README.md +78 -0
- package/dist/_tsup-dts-rollup.d.cts +64 -0
- package/dist/_tsup-dts-rollup.d.ts +64 -0
- package/dist/index.cjs +418 -0
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +396 -0
- package/eslint.config.js +6 -0
- package/package.json +40 -0
- package/src/index.test.ts +167 -0
- package/src/index.ts +229 -0
- package/src/voices.ts +215 -0
- package/tsconfig.json +5 -0
- package/vitest.config.ts +8 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { Readable } from 'stream';
|
|
2
|
+
import { MastraVoice } from '@mastra/core/voice';
|
|
3
|
+
import * as Azure from 'microsoft-cognitiveservices-speech-sdk';
|
|
4
|
+
|
|
5
|
+
// src/index.ts
|
|
6
|
+
|
|
7
|
+
// src/voices.ts
|
|
8
|
+
var AZURE_VOICES = [
|
|
9
|
+
"af-ZA-AdriNeural",
|
|
10
|
+
"af-ZA-WillemNeural",
|
|
11
|
+
"am-ET-MekdesNeural",
|
|
12
|
+
"am-ET-AmehaNeural",
|
|
13
|
+
"ar-AE-FatimaNeural",
|
|
14
|
+
"ar-AE-HamdanNeural",
|
|
15
|
+
"ar-BH-LailaNeural",
|
|
16
|
+
"ar-BH-AliNeural",
|
|
17
|
+
"ar-DZ-AminaNeural",
|
|
18
|
+
"ar-DZ-IsmaelNeural",
|
|
19
|
+
"ar-EG-SalmaNeural",
|
|
20
|
+
"ar-EG-ShakirNeural",
|
|
21
|
+
"ar-IQ-RanaNeural",
|
|
22
|
+
"ar-IQ-BasselNeural",
|
|
23
|
+
"ar-JO-SanaNeural",
|
|
24
|
+
"ar-JO-TaimNeural",
|
|
25
|
+
"ar-KW-NouraNeural",
|
|
26
|
+
"ar-KW-FahedNeural",
|
|
27
|
+
"ar-LB-LaylaNeural",
|
|
28
|
+
"ar-LB-RamiNeural",
|
|
29
|
+
"ar-LY-ImanNeural",
|
|
30
|
+
"ar-LY-OmarNeural",
|
|
31
|
+
"ar-MA-MounaNeural",
|
|
32
|
+
"ar-MA-JamalNeural",
|
|
33
|
+
"ar-OM-AyshaNeural",
|
|
34
|
+
"ar-OM-AbdullahNeural",
|
|
35
|
+
"ar-QA-AmalNeural",
|
|
36
|
+
"ar-QA-MoazNeural",
|
|
37
|
+
"ar-SA-ZariyahNeural",
|
|
38
|
+
"ar-SA-HamedNeural",
|
|
39
|
+
"ar-SY-AmanyNeural",
|
|
40
|
+
"ar-SY-LaithNeural",
|
|
41
|
+
"ar-TN-ReemNeural",
|
|
42
|
+
"ar-TN-HediNeural",
|
|
43
|
+
"ar-YE-MaryamNeural",
|
|
44
|
+
"ar-YE-SalehNeural",
|
|
45
|
+
"as-IN-YashicaNeural",
|
|
46
|
+
"as-IN-PriyomNeural",
|
|
47
|
+
"az-AZ-BanuNeural",
|
|
48
|
+
"az-AZ-BabekNeural",
|
|
49
|
+
"bg-BG-KalinaNeural",
|
|
50
|
+
"bg-BG-BorislavNeural",
|
|
51
|
+
"bn-BD-NabanitaNeural",
|
|
52
|
+
"bn-BD-PradeepNeural",
|
|
53
|
+
"bn-IN-TanishaaNeural",
|
|
54
|
+
"bn-IN-BashkarNeural",
|
|
55
|
+
"bs-BA-VesnaNeural",
|
|
56
|
+
"bs-BA-GoranNeural",
|
|
57
|
+
"ca-ES-JoanaNeural",
|
|
58
|
+
"ca-ES-EnricNeural",
|
|
59
|
+
"ca-ES-AlbaNeural",
|
|
60
|
+
"cs-CZ-VlastaNeural",
|
|
61
|
+
"cs-CZ-AntoninNeural",
|
|
62
|
+
"cy-GB-NiaNeural",
|
|
63
|
+
"cy-GB-AledNeural",
|
|
64
|
+
"da-DK-ChristelNeural",
|
|
65
|
+
"da-DK-JeppeNeural",
|
|
66
|
+
"de-AT-IngridNeural",
|
|
67
|
+
"de-AT-JonasNeural",
|
|
68
|
+
"de-CH-LeniNeural",
|
|
69
|
+
"de-CH-JanNeural",
|
|
70
|
+
"de-DE-KatjaNeural",
|
|
71
|
+
"de-DE-ConradNeural",
|
|
72
|
+
"de-DE-SeraphinaMultilingualNeural",
|
|
73
|
+
"de-DE-FlorianMultilingualNeural",
|
|
74
|
+
"de-DE-AmalaNeural",
|
|
75
|
+
"de-DE-BerndNeural",
|
|
76
|
+
"de-DE-ChristophNeural",
|
|
77
|
+
"de-DE-ElkeNeural",
|
|
78
|
+
"de-DE-GiselaNeural",
|
|
79
|
+
"de-DE-KasperNeural",
|
|
80
|
+
"de-DE-KillianNeural",
|
|
81
|
+
"de-DE-KlarissaNeural",
|
|
82
|
+
"de-DE-KlausNeural",
|
|
83
|
+
"de-DE-LouisaNeural",
|
|
84
|
+
"de-DE-MajaNeural",
|
|
85
|
+
"de-DE-RalfNeural",
|
|
86
|
+
"de-DE-TanjaNeural",
|
|
87
|
+
"de-DE-Seraphina:DragonHDLatestNeural",
|
|
88
|
+
"el-GR-AthinaNeural",
|
|
89
|
+
"el-GR-NestorasNeural",
|
|
90
|
+
"en-AU-NatashaNeural",
|
|
91
|
+
"en-AU-WilliamNeural",
|
|
92
|
+
"en-AU-AnnetteNeural",
|
|
93
|
+
"en-AU-CarlyNeural",
|
|
94
|
+
"en-AU-DarrenNeural",
|
|
95
|
+
"en-AU-DuncanNeural",
|
|
96
|
+
"en-AU-ElsieNeural",
|
|
97
|
+
"en-AU-FreyaNeural",
|
|
98
|
+
"en-AU-JoanneNeural",
|
|
99
|
+
"en-AU-KenNeural",
|
|
100
|
+
"en-AU-KimNeural",
|
|
101
|
+
"en-AU-NeilNeural",
|
|
102
|
+
"en-AU-TimNeural",
|
|
103
|
+
"en-AU-TinaNeural",
|
|
104
|
+
"en-CA-ClaraNeural",
|
|
105
|
+
"en-CA-LiamNeural",
|
|
106
|
+
"en-GB-SoniaNeural",
|
|
107
|
+
"en-GB-RyanNeural",
|
|
108
|
+
"en-GB-LibbyNeural",
|
|
109
|
+
"en-GB-AdaMultilingualNeural",
|
|
110
|
+
"en-GB-OllieMultilingualNeural",
|
|
111
|
+
"en-GB-AbbiNeural",
|
|
112
|
+
"en-GB-AlfieNeural",
|
|
113
|
+
"en-GB-BellaNeural",
|
|
114
|
+
"en-GB-ElliotNeural",
|
|
115
|
+
"en-GB-EthanNeural",
|
|
116
|
+
"en-GB-HollieNeural",
|
|
117
|
+
"en-GB-MaisieNeural",
|
|
118
|
+
"en-GB-NoahNeural",
|
|
119
|
+
"en-GB-OliverNeural",
|
|
120
|
+
"en-GB-OliviaNeural",
|
|
121
|
+
"en-GB-ThomasNeural",
|
|
122
|
+
"en-GB-MiaNeural",
|
|
123
|
+
"en-HK-YanNeural",
|
|
124
|
+
"en-HK-SamNeural",
|
|
125
|
+
"en-IE-EmilyNeural",
|
|
126
|
+
"en-IE-ConnorNeural",
|
|
127
|
+
"en-IN-AaravNeural",
|
|
128
|
+
"en-IN-AashiNeural",
|
|
129
|
+
"en-IN-AnanyaNeural",
|
|
130
|
+
"en-IN-KavyaNeural",
|
|
131
|
+
"en-IN-KunalNeural",
|
|
132
|
+
"en-IN-NeerjaNeural",
|
|
133
|
+
"en-IN-PrabhatNeural",
|
|
134
|
+
"en-IN-RehaanNeural",
|
|
135
|
+
"en-IN-AartiNeural",
|
|
136
|
+
"en-IN-ArjunNeural",
|
|
137
|
+
"en-KE-AsiliaNeural",
|
|
138
|
+
"en-KE-ChilembaNeural",
|
|
139
|
+
"en-NG-EzinneNeural",
|
|
140
|
+
"en-NG-AbeoNeural",
|
|
141
|
+
"en-NZ-MollyNeural",
|
|
142
|
+
"en-NZ-MitchellNeural",
|
|
143
|
+
"en-PH-RosaNeural",
|
|
144
|
+
"en-PH-JamesNeural",
|
|
145
|
+
"en-SG-LunaNeural",
|
|
146
|
+
"en-SG-WayneNeural",
|
|
147
|
+
"en-TZ-ImaniNeural",
|
|
148
|
+
"en-TZ-ElimuNeural",
|
|
149
|
+
"en-US-AvaMultilingualNeural",
|
|
150
|
+
"en-US-AndrewMultilingualNeural",
|
|
151
|
+
"en-US-EmmaMultilingualNeural",
|
|
152
|
+
"en-US-BrianMultilingualNeural",
|
|
153
|
+
"en-US-AvaNeural",
|
|
154
|
+
"en-US-AndrewNeural",
|
|
155
|
+
"en-US-EmmaNeural",
|
|
156
|
+
"en-US-BrianNeural",
|
|
157
|
+
"en-US-JennyNeural",
|
|
158
|
+
"en-US-GuyNeural",
|
|
159
|
+
"en-US-AriaNeural",
|
|
160
|
+
"en-US-DavisNeural",
|
|
161
|
+
"en-US-JaneNeural",
|
|
162
|
+
"en-US-JasonNeural",
|
|
163
|
+
"en-US-KaiNeural",
|
|
164
|
+
"en-US-LunaNeural",
|
|
165
|
+
"en-US-SaraNeural",
|
|
166
|
+
"en-US-TonyNeural",
|
|
167
|
+
"en-US-NancyNeural",
|
|
168
|
+
"en-US-CoraMultilingualNeural",
|
|
169
|
+
"en-US-ChristopherMultilingualNeural",
|
|
170
|
+
"en-US-BrandonMultilingualNeural",
|
|
171
|
+
"en-US-AmberNeural",
|
|
172
|
+
"en-US-AnaNeural",
|
|
173
|
+
"en-US-AshleyNeural",
|
|
174
|
+
"en-US-BrandonNeural",
|
|
175
|
+
"en-US-ChristopherNeural",
|
|
176
|
+
"en-US-CoraNeural",
|
|
177
|
+
"en-US-ElizabethNeural",
|
|
178
|
+
"en-US-EricNeural",
|
|
179
|
+
"en-US-JacobNeural",
|
|
180
|
+
"en-US-JennyMultilingualNeural",
|
|
181
|
+
"en-US-MichelleNeural",
|
|
182
|
+
"en-US-MonicaNeural",
|
|
183
|
+
"en-US-RogerNeural",
|
|
184
|
+
"en-US-RyanMultilingualNeural",
|
|
185
|
+
"en-US-SteffanNeural",
|
|
186
|
+
"en-US-AdamMultilingualNeural",
|
|
187
|
+
"en-US-AIGenerate1Neural",
|
|
188
|
+
"en-US-AIGenerate2Neural",
|
|
189
|
+
"en-US-AlloyTurboMultilingualNeural",
|
|
190
|
+
"en-US-AmandaMultilingualNeural",
|
|
191
|
+
"en-US-BlueNeural",
|
|
192
|
+
"en-US-DavisMultilingualNeural",
|
|
193
|
+
"en-US-DerekMultilingualNeural",
|
|
194
|
+
"en-US-DustinMultilingualNeural",
|
|
195
|
+
"en-US-EchoTurboMultilingualNeural",
|
|
196
|
+
"en-US-EvelynMultilingualNeural",
|
|
197
|
+
"en-US-FableTurboMultilingualNeural",
|
|
198
|
+
"en-US-LewisMultilingualNeural",
|
|
199
|
+
"en-US-LolaMultilingualNeural",
|
|
200
|
+
"en-US-NancyMultilingualNeural",
|
|
201
|
+
"en-US-NovaTurboMultilingualNeural",
|
|
202
|
+
"en-US-OnyxTurboMultilingualNeural",
|
|
203
|
+
"en-US-PhoebeMultilingualNeural",
|
|
204
|
+
"en-US-SamuelMultilingualNeural",
|
|
205
|
+
"en-US-SerenaMultilingualNeural",
|
|
206
|
+
"en-US-ShimmerTurboMultilingualNeural",
|
|
207
|
+
"en-US-SteffanMultilingualNeural",
|
|
208
|
+
"en-US-Andrew:DragonHDLatestNeural",
|
|
209
|
+
"en-US-Andrew2:DragonHDLatestNeural",
|
|
210
|
+
"en-US-Aria:DragonHDLatestNeural",
|
|
211
|
+
"en-US-Ava:DragonHDLatestNeural",
|
|
212
|
+
"en-US-Brian:DragonHDLatestNeural",
|
|
213
|
+
"en-US-Davis:DragonHDLatestNeural",
|
|
214
|
+
"en-US-Emma:DragonHDLatestNeural",
|
|
215
|
+
"en-US-Emma2:DragonHDLatestNeural",
|
|
216
|
+
"en-US-Jenny:DragonHDLatestNeural",
|
|
217
|
+
"en-US-Steffan:DragonHDLatestNeural",
|
|
218
|
+
"en-ZA-LeahNeural",
|
|
219
|
+
"en-ZA-LukeNeural"
|
|
220
|
+
];
|
|
221
|
+
|
|
222
|
+
// src/index.ts
|
|
223
|
+
var AzureVoice = class extends MastraVoice {
|
|
224
|
+
speechConfig;
|
|
225
|
+
listeningConfig;
|
|
226
|
+
speechSynthesizer;
|
|
227
|
+
speechRecognizer;
|
|
228
|
+
/**
|
|
229
|
+
* Creates a new instance of AzureVoice for text-to-speech and speech-to-text services.
|
|
230
|
+
*
|
|
231
|
+
* @param {Object} config - Configuration options
|
|
232
|
+
* @param {AzureVoiceConfig} [config.speechModel] - Configuration for text-to-speech
|
|
233
|
+
* @param {AzureVoiceConfig} [config.listeningModel] - Configuration for speech-to-text
|
|
234
|
+
* @param {VoiceId} [config.speaker] - Default voice ID for speech synthesis
|
|
235
|
+
*/
|
|
236
|
+
constructor({
|
|
237
|
+
speechModel,
|
|
238
|
+
listeningModel,
|
|
239
|
+
speaker
|
|
240
|
+
} = {}) {
|
|
241
|
+
super({
|
|
242
|
+
speechModel: {
|
|
243
|
+
name: "",
|
|
244
|
+
apiKey: speechModel?.apiKey ?? process.env.AZURE_API_KEY
|
|
245
|
+
},
|
|
246
|
+
listeningModel: {
|
|
247
|
+
name: "",
|
|
248
|
+
apiKey: listeningModel?.apiKey ?? process.env.AZURE_API_KEY
|
|
249
|
+
},
|
|
250
|
+
speaker
|
|
251
|
+
});
|
|
252
|
+
const envApiKey = process.env.AZURE_API_KEY;
|
|
253
|
+
const envRegion = process.env.AZURE_REGION;
|
|
254
|
+
if (speechModel) {
|
|
255
|
+
const apiKey = speechModel.apiKey ?? envApiKey;
|
|
256
|
+
const region = speechModel.region ?? envRegion;
|
|
257
|
+
if (!apiKey) throw new Error("No Azure API key provided for speech model");
|
|
258
|
+
if (!region) throw new Error("No region provided for speech model");
|
|
259
|
+
this.speechConfig = Azure.SpeechConfig.fromSubscription(apiKey, region);
|
|
260
|
+
this.speechConfig.speechSynthesisVoiceName = speechModel.voiceName || speaker || "en-US-AriaNeural";
|
|
261
|
+
this.speechSynthesizer = new Azure.SpeechSynthesizer(this.speechConfig);
|
|
262
|
+
}
|
|
263
|
+
if (listeningModel) {
|
|
264
|
+
const apiKey = listeningModel.apiKey ?? envApiKey;
|
|
265
|
+
const region = listeningModel.region ?? envRegion;
|
|
266
|
+
if (!apiKey) throw new Error("No Azure API key provided for listening model");
|
|
267
|
+
if (!region) throw new Error("No region provided for listening model");
|
|
268
|
+
this.listeningConfig = Azure.SpeechConfig.fromSubscription(apiKey, region);
|
|
269
|
+
if (listeningModel.language) {
|
|
270
|
+
this.listeningConfig.speechRecognitionLanguage = listeningModel.language;
|
|
271
|
+
}
|
|
272
|
+
this.speechRecognizer = new Azure.SpeechRecognizer(this.listeningConfig);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Gets a list of available voices for speech synthesis.
|
|
277
|
+
*
|
|
278
|
+
* @returns {Promise<Array<{ voiceId: string; language: string; region: string; }>>} List of available voices
|
|
279
|
+
*/
|
|
280
|
+
async getSpeakers() {
|
|
281
|
+
return this.traced(async () => {
|
|
282
|
+
return AZURE_VOICES.map((voice) => ({
|
|
283
|
+
voiceId: voice,
|
|
284
|
+
language: voice.split("-")[0],
|
|
285
|
+
region: voice.split("-")[1]
|
|
286
|
+
}));
|
|
287
|
+
}, "voice.azure.voices")();
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Converts text to speech using Azure's Text-to-Speech service.
|
|
291
|
+
*
|
|
292
|
+
* @param {string | NodeJS.ReadableStream} input - Text to convert to speech
|
|
293
|
+
* @param {Object} [options] - Optional parameters
|
|
294
|
+
* @param {string} [options.speaker] - Voice ID to use for synthesis
|
|
295
|
+
* @returns {Promise<NodeJS.ReadableStream>} Stream containing the synthesized audio
|
|
296
|
+
* @throws {Error} If speech model is not configured or synthesis fails
|
|
297
|
+
*/
|
|
298
|
+
async speak(input, options) {
|
|
299
|
+
if (!this.speechConfig) {
|
|
300
|
+
throw new Error("Speech model (Azure) not configured");
|
|
301
|
+
}
|
|
302
|
+
if (typeof input !== "string") {
|
|
303
|
+
const chunks = [];
|
|
304
|
+
try {
|
|
305
|
+
for await (const chunk of input) {
|
|
306
|
+
chunks.push(chunk);
|
|
307
|
+
}
|
|
308
|
+
input = Buffer.concat(chunks).toString("utf-8");
|
|
309
|
+
} catch (error) {
|
|
310
|
+
throw new Error(`Failed to read input stream: ${error instanceof Error ? error.message : String(error)}`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (!input?.trim()) {
|
|
314
|
+
throw new Error("Input text is empty");
|
|
315
|
+
}
|
|
316
|
+
if (options?.speaker) {
|
|
317
|
+
this.speechConfig.speechSynthesisVoiceName = options.speaker;
|
|
318
|
+
}
|
|
319
|
+
const synthesizer = new Azure.SpeechSynthesizer(this.speechConfig);
|
|
320
|
+
try {
|
|
321
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
322
|
+
setTimeout(() => reject(new Error("Speech synthesis timed out")), 5e3);
|
|
323
|
+
});
|
|
324
|
+
const synthesisPromise = this.traced(
|
|
325
|
+
() => new Promise((resolve, reject) => {
|
|
326
|
+
synthesizer.speakTextAsync(
|
|
327
|
+
input,
|
|
328
|
+
(result2) => result2.errorDetails ? reject(new Error(`Speech synthesis failed: ${result2.errorDetails}`)) : resolve(result2),
|
|
329
|
+
(error) => reject(new Error(`Speech synthesis error: ${String(error)}`))
|
|
330
|
+
);
|
|
331
|
+
}),
|
|
332
|
+
"voice.azure.speak"
|
|
333
|
+
)();
|
|
334
|
+
const result = await Promise.race([synthesisPromise, timeoutPromise]);
|
|
335
|
+
synthesizer.close();
|
|
336
|
+
if (result.reason !== Azure.ResultReason.SynthesizingAudioCompleted) {
|
|
337
|
+
throw new Error(`Speech synthesis failed: ${result.errorDetails || result.reason}`);
|
|
338
|
+
}
|
|
339
|
+
return Readable.from([Buffer.from(result.audioData)]);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
synthesizer.close();
|
|
342
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Transcribes audio (STT) from a Node.js stream using Azure.
|
|
347
|
+
*
|
|
348
|
+
* @param {NodeJS.ReadableStream} audioStream - The audio to be transcribed, must be in .wav format.
|
|
349
|
+
* @returns {Promise<string>} - The recognized text.
|
|
350
|
+
*/
|
|
351
|
+
async listen(audioStream) {
|
|
352
|
+
if (!this.listeningConfig || !this.speechRecognizer) {
|
|
353
|
+
throw new Error("Listening model (Azure) not configured");
|
|
354
|
+
}
|
|
355
|
+
const chunks = [];
|
|
356
|
+
for await (const chunk of audioStream) {
|
|
357
|
+
chunks.push(chunk);
|
|
358
|
+
}
|
|
359
|
+
const audioData = Buffer.concat(chunks);
|
|
360
|
+
const pushStream = Azure.AudioInputStream.createPushStream();
|
|
361
|
+
const audioConfig = Azure.AudioConfig.fromStreamInput(pushStream);
|
|
362
|
+
const recognizer = new Azure.SpeechRecognizer(this.listeningConfig, audioConfig);
|
|
363
|
+
try {
|
|
364
|
+
const recognitionPromise = new Promise((resolve, reject) => {
|
|
365
|
+
recognizer.recognizeOnceAsync(
|
|
366
|
+
(result) => {
|
|
367
|
+
if (result.reason === Azure.ResultReason.RecognizedSpeech) {
|
|
368
|
+
resolve(result.text);
|
|
369
|
+
} else {
|
|
370
|
+
const reason = Azure.ResultReason[result.reason] || result.reason;
|
|
371
|
+
reject(new Error(`Speech recognition failed: ${reason} - ${result.errorDetails || ""}`));
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
(error) => reject(new Error(`Speech recognition error: ${String(error)}`))
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
const chunkSize = 4096;
|
|
378
|
+
for (let i = 0; i < audioData.length; i += chunkSize) {
|
|
379
|
+
const chunk = audioData.slice(i, i + chunkSize);
|
|
380
|
+
pushStream.write(chunk);
|
|
381
|
+
}
|
|
382
|
+
pushStream.close();
|
|
383
|
+
const text = await this.traced(() => recognitionPromise, "voice.azure.listen")();
|
|
384
|
+
return text;
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (error instanceof Error) {
|
|
387
|
+
throw error;
|
|
388
|
+
}
|
|
389
|
+
throw new Error(String(error));
|
|
390
|
+
} finally {
|
|
391
|
+
recognizer.close();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
export { AzureVoice };
|
package/eslint.config.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mastra/voice-azure",
|
|
3
|
+
"version": "0.1.0-alpha.1",
|
|
4
|
+
"description": "Mastra Azure speech integration",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"require": {
|
|
15
|
+
"types": "./dist/index.d.cts",
|
|
16
|
+
"default": "./dist/index.cjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"./package.json": "./package.json"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"microsoft-cognitiveservices-speech-sdk": "^1.42.0",
|
|
23
|
+
"@mastra/core": "^0.6.4-alpha.1"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@microsoft/api-extractor": "^7.52.1",
|
|
27
|
+
"@types/node": "^22.13.10",
|
|
28
|
+
"eslint": "^9.22.0",
|
|
29
|
+
"tsup": "^8.4.0",
|
|
30
|
+
"typescript": "^5.8.2",
|
|
31
|
+
"vitest": "^2.1.9",
|
|
32
|
+
"@internal/lint": "0.0.1"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup src/index.ts --format esm,cjs --experimental-dts --clean --treeshake=smallest --splitting",
|
|
36
|
+
"build:watch": "pnpm build --watch",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"lint": "eslint ."
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { createReadStream, writeFileSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { Readable } from 'stream';
|
|
4
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
5
|
+
|
|
6
|
+
import { AzureVoice } from './index';
|
|
7
|
+
|
|
8
|
+
describe('AzureVoice Integration Tests', () => {
|
|
9
|
+
let voice: AzureVoice;
|
|
10
|
+
const outputDir = join(process.cwd(), 'test-outputs');
|
|
11
|
+
const subscriptionKey = process.env.AZURE_API_KEY ?? 'fake-key';
|
|
12
|
+
const region = process.env.AZURE_REGION ?? 'eastus';
|
|
13
|
+
|
|
14
|
+
beforeAll(() => {
|
|
15
|
+
try {
|
|
16
|
+
mkdirSync(outputDir, { recursive: true });
|
|
17
|
+
} catch (err) {
|
|
18
|
+
// Ignore if directory already exists
|
|
19
|
+
console.log('Directory already exists:', err);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
voice = new AzureVoice({
|
|
23
|
+
speechModel: { apiKey: subscriptionKey, region },
|
|
24
|
+
listeningModel: { apiKey: subscriptionKey, region },
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('getSpeakers', () => {
|
|
29
|
+
it('should list available voices', async () => {
|
|
30
|
+
const voices = await voice.getSpeakers();
|
|
31
|
+
expect(voices.length).toBeGreaterThan(0);
|
|
32
|
+
expect(voices[0]).toHaveProperty('voiceId');
|
|
33
|
+
expect(voices[0]).toHaveProperty('language');
|
|
34
|
+
expect(voices[0]).toHaveProperty('region');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should initialize with default parameters', async () => {
|
|
39
|
+
const defaultVoice = new AzureVoice();
|
|
40
|
+
const voices = await defaultVoice.getSpeakers();
|
|
41
|
+
expect(voices).toBeInstanceOf(Array);
|
|
42
|
+
expect(voices.length).toBeGreaterThan(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('speak', () => {
|
|
46
|
+
it('should speak with default parameters', async () => {
|
|
47
|
+
const defaultVoice = new AzureVoice({
|
|
48
|
+
speechModel: { apiKey: subscriptionKey, region },
|
|
49
|
+
});
|
|
50
|
+
const audioStream = await defaultVoice.speak('Hello with defaults');
|
|
51
|
+
|
|
52
|
+
const chunks: Buffer[] = [];
|
|
53
|
+
for await (const chunk of audioStream) {
|
|
54
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
55
|
+
}
|
|
56
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
57
|
+
expect(audioBuffer.length).toBeGreaterThan(0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should generate audio stream from text', async () => {
|
|
61
|
+
const audioStream = await voice.speak('Hello World', {
|
|
62
|
+
speaker: 'en-US-AriaNeural',
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const chunks: Buffer[] = [];
|
|
66
|
+
for await (const chunk of audioStream) {
|
|
67
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
68
|
+
}
|
|
69
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
70
|
+
expect(audioBuffer.length).toBeGreaterThan(0);
|
|
71
|
+
|
|
72
|
+
const outputPath = join(outputDir, 'azure-speech-test.wav');
|
|
73
|
+
writeFileSync(outputPath, audioBuffer);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should work with different parameters', async () => {
|
|
77
|
+
const audioStream = await voice.speak('Test with parameters', {
|
|
78
|
+
speaker: 'en-US-JennyNeural',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const chunks: Buffer[] = [];
|
|
82
|
+
for await (const chunk of audioStream) {
|
|
83
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
84
|
+
}
|
|
85
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
86
|
+
expect(audioBuffer.length).toBeGreaterThan(0);
|
|
87
|
+
|
|
88
|
+
const outputPath = join(outputDir, 'azure-speech-params.wav');
|
|
89
|
+
writeFileSync(outputPath, audioBuffer);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should accept text stream as input', async () => {
|
|
93
|
+
const inputStream = new Readable();
|
|
94
|
+
inputStream.push('Hello from stream');
|
|
95
|
+
inputStream.push(null);
|
|
96
|
+
|
|
97
|
+
const audioStream = await voice.speak(inputStream, {
|
|
98
|
+
speaker: 'en-US-AriaNeural',
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const chunks: Buffer[] = [];
|
|
102
|
+
for await (const chunk of audioStream) {
|
|
103
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
104
|
+
}
|
|
105
|
+
const audioBuffer = Buffer.concat(chunks);
|
|
106
|
+
expect(audioBuffer.length).toBeGreaterThan(0);
|
|
107
|
+
|
|
108
|
+
const outputPath = join(outputDir, 'azure-speech-stream.wav');
|
|
109
|
+
writeFileSync(outputPath, audioBuffer);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('listen', () => {
|
|
114
|
+
it('should listen with default parameters', async () => {
|
|
115
|
+
const defaultVoice = new AzureVoice({
|
|
116
|
+
speechModel: { apiKey: subscriptionKey, region },
|
|
117
|
+
listeningModel: { apiKey: subscriptionKey, region },
|
|
118
|
+
});
|
|
119
|
+
const audioStream = await defaultVoice.speak('Listening test with defaults');
|
|
120
|
+
|
|
121
|
+
const text = await defaultVoice.listen(audioStream);
|
|
122
|
+
expect(text).toBeTruthy();
|
|
123
|
+
expect(typeof text).toBe('string');
|
|
124
|
+
expect(text.toLowerCase()).toContain('listening test');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('should transcribe audio from file', async () => {
|
|
128
|
+
const filePath = join(outputDir, 'azure-speech-test.wav');
|
|
129
|
+
const audioStream = createReadStream(filePath);
|
|
130
|
+
|
|
131
|
+
const text = await voice.listen(audioStream);
|
|
132
|
+
expect(text).toBeTruthy();
|
|
133
|
+
expect(typeof text).toBe('string');
|
|
134
|
+
expect(text.length).toBeGreaterThan(0);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should transcribe audio stream', async () => {
|
|
138
|
+
const audioStream = await voice.speak('This is a test for transcription', {
|
|
139
|
+
speaker: 'en-US-AriaNeural',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const text = await voice.listen(audioStream);
|
|
143
|
+
expect(text).toBeTruthy();
|
|
144
|
+
expect(typeof text).toBe('string');
|
|
145
|
+
expect(text.toLowerCase()).toContain('test');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('error handling', () => {
|
|
150
|
+
it('should handle empty text', async () => {
|
|
151
|
+
await expect(voice.speak('')).rejects.toThrow('Input text is empty');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should handle missing API key', () => {
|
|
155
|
+
const { AZURE_API_KEY } = process.env;
|
|
156
|
+
delete process.env.AZURE_API_KEY;
|
|
157
|
+
|
|
158
|
+
expect(() => {
|
|
159
|
+
new AzureVoice({
|
|
160
|
+
speechModel: { region: 'eastus' },
|
|
161
|
+
});
|
|
162
|
+
}).toThrow('No Azure API key provided for speech model');
|
|
163
|
+
|
|
164
|
+
process.env.AZURE_API_KEY = AZURE_API_KEY;
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|