Repository navigation
feat: add EdgeTTS audio-tts provider (#6668) - #7605
Conversation
Registers Microsoft Edge "Read Aloud" as a new no-API-key AUDIO_SPEECH_PROVIDERS
entry — the first WebSocket-transport TTS provider in the registry. Reverse-
engineered/unofficial endpoint, same class of integration already accepted for
other "-web" style providers (chatgpt-web.ts, copilot-web.ts).
- open-sse/executors/edgeTts.ts: pure Sec-MS-GEC token construction (SHA-256
over a public trusted-client-token + rounded Windows file-time ticks, ported
from rany2/edge-tts drm.py), WS message framing (speech.config/ssml),
binary-chunk demuxing, SSML building/escaping, and the WS synth call itself
(injectable WebSocket ctor for tests, lazy `import("ws")` in production so
it never enters esbuild's top-level CJS bundle graph). Per-client-IP
sliding-window throttle (SlidingWindowLimiter) since there's no per-user key
— one abusive deployment could otherwise get the shared trusted token
rate-limited for everyone.
- open-sse/utils/publicCreds.ts: embeds the trusted-client-token via
resolvePublicCred() (Hard Rule #11) — it's a constant hardcoded in every
Edge build and every open-source edge-tts port, not a per-user secret.
- Extracted open-sse/utils/audioResponse.ts (shared response helpers) and
open-sse/executors/awsPollyTts.ts (AWS Polly handler) out of
open-sse/handlers/audioSpeech.ts to stay under its frozen file-size ratchet
baseline while making room for the new branch — no behavior change to
either extracted piece.
- src/app/api/v1/audio/speech/route.ts: thread the caller's IP through to the
handler for the new throttle.
Tests: tests/unit/edgetts-provider.test.ts (23 cases) — Sec-MS-GEC determinism
and cross-check against a hand-derived reference vector, message framing,
binary demux, SSML escaping/injection-safety, registry lookup, publicCreds
shape, and the error path via an injected fake WebSocket (upstream failure ->
sanitized 502, no stack/path leak; Hard Rule #12), plus the per-IP rate limit.
No live upstream is required or used — the reverse-engineered protocol can't
be validated against real credentials, but every pure/testable seam is
covered per the TDD path in the bug/feature validation gate.
There was a problem hiding this comment.
Code Review
This pull request introduces EdgeTTS (Microsoft Edge "Read Aloud") as a free, no-API-key audio-tts provider using WebSocket transport. To accommodate this addition, the AWS Polly TTS handler and shared audio response helpers were refactored into separate modules. Two critical issues were identified in the new edgeTts.ts executor: first, a bug in the WebSocket message handler where text frames are incorrectly treated as binary frames in modern ws (v8+) versions, leading to connection timeouts; second, a potential floating-point precision error in the computeSecMsGec tick calculation that could cause intermittent authentication failures.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| ws.on("message", (data: unknown, isBinary?: unknown) => { | ||
| const binary = isBinary === true || Buffer.isBuffer(data); | ||
| if (binary) { | ||
| const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); | ||
| const demuxed = demuxAudioChunk(buf); | ||
| if (demuxed) { | ||
| const typeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(demuxed.headers); | ||
| if (typeMatch) contentType = typeMatch[1].trim(); | ||
| if (demuxed.audio.length > 0) chunks.push(demuxed.audio); | ||
| } | ||
| return; | ||
| } |
There was a problem hiding this comment.
In ws.on("message"), the check const binary = isBinary === true || Buffer.isBuffer(data); will incorrectly evaluate to true for text frames in modern ws (v8+). In ws v8+, the data argument is always a Buffer (or ArrayBuffer), even for text frames, and isBinary is false.
Because Buffer.isBuffer(data) is true for these text frames, binary becomes true. This causes the handler to treat text frames (such as Path:turn.end) as binary audio chunks, skipping the text processing entirely. As a result, isTurnEndMessage(text) is never checked, and the WebSocket connection will never resolve normally, leading to a consistent 30-second timeout on every real request.
The unit tests passed only because the mock socket emitted a raw string instead of a Buffer for the text frame.
To fix this, check if isBinary is explicitly provided as a boolean, and only fall back to Buffer.isBuffer(data) if it is undefined.
| ws.on("message", (data: unknown, isBinary?: unknown) => { | |
| const binary = isBinary === true || Buffer.isBuffer(data); | |
| if (binary) { | |
| const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); | |
| const demuxed = demuxAudioChunk(buf); | |
| if (demuxed) { | |
| const typeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(demuxed.headers); | |
| if (typeMatch) contentType = typeMatch[1].trim(); | |
| if (demuxed.audio.length > 0) chunks.push(demuxed.audio); | |
| } | |
| return; | |
| } | |
| ws.on("message", (data: unknown, isBinary?: unknown) => { | |
| const binary = typeof isBinary === "boolean" ? isBinary : Buffer.isBuffer(data); | |
| if (binary) { | |
| const buf = Buffer.isBuffer(data) ? data : Buffer.from(data as ArrayBuffer); | |
| const demuxed = demuxAudioChunk(buf); | |
| if (demuxed) { | |
| const typeMatch = /Content-Type:\s*([^\r\n]+)/i.exec(demuxed.headers); | |
| if (typeMatch) contentType = typeMatch[1].trim(); | |
| if (demuxed.audio.length > 0) chunks.push(demuxed.audio); | |
| } | |
| return; | |
| } |
| export function computeSecMsGec(nowMs: number = Date.now()): string { | ||
| let ticks = nowMs / 1000 + WIN_EPOCH_OFFSET_SECONDS; | ||
| ticks -= ticks % SEC_MS_GEC_ROUND_SECONDS; | ||
| ticks *= 1e7; // seconds -> 100-nanosecond Windows file-time ticks | ||
| const strToHash = `${Math.floor(ticks)}${resolvePublicCred("edgetts_token")}`; | ||
| return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase(); | ||
| } |
There was a problem hiding this comment.
In computeSecMsGec, performing modulo operations and arithmetic directly on nowMs / 1000 (which is a floating-point number containing milliseconds) can introduce floating-point precision inaccuracies. For example, 13354473600.123 - (13354473600.123 % 300) can result in 13354473599.999998 instead of 13354473600. When multiplied by 1e7 and passed to Math.floor, this results in an incorrect tick count (off by 20 ticks). Since the Sec-MS-GEC token is a SHA-256 hash of this tick count, even a tiny difference will result in a completely different hash, causing intermittent authentication failures on the Microsoft server.
To fix this, truncate nowMs to integer seconds using Math.floor(nowMs / 1000) before performing any tick calculations.
| export function computeSecMsGec(nowMs: number = Date.now()): string { | |
| let ticks = nowMs / 1000 + WIN_EPOCH_OFFSET_SECONDS; | |
| ticks -= ticks % SEC_MS_GEC_ROUND_SECONDS; | |
| ticks *= 1e7; // seconds -> 100-nanosecond Windows file-time ticks | |
| const strToHash = `${Math.floor(ticks)}${resolvePublicCred("edgetts_token")}`; | |
| return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase(); | |
| } | |
| export function computeSecMsGec(nowMs: number = Date.now()): string { | |
| let seconds = Math.floor(nowMs / 1000); | |
| seconds -= seconds % SEC_MS_GEC_ROUND_SECONDS; | |
| const ticks = (seconds + WIN_EPOCH_OFFSET_SECONDS) * 1e7; | |
| const strToHash = `${ticks}${resolvePublicCred("edgetts_token")}`; | |
| return createHash("sha256").update(strToHash, "ascii").digest("hex").toUpperCase(); | |
| } |
…Files (#6668) The new provider's unit test covers a mutated module, so the strict mutation-test-coverage gate requires it in stryker.conf.json's tap.testFiles. Single-line addition (kept the file's existing formatting).
…r-inference#7605) * feat(sse): add EdgeTTS audio-tts provider (cheaper-inference#6668) Registers Microsoft Edge "Read Aloud" as a new no-API-key AUDIO_SPEECH_PROVIDERS entry — the first WebSocket-transport TTS provider in the registry. Reverse- engineered/unofficial endpoint, same class of integration already accepted for other "-web" style providers (chatgpt-web.ts, copilot-web.ts). - open-sse/executors/edgeTts.ts: pure Sec-MS-GEC token construction (SHA-256 over a public trusted-client-token + rounded Windows file-time ticks, ported from rany2/edge-tts drm.py), WS message framing (speech.config/ssml), binary-chunk demuxing, SSML building/escaping, and the WS synth call itself (injectable WebSocket ctor for tests, lazy `import("ws")` in production so it never enters esbuild's top-level CJS bundle graph). Per-client-IP sliding-window throttle (SlidingWindowLimiter) since there's no per-user key — one abusive deployment could otherwise get the shared trusted token rate-limited for everyone. - open-sse/utils/publicCreds.ts: embeds the trusted-client-token via resolvePublicCred() (Hard Rule cheaper-inference#11) — it's a constant hardcoded in every Edge build and every open-source edge-tts port, not a per-user secret. - Extracted open-sse/utils/audioResponse.ts (shared response helpers) and open-sse/executors/awsPollyTts.ts (AWS Polly handler) out of open-sse/handlers/audioSpeech.ts to stay under its frozen file-size ratchet baseline while making room for the new branch — no behavior change to either extracted piece. - src/app/api/v1/audio/speech/route.ts: thread the caller's IP through to the handler for the new throttle. Tests: tests/unit/edgetts-provider.test.ts (23 cases) — Sec-MS-GEC determinism and cross-check against a hand-derived reference vector, message framing, binary demux, SSML escaping/injection-safety, registry lookup, publicCreds shape, and the error path via an injected fake WebSocket (upstream failure -> sanitized 502, no stack/path leak; Hard Rule cheaper-inference#12), plus the per-IP rate limit. No live upstream is required or used — the reverse-engineered protocol can't be validated against real credentials, but every pure/testable seam is covered per the TDD path in the bug/feature validation gate. * test(mutation): register edgetts-provider.test.ts in stryker tap.testFiles (cheaper-inference#6668) The new provider's unit test covers a mutated module, so the strict mutation-test-coverage gate requires it in stryker.conf.json's tap.testFiles. Single-line addition (kept the file's existing formatting).
…r-inference#7605) * feat(sse): add EdgeTTS audio-tts provider (cheaper-inference#6668) Registers Microsoft Edge "Read Aloud" as a new no-API-key AUDIO_SPEECH_PROVIDERS entry — the first WebSocket-transport TTS provider in the registry. Reverse- engineered/unofficial endpoint, same class of integration already accepted for other "-web" style providers (chatgpt-web.ts, copilot-web.ts). - open-sse/executors/edgeTts.ts: pure Sec-MS-GEC token construction (SHA-256 over a public trusted-client-token + rounded Windows file-time ticks, ported from rany2/edge-tts drm.py), WS message framing (speech.config/ssml), binary-chunk demuxing, SSML building/escaping, and the WS synth call itself (injectable WebSocket ctor for tests, lazy `import("ws")` in production so it never enters esbuild's top-level CJS bundle graph). Per-client-IP sliding-window throttle (SlidingWindowLimiter) since there's no per-user key — one abusive deployment could otherwise get the shared trusted token rate-limited for everyone. - open-sse/utils/publicCreds.ts: embeds the trusted-client-token via resolvePublicCred() (Hard Rule cheaper-inference#11) — it's a constant hardcoded in every Edge build and every open-source edge-tts port, not a per-user secret. - Extracted open-sse/utils/audioResponse.ts (shared response helpers) and open-sse/executors/awsPollyTts.ts (AWS Polly handler) out of open-sse/handlers/audioSpeech.ts to stay under its frozen file-size ratchet baseline while making room for the new branch — no behavior change to either extracted piece. - src/app/api/v1/audio/speech/route.ts: thread the caller's IP through to the handler for the new throttle. Tests: tests/unit/edgetts-provider.test.ts (23 cases) — Sec-MS-GEC determinism and cross-check against a hand-derived reference vector, message framing, binary demux, SSML escaping/injection-safety, registry lookup, publicCreds shape, and the error path via an injected fake WebSocket (upstream failure -> sanitized 502, no stack/path leak; Hard Rule cheaper-inference#12), plus the per-IP rate limit. No live upstream is required or used — the reverse-engineered protocol can't be validated against real credentials, but every pure/testable seam is covered per the TDD path in the bug/feature validation gate. * test(mutation): register edgetts-provider.test.ts in stryker tap.testFiles (cheaper-inference#6668) The new provider's unit test covers a mutated module, so the strict mutation-test-coverage gate requires it in stryker.conf.json's tap.testFiles. Single-line addition (kept the file's existing formatting).
Closes #6668
What
Registers Microsoft Edge "Read Aloud" (EdgeTTS) as a new free, no-API-key
audio-ttsprovider — the first WebSocket-transport speech provider inAUDIO_SPEECH_PROVIDERS. This is a reverse-engineered/unofficial Microsoft endpoint (not a published public API), the same class of integration already accepted here for other "-web" style providers (chatgpt-web.ts,copilot-web.ts).open-sse/executors/edgeTts.ts— new module with:Sec-MS-GECanti-abuse token construction (SHA-256 over a public trusted-client-token + rounded Windows file-time ticks), ported fromrany2/edge-tts'sdrm.py::generate_sec_ms_gec().speech.config/ssmltext frames) and binary-chunk demuxing (2-byte header-length prefix + ASCII headers + raw audio bytes).synthesizeEdgeTts()— the actual WS I/O, with an injectable WebSocket constructor for tests and a lazyimport("ws")in production (keepswsout of esbuild's top-level CJS bundle graph, mirroringcopilot-web.ts/chipotle.ts).SlidingWindowLimiter, 20 req/60s) — EdgeTTS has no per-user key, so every OmniRoute deployment shares the same trusted-token identity upstream; this caps one abusive caller from getting the shared token throttled/blocked for everyone.open-sse/utils/publicCreds.ts— the trusted-client-token is embedded viaresolvePublicCred()(Hard Rule chore(ui): rebrand to OmniRoute #11), not a string literal. It's a constant hardcoded in every Edge browser build and every open-sourceedge-ttsreimplementation, not a per-user secret.open-sse/config/audioRegistry.ts— newedgettsentry (authType: "none",format: "edgetts"), with a starter voice list.open-sse/handlers/audioSpeech.ts— new one-line dispatch branch forformat === "edgetts".src/app/api/v1/audio/speech/route.ts— threads the caller's IP (getClientIpFromRequest) through to the handler for the new throttle.File-size ratchet (audioSpeech.ts was frozen at baseline)
open-sse/handlers/audioSpeech.tswas 1 line under its frozenfile-size-baseline.jsoncap (1060/1061), so before adding the EdgeTTS branch I extracted two self-contained pieces out of it with no behavior change:open-sse/utils/audioResponse.ts— the sharedaudioStreamResponse/upstreamErrorResponseresponse helpers.open-sse/executors/awsPollyTts.ts— the AWS Polly handler + its SigV4-request-shaping helpers.Net result:
audioSpeech.tswent from 1060 → 868 lines, well under baseline, with the new EdgeTTS branch added on top.Why no live-upstream test (Hard Rule #18 TDD path)
EdgeTTS is a reverse-engineered endpoint with no API key/account — there is no credential to validate against in CI, and hitting the real
speech.platform.bing.comWebSocket from a test suite would be flaky/against Microsoft's ToS for automated test traffic. Per the TDD path, every part of the implementation that is a pure function is unit-tested directly, and the network-touching seam (synthesizeEdgeTts) is tested via an injectable fakeWebSocketimplementing the same event interface — no live upstream, no live credentials, deterministic.Tests
tests/unit/edgetts-provider.test.ts— 23 cases, all green:computeSecMsGec: deterministic within a 5-minute window, changes across the boundary, and cross-checked against a hand-derived reference vector using the documentedrany2/edge-ttsalgorithm.publicCredsshape assertion (Hard Rule chore(ui): rebrand to OmniRoute #11):resolvePublicCred("edgetts_token")decodes to a well-formed 32-char hex token, stable across calls.buildSpeechConfigMessage,buildSsmlMessage), connection-id format/uniqueness, and the full WS URL's query params.ratevalue can't break out of the attribute; malicioustextis escaped, not embedded raw).nullinstead of throwing.getSpeechProvider("edgetts")andparseSpeechModel("edgetts/en-US-AriaNeural").synthesizeEdgeTtsend-to-end against an injected fake WebSocket: resolves with concatenated audio onturn.end, rejects on a socket error.handleEdgeTtsSpeech: 400 on empty input (no network touched), sanitized 502 on upstream WS failure with an assertion that neither a filesystem path nor a stack frame leaks into the response body (Hard Rule fix(ui): fix Select dropdown dark theme inconsistency #12), and the per-IP rate limit tripping to 429 after 20 requests.Also reran the pre-existing audio-speech suites to confirm the
audioSpeech.tsextraction didn't regress anything:Gates run (all green)
node scripts/check/check-file-size.mjs— OK (audioSpeech.ts shrank from 1060→868, well under its 1061 baseline; new files under cap)npm run check:complexity-ratchets— OK (complexity 2055 ≤ baseline 2056, cognitive 889 ≤ baseline 890)npm run check:complexity— OKnpm run typecheck:core— cleannpm run typecheck:noimplicit:core— clean (pre-existing violations elsewhere untouched by this PR)npx eslint --suppressions-location config/quality/eslint-suppressions.json <changed files>— clean, nono-explicit-anyviolationsnpm run check:cycles— OKnpm run check:any-budget:t11— OKnode scripts/check/check-test-discovery.mjs— OK, new test file discovered normally (no orphan)npm run check:docs-sync— PASSawaitin anysrc//open-sse/production module (wsis lazily imported insidesynthesizeEdgeTts())docs/guides/USER_GUIDE.md— addededgetts/to the TTS provider listNot applicable to this PR:
check:docs-counts/gen:provider-reference(EdgeTTS isaudio-tts-only, not registered insrc/shared/constants/providers.ts, so it doesn't move the "Total providers" count that gate tracks — confirmed by readingscripts/docs/gen-provider-reference.ts's imports), golden snapshot regen (no translator/format-conversion path — TTS goes through a dedicated WS handler branch, nottranslateRequest()), i18n (no UI strings added).Changelog
changelog.d/features/6668-edgetts-audio-tts-provider.md