Skip to content

feat: add EdgeTTS audio-tts provider (#6668) - #7605

Merged
diegosouzapw merged 2 commits into
release/v3.8.49from
feat/6668-edgetts-audio-tts-provider
Jul 17, 2026
Merged

diegosouzapw merged 2 commits into
release/v3.8.49from
feat/6668-edgetts-audio-tts-provider

Conversation

@diegosouzapw

Copy link
Copy Markdown
Collaborator

Closes #6668

What

Registers Microsoft Edge "Read Aloud" (EdgeTTS) as a new free, no-API-key audio-tts provider — the first WebSocket-transport speech provider in AUDIO_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:
    • Pure Sec-MS-GEC anti-abuse token construction (SHA-256 over a public trusted-client-token + rounded Windows file-time ticks), ported from rany2/edge-tts's drm.py::generate_sec_ms_gec().
    • Pure WS message framing (speech.config / ssml text frames) and binary-chunk demuxing (2-byte header-length prefix + ASCII headers + raw audio bytes).
    • SSML building with XML escaping and a clamped prosody-attribute allowlist (untrusted-input safety).
    • synthesizeEdgeTts() — the actual WS I/O, with an injectable WebSocket constructor for tests and a lazy import("ws") in production (keeps ws out of esbuild's top-level CJS bundle graph, mirroring copilot-web.ts/chipotle.ts).
    • Per-client-IP sliding-window rate limit (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 via resolvePublicCred() (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-source edge-tts reimplementation, not a per-user secret.
  • open-sse/config/audioRegistry.ts — new edgetts entry (authType: "none", format: "edgetts"), with a starter voice list.
  • open-sse/handlers/audioSpeech.ts — new one-line dispatch branch for format === "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.ts was 1 line under its frozen file-size-baseline.json cap (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 shared audioStreamResponse/upstreamErrorResponse response helpers.
  • open-sse/executors/awsPollyTts.ts — the AWS Polly handler + its SigV4-request-shaping helpers.

Net result: audioSpeech.ts went 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.com WebSocket 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 fake WebSocket implementing 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 documented rany2/edge-tts algorithm.
  • publicCreds shape assertion (Hard Rule chore(ui): rebrand to OmniRoute #11): resolvePublicCred("edgetts_token") decodes to a well-formed 32-char hex token, stable across calls.
  • WS message framing (buildSpeechConfigMessage, buildSsmlMessage), connection-id format/uniqueness, and the full WS URL's query params.
  • SSML escaping (all 5 XML special chars) and injection-safety (a malicious rate value can't break out of the attribute; malicious text is escaped, not embedded raw).
  • Binary chunk demux: valid frame splits into headers+audio; truncated/malformed frames return null instead of throwing.
  • Registry wiring: getSpeechProvider("edgetts") and parseSpeechModel("edgetts/en-US-AriaNeural").
  • synthesizeEdgeTts end-to-end against an injected fake WebSocket: resolves with concatenated audio on turn.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.
node --import tsx/esm --test tests/unit/edgetts-provider.test.ts
ℹ tests 23
ℹ pass 23
ℹ fail 0

Also reran the pre-existing audio-speech suites to confirm the audioSpeech.ts extraction didn't regress anything:

node --import tsx/esm --test tests/unit/audio-speech-handler.test.ts tests/unit/minimax-tts-1043.test.ts tests/unit/route-edge-coverage.test.ts
ℹ tests 41
ℹ pass 41
ℹ fail 0

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 — OK
  • npm run typecheck:core — clean
  • npm 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, no no-explicit-any violations
  • npm run check:cycles — OK
  • npm run check:any-budget:t11 — OK
  • node scripts/check/check-test-discovery.mjs — OK, new test file discovered normally (no orphan)
  • npm run check:docs-sync — PASS
  • No top-level await in any src//open-sse/ production module (ws is lazily imported inside synthesizeEdgeTts())
  • docs/guides/USER_GUIDE.md — added edgetts/ to the TTS provider list

Not applicable to this PR: check:docs-counts/gen:provider-reference (EdgeTTS is audio-tts-only, not registered in src/shared/constants/providers.ts, so it doesn't move the "Total providers" count that gate tracks — confirmed by reading scripts/docs/gen-provider-reference.ts's imports), golden snapshot regen (no translator/format-conversion path — TTS goes through a dedicated WS handler branch, not translateRequest()), i18n (no UI strings added).

Changelog

changelog.d/features/6668-edgetts-audio-tts-provider.md

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.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +269 to +280
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

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.

Suggested change
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;
}

Comment on lines +85 to +91
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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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).
@diegosouzapw
diegosouzapw merged commit 6695cbb into release/v3.8.49 Jul 17, 2026
20 checks passed
@diegosouzapw
diegosouzapw deleted the feat/6668-edgetts-audio-tts-provider branch July 19, 2026 21:00
HouMinXi pushed a commit to HouMinXi/OmniRoute that referenced this pull request Aug 2, 2026
…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).
@diegosouzapw diegosouzapw mentioned this pull request Aug 23, 2026
muhamadgalihsaputra pushed a commit to niyatna/NiyatnaRoute that referenced this pull request Sep 27, 2026
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(providers): add EdgeTTS (Microsoft Edge free TTS) as audio-tts provider (free tier available)

1 participant