diff --git a/changelog.d/features/6668-edgetts-audio-tts-provider.md b/changelog.d/features/6668-edgetts-audio-tts-provider.md new file mode 100644 index 0000000000..28b0f9995b --- /dev/null +++ b/changelog.d/features/6668-edgetts-audio-tts-provider.md @@ -0,0 +1 @@ +- **feat(sse):** add EdgeTTS (Microsoft Edge "Read Aloud") as a free, no-API-key `audio-tts` provider — the first WebSocket-transport speech provider, with per-client-IP rate limiting. (#6668) diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 3f9fdfc7f9..b35a8094a5 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -945,6 +945,7 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \ - `kie/` - `aws-polly/` - `xiaomi-mimo/` +- `edgetts/` (Microsoft Edge "Read Aloud" — free, no API key; unofficial/reverse-engineered endpoint) - `coqui/`, `tortoise/` - `qwen/` diff --git a/open-sse/config/audioRegistry.ts b/open-sse/config/audioRegistry.ts index 5dca0e6ec4..c7ab77f7bd 100644 --- a/open-sse/config/audioRegistry.ts +++ b/open-sse/config/audioRegistry.ts @@ -419,6 +419,31 @@ export const AUDIO_SPEECH_PROVIDERS: Record = { ], }, + edgetts: { + id: "edgetts", + // Microsoft Edge "Read Aloud" — reverse-engineered, no API key required. + // WebSocket transport (unlike every other entry here) — handled by + // open-sse/executors/edgeTts.ts, dispatched via the "edgetts" format. + baseUrl: "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1", + authType: "none", + authHeader: "none", + format: "edgetts", + supportedFormats: ["mp3"], + models: [ + { id: "en-US-AriaNeural", name: "Aria (EN-US, Female)" }, + { id: "en-US-GuyNeural", name: "Guy (EN-US, Male)" }, + { id: "en-GB-SoniaNeural", name: "Sonia (EN-GB, Female)" }, + { id: "en-GB-RyanNeural", name: "Ryan (EN-GB, Male)" }, + { id: "es-ES-ElviraNeural", name: "Elvira (ES-ES, Female)" }, + { id: "pt-BR-FranciscaNeural", name: "Francisca (PT-BR, Female)" }, + { id: "pt-BR-AntonioNeural", name: "Antonio (PT-BR, Male)" }, + { id: "fr-FR-DeniseNeural", name: "Denise (FR-FR, Female)" }, + { id: "de-DE-KatjaNeural", name: "Katja (DE-DE, Female)" }, + { id: "ja-JP-NanamiNeural", name: "Nanami (JA-JP, Female)" }, + { id: "zh-CN-XiaoxiaoNeural", name: "Xiaoxiao (ZH-CN, Female)" }, + ], + }, + "xiaomi-mimo": { id: "xiaomi-mimo", baseUrl: "https://api.xiaomimimo.com/v1/chat/completions", diff --git a/open-sse/executors/awsPollyTts.ts b/open-sse/executors/awsPollyTts.ts new file mode 100644 index 0000000000..311b2148dd --- /dev/null +++ b/open-sse/executors/awsPollyTts.ts @@ -0,0 +1,162 @@ +/** + * AWS Polly TTS handler. + * + * Extracted out of `open-sse/handlers/audioSpeech.ts` (frozen at its + * file-size ratchet baseline — config/quality/file-size-baseline.json) to + * make room for the new EdgeTTS WebSocket branch (#6668). Pure provider + * adapter, no behavior change vs. the original inline implementation. + * + * POST /v1/speech signed with AWS SigV4. The configured apiKey stores AWS + * Secret Access Key; providerSpecificData.accessKeyId stores AWS Access Key + * ID, with optional region/baseUrl/defaultVoice/sessionToken. + */ +import { stripTrailingSlashes } from "../utils/urlSanitize.ts"; +import { signAwsRequest } from "../utils/awsSigV4.ts"; +import { errorResponse } from "../utils/error.ts"; +import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts"; + +function getStringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function getAwsPollyProviderData(credentials) { + return credentials?.providerSpecificData && + typeof credentials.providerSpecificData === "object" && + !Array.isArray(credentials.providerSpecificData) + ? credentials.providerSpecificData + : {}; +} + +function resolveAwsPollyRegion(providerSpecificData) { + return ( + getStringValue(providerSpecificData.region) || + getStringValue(providerSpecificData.awsRegion) || + process.env.AWS_REGION || + process.env.AWS_DEFAULT_REGION || + "us-east-1" + ); +} + +function resolveAwsPollyBaseUrl(providerSpecificData, region) { + const configuredBaseUrl = getStringValue(providerSpecificData.baseUrl); + const baseUrl = configuredBaseUrl || `https://polly.${region}.amazonaws.com`; + return stripTrailingSlashes(baseUrl.replace(/\/v1\/speech\/?$/i, "")); +} + +function normalizeAwsPollyEngine(modelId) { + const engine = getStringValue(modelId) || "standard"; + return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard"; +} + +function normalizeAwsPollyOutputFormat(responseFormat) { + const format = getStringValue(responseFormat)?.toLowerCase(); + switch (format) { + case "pcm": + case "wav": + return "pcm"; + case "opus": + case "ogg_opus": + return "ogg_opus"; + case "ogg": + case "ogg_vorbis": + return "ogg_vorbis"; + case "json": + return "json"; + case "mp3": + default: + return "mp3"; + } +} + +function normalizeAwsPollyTextType(body) { + const explicitTextType = getStringValue(body.text_type || body.textType)?.toLowerCase(); + if (explicitTextType === "ssml") return "ssml"; + if (explicitTextType === "text") return "text"; + + const input = getStringValue(body.input) || ""; + return input.trim().startsWith(" { + const providerSpecificData = getAwsPollyProviderData(credentials); + const accessKeyId = + getStringValue(providerSpecificData.accessKeyId) || + getStringValue(providerSpecificData.awsAccessKeyId); + const secretAccessKey = getStringValue(token); + + if (!accessKeyId) { + return errorResponse(400, "AWS Polly requires providerSpecificData.accessKeyId"); + } + if (!secretAccessKey) { + return errorResponse(401, "No AWS Secret Access Key for AWS Polly"); + } + + const region = resolveAwsPollyRegion(providerSpecificData); + const baseUrl = resolveAwsPollyBaseUrl(providerSpecificData, region); + const url = `${baseUrl}/v1/speech`; + const outputFormat = normalizeAwsPollyOutputFormat(body.response_format); + const sampleRate = getAwsPollySampleRate( + body.response_format, + body.sample_rate || body.sampleRate + ); + + const requestBody = { + Engine: normalizeAwsPollyEngine(modelId), + OutputFormat: outputFormat, + Text: body.input, + TextType: normalizeAwsPollyTextType(body), + VoiceId: + getStringValue(body.voice) || getStringValue(providerSpecificData.defaultVoice) || "Joanna", + ...(getStringValue(body.language_code || body.languageCode) + ? { LanguageCode: getStringValue(body.language_code || body.languageCode) } + : {}), + ...(sampleRate ? { SampleRate: sampleRate } : {}), + }; + const serializedBody = JSON.stringify(requestBody); + + const signedHeaders = signAwsRequest({ + method: "POST", + url, + region, + service: "polly", + headers: { + "content-type": "application/json", + }, + body: serializedBody, + credentials: { + accessKeyId, + secretAccessKey, + sessionToken: + getStringValue(providerSpecificData.sessionToken) || + getStringValue(providerSpecificData.awsSessionToken), + }, + }); + + const res = await fetch(url, { + method: "POST", + headers: signedHeaders, + body: serializedBody, + }); + + if (!res.ok) { + return upstreamErrorResponse(res, await res.text()); + } + + return audioStreamResponse(res, outputFormat === "pcm" ? "audio/pcm" : "audio/mpeg"); +} diff --git a/open-sse/executors/edgeTts.ts b/open-sse/executors/edgeTts.ts new file mode 100644 index 0000000000..74a9c7f333 --- /dev/null +++ b/open-sse/executors/edgeTts.ts @@ -0,0 +1,354 @@ +/** + * EdgeTTS — Microsoft Edge "Read Aloud" text-to-speech (#6668). + * + * Reverse-engineered, unofficial, undocumented endpoint (not a published + * Microsoft public API) — the same class of integration this codebase + * already accepts for other "-web" style providers (chatgpt-web.ts, + * copilot-web.ts). No user account/API key is required; Microsoft gates + * abuse with a `Sec-MS-GEC` header computed from a public "trusted client + * token" (see `open-sse/utils/publicCreds.ts::edgetts_token` — Hard Rule + * #11, this is a constant hardcoded in every Edge browser build and every + * open-source edge-tts reimplementation, not a per-user secret). + * + * Protocol (verified against rany2/edge-tts + msedge-tts + edge-tts-universal): + * 1. WS connect to + * wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1 + * with `TrustedClientToken`, `Sec-MS-GEC`, `Sec-MS-GEC-Version` query params. + * 2. Send a `speech.config` text frame (output format, metadata options). + * 3. Send an `ssml` text frame carrying the SSML payload to synthesize. + * 4. Receive interleaved text frames (turn.start / audio.metadata / turn.end) + * and binary frames — each binary frame is a 2-byte big-endian header + * length, followed by ASCII headers, followed by raw audio bytes. + * 5. `turn.end` (or WS close) marks the end of the stream; concatenated + * audio chunks are the final MP3. + * + * All parsing above (Sec-MS-GEC HMAC input, message framing, binary chunk + * demux) is implemented as pure functions so it can be unit-tested without a + * live upstream connection — only `synthesizeEdgeTts()` itself touches the + * network, and it accepts an injectable WebSocket constructor for tests. + */ +import { createHash, randomBytes } from "node:crypto"; +import { resolvePublicCred } from "../utils/publicCreds.ts"; +import { errorResponse } from "../utils/error.ts"; +import { SlidingWindowLimiter } from "../services/slidingWindowLimiter.ts"; + +const EDGE_TTS_WS_URL = + "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1"; +const EDGE_TTS_GEC_VERSION = "1-138.0.0.0"; +const WIN_EPOCH_OFFSET_SECONDS = 11644473600; +const SEC_MS_GEC_ROUND_SECONDS = 300; // 5 minutes +const DEFAULT_VOICE = "en-US-AriaNeural"; +const DEFAULT_OUTPUT_FORMAT = "audio-24khz-48kbitrate-mono-mp3"; +const CONNECT_TIMEOUT_MS = 10_000; +const SYNTH_TIMEOUT_MS = 30_000; + +// Per-client-IP throttle — EdgeTTS has no per-user key, so every OmniRoute +// deployment shares the same trusted-token identity upstream. A single +// abusive caller could get the shared token rate-limited/blocked for +// everyone, so we cap requests per source IP before we ever open a socket. +const EDGE_TTS_RATE_WINDOW = { requests: 20, windowMs: 60_000 }; +const edgeTtsLimiter = new SlidingWindowLimiter(); + +export interface EdgeTtsSynthInput { + text: string; + voice?: string; + rate?: string; + pitch?: string; + volume?: string; +} + +export interface EdgeTtsSynthResult { + audio: Buffer; + contentType: string; +} + +/** + * A minimal shape of the subset of the `ws`/DOM WebSocket API this module + * needs — lets tests inject a fake implementation without touching the real + * network or the `ws` package. + */ +export interface MinimalWebSocket { + on(event: "open" | "message" | "close" | "error", listener: (...args: unknown[]) => void): void; + send(data: string): void; + close(): void; +} + +export type WebSocketCtor = new (url: string, opts?: unknown) => MinimalWebSocket; + +// ─── Pure helpers (unit-testable, no I/O) ────────────────────────────────── + +/** + * Compute the `Sec-MS-GEC` anti-abuse token Microsoft's Read Aloud endpoint + * requires. `nowMs` is injectable so the function is deterministic in tests. + * Algorithm ported from rany2/edge-tts `drm.py::generate_sec_ms_gec()`. + */ +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(); +} + +/** Random 32-hex-char connection id (no dashes), as the protocol expects. */ +export function buildConnectionId(): string { + return randomBytes(16).toString("hex"); +} + +function toIsoTimestamp(): string { + // Edge's protocol wants a JS-Date-toString-like timestamp; ISO is accepted + // by every reference implementation and is trivially deterministic/testable. + return new Date().toUTCString(); +} + +/** Build the `speech.config` WS text frame sent right after connecting. */ +export function buildSpeechConfigMessage(timestamp: string = toIsoTimestamp()): string { + const config = { + context: { + synthesis: { + audio: { + metadataoptions: { + sentenceBoundaryEnabled: "false", + wordBoundaryEnabled: "false", + }, + outputFormat: DEFAULT_OUTPUT_FORMAT, + }, + }, + }, + }; + return ( + `X-Timestamp:${timestamp}\r\n` + + `Content-Type:application/json; charset=utf-8\r\n` + + `Path:speech.config\r\n\r\n` + + `${JSON.stringify(config)}` + ); +} + +/** Escape user text for safe embedding inside an SSML `` element. */ +export function escapeSsmlText(text: string): string { + return String(text ?? "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** Normalize a caller-supplied voice name, falling back to the default voice. */ +export function normalizeEdgeVoice(voice: unknown): string { + const value = typeof voice === "string" ? voice.trim() : ""; + // Edge voice names are e.g. "en-US-AriaNeural" — locale-Name-Neural. + return /^[A-Za-z]{2,3}-[A-Za-z]{2,3}-[A-Za-z0-9]+Neural$/.test(value) ? value : DEFAULT_VOICE; +} + +function clampProsodyValue(value: unknown, fallback: string): string { + const str = typeof value === "string" ? value.trim() : ""; + // Accept "+10%", "-20%", "default", or a bare number — reject anything else + // to keep this untrusted-input path from injecting SSML markup. + return /^(default|[+-]?\d{1,3}%|[+-]?\d{1,3}(\.\d+)?)$/.test(str) ? str : fallback; +} + +/** Build the full SSML payload for one synthesis request. */ +export function buildSsml(input: EdgeTtsSynthInput): string { + const voice = normalizeEdgeVoice(input.voice); + const rate = clampProsodyValue(input.rate, "default"); + const pitch = clampProsodyValue(input.pitch, "default"); + const volume = clampProsodyValue(input.volume, "default"); + const text = escapeSsmlText(input.text); + return ( + `` + + `` + + `${text}` + + `` + ); +} + +/** Build the `ssml` WS text frame carrying the synthesis payload. */ +export function buildSsmlMessage( + requestId: string, + ssml: string, + timestamp: string = toIsoTimestamp() +): string { + return ( + `X-RequestId:${requestId}\r\n` + + `Content-Type:application/ssml+xml\r\n` + + `X-Timestamp:${timestamp}\r\n` + + `Path:ssml\r\n\r\n` + + `${ssml}` + ); +} + +/** True when a received text frame marks the end of the synthesis turn. */ +export function isTurnEndMessage(message: string): boolean { + return typeof message === "string" && message.includes("Path:turn.end"); +} + +/** + * Demux one binary WS frame into its header block and raw audio payload. + * Frame shape: 2-byte big-endian header length, then that many bytes of + * ASCII headers, then the remaining bytes are audio data. Returns `null` + * for a frame too short to contain a valid header-length prefix. + */ +export function demuxAudioChunk( + frame: Buffer +): { headers: string; audio: Buffer } | null { + if (!Buffer.isBuffer(frame) || frame.length < 2) return null; + const headerLength = frame.readUInt16BE(0); + if (2 + headerLength > frame.length) return null; + const headers = frame.subarray(2, 2 + headerLength).toString("ascii"); + const audio = frame.subarray(2 + headerLength); + return { headers, audio }; +} + +/** Build the WS connection URL, including the freshly-computed Sec-MS-GEC token. */ +export function buildEdgeTtsWsUrl(nowMs: number = Date.now()): string { + const params = new URLSearchParams({ + TrustedClientToken: resolvePublicCred("edgetts_token"), + "Sec-MS-GEC": computeSecMsGec(nowMs), + "Sec-MS-GEC-Version": EDGE_TTS_GEC_VERSION, + ConnectionId: buildConnectionId(), + }); + return `${EDGE_TTS_WS_URL}?${params.toString()}`; +} + +// ─── Network I/O ──────────────────────────────────────────────────────────── + +/** + * Open a WS connection to Edge's Read Aloud service and synthesize `input`. + * `WebSocketCtor` is injectable for tests; production callers omit it and + * this lazily imports the `ws` package (mirrors the pattern used in + * copilot-web.ts / chipotle.ts — keeps `ws` out of the esbuild CJS bundle's + * top-level graph). + */ +export async function synthesizeEdgeTts( + input: EdgeTtsSynthInput, + WebSocketCtor?: WebSocketCtor +): Promise { + const Ctor = WebSocketCtor ?? ((await import("ws")).default as unknown as WebSocketCtor); + const url = buildEdgeTtsWsUrl(); + const ssml = buildSsml(input); + const requestId = buildConnectionId(); + + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let settled = false; + let contentType = "audio/mpeg"; + + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(); + }; + + const timer = setTimeout(() => { + finish(() => { + try { + ws.close(); + } catch { + // best-effort close on timeout + } + reject(new Error("EdgeTTS synthesis timed out")); + }); + }, SYNTH_TIMEOUT_MS); + + let ws: MinimalWebSocket; + try { + ws = new Ctor(url, { handshakeTimeout: CONNECT_TIMEOUT_MS }); + } catch (err) { + clearTimeout(timer); + reject(err instanceof Error ? err : new Error(String(err))); + return; + } + + ws.on("open", () => { + ws.send(buildSpeechConfigMessage()); + ws.send(buildSsmlMessage(requestId, ssml)); + }); + + 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; + } + const text = String(data); + if (isTurnEndMessage(text)) { + finish(() => { + try { + ws.close(); + } catch { + // best-effort close + } + resolve({ audio: Buffer.concat(chunks), contentType }); + }); + } + }); + + ws.on("error", (err: unknown) => { + finish(() => reject(err instanceof Error ? err : new Error(String(err)))); + }); + + ws.on("close", () => { + finish(() => { + if (chunks.length > 0) { + resolve({ audio: Buffer.concat(chunks), contentType }); + } else { + reject(new Error("EdgeTTS connection closed before receiving audio")); + } + }); + }); + }); +} + +// ─── Handler entrypoint (called from audioSpeech.ts) ─────────────────────── + +/** + * Handle an EdgeTTS `/v1/audio/speech` request. `clientIp` is optional — when + * provided, this enforces the per-IP sliding-window throttle described above. + */ +export async function handleEdgeTtsSpeech( + body: { input?: unknown; voice?: unknown }, + clientIp?: string | null, + WebSocketCtor?: WebSocketCtor +): Promise { + if (clientIp) { + const { allowed, retryAfterMs } = edgeTtsLimiter.tryAcquire(clientIp, EDGE_TTS_RATE_WINDOW); + if (!allowed) { + return errorResponse( + 429, + `EdgeTTS rate limit exceeded, retry after ${Math.ceil(retryAfterMs / 1000)}s` + ); + } + } + + const text = typeof body?.input === "string" ? body.input : ""; + if (!text.trim()) { + return errorResponse(400, "input is required"); + } + + try { + const { audio, contentType } = await synthesizeEdgeTts( + { + text, + voice: typeof body.voice === "string" ? body.voice : undefined, + }, + WebSocketCtor + ); + return new Response(audio, { + status: 200, + headers: { "Content-Type": contentType }, + }); + } catch (err) { + return errorResponse( + 502, + `EdgeTTS request failed: ${err instanceof Error ? err.message : String(err)}` + ); + } +} diff --git a/open-sse/handlers/audioSpeech.ts b/open-sse/handlers/audioSpeech.ts index 8885d4edba..d7053a1b09 100644 --- a/open-sse/handlers/audioSpeech.ts +++ b/open-sse/handlers/audioSpeech.ts @@ -21,7 +21,10 @@ import { getSpeechProvider, parseSpeechModel } from "../config/audioRegistry.ts" import { buildAuthHeaders } from "../config/registryUtils.ts"; import { kieExecutor } from "../executors/kie.ts"; import { vertexGenerateSpeech } from "../executors/vertexMedia.ts"; +import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts"; +import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts"; import { errorResponse } from "../utils/error.ts"; +import { audioStreamResponse, upstreamErrorResponse } from "../utils/audioResponse.ts"; import { getKieCallbackUrl, getKieErrorMessage, @@ -29,59 +32,6 @@ import { isJsonObject, parseKieResultJson, } from "../utils/kieTask.ts"; -import { signAwsRequest } from "../utils/awsSigV4.ts"; - -/** - * Return a CORS error response from an upstream fetch failure - */ -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; -} - -function upstreamErrorResponse(res, errText) { - // 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 - */ -function audioStreamResponse(res, defaultContentType = "audio/mpeg") { - const contentType = res.headers.get("content-type") || defaultContentType; - return new Response(res.body, { - status: 200, - headers: { - ...CORS_HEADERS, - "Content-Type": contentType, - "Transfer-Encoding": "chunked", - }, - }); -} function normalizeKieElevenLabsVoice(voice: unknown): string { const value = typeof voice === "string" ? voice.trim() : ""; @@ -178,30 +128,6 @@ function getStringValue(value): string | null { return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; } -function getAwsPollyProviderData(credentials) { - return credentials?.providerSpecificData && - typeof credentials.providerSpecificData === "object" && - !Array.isArray(credentials.providerSpecificData) - ? credentials.providerSpecificData - : {}; -} - -function resolveAwsPollyRegion(providerSpecificData) { - return ( - getStringValue(providerSpecificData.region) || - getStringValue(providerSpecificData.awsRegion) || - process.env.AWS_REGION || - process.env.AWS_DEFAULT_REGION || - "us-east-1" - ); -} - -function resolveAwsPollyBaseUrl(providerSpecificData, region) { - const configuredBaseUrl = getStringValue(providerSpecificData.baseUrl); - const baseUrl = configuredBaseUrl || `https://polly.${region}.amazonaws.com`; - return stripTrailingSlashes(baseUrl.replace(/\/v1\/speech\/?$/i, "")); -} - function getProviderSpecificData(credentials) { return credentials?.providerSpecificData && typeof credentials.providerSpecificData === "object" && @@ -249,50 +175,6 @@ function getXiaomiMimoAudioData(data) { ); } -function normalizeAwsPollyEngine(modelId) { - const engine = getStringValue(modelId) || "standard"; - return ["standard", "neural", "long-form", "generative"].includes(engine) ? engine : "standard"; -} - -function normalizeAwsPollyOutputFormat(responseFormat) { - const format = getStringValue(responseFormat)?.toLowerCase(); - switch (format) { - case "pcm": - case "wav": - return "pcm"; - case "opus": - case "ogg_opus": - return "ogg_opus"; - case "ogg": - case "ogg_vorbis": - return "ogg_vorbis"; - case "json": - return "json"; - case "mp3": - default: - return "mp3"; - } -} - -function normalizeAwsPollyTextType(body) { - const explicitTextType = getStringValue(body.text_type || body.textType)?.toLowerCase(); - if (explicitTextType === "ssml") return "ssml"; - if (explicitTextType === "text") return "text"; - - const input = getStringValue(body.input) || ""; - return input.trim().startsWith(" sanitized error response, no stack leak). +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; + +import { + computeSecMsGec, + buildConnectionId, + buildSpeechConfigMessage, + buildSsmlMessage, + buildSsml, + buildEdgeTtsWsUrl, + escapeSsmlText, + normalizeEdgeVoice, + isTurnEndMessage, + demuxAudioChunk, + synthesizeEdgeTts, + handleEdgeTtsSpeech, + type MinimalWebSocket, +} from "../../open-sse/executors/edgeTts.ts"; +import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts"; +import { resolvePublicCred } from "../../open-sse/utils/publicCreds.ts"; + +// ─── Sec-MS-GEC token (HMAC-ish SHA-256 construction) ────────────────────── + +test("computeSecMsGec is deterministic for the same 5-minute window", () => { + const base = Date.UTC(2026, 6, 17, 12, 0, 0); // 2026-07-17T12:00:00Z + const a = computeSecMsGec(base); + const b = computeSecMsGec(base + 60_000); // +1 minute, same 5-minute bucket + assert.equal(a, b, "same rounded-down 5-minute window must hash identically"); + assert.match(a, /^[0-9A-F]{64}$/, "output must be a 64-char uppercase hex SHA-256 digest"); +}); + +test("computeSecMsGec changes across a 5-minute window boundary", () => { + const base = Date.UTC(2026, 6, 17, 12, 0, 0); + const before = computeSecMsGec(base - 1); // just before the 5-min bucket + const after = computeSecMsGec(base); + assert.notEqual(before, after); +}); + +test("computeSecMsGec matches the reference rany2/edge-tts algorithm shape", () => { + // Cross-check against a hand-computed reference vector for a fixed instant, + // using the same constants/algorithm documented in drm.py: + // ticks = floor((nowMs/1000 + 11644473600) - (... % 300)) * 1e7 + // sha256(`${ticks}${TRUSTED_CLIENT_TOKEN}`).hexdigest().upper() + const nowMs = Date.UTC(2026, 0, 1, 0, 0, 0); + const winEpoch = 11644473600; + let ticks = nowMs / 1000 + winEpoch; + ticks -= ticks % 300; + ticks *= 1e7; + const token = resolvePublicCred("edgetts_token"); + const expected = createHash("sha256") + .update(`${Math.floor(ticks)}${token}`, "ascii") + .digest("hex") + .toUpperCase(); + assert.equal(computeSecMsGec(nowMs), expected); +}); + +// ─── publicCreds shape assertion (Hard Rule #11) ─────────────────────────── + +test("edgetts_token is embedded via resolvePublicCred, not a string literal", () => { + const token = resolvePublicCred("edgetts_token"); + assert.equal(typeof token, "string"); + assert.ok(token.length > 0, "embedded default must decode to a non-empty token"); + // The well-known public trusted-client-token format used by every Edge + // build and every open-source edge-tts port: 32 uppercase hex chars. + assert.match(token, /^[0-9A-F]{32}$/); +}); + +test("resolvePublicCred('edgetts_token') is stable across repeated calls", () => { + // No envName is passed for this key (there's no legacy .env var to migrate + // from — it's a brand-new provider), so it must always resolve to the same + // embedded default rather than reading from process.env. + assert.equal(resolvePublicCred("edgetts_token"), resolvePublicCred("edgetts_token")); +}); + +// ─── Connection id / message framing ─────────────────────────────────────── + +test("buildConnectionId returns a 32-char lowercase hex id with no dashes", () => { + const id = buildConnectionId(); + assert.match(id, /^[0-9a-f]{32}$/); +}); + +test("buildConnectionId is unique per call", () => { + const ids = new Set(Array.from({ length: 20 }, () => buildConnectionId())); + assert.equal(ids.size, 20); +}); + +test("buildSpeechConfigMessage frames a valid speech.config WS text message", () => { + const msg = buildSpeechConfigMessage("Tue, 01 Jan 2026 00:00:00 GMT"); + assert.match(msg, /^X-Timestamp:Tue, 01 Jan 2026 00:00:00 GMT\r\n/); + assert.match(msg, /Content-Type:application\/json; charset=utf-8\r\n/); + assert.match(msg, /Path:speech\.config\r\n\r\n/); + const jsonPart = msg.slice(msg.indexOf("\r\n\r\n") + 4); + const parsed = JSON.parse(jsonPart); + assert.equal( + parsed.context.synthesis.audio.outputFormat, + "audio-24khz-48kbitrate-mono-mp3" + ); +}); + +test("buildSsmlMessage frames a valid ssml WS text message carrying the SSML body", () => { + const ssml = buildSsml({ text: "hello" }); + const msg = buildSsmlMessage("req-123", ssml, "Tue, 01 Jan 2026 00:00:00 GMT"); + assert.match(msg, /^X-RequestId:req-123\r\n/); + assert.match(msg, /Content-Type:application\/ssml\+xml\r\n/); + assert.match(msg, /Path:ssml\r\n\r\n/); + assert.ok(msg.endsWith(ssml), "message must end with the exact SSML payload"); +}); + +test("buildEdgeTtsWsUrl includes TrustedClientToken, Sec-MS-GEC, and ConnectionId", () => { + const url = new URL(buildEdgeTtsWsUrl(Date.UTC(2026, 6, 17))); + assert.equal(url.protocol, "wss:"); + assert.equal(url.hostname, "speech.platform.bing.com"); + assert.ok(url.searchParams.get("TrustedClientToken")); + assert.match(url.searchParams.get("Sec-MS-GEC") || "", /^[0-9A-F]{64}$/); + assert.match(url.searchParams.get("ConnectionId") || "", /^[0-9a-f]{32}$/); +}); + +// ─── SSML building / escaping (untrusted-input safety) ───────────────────── + +test("escapeSsmlText escapes all five XML special characters", () => { + assert.equal( + escapeSsmlText(` & "quoted" 'single'`), + "<tag> & "quoted" 'single'" + ); +}); + +test("buildSsml embeds escaped text and rejects SSML injection via prosody attrs", () => { + const ssml = buildSsml({ + text: "pwned", + rate: "'; ", + }); + assert.ok(!ssml.includes("