Spaces:
Running
Running
Khôi phục 100% về commit 077b53285f03cbd22093c875097dd5e96ba063a5: full restore of target commit tree
723af03 verified | /** | |
| * 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, "<").replace(/>/g, ">"); | |
| return `<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>` + | |
| `<voice name='${voice}'><prosody pitch='${pitch}' rate='${rate}' volume='${volume}'>` + | |
| `${esc}</prosody></voice></speak>`; | |
| } | |
| /** 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<Uint8Array>} | |
| */ | |
| 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); | |
| }; | |
| }); | |
| } | |