mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 21:32:10 +03:00
Validated in merge-train --fast @ 6cafcbb (static gates + 9 changed test files + vitest green, 2m35s; full suite ran today on train 2c tip)
188 lines
7.7 KiB
TypeScript
188 lines
7.7 KiB
TypeScript
// gTTS (Google Translate TTS) audio-tts provider (#6667).
|
|
//
|
|
// The originally-proposed `/translate_tts` GET endpoint is deprecated;
|
|
// this covers the current `batchexecute` RPC mechanism: chunking to the
|
|
// 100-char limit, RPC body construction, response parsing, registry lookup,
|
|
// and the handler's dispatch + error path.
|
|
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import {
|
|
GOOGLE_TTS_MAX_CHARS,
|
|
GttsUpstreamError,
|
|
buildGttsRpcBody,
|
|
chunkGttsText,
|
|
normalizeGttsLang,
|
|
parseBatchExecuteResponse,
|
|
synthesizeGtts,
|
|
} from "../../open-sse/executors/gtts.ts";
|
|
import { getSpeechProvider, parseSpeechModel } from "../../open-sse/config/audioRegistry.ts";
|
|
import { handleAudioSpeech } from "../../open-sse/handlers/audioSpeech.ts";
|
|
|
|
// ─── chunkGttsText ──────────────────────────────────────────────────────
|
|
|
|
test("chunkGttsText returns a single chunk for short input", () => {
|
|
assert.deepEqual(chunkGttsText("hello world"), ["hello world"]);
|
|
});
|
|
|
|
test("chunkGttsText returns [] for empty/whitespace-only input", () => {
|
|
assert.deepEqual(chunkGttsText(""), []);
|
|
assert.deepEqual(chunkGttsText(" "), []);
|
|
assert.deepEqual(chunkGttsText(undefined), []);
|
|
});
|
|
|
|
test("chunkGttsText splits long input on whitespace within the 100-char limit", () => {
|
|
const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" "); // > 100 chars
|
|
const chunks = chunkGttsText(text);
|
|
assert.ok(chunks.length > 1, "must split into multiple chunks");
|
|
for (const chunk of chunks) {
|
|
assert.ok(chunk.length <= GOOGLE_TTS_MAX_CHARS, `chunk exceeds limit: "${chunk}"`);
|
|
}
|
|
// Rejoining chunks must reconstruct the original words (no character loss).
|
|
assert.equal(chunks.join(" "), text);
|
|
});
|
|
|
|
test("chunkGttsText hard-splits a single word longer than the limit", () => {
|
|
const longWord = "a".repeat(250);
|
|
const chunks = chunkGttsText(longWord, 100);
|
|
assert.equal(chunks.length, 3);
|
|
assert.equal(chunks.join(""), longWord);
|
|
for (const chunk of chunks) {
|
|
assert.ok(chunk.length <= 100);
|
|
}
|
|
});
|
|
|
|
// ─── normalizeGttsLang ──────────────────────────────────────────────────
|
|
|
|
test("normalizeGttsLang accepts simple language codes and defaults to English", () => {
|
|
assert.equal(normalizeGttsLang("pt-BR"), "pt-BR");
|
|
assert.equal(normalizeGttsLang("es"), "es");
|
|
assert.equal(normalizeGttsLang(""), "en");
|
|
assert.equal(normalizeGttsLang(undefined), "en");
|
|
assert.equal(normalizeGttsLang("<script>alert(1)</script>"), "en");
|
|
});
|
|
|
|
// ─── buildGttsRpcBody ───────────────────────────────────────────────────
|
|
|
|
test("buildGttsRpcBody wraps text/lang under the jQ1olc RPC id, urlencoded", () => {
|
|
const body = buildGttsRpcBody("hello", "en");
|
|
assert.match(body, /^f\.req=/);
|
|
assert.ok(body.endsWith("&"));
|
|
|
|
const encoded = body.slice("f.req=".length, -1);
|
|
const envelope = JSON.parse(decodeURIComponent(encoded));
|
|
assert.deepEqual(envelope, [[["jQ1olc", JSON.stringify(["hello", "en", true, "null"]), null, "generic"]]]);
|
|
});
|
|
|
|
// ─── parseBatchExecuteResponse ──────────────────────────────────────────
|
|
|
|
function buildBatchExecuteFixture(base64Audio: string): string {
|
|
const inner = JSON.stringify([base64Audio, null, null, null, null, null, []]);
|
|
const outer = JSON.stringify([["wrb.fr", "jQ1olc", inner, null, null, null, "generic"]]);
|
|
return `)]}'\n\n${outer.length}\n${outer}\n`;
|
|
}
|
|
|
|
test("parseBatchExecuteResponse extracts the base64 audio payload", () => {
|
|
const raw = buildBatchExecuteFixture("aGVsbG8=");
|
|
assert.equal(parseBatchExecuteResponse(raw), "aGVsbG8=");
|
|
});
|
|
|
|
test("parseBatchExecuteResponse throws GttsUpstreamError when no payload is found", () => {
|
|
assert.throws(() => parseBatchExecuteResponse(")]}'\n\nnot json at all"), GttsUpstreamError);
|
|
});
|
|
|
|
// ─── synthesizeGtts (network via injectable fetch) ───────────────────────
|
|
|
|
test("synthesizeGtts concatenates decoded audio across multiple chunks", async () => {
|
|
const text = Array.from({ length: 20 }, (_, i) => `word${i}`).join(" ");
|
|
const expectedChunks = chunkGttsText(text);
|
|
assert.ok(expectedChunks.length > 1);
|
|
|
|
const calls: string[] = [];
|
|
const fetchImpl = async (url: string, init: RequestInit) => {
|
|
calls.push(String(init.body));
|
|
return new Response(buildBatchExecuteFixture(Buffer.from("chunk-audio").toString("base64")), {
|
|
status: 200,
|
|
headers: { "content-type": "text/plain" },
|
|
});
|
|
};
|
|
|
|
const audio = await synthesizeGtts({ text, lang: "en" }, fetchImpl);
|
|
assert.equal(calls.length, expectedChunks.length);
|
|
assert.equal(audio.toString(), "chunk-audio".repeat(expectedChunks.length));
|
|
});
|
|
|
|
test("synthesizeGtts throws GttsUpstreamError with the upstream status on a non-ok response", async () => {
|
|
const fetchImpl = async () =>
|
|
new Response("rate limited", { status: 429 });
|
|
|
|
await assert.rejects(
|
|
() => synthesizeGtts({ text: "hi", lang: "en" }, fetchImpl),
|
|
(err: unknown) => err instanceof GttsUpstreamError && err.status === 429
|
|
);
|
|
});
|
|
|
|
// ─── registry lookup ──────────────────────────────────────────────────
|
|
|
|
test("gtts is registered as a no-auth speech provider", () => {
|
|
const provider = getSpeechProvider("gtts");
|
|
assert.ok(provider);
|
|
assert.equal(provider?.authType, "none");
|
|
assert.equal(provider?.format, "gtts");
|
|
|
|
const parsed = parseSpeechModel("gtts/default");
|
|
assert.equal(parsed.provider, "gtts");
|
|
assert.equal(parsed.model, "default");
|
|
});
|
|
|
|
// ─── handleAudioSpeech dispatch ─────────────────────────────────────────
|
|
|
|
test("handleAudioSpeech routes gtts requests without requiring credentials", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async (_url: string, init: RequestInit) => {
|
|
assert.match(String(init.body), /^f\.req=/);
|
|
return new Response(buildBatchExecuteFixture(Buffer.from("hi-audio").toString("base64")), {
|
|
status: 200,
|
|
headers: { "content-type": "text/plain" },
|
|
});
|
|
}) as typeof fetch;
|
|
|
|
try {
|
|
const providerConfig = getSpeechProvider("gtts");
|
|
const response = await handleAudioSpeech({
|
|
body: { model: "gtts/default", input: "hi there", voice: "en" },
|
|
credentials: null,
|
|
resolvedProvider: providerConfig,
|
|
resolvedModel: "default",
|
|
});
|
|
|
|
assert.equal(response.status, 200);
|
|
assert.equal(response.headers.get("content-type"), "audio/mpeg");
|
|
const buf = Buffer.from(await response.arrayBuffer());
|
|
assert.equal(buf.toString(), "hi-audio");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|
|
|
|
test("handleAudioSpeech surfaces gtts upstream errors without leaking a stack trace", async () => {
|
|
const originalFetch = globalThis.fetch;
|
|
globalThis.fetch = (async () => new Response("blocked", { status: 429 })) as typeof fetch;
|
|
|
|
try {
|
|
const providerConfig = getSpeechProvider("gtts");
|
|
const response = await handleAudioSpeech({
|
|
body: { model: "gtts/default", input: "hi there" },
|
|
credentials: null,
|
|
resolvedProvider: providerConfig,
|
|
resolvedModel: "default",
|
|
});
|
|
const payload = (await response.json()) as { error: { message: string } };
|
|
|
|
assert.equal(response.status, 429);
|
|
assert.ok(!payload.error.message.includes("at /"), "error body must not leak a stack trace");
|
|
} finally {
|
|
globalThis.fetch = originalFetch;
|
|
}
|
|
});
|