feat(tts): add Xiaomi MiMo text-to-speech support

Adds mimo-v2.5-tts as a Media Provider TTS through the existing
OpenAI-compatible chat-completions endpoint. Voice is selected via the
top-level audio.voice field, and an optional style/language hint is
threaded through tts.js -> ttsCore.js -> the new adapter.
This commit is contained in:
MiQieR
2026-08-05 11:45:27 +07:00
committed by decolua
parent d0751bcff7
commit c570fe33ae
44 changed files with 396 additions and 20 deletions

View File

@@ -6,6 +6,7 @@ import { AI_PROVIDERS, getProviderAlias } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { TTS_PROVIDER_CONFIG } from "@/shared/constants/ttsProviders";
import { translate } from "@/i18n/runtime";
import { getTtsVoicesForModel } from "open-sse/config/ttsModels.js";
import { GOOGLE_TTS_LANGUAGES } from "open-sse/config/googleTtsLanguages.js";
import { Row } from "./exampleShared";
@@ -40,6 +41,7 @@ export function TtsExampleCard({ providerId }) {
// Form state
const [input, setInput] = useState("Hello, this is a text to speech test.");
const [style, setStyle] = useState(""); // style/voice instructions (e.g. MiMo voicedesign)
const [apiKey, setApiKey] = useState("");
const [useTunnel, setUseTunnel] = useState(false);
const [localEndpoint, setLocalEndpoint] = useState("");
@@ -59,8 +61,10 @@ export function TtsExampleCard({ providerId }) {
const [modalSearch, setModalSearch] = useState("");
const [modalError, setModalError] = useState("");
const [byLang, setByLang] = useState({});
// Language hint (e.g. Gemini): controls the spoken language without affecting voice selection
// Language hint (e.g. Gemini/MiMo): guides the spoken language without affecting voice selection
const [languageHint, setLanguageHint] = useState("");
// Number of stored provider connections (shown when no dashboard API key)
const [connectionCount, setConnectionCount] = useState(0);
useEffect(() => {
setLocalEndpoint(window.location.origin);
@@ -68,6 +72,10 @@ export function TtsExampleCard({ providerId }) {
.then((r) => r.json())
.then((d) => { setApiKey((d.keys || []).find((k) => k.isActive !== false)?.key || ""); })
.catch(() => {});
fetch("/api/providers", { cache: "no-store" })
.then((r) => r.json())
.then((d) => { setConnectionCount((d.connections || []).filter((c) => c.provider === providerId && c.isActive !== false).length); })
.catch(() => {});
fetch("/api/tunnel/status")
.then((r) => r.json())
.then((d) => { if (d.publicUrl) setTunnelEndpoint(d.publicUrl); })
@@ -111,6 +119,10 @@ export function TtsExampleCard({ providerId }) {
if (voices.length) {
setSelectedVoice(voices[0].id);
setSelectedVoiceName(voices[0].name || voices[0].id);
} else {
// Model has no preset voices (voicedesign/voiceclone) — drop stale voice
setSelectedVoice("");
setSelectedVoiceName("");
}
}, [selectedModel]);
@@ -184,6 +196,7 @@ export function TtsExampleCard({ providerId }) {
const ttsBody = (() => {
const b = { model: modelFull, input };
if (config.hasLanguageHint && languageHint) b.language = languageHint;
if (config.hasStyleInput && style.trim()) b.style = style.trim();
return b;
})();
const curlSnippet = `curl -X POST ${endpoint}/v1/audio/speech${responseFormat === "json" ? "?response_format=json" : ""} \\
@@ -218,7 +231,8 @@ export function TtsExampleCard({ providerId }) {
if (responseFormat === "json") {
const data = await res.json();
setJsonResponse(data); // Store full JSON response
const audioBlob = await fetch(`data:audio/mp3;base64,${data.audio}`).then(r => r.blob());
const format = data.format || "mp3";
const audioBlob = await fetch(`data:audio/${format};base64,${data.audio}`).then(r => r.blob());
setAudioUrl(URL.createObjectURL(audioBlob));
} else {
const blob = await res.blob();
@@ -259,7 +273,11 @@ export function TtsExampleCard({ providerId }) {
</Row>
<Row label="API Key">
<span className="px-3 py-1.5 text-sm font-mono text-text-main bg-sidebar rounded-lg truncate block">
{apiKey ? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}` : <span className="text-text-muted italic">No key configured</span>}
{apiKey
? `${apiKey.slice(0, 8)}${"•".repeat(Math.min(20, apiKey.length - 8))}`
: connectionCount > 0
? <span className="text-text-muted italic">Using stored key(s) · {connectionCount} connection{connectionCount > 1 ? "s" : ""}</span>
: <span className="text-text-muted italic">No key configured</span>}
</span>
</Row>
@@ -281,7 +299,7 @@ export function TtsExampleCard({ providerId }) {
</Row>
)}
{/* Language hint dropdown (Gemini) — sends body.language to guide pronunciation */}
{/* Language hint dropdown (Gemini, Xiaomi MiMo) — sends body.language to guide pronunciation */}
{config.hasLanguageHint && (
<Row label="Language">
<select
@@ -290,9 +308,11 @@ export function TtsExampleCard({ providerId }) {
className="w-full px-3 py-1.5 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
>
<option value="">Auto-detect</option>
{GOOGLE_TTS_LANGUAGES.map((l) => (
<option key={l.id} value={l.name}>{l.name}</option>
))}
{(config.languageOptions || GOOGLE_TTS_LANGUAGES).map((l) =>
typeof l === "string"
? <option key={l} value={l}>{l}</option>
: <option key={l.id} value={l.name}>{l.name}</option>
)}
</select>
</Row>
)}
@@ -320,7 +340,7 @@ export function TtsExampleCard({ providerId }) {
</Row>
)}
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs) */}
{/* Voice chips — shown after language picked (edge-tts, local-device) or always (OpenAI/ElevenLabs/MiMo) */}
{countryVoices.length > 0 && (
<Row label="Voice">
<div className="flex flex-wrap gap-1.5">
@@ -338,7 +358,9 @@ export function TtsExampleCard({ providerId }) {
: "border-border text-text-muted hover:text-primary hover:border-primary/40"
}`}
>
{v.name}{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
{v.name}
{v.language ? ` · ${v.language}` : ""}
{v.gender ? ` · ${v.gender[0].toUpperCase()}` : ""}
{v.free_users_allowed === true && (
<span className="ml-1.5 px-1 py-0.5 text-[9px] font-semibold rounded bg-green-500/15 text-green-600 border border-green-500/20">Free</span>
)}
@@ -418,6 +440,30 @@ export function TtsExampleCard({ providerId }) {
</div>
</Row>
{/* Style / voice instructions (Xiaomi MiMo) */}
{config.hasStyleInput && (
<Row label={translate("Style")}>
<div className="relative">
<textarea
value={style}
onChange={(e) => setStyle(e.target.value)}
placeholder={translate("e.g. a warm, gentle voice, speaking slowly with a British accent")}
rows={2}
className="w-full px-3 py-1.5 pr-7 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary resize-none"
/>
{style && (
<button
type="button"
onClick={() => setStyle("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
</Row>
)}
{/* Output Format */}
<Row label="Output Format">
<select

View File

@@ -135,4 +135,16 @@ export const TTS_PROVIDER_CONFIG = {
voiceKey: "gemini-tts-voices",
voicesPerModel: true,
},
"xiaomi-mimo": {
hasLanguageDropdown: false,
hasModelSelector: true,
hasBrowseButton: false,
hasVoiceIdInput: false,
hasStyleInput: true, // style/voice instructions (role: user)
hasLanguageHint: true, // language dropdown (Auto-detect default); voices are language-independent
languageOptions: ["Chinese", "English"],
voiceSource: "hardcoded",
modelKey: "xiaomi-mimo-tts-models",
voicesPerModel: true,
},
};

View File

@@ -30,6 +30,7 @@ export async function handleTts(request) {
const modelStr = body.model;
const responseFormat = url.searchParams.get("response_format") || "mp3"; // mp3 (default) | json
const language = body.language || ""; // Optional language hint (currently used by Gemini)
const style = body.style || ""; // Optional style/voice instructions (e.g. Xiaomi MiMo)
log.request("POST", `${url.pathname} | ${modelStr} | format=${responseFormat}${language ? ` | lang=${language}` : ""}`);
const settings = await getSettings();
@@ -53,7 +54,7 @@ export async function handleTts(request) {
return handleComboChat({
body,
models: comboModels,
handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language),
handleSingleModel: (b, m) => handleSingleModelTts(b, m, responseFormat, language, style),
log,
comboName: modelStr,
comboStrategy,
@@ -61,10 +62,10 @@ export async function handleTts(request) {
});
}
return handleSingleModelTts(body, modelStr, responseFormat, language);
return handleSingleModelTts(body, modelStr, responseFormat, language, style);
}
async function handleSingleModelTts(body, modelStr, responseFormat, language) {
async function handleSingleModelTts(body, modelStr, responseFormat, language, style) {
const modelInfo = await getModelInfo(modelStr);
if (!modelInfo.provider) return errorResponse(HTTP_STATUS.BAD_REQUEST, "Invalid model format");
@@ -73,7 +74,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language) {
// noAuth providers — no credential needed
if (!CREDENTIALED_PROVIDERS.has(provider)) {
const result = await handleTtsCore({ provider, model, input: body.input, responseFormat, language });
const result = await handleTtsCore({ provider, model, input: body.input, responseFormat, language, style });
if (result.success) return result.response;
return errorResponse(result.status || HTTP_STATUS.BAD_GATEWAY, result.error || "TTS failed");
}
@@ -98,7 +99,7 @@ async function handleSingleModelTts(body, modelStr, responseFormat, language) {
log.info("AUTH", `\x1b[32mUsing ${provider} account: ${credentials.connectionName}\x1b[0m`);
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language });
const result = await handleTtsCore({ provider, model, input: body.input, credentials, responseFormat, language, style });
if (result.success) return result.response;