mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 14:22:09 +03:00
* 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).
163 lines
5.2 KiB
TypeScript
163 lines
5.2 KiB
TypeScript
/**
|
|
* 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("<speak") ? "ssml" : "text";
|
|
}
|
|
|
|
function getAwsPollySampleRate(responseFormat, sampleRate) {
|
|
const explicit = getStringValue(sampleRate || null);
|
|
if (explicit) return explicit;
|
|
|
|
const outputFormat = normalizeAwsPollyOutputFormat(responseFormat);
|
|
if (outputFormat === "ogg_opus") return "48000";
|
|
if (outputFormat === "pcm") return "16000";
|
|
return undefined;
|
|
}
|
|
|
|
export async function handleAwsPollySpeech(
|
|
providerConfig,
|
|
body,
|
|
modelId,
|
|
token,
|
|
credentials
|
|
): Promise<Response> {
|
|
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");
|
|
}
|