mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-27 01:22:10 +03:00
chore(audio): retire EdgeTTS pending license review
This commit is contained in:
@@ -557,7 +557,7 @@ the current catalog at **[radar.omniroute.online/planos](https://radar.omniroute
|
||||
- **🧠 Memory you control** — off by default, opt-in int8 vector quantization + typed decay, per-request `x-omniroute-no-memory`. → [Memory](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Security** — prompt-injection guard on every LLM route (red-team suite), opt-in credential-masking guardrail (redacts leaked API keys/secrets in both directions), free DuckDuckGo last-resort web search, and an optional OIDC login gate for the dashboard (password login always stays available). → [Guardrails](docs/security/GUARDRAILS.md)
|
||||
- **🖼️ New endpoints** — `/v1/ocr` (Mistral OCR) and `/v1/audio/translations` (Whisper-style) round out the media surface. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Image / video / audio generation** — one API for media: xAI Grok Imagine & Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, and speech providers such as ElevenLabs. → [API Reference](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment & ops** — reverse-proxy `basePath`, browser-language auto-detect, per-key device tracking, root-less MITM trust, zh-TW localization. → [Environment](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 More providers & agents** — Cursor Cloud Agent, Grok Build (xAI) with browser + OAuth login, Ollama first-class card, Claude Opus 5 & Sonnet 5, Kimi official partnership (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… and a refreshed **352-provider catalog**. → [Providers](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Routing transparency** — every response carries an `X-OmniRoute-Decision` header naming the strategy/provider/latency that served it, a new `cache-optimized` combo strategy + Auto-Combo `cacheAffinity` factor route repeat requests back to the connection holding the cached prefix, and a read-only `/v1/auto-combo/{channel}/candidates` endpoint exposes an `auto/*` channel's live candidate pool. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
|
||||
1
changelog.d/maintenance/0000-retire-edge-tts.md
Normal file
1
changelog.d/maintenance/0000-retire-edge-tts.md
Normal file
@@ -0,0 +1 @@
|
||||
- **chore(audio):** retire the built-in EdgeTTS provider and its 11-voice catalog while provenance/licensing review remains on HOLD; the generic `/v1/audio/speech` endpoint and the other speech providers remain available ([#0000](https://github.com/diegosouzapw/OmniRoute/pull/0000))
|
||||
@@ -74,14 +74,16 @@
|
||||
"reason": "v3.8.50 #9126 (commit 8fac6bcd48): pluginWorker.ts, sandbox.ts e signing.ts foram removidos por completo (\"zero importers confirmed\") — o subsistema de sandbox de plugins com worker-thread nunca foi ligado a nenhum consumidor. O teste era source-scan sobre pluginWorker.ts (ver docstring do arquivo deletado); sem o arquivo-fonte não há mais o que testar. OMNIROUTE_PLUGINS_ALLOW_EXEC também foi removido de .env.example e da doc na mesma release. Sem substituto porque a feature foi extinta, não migrada."
|
||||
},
|
||||
"tests/unit/plugins-sandbox.test.ts": {
|
||||
"sourceRemoved": [
|
||||
"src/lib/plugins/sandbox.ts"
|
||||
],
|
||||
"sourceRemoved": ["src/lib/plugins/sandbox.ts"],
|
||||
"reason": "v3.8.50 #9126 (commit 8fac6bcd48): sandbox.ts foi removido por completo junto com pluginWorker.ts e signing.ts (\"zero importers confirmed\", subsistema de sandbox de plugins nunca ligado a nenhum consumidor). O teste cobria SandboxLevel/getSandboxLabel exportados por sandbox.ts; sem o arquivo-fonte não há mais símbolo a testar. Mesma causa-raiz de tests/unit/plugin-sandbox-permissions.test.ts nesta entrada."
|
||||
},
|
||||
"tests/unit/gemini-3-5-flash-thinking.test.ts": {
|
||||
"replacement": "tests/unit/gemini-models-parser.test.ts",
|
||||
"reason": "v3.8.50 #10286 superseded by 2764812ee4 \"fix(providers): eliminate Gemini 3.5 Flash\": the model was RETIRED on this line, so its thinking-capability regression test (added by #10450 for the pre-retirement behavior) was deleted together with the model's MODEL_SPECS entry, its changelog fragment and its rate-limit config — feature removal by the maintainer, not masking. The replacement pins the NEW contract: \"parseGeminiModelsList excludes retired Gemini 3.5 Flash but keeps Flash Lite\". The gate only sees the deletion because main still carries the pre-retirement file; it prunes itself once v3.8.50 merges to main. Human review: presented to the operator during the v3.8.50 release pre-flight."
|
||||
},
|
||||
"tests/unit/edgetts-provider.test.ts": {
|
||||
"replacement": "tests/unit/edgetts-retirement.test.ts",
|
||||
"reason": "v3.8.50 pending PR: the EdgeTTS executor and its positive protocol tests were retired together after a provenance/licensing HOLD. The replacement pins the new public contract across the speech registry, handler and UI-derived catalog while preserving gTTS, AWS Polly and ElevenLabs. Feature retirement, not test masking; prune after v3.8.50 merges to main."
|
||||
}
|
||||
},
|
||||
"tests/unit/catalog-updates-v3x.test.ts": "v3.8.45 #6248: fix(providers) remove deprecated MiMo V2 entries — os 5 asserts removidos pinavam specs de modelos mimo-v2-* que deixaram de existir no catálogo (54→49). Asserts seguem a remoção dos modelos, não enfraquecimento. Verificado legítimo. Prune após v3.8.45 mergear para main.",
|
||||
|
||||
@@ -27,7 +27,7 @@ Core capabilities:
|
||||
- Embedding generation via `/v1/embeddings` (6 providers, 9 models)
|
||||
- Image generation via `/v1/images/generations` (10+ providers, 20+ models)
|
||||
- Audio transcription via `/v1/audio/transcriptions` (7 providers)
|
||||
- Text-to-speech via `/v1/audio/speech` (10 providers)
|
||||
- Text-to-speech via `/v1/audio/speech` (24 built-in providers)
|
||||
- Video generation via `/v1/videos/generations` (ComfyUI + SD WebUI)
|
||||
- Music generation via `/v1/music/generations` (ComfyUI)
|
||||
- Web search via `/v1/search` (5 providers)
|
||||
@@ -980,7 +980,7 @@ All other providers (including custom compatible nodes) use the `DefaultExecutor
|
||||
| KIE | openai | API Key | ✅ | ✅ | ❌ | ❌ |
|
||||
| Devin Desktop | openai | Imported API key | ✅ (Connect→SSE) | ✅ | ❌ | ⚠️ Per request |
|
||||
| GitLab Duo | openai | OAuth (GitLab) | ✅ | ✅ | ✅ | ❌ |
|
||||
| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API |
|
||||
| Devin CLI | openai | Local CLI login | ✅ | ✅ | ❌ | ✅ Task API |
|
||||
| Codex Cloud | openai-responses | OAuth | ✅ | ❌ | ✅ | ✅ Rate limits |
|
||||
| Jules | openai | OAuth | ✅ | ✅ | ✅ | ✅ Task API |
|
||||
| AgentRouter | openai | API Key | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
@@ -1027,7 +1027,6 @@ If only OpenRouter is configured, use `openrouter/deepgram/nova-3`.
|
||||
- `kie/`
|
||||
- `aws-polly/`
|
||||
- `xiaomi-mimo/`
|
||||
- `edgetts/` (Microsoft Edge "Read Aloud" — free, no API key; unofficial/reverse-engineered endpoint)
|
||||
- `coqui/`, `tortoise/`
|
||||
- `qwen/`
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@ Radar è opt-in e usa soltanto richieste GET. Il client OmniRoute non carica pro
|
||||
- **🧠 Memoria sotto il tuo controllo** — disattivata per impostazione predefinita, quantizzazione vettoriale int8 opt-in + decadimento tipizzato, `x-omniroute-no-memory` per-request. → [Memoria](../../frameworks/MEMORY.md)
|
||||
- **🛡️ Sicurezza** — guard contro la prompt injection su ogni route LLM (suite red-team), guardrail opzionale per il masking delle credenziali (oscura API key/secret trapelati in entrambe le direzioni), web search DuckDuckGo gratuita come ultima risorsa e gate di login OIDC opzionale per la dashboard (il login con password resta sempre disponibile). → [Guardrail](../../security/GUARDRAILS.md)
|
||||
- **🖼️ Nuovi endpoint** — `/v1/ocr` (Mistral OCR) e `/v1/audio/translations` (stile Whisper) completano la superficie media. → [Riferimento API](../../reference/API_REFERENCE.md)
|
||||
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [Riferimento API](../../reference/API_REFERENCE.md)
|
||||
- **🎨 Generazione immagini / video / audio** — una sola API per i media: xAI Grok Imagine e Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind e provider vocali come ElevenLabs. → [Riferimento API](../../reference/API_REFERENCE.md)
|
||||
- **🌍 Deployment e operazioni** — `basePath` del reverse proxy, rilevamento automatico della lingua del browser, tracking dei dispositivi per chiave, trust MITM senza root, localizzazione zh-TW. → [Ambiente](../../reference/ENVIRONMENT.md)
|
||||
- **🤝 Più provider e agenti** — Cursor Cloud Agent, Grok Build (xAI) con login browser + OAuth, scheda Ollama di prima classe, Claude Opus 5 e Sonnet 5, partnership ufficiale Kimi (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… e un catalogo aggiornato di **350 provider**. → [Provider](../../reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Trasparenza del routing** — ogni risposta include un header `X-OmniRoute-Decision` con strategia/provider/latenza che l'ha servita; una nuova strategia combo `cache-optimized` + il fattore `cacheAffinity` di Auto-Combo riportano le richieste ripetute alla connessione che possiede il prefisso in cache; un endpoint read-only `/v1/auto-combo/{channel}/candidates` espone il pool di candidati live di un canale `auto/*`. → [Auto-Combo](../../routing/AUTO-COMBO.md)
|
||||
|
||||
@@ -973,7 +973,6 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \
|
||||
- `kie/`
|
||||
- `aws-polly/`
|
||||
- `xiaomi-mimo/`
|
||||
- `edgetts/` (Microsoft Edge „Read Aloud” — darmowe, bez klucza API; nieoficjalny/reverse-engineered endpoint)
|
||||
- `coqui/`, `tortoise/`
|
||||
- `qwen/`
|
||||
|
||||
|
||||
@@ -548,7 +548,7 @@ Radar isteğe bağlıdır (opt-in) ve yalnızca GET istekleri yapar. OmniRoute i
|
||||
- **🧠 Kontrol ettiğiniz bellek** — Varsayılan olarak kapalı, isteğe bağlı int8 vektör niceleme + tipli sönümleme, istek başına `x-omniroute-no-memory`. → [Bellek](docs/frameworks/MEMORY.md)
|
||||
- **🛡️ Güvenlik** — Her LLM rotasında istem enjeksiyonu koruması (red-team paketi), isteğe bağlı kimlik bilgisi maskeleme koruması (her iki yönde de sızan API anahtarlarını/gizli bilgileri sansürler), ücretsiz DuckDuckGo son çare web araması ve pano için isteğe bağlı OIDC giriş kapısı (şifreyle giriş her zaman kullanılabilir kalır). → [Güvenlik Önlemleri (Guardrails)](docs/security/GUARDRAILS.md)
|
||||
- **🖼️ Yeni uç noktalar** — `/v1/ocr` (Mistral OCR) ve `/v1/audio/translations` (Whisper tarzı) medya yüzeyini tamamlar. → [API Referansı](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind, EdgeTTS. → [API Referansı](docs/reference/API_REFERENCE.md)
|
||||
- **🎨 Görsel / video / ses üretimi** — Medya için tek bir API: xAI Grok Imagine ve Novita AI video, ComfyUI, Freepik, Adobe Firefly, Microsoft Designer, Segmind ve ElevenLabs gibi konuşma sağlayıcıları. → [API Referansı](docs/reference/API_REFERENCE.md)
|
||||
- **🌍 Dağıtım ve operasyonlar** — Ters proxy `basePath`, tarayıcı dili otomatik algılama, anahtar başına cihaz takibi, root gerektirmeyen MITM güveni, zh-TW yerelleştirmesi. → [Ortam Değişkenleri](docs/reference/ENVIRONMENT.md)
|
||||
- **🤝 Daha fazla sağlayıcı ve ajan** — Cursor Cloud Agent, tarayıcı + OAuth girişiyle Grok Build (xAI), Ollama birinci sınıf kartı, Claude Opus 5 ve Sonnet 5, Kimi resmi ortaklığı (Code/Web/Moonshot), Zed, Requesty, SenseNova, Yuanbao, Agnes AI… ve yenilenen **350 sağlayıcılı katalog**. → [Sağlayıcılar](docs/reference/PROVIDER_REFERENCE.md)
|
||||
- **📡 Yönlendirme şeffaflığı** — Her yanıt, isteğe hizmet veren stratejiyi/sağlayıcıyı/gecikmeyi belirten bir `X-OmniRoute-Decision` başlığı taşır, yeni bir `cache-optimized` kombo stratejisi + Auto-Combo `cacheAffinity` faktörü yinelenen istekleri önbelleğe alınmış öneki tutan bağlantıya geri yönlendirir ve salt okunur bir `/v1/auto-combo/{channel}/candidates` uç noktası bir `auto/*` kanalının canlı aday havuzunu gösterir. → [Auto-Combo](docs/routing/AUTO-COMBO.md)
|
||||
|
||||
@@ -973,7 +973,6 @@ curl -X POST http://localhost:20128/v1/audio/transcriptions \
|
||||
- `kie/`
|
||||
- `aws-polly/`
|
||||
- `xiaomi-mimo/`
|
||||
- `edgetts/`(Microsoft Edge「朗讀功能」— 免費,無需 API 金鑰;非官方/逆向工程端點)
|
||||
- `coqui/`, `tortoise/`
|
||||
- `qwen/`
|
||||
|
||||
|
||||
@@ -544,31 +544,6 @@ export const AUDIO_SPEECH_PROVIDERS: Record<string, AudioProvider> = {
|
||||
],
|
||||
},
|
||||
|
||||
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)" },
|
||||
],
|
||||
},
|
||||
|
||||
gtts: {
|
||||
id: "gtts",
|
||||
// Google Translate TTS — reverse-engineered, no API key required.
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
* 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.
|
||||
* file-size ratchet baseline — config/quality/file-size-baseline.json).
|
||||
* 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
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
/**
|
||||
* 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<ArrayBuffer>;
|
||||
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 `<voice>` element. */
|
||||
export function escapeSsmlText(text: string): string {
|
||||
return String(text ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.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 (
|
||||
`<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>` +
|
||||
`<voice name='${voice}'>` +
|
||||
`<prosody rate='${rate}' pitch='${pitch}' volume='${volume}'>${text}</prosody>` +
|
||||
`</voice></speak>`
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<EdgeTtsSynthResult> {
|
||||
const Ctor = WebSocketCtor ?? ((await import("ws")).default as unknown as WebSocketCtor);
|
||||
const url = buildEdgeTtsWsUrl();
|
||||
const ssml = buildSsml(input);
|
||||
const requestId = buildConnectionId();
|
||||
|
||||
return new Promise<EdgeTtsSynthResult>((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<Response> {
|
||||
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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Reverse-engineered, unofficial, undocumented endpoint (not a published
|
||||
* Google public API) — the same class of integration this codebase already
|
||||
* accepts for other "-web"/no-auth style providers (edgeTts.ts, chipotle.ts).
|
||||
* accepts for other "-web"/no-auth style providers.
|
||||
* No user account/API key is required.
|
||||
*
|
||||
* The issue's originally proposed endpoint
|
||||
|
||||
@@ -23,7 +23,6 @@ import { kieExecutor } from "../executors/kie.ts";
|
||||
import { vertexGenerateSpeech } from "../executors/vertexMedia.ts";
|
||||
import { handleGeminiTtsSpeech } from "../executors/geminiTts.ts";
|
||||
import { handleAwsPollySpeech } from "../executors/awsPollyTts.ts";
|
||||
import { handleEdgeTtsSpeech } from "../executors/edgeTts.ts";
|
||||
import { GttsUpstreamError, normalizeGttsLang, synthesizeGtts } from "../executors/gtts.ts";
|
||||
import { errorResponse } from "../utils/error.ts";
|
||||
import { resolveElevenLabsVoiceId } from "./elevenLabsVoiceMap.ts";
|
||||
@@ -844,7 +843,6 @@ export async function handleAudioSpeech({
|
||||
credentials,
|
||||
resolvedProvider = null,
|
||||
resolvedModel = null,
|
||||
clientIp = null,
|
||||
}) {
|
||||
if (!body.model) {
|
||||
return errorResponse(400, "model is required");
|
||||
@@ -866,7 +864,7 @@ export async function handleAudioSpeech({
|
||||
if (!providerConfig) {
|
||||
return errorResponse(
|
||||
400,
|
||||
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, fishaudio, playht, kie, aws-polly, xiaomi-mimo, edgetts, gtts, coqui, tortoise, qwen`
|
||||
`No speech provider found for model "${body.model}". Use format provider/model. Available: openai, hyperbolic, deepgram, nvidia, elevenlabs, huggingface, inworld, cartesia, fishaudio, playht, kie, aws-polly, xiaomi-mimo, gtts, coqui, tortoise, qwen`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -946,10 +944,6 @@ export async function handleAudioSpeech({
|
||||
return handleAwsPollySpeech(providerConfig, body, modelId, token, credentials);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "edgetts") {
|
||||
return handleEdgeTtsSpeech(body, clientIp);
|
||||
}
|
||||
|
||||
if (providerConfig.format === "gtts") {
|
||||
return handleGttsSpeech(body);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { handleAudioSpeech } from "@omniroute/open-sse/handlers/audioSpeech.ts";
|
||||
import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { calculateModalCost } from "@/lib/usage/costCalculator";
|
||||
import { getClientIpFromRequest } from "@/lib/ipUtils";
|
||||
import { toJsonErrorPayload } from "@/shared/utils/upstreamError";
|
||||
import { HTTP_STATUS } from "@omniroute/open-sse/config/constants.ts";
|
||||
import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
@@ -35,10 +34,6 @@ import { errorResponse } from "@omniroute/open-sse/utils/error.ts";
|
||||
export async function executeSpeechCombo(
|
||||
comboName: string,
|
||||
body: Record<string, unknown>,
|
||||
auth: {
|
||||
request: Request;
|
||||
policy: { apiKeyInfo?: { id?: string; name?: string } | null };
|
||||
},
|
||||
startTime: number
|
||||
): Promise<Response> {
|
||||
const combo = await getComboByName(comboName);
|
||||
@@ -80,7 +75,6 @@ export async function executeSpeechCombo(
|
||||
);
|
||||
}
|
||||
|
||||
const clientIp = getClientIpFromRequest(auth.request);
|
||||
let lastError: { status: number; error: string } | null = null;
|
||||
let fallbackCount = 0;
|
||||
|
||||
@@ -129,7 +123,6 @@ export async function executeSpeechCombo(
|
||||
credentials,
|
||||
resolvedProvider: providerConfig,
|
||||
resolvedModel,
|
||||
clientIp,
|
||||
});
|
||||
|
||||
if (response?.ok) {
|
||||
|
||||
@@ -186,15 +186,6 @@ const EMBEDDED_DEFAULTS = {
|
||||
12, 93, 15, 11, 74, 12, 16, 77, 72, 72, 73, 20, 82, 65, 93, 81, 72, 65, 28, 13, 93, 88, 93, 95,
|
||||
92, 70, 16, 81, 31, 66, 17, 4, 88, 88, 5, 28,
|
||||
],
|
||||
// Microsoft Edge Read Aloud (EdgeTTS) — public "trusted client token" used to
|
||||
// derive the Sec-MS-GEC anti-abuse header. Hardcoded in every known Edge
|
||||
// browser build and every open-source edge-tts reimplementation (e.g.
|
||||
// rany2/edge-tts constants.py) — not a per-user secret, just an
|
||||
// abuse-mitigation constant Microsoft ships in public client binaries.
|
||||
edgetts_token: [
|
||||
89, 44, 91, 40, 51, 94, 49, 64, 32, 108, 54, 51, 86, 41, 80, 37, 111, 69, 6, 42, 95, 93, 45, 68,
|
||||
87, 65, 77, 84, 105, 70, 51, 86,
|
||||
],
|
||||
// Adobe Firefly web (firefly.adobe.com) — public x-api-key + IMS client_id
|
||||
// (`clio-playground-web`). Captured from live browser generate/discovery calls.
|
||||
// Not a per-user secret; every Firefly SPA session sends the same value.
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import { attachOmniRouteMetaToResponse } from "@/domain/omnirouteResponseMeta";
|
||||
import { calculateModalCost } from "@/lib/usage/costCalculator";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { getClientIpFromRequest } from "@/lib/ipUtils";
|
||||
|
||||
/**
|
||||
* Handle CORS preflight
|
||||
@@ -64,7 +63,7 @@ async function postHandler(request, context) {
|
||||
const combo = await getComboByName(body.model);
|
||||
if (combo) {
|
||||
const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo");
|
||||
return executeSpeechCombo(body.model, body, { request, policy }, startTime);
|
||||
return executeSpeechCombo(body.model, body, startTime);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +101,6 @@ async function postHandler(request, context) {
|
||||
credentials,
|
||||
resolvedProvider: providerConfig,
|
||||
resolvedModel,
|
||||
clientIp: getClientIpFromRequest(request),
|
||||
});
|
||||
if (response?.ok) {
|
||||
await clearRecoveredProviderState(credentials);
|
||||
|
||||
@@ -226,7 +226,6 @@
|
||||
"tests/unit/db/stats-dbstat-optional.test.ts",
|
||||
"tests/unit/ddg-circuit-breaker-null-content-6999-7000.test.ts",
|
||||
"tests/unit/domain-persistence.test.ts",
|
||||
"tests/unit/edgetts-provider.test.ts",
|
||||
"tests/unit/embedding-account-cooldown-10347.test.ts",
|
||||
"tests/unit/embedding-cooldown-integration-10347.test.ts",
|
||||
"tests/unit/embeddings-auth.test.ts",
|
||||
|
||||
@@ -22,21 +22,6 @@ const core = await import("@/lib/db/core.ts");
|
||||
const { createCombo } = await import("@/lib/db/combos");
|
||||
const { executeSpeechCombo } = await import("@omniroute/open-sse/services/speechCombo");
|
||||
|
||||
function createRequest(model: string): Request {
|
||||
return new Request("http://localhost:20128/v1/audio/speech", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model, input: "hello there" }),
|
||||
});
|
||||
}
|
||||
|
||||
function createMockAuth() {
|
||||
return {
|
||||
request: createRequest("test-combo"),
|
||||
policy: { apiKeyInfo: { id: "test-key", name: "test-key" } },
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupTestDataDir() {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
@@ -66,7 +51,6 @@ test("returns 400 when combo is not found", async () => {
|
||||
const response = await executeSpeechCombo(
|
||||
"nonexistent-combo",
|
||||
{ model: "nonexistent-combo", input: "hello there" },
|
||||
createMockAuth(),
|
||||
Date.now()
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
@@ -84,7 +68,6 @@ test("returns 400 when combo has no speech-capable targets", async () => {
|
||||
const response = await executeSpeechCombo(
|
||||
"chat-only-combo",
|
||||
{ model: "chat-only-combo", input: "hello there" },
|
||||
createMockAuth(),
|
||||
Date.now()
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
@@ -93,13 +76,31 @@ test("returns 400 when combo has no speech-capable targets", async () => {
|
||||
assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces");
|
||||
});
|
||||
|
||||
test("does not select retired EdgeTTS targets as speech-capable", async () => {
|
||||
await createCombo({
|
||||
name: "retired-edgetts-combo",
|
||||
strategy: "priority",
|
||||
models: ["edgetts/en-US-AriaNeural"],
|
||||
});
|
||||
|
||||
const response = await executeSpeechCombo(
|
||||
"retired-edgetts-combo",
|
||||
{ model: "retired-edgetts-combo", input: " " },
|
||||
Date.now()
|
||||
);
|
||||
const bodyStr = JSON.stringify(await response.json());
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.ok(bodyStr.includes("No speech-capable targets"));
|
||||
assert.ok(!bodyStr.includes("at "), "Error response does not leak stack traces");
|
||||
});
|
||||
|
||||
test("returns 400 when combo has no usable targets", async () => {
|
||||
await createCombo({ name: "empty-combo", strategy: "priority", models: [] });
|
||||
|
||||
const response = await executeSpeechCombo(
|
||||
"empty-combo",
|
||||
{ model: "empty-combo", input: "hello there" },
|
||||
createMockAuth(),
|
||||
Date.now()
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
@@ -115,7 +116,6 @@ test("fails cleanly when speech targets exist but no provider connection does",
|
||||
const response = await executeSpeechCombo(
|
||||
"spc-no-conn",
|
||||
{ model: "spc-no-conn", input: "hello there" },
|
||||
createMockAuth(),
|
||||
Date.now()
|
||||
);
|
||||
assert.ok(response.status >= 400, "Surfaces a failure rather than a fake success");
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
// EdgeTTS (Microsoft Edge "Read Aloud") audio-tts provider (#6668).
|
||||
//
|
||||
// EdgeTTS is a reverse-engineered WebSocket endpoint with no API key, so
|
||||
// there is no live upstream we can validate against in CI (Hard Rule #18
|
||||
// TDD path). This suite covers everything that is a pure function: the
|
||||
// Sec-MS-GEC token/HMAC construction, WS message framing, binary-chunk
|
||||
// demuxing, SSML building/escaping, registry lookup, and the error path
|
||||
// (mocked WebSocket failure -> 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(`<tag> & "quoted" 'single'`),
|
||||
"<tag> & "quoted" 'single'"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSsml embeds escaped text and rejects SSML injection via prosody attrs", () => {
|
||||
const ssml = buildSsml({
|
||||
text: "</voice><voice name='evil'>pwned",
|
||||
rate: "'; </prosody><script>alert(1)</script>",
|
||||
});
|
||||
assert.ok(!ssml.includes("<script>"), "malicious prosody rate must be clamped, not embedded");
|
||||
assert.ok(ssml.includes("</voice>"), "malicious text must be XML-escaped");
|
||||
assert.ok(ssml.includes("rate='default'"), "invalid rate falls back to default");
|
||||
});
|
||||
|
||||
test("normalizeEdgeVoice accepts well-formed voice names and rejects everything else", () => {
|
||||
assert.equal(normalizeEdgeVoice("en-US-AriaNeural"), "en-US-AriaNeural");
|
||||
assert.equal(normalizeEdgeVoice("pt-BR-FranciscaNeural"), "pt-BR-FranciscaNeural");
|
||||
assert.equal(normalizeEdgeVoice("not a voice; DROP TABLE"), "en-US-AriaNeural");
|
||||
assert.equal(normalizeEdgeVoice(undefined), "en-US-AriaNeural");
|
||||
});
|
||||
|
||||
// ─── Binary audio chunk demux (pure, no live socket needed) ────────────────
|
||||
|
||||
test("demuxAudioChunk splits a binary frame into headers + raw audio bytes", () => {
|
||||
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
|
||||
const headerBuf = Buffer.from(headers, "ascii");
|
||||
const audioBuf = Buffer.from([1, 2, 3, 4, 5]);
|
||||
const lenBuf = Buffer.alloc(2);
|
||||
lenBuf.writeUInt16BE(headerBuf.length, 0);
|
||||
const frame = Buffer.concat([lenBuf, headerBuf, audioBuf]);
|
||||
|
||||
const result = demuxAudioChunk(frame);
|
||||
assert.ok(result);
|
||||
assert.equal(result!.headers, headers);
|
||||
assert.deepEqual(Array.from(result!.audio), [1, 2, 3, 4, 5]);
|
||||
});
|
||||
|
||||
test("demuxAudioChunk returns null for a truncated/malformed frame", () => {
|
||||
assert.equal(demuxAudioChunk(Buffer.from([0])), null);
|
||||
const lenBuf = Buffer.alloc(2);
|
||||
lenBuf.writeUInt16BE(100, 0); // claims 100 header bytes but frame is short
|
||||
assert.equal(demuxAudioChunk(Buffer.concat([lenBuf, Buffer.from("short")])), null);
|
||||
});
|
||||
|
||||
test("isTurnEndMessage recognizes the turn.end marker and nothing else", () => {
|
||||
assert.equal(isTurnEndMessage("X-RequestId:abc\r\nPath:turn.end\r\n\r\n"), true);
|
||||
assert.equal(isTurnEndMessage("Path:turn.start\r\n\r\n"), false);
|
||||
assert.equal(isTurnEndMessage(""), false);
|
||||
});
|
||||
|
||||
// ─── Registry wiring ────────────────────────────────────────────────────────
|
||||
|
||||
test("edgetts is registered in AUDIO_SPEECH_PROVIDERS with no-key WS transport", () => {
|
||||
const provider = getSpeechProvider("edgetts");
|
||||
assert.ok(provider);
|
||||
assert.equal(provider!.authType, "none");
|
||||
assert.equal(provider!.format, "edgetts");
|
||||
assert.match(provider!.baseUrl, /^wss:\/\//);
|
||||
assert.ok(provider!.models.length > 0);
|
||||
});
|
||||
|
||||
test("parseSpeechModel resolves 'edgetts/<voice>' to the edgetts provider", () => {
|
||||
const { provider, model } = parseSpeechModel("edgetts/en-US-AriaNeural");
|
||||
assert.equal(provider, "edgetts");
|
||||
assert.equal(model, "en-US-AriaNeural");
|
||||
});
|
||||
|
||||
// ─── synthesizeEdgeTts / handleEdgeTtsSpeech with an injected fake WebSocket ─
|
||||
|
||||
class FakeEmitterSocket implements MinimalWebSocket {
|
||||
private listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
sent: string[] = [];
|
||||
closed = false;
|
||||
|
||||
on(event: string, listener: (...args: unknown[]) => void) {
|
||||
(this.listeners[event] ??= []).push(listener);
|
||||
}
|
||||
send(data: string) {
|
||||
this.sent.push(data);
|
||||
}
|
||||
close() {
|
||||
this.closed = true;
|
||||
}
|
||||
emit(event: string, ...args: unknown[]) {
|
||||
for (const l of this.listeners[event] || []) l(...args);
|
||||
}
|
||||
}
|
||||
|
||||
test("synthesizeEdgeTts resolves with concatenated audio on turn.end", async () => {
|
||||
let socket: FakeEmitterSocket;
|
||||
const Ctor = function (this: unknown, _url: string) {
|
||||
socket = new FakeEmitterSocket();
|
||||
// Simulate the server's protocol asynchronously after `open` is sent.
|
||||
queueMicrotask(() => {
|
||||
socket.emit("open");
|
||||
const headers = "Path:audio\r\nContent-Type:audio/mpeg\r\n";
|
||||
const headerBuf = Buffer.from(headers, "ascii");
|
||||
const lenBuf = Buffer.alloc(2);
|
||||
lenBuf.writeUInt16BE(headerBuf.length, 0);
|
||||
const frame = Buffer.concat([lenBuf, headerBuf, Buffer.from("audiobytes")]);
|
||||
socket.emit("message", frame, true);
|
||||
socket.emit("message", "Path:turn.end\r\n\r\n", false);
|
||||
});
|
||||
return socket;
|
||||
} as unknown as new (url: string) => MinimalWebSocket;
|
||||
|
||||
const result = await synthesizeEdgeTts({ text: "hello" }, Ctor);
|
||||
assert.equal(result.audio.toString(), "audiobytes");
|
||||
assert.equal(result.contentType, "audio/mpeg");
|
||||
});
|
||||
|
||||
test("synthesizeEdgeTts rejects when the socket errors", async () => {
|
||||
const Ctor = function (this: unknown, _url: string) {
|
||||
const socket = new FakeEmitterSocket();
|
||||
queueMicrotask(() => socket.emit("error", new Error("upstream refused connection")));
|
||||
return socket;
|
||||
} as unknown as new (url: string) => MinimalWebSocket;
|
||||
|
||||
await assert.rejects(
|
||||
() => synthesizeEdgeTts({ text: "hello" }, Ctor),
|
||||
/upstream refused connection/
|
||||
);
|
||||
});
|
||||
|
||||
test("handleEdgeTtsSpeech returns 400 without touching the network on empty input", async () => {
|
||||
const response = await handleEdgeTtsSpeech({ input: "" });
|
||||
assert.equal(response.status, 400);
|
||||
const bodyJson = await response.json();
|
||||
assert.equal(bodyJson.error.message, "input is required");
|
||||
});
|
||||
|
||||
test("handleEdgeTtsSpeech returns a sanitized 502 on upstream WS failure (Hard Rule #12)", async () => {
|
||||
const Ctor = function (this: unknown, _url: string) {
|
||||
const socket = new FakeEmitterSocket();
|
||||
// Simulate a raw upstream failure that could contain a stack trace or an
|
||||
// absolute filesystem path — the handler must never leak it verbatim.
|
||||
const err = new Error("connect ECONNREFUSED 127.0.0.1:443");
|
||||
(err as Error).stack = `Error: connect ECONNREFUSED\n at /home/user/secret/app.js:42:10`;
|
||||
queueMicrotask(() => socket.emit("error", err));
|
||||
return socket;
|
||||
} as unknown as new (url: string) => MinimalWebSocket;
|
||||
|
||||
const response = await handleEdgeTtsSpeech({ input: "hello" }, null, Ctor);
|
||||
assert.equal(response.status, 502);
|
||||
const bodyJson = await response.json();
|
||||
assert.ok(bodyJson.error.message.includes("ECONNREFUSED"));
|
||||
assert.ok(!bodyJson.error.message.includes("/home/user/secret"), "must not leak a filesystem path");
|
||||
assert.ok(!bodyJson.error.message.includes("at /"), "must not leak a stack trace frame");
|
||||
});
|
||||
|
||||
test("handleEdgeTtsSpeech enforces the per-IP sliding-window rate limit", async () => {
|
||||
const ip = `203.0.113.${Math.floor(Math.random() * 250) + 1}`;
|
||||
// Drain the window with the input-validation fast-path (still exercises
|
||||
// tryAcquire before validation) using an always-invalid body to avoid a
|
||||
// real network call, then assert the last call is 429, not 400.
|
||||
let last: Response | undefined;
|
||||
for (let i = 0; i < 21; i++) {
|
||||
last = await handleEdgeTtsSpeech({ input: "" }, ip);
|
||||
}
|
||||
assert.equal(last!.status, 429);
|
||||
const bodyJson = await last!.json();
|
||||
assert.match(bodyJson.error.message, /rate limit/i);
|
||||
});
|
||||
52
tests/unit/edgetts-retirement.test.ts
Normal file
52
tests/unit/edgetts-retirement.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
AUDIO_SPEECH_PROVIDERS,
|
||||
getAllAudioModels,
|
||||
getSpeechProvider,
|
||||
parseSpeechModel,
|
||||
} from "../../open-sse/config/audioRegistry.ts";
|
||||
import { getRegistryMediaKinds } from "../../open-sse/config/mediaServiceKinds.ts";
|
||||
import { handleAudioSpeech } from "../../open-sse/handlers/audioSpeech.ts";
|
||||
import { toProviderModels } from "../../src/app/(dashboard)/dashboard/cache/media/mediaProviderModels.ts";
|
||||
|
||||
test("retired EdgeTTS is absent from every published speech catalog", () => {
|
||||
assert.equal(getSpeechProvider("edgetts"), null);
|
||||
assert.deepEqual(parseSpeechModel("edgetts/en-US-AriaNeural"), {
|
||||
provider: null,
|
||||
model: "edgetts/en-US-AriaNeural",
|
||||
});
|
||||
assert.equal(Object.hasOwn(AUDIO_SPEECH_PROVIDERS, "edgetts"), false);
|
||||
assert.equal(
|
||||
getAllAudioModels().some((model) => model.provider === "edgetts"),
|
||||
false
|
||||
);
|
||||
assert.deepEqual(getRegistryMediaKinds("edgetts"), []);
|
||||
assert.equal(
|
||||
toProviderModels(AUDIO_SPEECH_PROVIDERS).some((provider) => provider.id === "edgetts"),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("speech requests no longer advertise or dispatch EdgeTTS", async () => {
|
||||
const response = await handleAudioSpeech({
|
||||
body: { model: "edgetts/en-US-AriaNeural", input: "hello" },
|
||||
credentials: null,
|
||||
});
|
||||
const payload = (await response.json()) as { error: { message: string } };
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(payload.error.message, /No speech provider found/);
|
||||
const available = payload.error.message.split("Available:")[1] || "";
|
||||
assert.doesNotMatch(available, /edgetts/i);
|
||||
for (const control of ["elevenlabs", "aws-polly", "gtts"]) {
|
||||
assert.match(available, new RegExp(`\\b${control}\\b`));
|
||||
}
|
||||
});
|
||||
|
||||
test("retiring EdgeTTS preserves the supported speech providers", () => {
|
||||
for (const control of ["gtts", "aws-polly", "elevenlabs"]) {
|
||||
assert.ok(getSpeechProvider(control), `${control} must remain registered`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user