Files
OmniRoute/open-sse/utils/audioResponse.ts
Diego Rodrigues de Sa e Souza 6695cbbf7a feat: add EdgeTTS audio-tts provider (#6668) (#7605)
* feat(sse): add EdgeTTS audio-tts provider (#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 #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.

* test(mutation): register edgetts-provider.test.ts in stryker tap.testFiles (#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).
2026-07-17 11:32:25 -03:00

66 lines
1.9 KiB
TypeScript

/**
* Shared audio/speech HTTP response helpers.
*
* Extracted from `open-sse/handlers/audioSpeech.ts` so that both the handler
* and any provider-specific adapter modules extracted alongside it (e.g.
* `open-sse/executors/awsPollyTts.ts`) can share the same response-shaping
* logic without importing from the (frozen, file-size-ratcheted) handler
* itself — which would create a circular import.
*/
import { CORS_HEADERS } from "./cors.ts";
/**
* Pull a human-readable error message out of a parsed upstream JSON error body.
*/
function extractUpstreamErrorMessage(parsed) {
const detail = parsed?.detail;
const candidates = [
parsed?.err_msg,
parsed?.error?.message,
typeof parsed?.error === "string" ? parsed.error : null,
parsed?.message,
typeof detail === "string" ? detail : detail?.message,
];
const raw = candidates.find(Boolean);
return raw ? String(raw) : null;
}
/**
* Return a CORS error response from an upstream fetch failure.
*/
export function upstreamErrorResponse(res: Response, errText: string): Response {
// Always return JSON so the client can detect 401/credential errors reliably
let errorMessage: string;
try {
const parsed = JSON.parse(errText);
errorMessage =
extractUpstreamErrorMessage(parsed) || errText || `Upstream error (${res.status})`;
} catch {
errorMessage = errText || `Upstream error (${res.status})`;
}
return Response.json(
{ error: { message: errorMessage, code: res.status } },
{
status: res.status,
headers: { ...CORS_HEADERS },
}
);
}
/**
* Return a CORS audio stream response.
*/
export function audioStreamResponse(res: Response, defaultContentType = "audio/mpeg"): Response {
const contentType = res.headers.get("content-type") || defaultContentType;
return new Response(res.body, {
status: 200,
headers: {
...CORS_HEADERS,
"Content-Type": contentType,
"Transfer-Encoding": "chunked",
},
});
}