Last active
August 18, 2025 15:00
-
-
Save playerx/e03765b03f3924a29c61536306a0c517 to your computer and use it in GitHub Desktop.
Edge-TTS low level client implementation (Deno example)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import WebSocket from "ws"; | |
| export async function textToSpeech( | |
| text: string, | |
| voice: string, | |
| options: { pitch?: string; rate?: string; volume?: string } | |
| ): Promise<Buffer<ArrayBuffer>> { | |
| const audio_stream: any[] = []; | |
| const secMsGEC = await generateSecMsGec( | |
| Constants.TRUSTED_CLIENT_TOKEN, | |
| ) | |
| return new Promise((resolve, reject) => { | |
| const reqId = crypto.randomUUID(); | |
| const url = `${Constants.WSS_URL}?TrustedClientToken=${Constants.TRUSTED_CLIENT_TOKEN}&Sec-MS-GEC=${secMsGEC}&Sec-MS-GEC-Version=1-130.0.2849.68&ConnectionId=${reqId}` | |
| const ws = new WebSocket(url); | |
| const SSML_text = getSSML(text, voice, options); | |
| ws.on("open", () => { | |
| const message = buildTTSConfigMessage(); | |
| ws.send(message); | |
| const speechMessage = `X-RequestId:${reqId}\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:${new Date().toISOString()}Z\r\nPath:ssml\r\n\r\n${SSML_text}`; | |
| ws.send(speechMessage); | |
| }); | |
| ws.on("message", (data: any) => { | |
| if (Buffer.isBuffer(data)) { | |
| const needle = Buffer.from("Path:audio\r\n"); | |
| const uint8Data = new Uint8Array(data); | |
| const start_ind = uint8Data.indexOf(needle[0]); | |
| if (start_ind !== -1) { | |
| const audioData = data.subarray(start_ind + needle.length); | |
| audio_stream.push(audioData); | |
| } | |
| if (data.includes("Path:turn.end")) { | |
| ws.close(); | |
| } | |
| } else { | |
| console.log(3, "not buffer"); | |
| } | |
| }); | |
| ws.on("close", () => { | |
| if (!audio_stream.length) { | |
| reject("No audio data available to save."); | |
| return; | |
| } | |
| const audioBuffer = Buffer.concat(audio_stream); | |
| resolve(audioBuffer); | |
| }); | |
| ws.on("error", (err: Error) => { | |
| reject(err); | |
| }); | |
| }); | |
| } | |
| export async function getVoices(): Promise<any[]> { | |
| const response = await fetch( | |
| `${Constants.VOICES_URL}?trustedclienttoken=${Constants.TRUSTED_CLIENT_TOKEN}` | |
| ); | |
| const data = await response.json(); | |
| return data.map((voice: any) => { | |
| delete voice.VoiceTag; | |
| delete voice.SuggestedCodec; | |
| delete voice.Status; | |
| return voice; | |
| }); | |
| } | |
| const Constants = { | |
| TRUSTED_CLIENT_TOKEN: "6A5AA1D4EAFF4E9FB37E23D68491D6F4", | |
| WSS_URL: | |
| "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1", | |
| VOICES_URL: | |
| "https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list", | |
| }; | |
| const buildTTSConfigMessage = (): string => { | |
| return ( | |
| `X-Timestamp:${new Date().toISOString()}Z\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` + | |
| `{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":false,"wordBoundaryEnabled":true},"outputFormat":"audio-24khz-48kbitrate-mono-mp3"}}}}` | |
| ); | |
| }; | |
| const getSSML = (text: string, voice: string, options: any = {}): string => { | |
| options.pitch = options.pitch?.replace("hz", "Hz"); | |
| const pitch = options.pitch || "0Hz"; // this.validatePitch(options.pitch || '0Hz'); | |
| const rate = options.rate || "0%"; // this.validateRate(options.rate || '0%'); | |
| const volume = options.volume || "0%"; // this.validateVolume(options.volume || '0%'); | |
| return `<speak version='1.0' xml:lang='en-US'><voice name='${voice}'><prosody pitch='${pitch}' rate='${rate}' volume='${volume}'>${text}</prosody></voice></speak>`; | |
| }; | |
| const generateSecMsGec = async ( | |
| trustedClientToken: string, | |
| ): Promise<string> => { | |
| const ticks = Math.floor(Date.now() / 1000) + 11644473600 | |
| const rounded = ticks - (ticks % 300) | |
| const windowsTicks = rounded * 10000000 | |
| const encoder = new TextEncoder() | |
| const data = encoder.encode(`${windowsTicks}${trustedClientToken}`) | |
| const hashBuffer = await crypto.subtle.digest('SHA-256', data) | |
| return Array.from(new Uint8Array(hashBuffer)) | |
| .map(b => b.toString(16).padStart(2, '0')) | |
| .join('') | |
| .toUpperCase() | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const res = await textToSpeech( | |
| text, | |
| speaker || 'echo', //"ka-GE-EkaNeural", | |
| { | |
| rate: "0%", // Speech rate (range: -100% to 100%) | |
| volume: "0%", // Speech volume (range: -100% to 100%) | |
| pitch: "0Hz", // Voice pitch (range: -100Hz to 100Hz) | |
| } | |
| ); | |
| return new Response(res, { | |
| headers: { | |
| "content-type": "audio/mp3", | |
| }, | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment