/** * 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); }; }); }