@andresaya/edge-tts 1.7.2 → 1.8.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.
@@ -30,30 +30,88 @@ class EdgeTTS {
30
30
  output_format = 'audio-24khz-48kbitrate-mono-mp3';
31
31
  word_boundaries = [];
32
32
  ws;
33
+ async normalizeVoices(data) {
34
+ const out = [];
35
+ for (const v of data || []) {
36
+ const short = v?.ShortName || "";
37
+ const locale = v?.Locale || "";
38
+ // base: remove locale prefix and Neural/NeuralHD suffix
39
+ let base = short.replace(/^[a-z]{2}-[A-Z]{2}-/, "");
40
+ base = base.replace(/NeuralHD$/, "").replace(/Neural$/, "").trim();
41
+ // VoiceType: if NeuralHD/Neural in Name or ShortName
42
+ const mix = `${v?.Name || ""} ${short}`;
43
+ const voiceType = v?.VoiceType || (/NeuralHD/i.test(mix) ? "NeuralHD" : "Neural");
44
+ // LocaleName: prefer LocaleName -> LanguageName -> locale
45
+ const localeName = v?.LocaleName || (locale || null);
46
+ // DisplayName: prefer DisplayName -> FriendlyName -> base -> short
47
+ let display = v?.DisplayName || v?.FriendlyName || base || short;
48
+ display = display.replace(/^Microsoft\s+/i, "");
49
+ display = display.split(" - ")[0].trim();
50
+ display = display.replace(/\s*Online\s*\(Natural\)\s*/i, " ");
51
+ display = display.replace(/\s*Online\s*/i, " ");
52
+ display = display.replace(/\s+/g, " ").trim();
53
+ // VoiceTag parsing
54
+ const tag = (v?.VoiceTag && typeof v.VoiceTag === "object") ? v.VoiceTag : {};
55
+ const tailored = Array.isArray(tag.TailoredScenarios)
56
+ ? tag.TailoredScenarios
57
+ : (Array.isArray(tag.ContentCategories) ? tag.ContentCategories : []);
58
+ const personalities = Array.isArray(tag.VoicePersonalities)
59
+ ? tag.VoicePersonalities
60
+ : [];
61
+ out.push({
62
+ Name: short || (v?.Name || ""),
63
+ DisplayName: display,
64
+ LocalName: display,
65
+ ShortName: short || (v?.Name || ""),
66
+ Gender: v?.Gender ?? null,
67
+ Locale: locale || null,
68
+ LocaleName: localeName,
69
+ SecondaryLocaleList: Array.isArray(v?.SecondaryLocaleList) ? v.SecondaryLocaleList : [],
70
+ VoiceType: voiceType,
71
+ VoiceTag: {
72
+ TailoredScenarios: tailored,
73
+ VoicePersonalities: personalities,
74
+ },
75
+ FriendlyName: `${display} (${voiceType}) - ${localeName}`,
76
+ });
77
+ }
78
+ return out;
79
+ }
33
80
  async getVoices() {
34
81
  const secMsGEC = await this.generateSecMsGec(constants_1.Constants.TRUSTED_CLIENT_TOKEN);
35
- const httpsAgent = new https_1.default.Agent({
36
- rejectUnauthorized: false,
37
- });
38
- const response = await new Promise((resolve, reject) => {
39
- const req = https_1.default.request(`${constants_1.Constants.VOICES_URL}?Ocp-Apim-Subscription-Key=${constants_1.Constants.TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${secMsGEC}&Sec-MS-GEC-Version=${constants_1.Constants.VERSION_MS_GEC}`, {
40
- headers: constants_1.Constants.getBaseHeaders(),
41
- agent: httpsAgent
42
- }, (res) => {
43
- let data = '';
44
- res.on('data', (chunk) => (data += chunk));
45
- res.on('end', () => resolve(new Response(data, { status: res.statusCode || 200 })));
82
+ const httpsAgent = new https_1.default.Agent({ rejectUnauthorized: false });
83
+ const url = `${constants_1.Constants.VOICES_URL}` +
84
+ `?TrustedClientToken=${constants_1.Constants.TRUSTED_CLIENT_TOKEN}` +
85
+ `&Sec-MS-GEC=${secMsGEC}` +
86
+ `&Sec-MS-GEC-Version=${constants_1.Constants.VERSION_MS_GEC}`;
87
+ const headers = {
88
+ ...constants_1.Constants.getBaseHeaders(),
89
+ "Accept-Encoding": "identity", // evita gzip/br/zstd
90
+ };
91
+ const voicesRaw = await new Promise((resolve, reject) => {
92
+ const req = https_1.default.request(url, { method: "GET", headers, agent: httpsAgent }, (res) => {
93
+ const chunks = [];
94
+ res.on("data", (chunk) => chunks.push(buffer_1.Buffer.isBuffer(chunk) ? chunk : buffer_1.Buffer.from(chunk)));
95
+ res.on("error", reject);
96
+ res.on("end", () => {
97
+ const body = buffer_1.Buffer.concat(chunks).toString("utf8");
98
+ try {
99
+ const parsed = JSON.parse(body);
100
+ // soporta array directo o { voices: [...] }
101
+ const voices = Array.isArray(parsed)
102
+ ? parsed
103
+ : (parsed.voices || parsed.Voices || []);
104
+ resolve(Array.isArray(voices) ? voices : []);
105
+ }
106
+ catch (e) {
107
+ reject(new Error("JSON inválido: " + (e?.message || String(e))));
108
+ }
109
+ });
46
110
  });
47
- req.on('error', reject);
111
+ req.on("error", reject);
48
112
  req.end();
49
113
  });
50
- const data = await response.json();
51
- return data.map((voice) => {
52
- voice.FriendlyName = voice.FriendlyName || voice.LocalName;
53
- delete voice.SampleRateHertz;
54
- delete voice.Status;
55
- return voice;
56
- });
114
+ return this.normalizeVoices(voicesRaw);
57
115
  }
58
116
  async getVoicesByLanguage(locale) {
59
117
  const voices = await this.getVoices();
@@ -114,7 +172,7 @@ class EdgeTTS {
114
172
  return new Promise((resolve, reject) => {
115
173
  this.audio_stream = [];
116
174
  const reqId = this.generateUUID();
117
- const url = `${constants_1.Constants.WSS_URL}?Ocp-Apim-Subscription-Key=${constants_1.Constants.TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${secMsGEC}&Sec-MS-GEC-Version=${constants_1.Constants.VERSION_MS_GEC}&ConnectionId=${reqId}`;
175
+ const url = `${constants_1.Constants.WSS_URL}?TrustedClientToken=${constants_1.Constants.TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${secMsGEC}&Sec-MS-GEC-Version=${constants_1.Constants.VERSION_MS_GEC}&ConnectionId=${reqId}`;
118
176
  this.ws = new ws_1.default(url, {
119
177
  headers: constants_1.Constants.getBaseHeaders(),
120
178
  rejectUnauthorized: false
@@ -161,31 +219,6 @@ class EdgeTTS {
161
219
  });
162
220
  });
163
221
  }
164
- detectSSML(content) {
165
- const trimmedContent = content.trim();
166
- const looksLikeSSML = /^<\?xml|^<speak/i.test(trimmedContent);
167
- if (!looksLikeSSML) {
168
- return { isValid: true, isSSML: false };
169
- }
170
- const errors = [];
171
- const hasSpeakTag = /<speak\b[^>]*>[\s\S]*<\/speak>/i.test(trimmedContent);
172
- const hasVoiceTag = /<voice\b[^>]*>[\s\S]*<\/voice>/i.test(trimmedContent);
173
- if (!hasSpeakTag) {
174
- throw new Error('Invalid SSML: Missing <speak> tag');
175
- }
176
- if (!hasVoiceTag) {
177
- throw new Error('Invalid SSML: Missing <voice> tag');
178
- }
179
- const hasCorrectNamespace = /xmlns="http:\/\/www\.w3\.org\/2001\/10\/synthesis"/i.test(trimmedContent);
180
- if (!hasCorrectNamespace && hasSpeakTag) {
181
- throw new Error('Invalid SSML: Missing or incorrect namespace declaration');
182
- }
183
- return {
184
- isValid: errors.length === 0,
185
- isSSML: hasSpeakTag || hasVoiceTag,
186
- errors: errors.length > 0 ? errors : undefined
187
- };
188
- }
189
222
  escapeXML(text) {
190
223
  return text
191
224
  .replace(/&/g, '&amp;')
@@ -195,48 +228,17 @@ class EdgeTTS {
195
228
  .replace(/'/g, '&apos;');
196
229
  }
197
230
  getSSML(content, voice, options = {}) {
198
- const inputType = options.inputType || 'auto';
199
- let treatAsSSML = false;
200
- if (inputType === 'ssml') {
201
- treatAsSSML = true;
202
- }
203
- else if (inputType === 'text') {
204
- treatAsSSML = false;
205
- }
206
- else { // 'auto'
207
- const detection = this.detectSSML(content);
208
- treatAsSSML = detection.isSSML;
209
- if (detection.isSSML) {
210
- console.log('→ Detected SSML input');
211
- if (!detection.isValid) {
212
- console.warn('⚠ SSML validation warnings:', detection.errors);
213
- }
214
- }
215
- else {
216
- console.log('→ Detected plain text input');
217
- }
218
- }
219
- if (treatAsSSML) {
220
- let ssml = content.trim();
221
- if (!ssml.includes('xmlns=')) {
222
- ssml = ssml.replace(/<speak([^>]*)>/i, '<speak$1 xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts">');
223
- }
224
- if (!/<voice\b[^>]*>/i.test(ssml) && voice) {
225
- ssml = ssml.replace(/(<speak[^>]*>)([\s\S]*?)(<\/speak>)/i, `$1<voice name="${voice}">$2</voice>$3`);
226
- }
227
- return ssml;
228
- }
229
231
  const pitch = this.validatePitch(options.pitch ?? 0);
230
232
  const rate = this.validateRate(options.rate ?? 0);
231
233
  const volume = this.validateVolume(options.volume ?? 0);
232
234
  const escapedText = this.escapeXML(content);
233
- return `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="en-US">
234
- <voice name="${voice}">
235
- <prosody pitch="${pitch}" rate="${rate}" volume="${volume}">
236
- ${escapedText}
237
- </prosody>
238
- </voice>
239
- </speak>
235
+ return `<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="en-US">
236
+ <voice name="${voice}">
237
+ <prosody pitch="${pitch}" rate="${rate}" volume="${volume}">
238
+ ${escapedText}
239
+ </prosody>
240
+ </voice>
241
+ </speak>
240
242
  `;
241
243
  }
242
244
  nowRFC1123(timeZone = 'UTC') {
@@ -266,7 +268,7 @@ class EdgeTTS {
266
268
  this.audio_stream = [];
267
269
  const reqId = this.generateUUID();
268
270
  const secMsGEC = await this.generateSecMsGec(constants_1.Constants.TRUSTED_CLIENT_TOKEN);
269
- const url = `${constants_1.Constants.WSS_URL}?Ocp-Apim-Subscription-Key=${constants_1.Constants.TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${secMsGEC}&Sec-MS-GEC-Version=${constants_1.Constants.VERSION_MS_GEC}&ConnectionId=${reqId}`;
271
+ const url = `${constants_1.Constants.WSS_URL}?TrustedClientToken=${constants_1.Constants.TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${secMsGEC}&Sec-MS-GEC-Version=${constants_1.Constants.VERSION_MS_GEC}&ConnectionId=${reqId}`;
270
272
  this.ws = new ws_1.default(url, {
271
273
  headers: constants_1.Constants.getBaseHeaders(),
272
274
  rejectUnauthorized: false
@@ -484,7 +486,16 @@ class EdgeTTS {
484
486
  if (this.audio_stream.length === 0) {
485
487
  throw new Error("No audio data available. Did you run synthesize() first?");
486
488
  }
487
- return buffer_1.Buffer.concat(this.audio_stream);
489
+ // When the service cannot speak the text it answers with a single empty
490
+ // chunk, so the stream is not empty but the audio is. Checking the byte
491
+ // count rather than the chunk count is what catches that.
492
+ const buffer = buffer_1.Buffer.concat(this.audio_stream);
493
+ if (buffer.length === 0) {
494
+ throw new Error("The service returned no audio. Voices are locale specific, so a voice cannot " +
495
+ "read text written in another script. Check that the voice matches the language " +
496
+ "of the text.");
497
+ }
498
+ return buffer;
488
499
  }
489
500
  async saveMetadata(outputPath) {
490
501
  if (this.word_boundaries.length === 0) {
package/package.json CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "@andresaya/edge-tts",
3
- "version": "1.7.2",
4
- "description": "Edge TTS is a package that allows access to the online text-to-speech service used by Microsoft Edge without the need for Microsoft Edge, Windows, or an API key.",
5
- "main": "dist/index.js",
6
- "browser": "dist/browser/edge-tts.esm.js",
7
- "unpkg": "dist/browser/edge-tts.umd.min.js",
8
- "jsdelivr": "dist/browser/edge-tts.umd.min.js",
9
- "keywords": [
10
- "edge",
11
- "tts",
12
- "text-to-speech",
13
- "microsoft",
14
- "microsoft edge",
15
- "speech-synthesis"
16
- ],
17
- "author": "Andres Aya",
18
- "license": "GPL-3.0-only",
19
- "homepage": "https://github.com/andresayac/edge-tts",
20
- "bugs": {
21
- "url": "https://github.com/andresayac/edge-tts/issues"
22
- },
23
- "scripts": {
24
- "build": "tsc",
25
- "build:bin": "bun scripts/build.ts",
26
- "build:browser": "bun scripts/build-browser.ts",
27
- "prepublishOnly": "npm run build && npm run build:bin && npm run build:browser"
28
- },
29
- "devDependencies": {
30
- "@types/bun": "latest",
31
- "@types/ws": "^8.18.1",
32
- "esbuild": "^0.25.12",
33
- "typescript": "^5.6.2"
34
- },
35
- "dependencies": {
36
- "@types/uuid": "^10.0.0",
37
- "commander": "^12.1.0",
38
- "events": "^3.3.0",
39
- "fs": "^0.0.1-security",
40
- "ws": "^8.18.3"
41
- },
42
- "peerDependencies": {
43
- "typescript": "^5.0.0"
44
- },
45
- "files": [
46
- "dist"
47
- ],
48
- "bin": {
49
- "edge-tts": "./dist/cli/edge-tts.js"
50
- },
51
- "engines": {
52
- "bun": ">=0.5.0"
53
- }
54
- }
1
+ {
2
+ "name": "@andresaya/edge-tts",
3
+ "version": "1.8.1",
4
+ "description": "Edge TTS is a package that allows access to the online text-to-speech service used by Microsoft Edge without the need for Microsoft Edge, Windows, or an API key.",
5
+ "main": "dist/index.js",
6
+ "browser": "dist/browser/edge-tts.esm.js",
7
+ "unpkg": "dist/browser/edge-tts.umd.min.js",
8
+ "jsdelivr": "dist/browser/edge-tts.umd.min.js",
9
+ "keywords": [
10
+ "edge",
11
+ "tts",
12
+ "text-to-speech",
13
+ "microsoft",
14
+ "microsoft edge",
15
+ "speech-synthesis"
16
+ ],
17
+ "author": "Andres Aya",
18
+ "license": "GPL-3.0-only",
19
+ "homepage": "https://github.com/andresayac/edge-tts",
20
+ "bugs": {
21
+ "url": "https://github.com/andresayac/edge-tts/issues"
22
+ },
23
+ "scripts": {
24
+ "build": "tsc",
25
+ "build:bin": "bun scripts/build.ts",
26
+ "build:browser": "bun scripts/build-browser.ts",
27
+ "prepublishOnly": "npm run build && npm run build:bin && npm run build:browser"
28
+ },
29
+ "devDependencies": {
30
+ "@types/bun": "latest",
31
+ "@types/ws": "^8.18.1",
32
+ "esbuild": "^0.25.12",
33
+ "typescript": "^5.6.2"
34
+ },
35
+ "dependencies": {
36
+ "@types/uuid": "^10.0.0",
37
+ "commander": "^12.1.0",
38
+ "events": "^3.3.0",
39
+ "fs": "^0.0.1-security",
40
+ "ws": "^8.18.3"
41
+ },
42
+ "peerDependencies": {
43
+ "typescript": "^5.0.0"
44
+ },
45
+ "files": [
46
+ "dist"
47
+ ],
48
+ "bin": {
49
+ "edge-tts": "dist/cli/edge-tts.js"
50
+ },
51
+ "engines": {
52
+ "bun": ">=0.5.0"
53
+ }
54
+ }