@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.
- package/README.md +543 -619
- package/dist/browser/edge-tts.esm.js +60 -117
- package/dist/browser/edge-tts.esm.js.map +2 -2
- package/dist/browser/edge-tts.esm.min.js +10 -10
- package/dist/browser/edge-tts.esm.min.js.map +3 -3
- package/dist/browser/edge-tts.umd.js +60 -117
- package/dist/browser/edge-tts.umd.js.map +2 -2
- package/dist/browser/edge-tts.umd.min.js +8 -8
- package/dist/browser/edge-tts.umd.min.js.map +3 -3
- package/dist/cli/edge-tts.js +1 -1
- package/dist/commands/SynthesizeCommand.js +2 -4
- package/dist/config/constants.d.ts +0 -33
- package/dist/config/constants.js +13 -48
- package/dist/services/EdgeTTS.d.ts +1 -2
- package/dist/services/EdgeTTS.js +96 -85
- package/package.json +54 -54
package/dist/services/EdgeTTS.js
CHANGED
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
111
|
+
req.on("error", reject);
|
|
48
112
|
req.end();
|
|
49
113
|
});
|
|
50
|
-
|
|
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}?
|
|
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, '&')
|
|
@@ -195,48 +228,17 @@ class EdgeTTS {
|
|
|
195
228
|
.replace(/'/g, ''');
|
|
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}?
|
|
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
|
-
|
|
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.
|
|
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": "
|
|
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
|
+
}
|