/**
* Edge TTS — zero-dependency Microsoft Edge neural text-to-speech for Bun/Node.
* Produces 24kHz 48kbit mono MP3 for Vietnamese voices:
* vi-VN-HoaiMyNeural (female, "Hoài My")
* vi-VN-NamMinhNeural (male, "Nam Minh")
*/
import { createHash, randomUUID, randomBytes } from "node:crypto";
const TRUSTED_CLIENT_TOKEN = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
const WIN_EPOCH = 11644473600;
const WSS_URL =
"wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1";
const SEC_MS_GEC_VERSION = "1-143.0.3650.75";
const CHROME_VER = "143";
const UA =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
`(KHTML, like Gecko) Chrome/${CHROME_VER}.0.0.0 Safari/537.36 Edg/${CHROME_VER}.0.0.0`;
function generateSecMsGec() {
let ticks = Date.now() / 1000 + WIN_EPOCH;
ticks -= ticks % 300;
ticks *= 1e7;
return createHash("sha256")
.update(`${ticks.toFixed(0)}${TRUSTED_CLIENT_TOKEN}`, "ascii")
.digest("hex")
.toUpperCase();
}
function dateToString() {
const d = new Date();
const days = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const p = (n) => String(n).padStart(2, "0");
return `${days[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${p(d.getUTCDate())} ` +
`${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} ` +
`GMT+0000 (Coordinated Universal Time)`;
}
function buildSSML(text, voice, { pitch = "+0Hz", rate = "+0%", volume = "+0%" } = {}) {
const esc = String(text).replace(/&/g, "&").replace(//g, ">");
return `` +
`` +
`${esc}`;
}
/** Split long text at sentence boundaries (Edge caps ~3000 chars/request). */
function splitText(text, max = 2800) {
if (text.length <= max) return [text];
const parts = [];
let rest = text;
while (rest.length > max) {
let cut = rest.lastIndexOf(". ", max);
if (cut < max * 0.5) cut = rest.lastIndexOf(" ", max);
if (cut <= 0) cut = max;
parts.push(rest.slice(0, cut + 1));
rest = rest.slice(cut + 1).trimStart();
}
if (rest) parts.push(rest);
return parts;
}
/**
* Synthesize text to mp3 bytes.
* @param {string} text
* @param {string} voice e.g. "vi-VN-HoaiMyNeural" | "vi-VN-NamMinhNeural"
* @returns {Promise}
*/
export async function synthesize(text, voice = "vi-VN-HoaiMyNeural") {
const clean = String(text || "").trim().replace(/\s+/g, " ").replace(/\*\*?|`|_{1,2}/g, "");
if (!clean) return new Uint8Array(0);
const parts = splitText(clean);
const buffers = [];
for (const part of parts) {
buffers.push(await synthesizeOne(part, voice));
}
const total = buffers.reduce((n, b) => n + b.length, 0);
const out = new Uint8Array(total);
let off = 0;
for (const b of buffers) { out.set(b, off); off += b.length; }
return out;
}
function synthesizeOne(text, voice) {
const reqId = randomUUID().replace(/-/g, "");
const sec = generateSecMsGec();
const url =
`${WSS_URL}?TrustedClientToken=${TRUSTED_CLIENT_TOKEN}` +
`&Sec-MS-GEC=${sec}&Sec-MS-GEC-Version=${SEC_MS_GEC_VERSION}` +
`&ConnectionId=${randomUUID().replace(/-/g, "")}`;
const headers = {
"User-Agent": UA,
"Origin": "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
"Cookie": `muid=${randomBytes(16).toString("hex").toUpperCase()};`,
};
// Bun native WebSocket accepts options with headers as 2nd arg.
const ws = new WebSocket(url, { headers });
return new Promise((resolve, reject) => {
const chunks = [];
let opened = false;
const timer = setTimeout(() => {
try { ws.close(); } catch {}
reject(new Error("Edge TTS timeout"));
}, 20000);
ws.onopen = () => {
opened = true;
ws.send(
`X-Timestamp:${dateToString()}\r\n` +
"Content-Type:application/json; charset=utf-8\r\n" +
"Path:speech.config\r\n\r\n" +
'{"context":{"synthesis":{"audio":{"metadataoptions":' +
'{"sentenceBoundaryEnabled":"true","wordBoundaryEnabled":"false"},' +
'"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}\r\n'
);
ws.send(
`X-RequestId:${reqId}\r\n` +
"Content-Type:application/ssml+xml\r\n" +
`X-Timestamp:${dateToString()}Z\r\n` +
"Path:ssml\r\n\r\n" +
buildSSML(text, voice)
);
};
ws.onmessage = (ev) => {
const data = ev.data;
if (typeof data === "string") return;
const buf = Buffer.isBuffer(data) ? data : Buffer.from(data);
if (buf.length < 2) return;
const hl = buf.readUInt16BE(0);
const header = buf.subarray(0, hl + 2).toString("latin1");
const body = buf.subarray(hl + 2);
if (header.includes("Path:audio")) chunks.push(body);
else if (header.includes("Path:turn.end")) {
clearTimeout(timer);
try { ws.close(); } catch {}
resolve(Buffer.concat(chunks));
}
};
ws.onerror = (e) => { if (!opened) { clearTimeout(timer); reject(e); } };
ws.onclose = () => {
if (chunks.length) { clearTimeout(timer); resolve(Buffer.concat(chunks)); }
else clearTimeout(timer);
};
});
}