feat(audio): MiniMax T2A v2 TTS dispatch in audioSpeech (port #1043) (#4553)

Integrated into release/v3.8.34 (rebuilt onto tip)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-22 17:10:56 -03:00
committed by GitHub
parent fc3b417f40
commit d6caf6ed39
3 changed files with 201 additions and 2 deletions

View File

@@ -118,7 +118,8 @@
"open-sse/executors/grok-web.ts": 1871,
"open-sse/executors/muse-spark-web.ts": 1284,
"open-sse/executors/perplexity-web.ts": 1013,
"open-sse/handlers/audioSpeech.ts": 965,
"_rebaseline_pr1043_minimax_tts": "Upstream port decolua/9router#1043 (toanalien) own growth: audioSpeech.ts 965->1061 (+96). Adds MiniMax T2A v2 TTS dispatch (handleMinimaxSpeech + hexToBytes helper) — provider entry was already in audioRegistry (format: minimax-tts) but no handler existed, falling through to the OpenAI-compatible default that fails (T2A has custom shape + hex-encoded audio + base_resp envelope). New branch sits next to the other inline provider branches (xiaomi-mimo, coqui, tortoise, aws-polly) — extracting would just create indirection. Covered by tests/unit/minimax-tts-1043.test.ts (3 tests, GREEN: success, base_resp error, invalid-hex).",
"open-sse/handlers/audioSpeech.ts": 1061,
"open-sse/handlers/chatCore.ts": 5125,
"open-sse/handlers/imageGeneration.ts": 3777,
"open-sse/handlers/responseSanitizer.ts": 1103,
@@ -235,7 +236,8 @@
"tests/unit/image-generation-handler.test.ts": 1996,
"tests/unit/model-sync-route.test.ts": 1016,
"tests/unit/models-catalog-route.test.ts": 1507,
"tests/unit/oauth-providers-config.test.ts": 855,
"_rebaseline_pr4561_qwen_oauth_url": "Reconcile #4561 (port decolua/9router#683) already-merged growth: oauth-providers-config.test.ts 855->867 (+12, qwen.ai URL regression-pin test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
"tests/unit/oauth-providers-config.test.ts": 867,
"tests/unit/perplexity-web.test.ts": 959,
"tests/unit/provider-models-route.test.ts": 1618,
"tests/unit/provider-validation-specialty.test.ts": 2752,

View File

@@ -771,6 +771,98 @@ async function handleXiaomiMimoSpeech(providerConfig, body, modelId, token, cred
});
}
/**
* MiniMax T2A v2 — POST returns hex-encoded audio in a JSON envelope guarded by
* `base_resp.status_code` (0 = success).
* Port of decolua/9router#1043 by toanalien <toanalien@gmail.com>.
*/
function hexToBytes(audioHex) {
const clean = typeof audioHex === "string" ? audioHex.trim() : "";
if (!clean) throw new Error("MiniMax TTS returned no audio");
if (clean.length % 2 !== 0 || !/^[0-9a-f]+$/i.test(clean)) {
throw new Error("MiniMax TTS returned invalid audio");
}
const len = clean.length / 2;
const out = new Uint8Array(len);
for (let i = 0; i < len; i++) {
out[i] = parseInt(clean.substr(i * 2, 2), 16);
}
return out;
}
async function handleMinimaxSpeech(providerConfig, body, modelId, token) {
const voiceId =
(typeof body.voice === "string" && body.voice) || "English_expressive_narrator";
const res = await fetch(providerConfig.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...buildAuthHeaders(providerConfig, token),
},
body: JSON.stringify({
model: modelId || "speech-2.8-hd",
text: body.input,
stream: false,
language_boost: "auto",
output_format: "hex",
voice_setting: {
voice_id: voiceId,
speed: typeof body.speed === "number" ? body.speed : 1,
vol: 1,
pitch: 0,
},
audio_setting: {
sample_rate: 32000,
bitrate: 128000,
format: "mp3",
channel: 1,
},
}),
});
const rawText = await res.text();
let data: Record<string, unknown> = {};
if (rawText) {
try {
const parsed = JSON.parse(rawText);
if (parsed && typeof parsed === "object") data = parsed as Record<string, unknown>;
} catch {
data = {};
}
}
if (!res.ok) {
return upstreamErrorResponse(res, rawText);
}
const baseResp =
((data.base_resp || data.baseResp) as Record<string, unknown> | undefined) || {};
const statusCode = Number(baseResp.status_code ?? baseResp.statusCode ?? 0);
const statusMessage = String(
baseResp.status_msg || baseResp.statusMsg || data.message || ""
);
if (statusCode !== 0) {
return errorResponse(502, `MiniMax TTS: ${statusMessage || "upstream error"}`);
}
const audioField = (data.data as Record<string, unknown> | undefined)?.audio;
let bytes: Uint8Array;
try {
bytes = hexToBytes(audioField);
} catch (err) {
const msg = err instanceof Error ? err.message : "invalid audio";
return errorResponse(502, `MiniMax TTS: ${msg}`);
}
return new Response(bytes, {
status: 200,
headers: {
...CORS_HEADERS,
"Content-Type": "audio/mpeg",
},
});
}
/**
* Handle Coqui TTS (local, no auth)
* POST {baseUrl} with { text, speaker_id } → WAV audio
@@ -929,6 +1021,10 @@ export async function handleAudioSpeech({
return handleXiaomiMimoSpeech(providerConfig, body, modelId, token, credentials);
}
if (providerConfig.format === "minimax-tts") {
return handleMinimaxSpeech(providerConfig, body, modelId, token);
}
if (providerConfig.format === "coqui") {
return handleCoquiSpeech(providerConfig, body);
}

View File

@@ -0,0 +1,101 @@
// Port of decolua/9router#1043 by toanalien <toanalien@gmail.com>
// MiniMax T2A v2 returns hex-encoded audio in a JSON envelope guarded by `base_resp`.
import test from "node:test";
import assert from "node:assert/strict";
const { handleAudioSpeech } = await import("../../open-sse/handlers/audioSpeech.ts");
const TEXT = "hello minimax";
const HEX_AUDIO = "deadbeefcafe1234"; // 8 bytes; base64 = "3q2+78r+EjQ="
test("handleAudioSpeech routes MiniMax format to T2A v2 with hex output", async () => {
const originalFetch = globalThis.fetch;
let captured: any;
globalThis.fetch = async (url: any, options: any = {}) => {
captured = {
url: String(url),
headers: options.headers,
body: JSON.parse(String(options.body || "{}")),
};
return new Response(
JSON.stringify({
data: { audio: HEX_AUDIO },
base_resp: { status_code: 0, status_msg: "success" },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
};
try {
const response = await handleAudioSpeech({
body: { model: "minimax/speech-2.8-hd", input: TEXT, voice: "English_expressive_narrator" },
credentials: { apiKey: "mm-key" },
});
assert.equal(response.status, 200, "should 200 on success");
assert.equal(captured.url, "https://api.minimax.io/v1/t2a_v2");
assert.equal((captured.headers as any).Authorization, "Bearer mm-key");
assert.equal(captured.body.model, "speech-2.8-hd");
assert.equal(captured.body.text, TEXT);
assert.equal(captured.body.stream, false);
assert.equal(captured.body.output_format, "hex");
assert.equal(captured.body.voice_setting.voice_id, "English_expressive_narrator");
assert.ok(captured.body.audio_setting, "audio_setting present");
const ct = response.headers.get("content-type") || "";
assert.ok(ct.startsWith("audio/"), `content-type should be audio/*, got ${ct}`);
const buf = new Uint8Array(await response.arrayBuffer());
assert.deepEqual(
Array.from(buf),
[0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0x12, 0x34],
"hex audio should be decoded to bytes"
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioSpeech surfaces MiniMax base_resp error", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({ base_resp: { status_code: 2013, status_msg: "invalid voice" } }),
{ status: 200, headers: { "content-type": "application/json" } }
);
try {
const response = await handleAudioSpeech({
body: { model: "minimax/speech-2.8-hd", input: TEXT },
credentials: { apiKey: "mm-key" },
});
assert.notEqual(response.status, 200, "non-zero base_resp.status_code must not be 200");
const payload = (await response.json()) as any;
assert.match(String(payload?.error?.message || ""), /invalid voice|MiniMax/i);
} finally {
globalThis.fetch = originalFetch;
}
});
test("handleAudioSpeech rejects invalid hex audio from MiniMax", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
new Response(
JSON.stringify({
data: { audio: "zzznot-hex" },
base_resp: { status_code: 0, status_msg: "" },
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
try {
const response = await handleAudioSpeech({
body: { model: "minimax/speech-2.8-hd", input: TEXT },
credentials: { apiKey: "mm-key" },
});
assert.notEqual(response.status, 200);
const payload = (await response.json()) as any;
assert.match(String(payload?.error?.message || ""), /invalid audio|MiniMax/i);
} finally {
globalThis.fetch = originalFetch;
}
});