@andresaya/edge-tts 1.8.3 → 1.9.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 +184 -46
- package/dist/browser/edge-tts.esm.js +495 -287
- package/dist/browser/edge-tts.esm.js.map +4 -4
- package/dist/browser/edge-tts.esm.min.js +20 -16
- package/dist/browser/edge-tts.esm.min.js.map +4 -4
- package/dist/browser/edge-tts.umd.js +495 -287
- package/dist/browser/edge-tts.umd.js.map +4 -4
- package/dist/browser/edge-tts.umd.min.js +20 -16
- package/dist/browser/edge-tts.umd.min.js.map +4 -4
- package/dist/cli/edge-tts.js +2 -1
- package/dist/commands/SynthesizeCommand.js +19 -3
- package/dist/commands/VoiceListCommand.js +6 -2
- package/dist/config/constants.js +2 -1
- package/dist/core/base.d.ts +33 -0
- package/dist/core/base.js +189 -0
- package/dist/core/clock.d.ts +3 -0
- package/dist/core/clock.js +20 -0
- package/dist/core/crypto.d.ts +8 -0
- package/dist/core/crypto.js +14 -0
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +15 -0
- package/dist/core/protocol.d.ts +23 -0
- package/dist/core/protocol.js +206 -0
- package/dist/core/session.d.ts +33 -0
- package/dist/core/session.js +103 -0
- package/dist/core/subtitles.d.ts +7 -0
- package/dist/core/subtitles.js +24 -0
- package/dist/core/types.d.ts +53 -0
- package/dist/core/types.js +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +6 -1
- package/dist/services/EdgeTTS.d.ts +18 -41
- package/dist/services/EdgeTTS.js +79 -376
- package/package.json +3 -2
package/dist/services/EdgeTTS.js
CHANGED
|
@@ -8,95 +8,53 @@ const ws_1 = __importDefault(require("ws"));
|
|
|
8
8
|
const constants_1 = require("../config/constants");
|
|
9
9
|
const promises_1 = require("fs/promises");
|
|
10
10
|
const buffer_1 = require("buffer");
|
|
11
|
+
const http_1 = __importDefault(require("http"));
|
|
11
12
|
const https_1 = __importDefault(require("https"));
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
const crypto_1 = require("crypto");
|
|
14
|
+
const base_1 = require("../core/base");
|
|
15
|
+
const clock_1 = require("../core/clock");
|
|
16
|
+
const crypto_2 = require("../core/crypto");
|
|
17
|
+
const errors_1 = require("../core/errors");
|
|
18
|
+
const protocol_1 = require("../core/protocol");
|
|
19
|
+
// NOTE: Node 18 has no global crypto.
|
|
20
|
+
if (!globalThis.crypto)
|
|
21
|
+
(0, crypto_2.setWebCrypto)(crypto_1.webcrypto);
|
|
22
|
+
class EdgeTTS extends base_1.EdgeTTSCore {
|
|
23
|
+
defaultVoice = 'en-US-AnaNeural';
|
|
24
|
+
ws;
|
|
25
|
+
constructor(options = {}) {
|
|
26
|
+
super(options);
|
|
21
27
|
}
|
|
22
|
-
|
|
23
|
-
return
|
|
28
|
+
generateSecMsGec = async (trustedClientToken) => {
|
|
29
|
+
return (0, protocol_1.generateSecMsGec)(trustedClientToken, (0, clock_1.now)());
|
|
30
|
+
};
|
|
31
|
+
async generate(text, voice, options) {
|
|
32
|
+
const result = await super.generate(text, voice, options);
|
|
33
|
+
return { ...result, audio: buffer_1.Buffer.from(result.audio.buffer, result.audio.byteOffset, result.audio.byteLength) };
|
|
24
34
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
output_format = 'audio-24khz-48kbitrate-mono-mp3';
|
|
31
|
-
word_boundaries = [];
|
|
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;
|
|
35
|
+
openSocket(url) {
|
|
36
|
+
this.ws = new ws_1.default(url, {
|
|
37
|
+
headers: { ...constants_1.Constants.getBaseHeaders(), ...constants_1.Constants.WSS_HEADERS, ...this.options.headers }
|
|
38
|
+
});
|
|
39
|
+
return this.ws;
|
|
79
40
|
}
|
|
80
|
-
|
|
81
|
-
const secMsGEC = await this.generateSecMsGec(constants_1.Constants.TRUSTED_CLIENT_TOKEN);
|
|
82
|
-
const url = `${constants_1.Constants.VOICES_URL}` +
|
|
83
|
-
`?TrustedClientToken=${constants_1.Constants.TRUSTED_CLIENT_TOKEN}` +
|
|
84
|
-
`&Sec-MS-GEC=${secMsGEC}` +
|
|
85
|
-
`&Sec-MS-GEC-Version=${constants_1.Constants.VERSION_MS_GEC}`;
|
|
41
|
+
requestVoices(url) {
|
|
86
42
|
const headers = {
|
|
87
43
|
...constants_1.Constants.getBaseHeaders(),
|
|
88
44
|
...constants_1.Constants.VOICE_HEADERS,
|
|
45
|
+
...this.options.headers,
|
|
89
46
|
"Accept-Encoding": "identity", // evita gzip/br/zstd
|
|
90
47
|
};
|
|
91
|
-
const
|
|
92
|
-
|
|
48
|
+
const client = url.startsWith('http:') ? http_1.default : https_1.default;
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const req = client.request(url, { method: "GET", headers }, (res) => {
|
|
93
51
|
const chunks = [];
|
|
94
52
|
res.on("data", (chunk) => chunks.push(buffer_1.Buffer.isBuffer(chunk) ? chunk : buffer_1.Buffer.from(chunk)));
|
|
95
53
|
res.on("error", reject);
|
|
96
54
|
res.on("end", () => {
|
|
97
55
|
const body = buffer_1.Buffer.concat(chunks).toString("utf8");
|
|
98
56
|
if (res.statusCode !== 200) {
|
|
99
|
-
reject(new
|
|
57
|
+
reject(new errors_1.EdgeTTSError(`Voice list request failed with status ${res.statusCode}`, 'VOICES', { status: res.statusCode }));
|
|
100
58
|
return;
|
|
101
59
|
}
|
|
102
60
|
try {
|
|
@@ -108,28 +66,47 @@ class EdgeTTS {
|
|
|
108
66
|
resolve(Array.isArray(voices) ? voices : []);
|
|
109
67
|
}
|
|
110
68
|
catch (e) {
|
|
111
|
-
reject(new
|
|
69
|
+
reject(new errors_1.EdgeTTSError("Invalid voice list JSON: " + (e?.message || String(e)), 'VOICES', { cause: e }));
|
|
112
70
|
}
|
|
113
71
|
});
|
|
114
72
|
});
|
|
115
73
|
req.on("error", reject);
|
|
116
74
|
req.end();
|
|
117
75
|
});
|
|
118
|
-
return this.normalizeVoices(voicesRaw);
|
|
119
76
|
}
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
77
|
+
buildSSML(text, voice, options) {
|
|
78
|
+
const pitch = this.validatePitch(options.pitch ?? 0);
|
|
79
|
+
const rate = this.validateRate(options.rate ?? 0);
|
|
80
|
+
const volume = this.validateVolume(options.volume ?? 0);
|
|
81
|
+
return (0, protocol_1.buildSSML)(text, voice, pitch, rate, volume);
|
|
82
|
+
}
|
|
83
|
+
timestamp() {
|
|
84
|
+
return this.nowRFC1123();
|
|
85
|
+
}
|
|
86
|
+
socketError(event, opened) {
|
|
87
|
+
const { error, message } = event;
|
|
88
|
+
const cause = error instanceof Error ? error : new Error(message || 'WebSocket error');
|
|
89
|
+
if (opened || cause.code !== undefined)
|
|
90
|
+
return cause;
|
|
91
|
+
const status = /Unexpected server response: (\d{3})/.exec(cause.message)?.[1];
|
|
92
|
+
return new errors_1.EdgeTTSError(cause.message, 'HANDSHAKE', { status: status ? Number(status) : undefined, cause });
|
|
93
|
+
}
|
|
94
|
+
async recoverHandshake() {
|
|
95
|
+
const serverTime = await this.serverTime();
|
|
96
|
+
return serverTime !== undefined && (0, clock_1.adjustClock)(serverTime);
|
|
97
|
+
}
|
|
98
|
+
serverTime() {
|
|
99
|
+
const origin = new URL((this.options.wssUrl ?? constants_1.Constants.WSS_URL).replace(/^ws/, 'http')).origin + '/';
|
|
100
|
+
const client = origin.startsWith('http:') ? http_1.default : https_1.default;
|
|
101
|
+
return new Promise(resolve => {
|
|
102
|
+
const req = client.request(origin, { method: 'HEAD', timeout: 10_000 }, (res) => {
|
|
103
|
+
res.resume();
|
|
104
|
+
const date = Date.parse(res.headers.date ?? '');
|
|
105
|
+
resolve(Number.isNaN(date) ? undefined : date);
|
|
106
|
+
});
|
|
107
|
+
req.on('timeout', () => req.destroy());
|
|
108
|
+
req.on('error', () => resolve(undefined));
|
|
109
|
+
req.end();
|
|
133
110
|
});
|
|
134
111
|
}
|
|
135
112
|
validatePitch(pitch) {
|
|
@@ -137,7 +114,7 @@ class EdgeTTS {
|
|
|
137
114
|
return (pitch >= 0 ? `+${pitch}Hz` : `${pitch}Hz`);
|
|
138
115
|
}
|
|
139
116
|
if (!/^[+-]?\d{1,3}(?:\.\d+)?Hz$/.test(pitch)) {
|
|
140
|
-
throw new
|
|
117
|
+
throw new errors_1.EdgeTTSError("Invalid pitch format. Expected format: '-100Hz to +100Hz' or a number.", 'INVALID_OPTION');
|
|
141
118
|
}
|
|
142
119
|
return pitch;
|
|
143
120
|
}
|
|
@@ -146,7 +123,7 @@ class EdgeTTS {
|
|
|
146
123
|
if (typeof rate === 'string') {
|
|
147
124
|
rateValue = parseFloat(rate.replace('%', ''));
|
|
148
125
|
if (isNaN(rateValue))
|
|
149
|
-
throw new
|
|
126
|
+
throw new errors_1.EdgeTTSError("Invalid rate format.", 'INVALID_OPTION');
|
|
150
127
|
}
|
|
151
128
|
else {
|
|
152
129
|
rateValue = rate;
|
|
@@ -161,90 +138,16 @@ class EdgeTTS {
|
|
|
161
138
|
if (typeof volume === 'string') {
|
|
162
139
|
volumeValue = parseInt(volume.replace('%', ''), 10);
|
|
163
140
|
if (isNaN(volumeValue))
|
|
164
|
-
throw new
|
|
141
|
+
throw new errors_1.EdgeTTSError("Invalid volume format.", 'INVALID_OPTION');
|
|
165
142
|
}
|
|
166
143
|
else {
|
|
167
144
|
volumeValue = volume;
|
|
168
145
|
}
|
|
169
146
|
if (volumeValue < -100 || volumeValue > 100) {
|
|
170
|
-
throw new
|
|
147
|
+
throw new errors_1.EdgeTTSError("Invalid volume. Expected a value from -100% to 100%.", 'INVALID_OPTION');
|
|
171
148
|
}
|
|
172
149
|
return `${volumeValue}%`;
|
|
173
150
|
}
|
|
174
|
-
async synthesize(text, voice = 'en-US-AnaNeural', options = {}) {
|
|
175
|
-
const secMsGEC = await this.generateSecMsGec(constants_1.Constants.TRUSTED_CLIENT_TOKEN);
|
|
176
|
-
return new Promise((resolve, reject) => {
|
|
177
|
-
this.audio_stream = [];
|
|
178
|
-
this.word_boundaries = [];
|
|
179
|
-
const reqId = this.generateUUID();
|
|
180
|
-
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}`;
|
|
181
|
-
this.ws = new ws_1.default(url, {
|
|
182
|
-
headers: { ...constants_1.Constants.getBaseHeaders(), ...constants_1.Constants.WSS_HEADERS }
|
|
183
|
-
});
|
|
184
|
-
const SSML_text = this.getSSML(text, voice, options);
|
|
185
|
-
const outputFormat = options.outputFormat || 'audio-24khz-48kbitrate-mono-mp3';
|
|
186
|
-
this.output_format = outputFormat;
|
|
187
|
-
let timedOut = false;
|
|
188
|
-
let inactivityTimeout;
|
|
189
|
-
const resetInactivityTimeout = () => {
|
|
190
|
-
clearTimeout(inactivityTimeout);
|
|
191
|
-
inactivityTimeout = setTimeout(() => {
|
|
192
|
-
timedOut = true;
|
|
193
|
-
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
194
|
-
this.ws.close();
|
|
195
|
-
}
|
|
196
|
-
reject(new Error("WebSocket inactivity timeout - no response from server"));
|
|
197
|
-
}, 30000); // 30 seconds of inactivity
|
|
198
|
-
};
|
|
199
|
-
this.ws.on('open', () => {
|
|
200
|
-
resetInactivityTimeout(); // start the inactivity timeout
|
|
201
|
-
const message = this.buildTTSConfigMessage(outputFormat);
|
|
202
|
-
this.ws.send(message);
|
|
203
|
-
const timestamp = this.nowRFC1123();
|
|
204
|
-
const speechMessage = `X-RequestId:${reqId}\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:${timestamp}\r\nPath:ssml\r\n\r\n${SSML_text}`;
|
|
205
|
-
this.ws.send(speechMessage);
|
|
206
|
-
});
|
|
207
|
-
this.ws.on('message', (data) => {
|
|
208
|
-
resetInactivityTimeout(); // restart inactivity timeout
|
|
209
|
-
this.processAudioData(data);
|
|
210
|
-
});
|
|
211
|
-
this.ws.on('error', (err) => {
|
|
212
|
-
clearTimeout(inactivityTimeout);
|
|
213
|
-
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
214
|
-
this.ws.close();
|
|
215
|
-
}
|
|
216
|
-
reject(err);
|
|
217
|
-
});
|
|
218
|
-
this.ws.on('close', () => {
|
|
219
|
-
clearTimeout(inactivityTimeout);
|
|
220
|
-
if (!timedOut) {
|
|
221
|
-
resolve();
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
}
|
|
226
|
-
escapeXML(text) {
|
|
227
|
-
return text
|
|
228
|
-
.replace(/&/g, '&')
|
|
229
|
-
.replace(/</g, '<')
|
|
230
|
-
.replace(/>/g, '>')
|
|
231
|
-
.replace(/"/g, '"')
|
|
232
|
-
.replace(/'/g, ''');
|
|
233
|
-
}
|
|
234
|
-
getSSML(content, voice, options = {}) {
|
|
235
|
-
const pitch = this.validatePitch(options.pitch ?? 0);
|
|
236
|
-
const rate = this.validateRate(options.rate ?? 0);
|
|
237
|
-
const volume = this.validateVolume(options.volume ?? 0);
|
|
238
|
-
const escapedText = this.escapeXML(content);
|
|
239
|
-
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">
|
|
240
|
-
<voice name="${voice}">
|
|
241
|
-
<prosody pitch="${pitch}" rate="${rate}" volume="${volume}">
|
|
242
|
-
${escapedText}
|
|
243
|
-
</prosody>
|
|
244
|
-
</voice>
|
|
245
|
-
</speak>
|
|
246
|
-
`;
|
|
247
|
-
}
|
|
248
151
|
nowRFC1123(timeZone = 'UTC') {
|
|
249
152
|
const now = new Date();
|
|
250
153
|
const options = {
|
|
@@ -260,223 +163,28 @@ class EdgeTTS {
|
|
|
260
163
|
};
|
|
261
164
|
return now.toLocaleString('en-US', options);
|
|
262
165
|
}
|
|
263
|
-
parseRFC1123(rfcStr) {
|
|
264
|
-
return new Date(rfcStr);
|
|
265
|
-
}
|
|
266
|
-
buildTTSConfigMessage(outputFormat = 'audio-24khz-48kbitrate-mono-mp3') {
|
|
267
|
-
const timestamp = this.nowRFC1123();
|
|
268
|
-
return `X-Timestamp:${timestamp}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` +
|
|
269
|
-
`{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":false,"wordBoundaryEnabled":true},"outputFormat":"${outputFormat}"}}}}`;
|
|
270
|
-
}
|
|
271
|
-
async *synthesizeStream(text, voice = 'en-US-AnaNeural', options = {}) {
|
|
272
|
-
this.audio_stream = [];
|
|
273
|
-
this.word_boundaries = [];
|
|
274
|
-
const reqId = this.generateUUID();
|
|
275
|
-
const secMsGEC = await this.generateSecMsGec(constants_1.Constants.TRUSTED_CLIENT_TOKEN);
|
|
276
|
-
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}`;
|
|
277
|
-
this.ws = new ws_1.default(url, {
|
|
278
|
-
headers: { ...constants_1.Constants.getBaseHeaders(), ...constants_1.Constants.WSS_HEADERS }
|
|
279
|
-
});
|
|
280
|
-
const SSML_text = this.getSSML(text, voice, options);
|
|
281
|
-
const outputFormat = options.outputFormat || 'audio-24khz-48kbitrate-mono-mp3';
|
|
282
|
-
this.output_format = outputFormat;
|
|
283
|
-
const queue = [];
|
|
284
|
-
let done = false;
|
|
285
|
-
let error = null;
|
|
286
|
-
let notify = null;
|
|
287
|
-
const push = (chunk) => {
|
|
288
|
-
queue.push(chunk);
|
|
289
|
-
if (notify) {
|
|
290
|
-
notify();
|
|
291
|
-
notify = null;
|
|
292
|
-
}
|
|
293
|
-
};
|
|
294
|
-
let timedOut = false;
|
|
295
|
-
let inactivityTimeout;
|
|
296
|
-
const resetInactivityTimeout = () => {
|
|
297
|
-
clearTimeout(inactivityTimeout);
|
|
298
|
-
inactivityTimeout = setTimeout(() => {
|
|
299
|
-
timedOut = true;
|
|
300
|
-
error = new Error("WebSocket inactivity timeout - no response from server");
|
|
301
|
-
done = true;
|
|
302
|
-
if (this.ws && this.ws.readyState === ws_1.default.OPEN) {
|
|
303
|
-
this.ws.close();
|
|
304
|
-
}
|
|
305
|
-
if (notify) {
|
|
306
|
-
notify();
|
|
307
|
-
notify = null;
|
|
308
|
-
}
|
|
309
|
-
}, 30000); // 30 seconds of inactivity
|
|
310
|
-
};
|
|
311
|
-
this.ws.on('open', () => {
|
|
312
|
-
resetInactivityTimeout(); // start the inactivity timeout
|
|
313
|
-
const message = this.buildTTSConfigMessage(outputFormat);
|
|
314
|
-
this.ws.send(message);
|
|
315
|
-
const timestamp = this.nowRFC1123();
|
|
316
|
-
const speechMessage = `X-RequestId:${reqId}\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:${timestamp}\r\nPath:ssml\r\n\r\n${SSML_text}`;
|
|
317
|
-
this.ws.send(speechMessage);
|
|
318
|
-
});
|
|
319
|
-
this.ws.on('message', (data) => {
|
|
320
|
-
resetInactivityTimeout(); // restart inactivity timeout
|
|
321
|
-
const buffer = ensureBuffer(data);
|
|
322
|
-
const needle = buffer_1.Buffer.from('Path:audio\r\n');
|
|
323
|
-
const audioStartIndex = buffer.indexOf(new Uint8Array(needle));
|
|
324
|
-
if (audioStartIndex !== -1) {
|
|
325
|
-
const audioChunk = buffer.subarray(audioStartIndex + needle.length);
|
|
326
|
-
const chunk = new Uint8Array(audioChunk);
|
|
327
|
-
this.audio_stream.push(chunk);
|
|
328
|
-
push(chunk);
|
|
329
|
-
}
|
|
330
|
-
if (buffer.toString().includes("Path:audio.metadata")) {
|
|
331
|
-
const metadataStart = buffer.indexOf("\r\n\r\n") + 4;
|
|
332
|
-
const metadataJson = buffer.toString().substring(metadataStart);
|
|
333
|
-
const meta = this.parseMetadata(metadataJson);
|
|
334
|
-
if (meta !== null) {
|
|
335
|
-
this.word_boundaries.push(meta);
|
|
336
|
-
}
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
if (buffer.toString().includes('Path:turn.end')) {
|
|
340
|
-
this.ws?.close();
|
|
341
|
-
}
|
|
342
|
-
});
|
|
343
|
-
this.ws.on('error', (err) => {
|
|
344
|
-
clearTimeout(inactivityTimeout);
|
|
345
|
-
error = err;
|
|
346
|
-
done = true;
|
|
347
|
-
if (notify) {
|
|
348
|
-
notify();
|
|
349
|
-
notify = null;
|
|
350
|
-
}
|
|
351
|
-
});
|
|
352
|
-
this.ws.on('close', () => {
|
|
353
|
-
clearTimeout(inactivityTimeout);
|
|
354
|
-
done = true;
|
|
355
|
-
if (notify) {
|
|
356
|
-
notify();
|
|
357
|
-
notify = null;
|
|
358
|
-
}
|
|
359
|
-
});
|
|
360
|
-
while (!done || queue.length > 0) {
|
|
361
|
-
if (queue.length === 0) {
|
|
362
|
-
await new Promise(resolve => (notify = resolve));
|
|
363
|
-
continue;
|
|
364
|
-
}
|
|
365
|
-
const chunk = queue.shift();
|
|
366
|
-
if (chunk) {
|
|
367
|
-
yield chunk;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
if (error) {
|
|
371
|
-
throw error;
|
|
372
|
-
}
|
|
373
|
-
}
|
|
374
|
-
processAudioData(data) {
|
|
375
|
-
const buffer = ensureBuffer(data);
|
|
376
|
-
const needle = buffer_1.Buffer.from("Path:audio\r\n");
|
|
377
|
-
const audioStartIndex = buffer.indexOf(new Uint8Array(needle));
|
|
378
|
-
if (audioStartIndex !== -1) {
|
|
379
|
-
const audioChunk = buffer.subarray(audioStartIndex + needle.length);
|
|
380
|
-
this.audio_stream.push(new Uint8Array(audioChunk));
|
|
381
|
-
}
|
|
382
|
-
if (buffer.toString().includes("Path:audio.metadata")) {
|
|
383
|
-
const metadataStart = buffer.indexOf("\r\n\r\n") + 4;
|
|
384
|
-
const metadataJson = buffer.toString().substring(metadataStart);
|
|
385
|
-
const meta = this.parseMetadata(metadataJson);
|
|
386
|
-
if (meta !== null) {
|
|
387
|
-
this.word_boundaries.push(meta);
|
|
388
|
-
}
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
if (buffer.toString().includes("Path:turn.end")) {
|
|
392
|
-
this.ws?.close();
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
parseMetadata(data, offsetCompensation = 0) {
|
|
396
|
-
let metadata;
|
|
397
|
-
try {
|
|
398
|
-
metadata = JSON.parse(data);
|
|
399
|
-
}
|
|
400
|
-
catch {
|
|
401
|
-
return null;
|
|
402
|
-
}
|
|
403
|
-
if (!metadata.Metadata) {
|
|
404
|
-
return null;
|
|
405
|
-
}
|
|
406
|
-
for (const metaObj of metadata.Metadata) {
|
|
407
|
-
if (metaObj.Type === "WordBoundary") {
|
|
408
|
-
const currentOffset = metaObj.Data.Offset + offsetCompensation;
|
|
409
|
-
const currentDuration = metaObj.Data.Duration;
|
|
410
|
-
return {
|
|
411
|
-
type: "WordBoundary",
|
|
412
|
-
offset: currentOffset,
|
|
413
|
-
duration: currentDuration,
|
|
414
|
-
text: metaObj.Data.text?.Text,
|
|
415
|
-
};
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
generateSecMsGec = async (trustedClientToken) => {
|
|
421
|
-
const now = this.nowRFC1123();
|
|
422
|
-
const fixedDate = this.parseRFC1123(now);
|
|
423
|
-
const ticks = Math.floor(fixedDate.getTime() / 1000) + 11644473600;
|
|
424
|
-
const rounded = ticks - (ticks % 300);
|
|
425
|
-
const windowsTicks = rounded * 10_000_000;
|
|
426
|
-
const encoder = new TextEncoder();
|
|
427
|
-
const data = encoder.encode(`${windowsTicks}${trustedClientToken}`);
|
|
428
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
429
|
-
return Array.from(new Uint8Array(hashBuffer))
|
|
430
|
-
.map(b => b.toString(16).padStart(2, '0'))
|
|
431
|
-
.join('')
|
|
432
|
-
.toUpperCase();
|
|
433
|
-
};
|
|
434
166
|
getDuration() {
|
|
435
167
|
if (this.audio_stream.length === 0) {
|
|
436
|
-
throw new
|
|
168
|
+
throw new errors_1.EdgeTTSError("No audio data available", 'NO_AUDIO');
|
|
437
169
|
}
|
|
438
|
-
|
|
439
|
-
const bufferSize = this.toBuffer().length;
|
|
440
|
-
const kbps = Number(/(\d+)kbitrate/.exec(this.output_format)?.[1] ?? 48);
|
|
441
|
-
return (bufferSize * 8) / (kbps * 1000);
|
|
442
|
-
}
|
|
443
|
-
getFileExtension(format) {
|
|
444
|
-
if (format.includes('mp3'))
|
|
445
|
-
return 'mp3';
|
|
446
|
-
if (format.includes('opus') && format.includes('webm'))
|
|
447
|
-
return 'webm';
|
|
448
|
-
if (format.includes('opus') && format.includes('ogg'))
|
|
449
|
-
return 'ogg';
|
|
450
|
-
if (format.includes('wav') || format.includes('riff'))
|
|
451
|
-
return 'wav';
|
|
452
|
-
if (format.includes('pcm') && format.includes('raw'))
|
|
453
|
-
return 'pcm';
|
|
454
|
-
if (format.includes('alaw'))
|
|
455
|
-
return 'alaw';
|
|
456
|
-
if (format.includes('mulaw'))
|
|
457
|
-
return 'mulaw';
|
|
458
|
-
if (format.includes('truesilk'))
|
|
459
|
-
return 'silk';
|
|
460
|
-
if (format.includes('g722'))
|
|
461
|
-
return 'g722';
|
|
462
|
-
if (format.includes('amr'))
|
|
463
|
-
return 'amr';
|
|
464
|
-
return 'audio';
|
|
170
|
+
return (0, protocol_1.estimateDuration)(this.toBuffer().length, this.output_format);
|
|
465
171
|
}
|
|
466
172
|
getAudioInfo() {
|
|
467
173
|
const buffer = this.toBuffer();
|
|
468
174
|
return {
|
|
469
175
|
size: buffer.length,
|
|
470
|
-
format:
|
|
176
|
+
format: (0, protocol_1.fileExtension)(this.output_format),
|
|
471
177
|
estimatedDuration: this.getDuration()
|
|
472
178
|
};
|
|
473
179
|
}
|
|
474
180
|
async toFile(outputPath, format) {
|
|
475
181
|
if (!format) {
|
|
476
|
-
format =
|
|
182
|
+
format = (0, protocol_1.fileExtension)(this.output_format);
|
|
477
183
|
}
|
|
478
184
|
const audioBuffer = this.toBuffer();
|
|
479
|
-
const finalPath =
|
|
185
|
+
const finalPath = outputPath.toLowerCase().endsWith(`.${format.toLowerCase()}`)
|
|
186
|
+
? outputPath
|
|
187
|
+
: `${outputPath}.${format}`;
|
|
480
188
|
await (0, promises_1.writeFile)(finalPath, new Uint8Array(audioBuffer));
|
|
481
189
|
return finalPath;
|
|
482
190
|
}
|
|
@@ -488,16 +196,14 @@ class EdgeTTS {
|
|
|
488
196
|
}
|
|
489
197
|
toBuffer() {
|
|
490
198
|
if (this.audio_stream.length === 0) {
|
|
491
|
-
throw new
|
|
199
|
+
throw new errors_1.EdgeTTSError("No audio data available. Did you run synthesize() first?", 'NO_AUDIO');
|
|
492
200
|
}
|
|
493
201
|
// When the service cannot speak the text it answers with a single empty
|
|
494
202
|
// chunk, so the stream is not empty but the audio is. Checking the byte
|
|
495
203
|
// count rather than the chunk count is what catches that.
|
|
496
204
|
const buffer = buffer_1.Buffer.concat(this.audio_stream);
|
|
497
205
|
if (buffer.length === 0) {
|
|
498
|
-
throw new
|
|
499
|
-
"read text written in another script. Check that the voice matches the language " +
|
|
500
|
-
"of the text.");
|
|
206
|
+
throw new errors_1.EdgeTTSError((0, base_1.noAudioMessage)(this.output_format), 'NO_AUDIO');
|
|
501
207
|
}
|
|
502
208
|
return buffer;
|
|
503
209
|
}
|
|
@@ -508,8 +214,5 @@ class EdgeTTS {
|
|
|
508
214
|
const json = JSON.stringify(this.word_boundaries, null, 4);
|
|
509
215
|
await (0, promises_1.writeFile)(outputPath, json);
|
|
510
216
|
}
|
|
511
|
-
getWordBoundaries() {
|
|
512
|
-
return this.word_boundaries;
|
|
513
|
-
}
|
|
514
217
|
}
|
|
515
218
|
exports.EdgeTTS = EdgeTTS;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@andresaya/edge-tts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
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
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"url": "https://github.com/andresayac/edge-tts/issues"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
|
-
"@types/bun": "
|
|
25
|
+
"@types/bun": "^1.3.3",
|
|
26
26
|
"@types/ws": "^8.18.1",
|
|
27
27
|
"esbuild": "^0.25.12",
|
|
28
28
|
"typescript": "^5.6.2"
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "tsc",
|
|
45
|
+
"test": "bun test",
|
|
45
46
|
"build:bin": "bun scripts/build.ts",
|
|
46
47
|
"build:browser": "bun scripts/build-browser.ts"
|
|
47
48
|
}
|