feat: add MiMo TTS provider 语音TTS提供接入MiMo (#752)

* feat: add MiMo TTS provider with preset voices, voice design and voice clone

* refactor: remove MiMo voice clone feature
This commit is contained in:
ZhangKai | 张凯
2026-05-16 08:55:23 +08:00
committed by GitHub
parent 3f8461d9eb
commit 87a8e95d66
13 changed files with 609 additions and 11 deletions
+155 -3
View File
@@ -15,6 +15,15 @@ export interface OpenaiTtsOptions {
pitch?: string // Edge TTS pitch format, e.g. "-8Hz"
}
export interface MimoTtsOptions {
baseUrl: string
apiKey: string
model: string
voice: string // preset voice ID (preset mode) or data URI (clone mode)
voiceDesignDesc?: string // voice design description text (voice design mode)
stylePrompt?: string // natural language style instruction
}
export interface SpeechState {
isPlaying: boolean
isPaused: boolean
@@ -333,20 +342,17 @@ export function useSpeech() {
function openaiToggle(messageId: string, content: string, opts: OpenaiTtsOptions) {
if (currentCustomMessageId.value === messageId && isCustomPlaying.value) {
if (isCustomPaused.value) {
// Resume
if (customAudio) {
customAudio.play()
}
isCustomPaused.value = false
} else {
// Pause
if (customAudio) {
customAudio.pause()
}
isCustomPaused.value = true
}
} else {
// Stop other speech and start new
stop(false)
if (customAudio) {
customAudio.pause()
@@ -356,6 +362,148 @@ export function useSpeech() {
}
}
// ─── MiMo TTS Engine ──────────────────────────────────────────
async function mimoPlay(
messageId: string,
content: string,
opts: MimoTtsOptions,
) {
const text = extractReadableText(content)
if (!text) return
const token = ++playbackToken
isCustomPlaying.value = true
isCustomPaused.value = false
currentCustomMessageId.value = messageId
// Build messages based on model type
const messages: Array<{ role: string; content: string }> = []
if (opts.model === 'mimo-v2.5-tts-voicedesign') {
// Voice design: user message = voice description (+ appended style prompt)
const desc = opts.voiceDesignDesc || ''
const userContent = opts.stylePrompt
? `${desc}\n风格指令:${opts.stylePrompt}`
: desc
messages.push({ role: 'user', content: userContent || '默认音色' })
} else {
// Preset voices: user message = style prompt or empty
messages.push({ role: 'user', content: opts.stylePrompt || '' })
}
// assistant message = synthesis text
messages.push({ role: 'assistant', content: text })
const audio: Record<string, any> = { format: 'wav' }
// Voice design model does not accept audio.voice
if (opts.model !== 'mimo-v2.5-tts-voicedesign') {
audio.voice = opts.voice
}
const body: Record<string, any> = {
model: opts.model,
messages,
audio,
}
const url = `${opts.baseUrl.replace(/\/+$/, '')}/chat/completions`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'api-key': opts.apiKey,
}
try {
const res = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
if (token !== playbackToken) return
if (!res.ok) {
const errText = await res.text().catch(() => '')
throw new Error(`MiMo TTS 返回 ${res.status}: ${errText || res.statusText}`)
}
const json = await res.json()
if (token !== playbackToken) return
const audioBase64 = json?.choices?.[0]?.message?.audio?.data
if (!audioBase64) {
throw new Error('MiMo TTS 响应中未找到音频数据')
}
// base64 → binary → Blob
const binaryStr = atob(audioBase64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
const audioBlob = new Blob([bytes], { type: 'audio/wav' })
if (token !== playbackToken) return
const audioUrl = URL.createObjectURL(audioBlob)
const audio = new Audio(audioUrl)
customAudio = audio
audio.onended = () => {
if (token !== playbackToken) return
URL.revokeObjectURL(audioUrl)
isCustomPlaying.value = false
isCustomPaused.value = false
currentCustomMessageId.value = null
customAudio = null
}
audio.onerror = () => {
if (token !== playbackToken) return
URL.revokeObjectURL(audioUrl)
console.warn('[useSpeech] MiMo TTS audio playback error')
isCustomPlaying.value = false
isCustomPaused.value = false
currentCustomMessageId.value = null
customAudio = null
}
await audio.play()
} catch (err) {
if (token !== playbackToken) return
console.error('[useSpeech] MiMo TTS 请求失败:', err)
isCustomPlaying.value = false
isCustomPaused.value = false
currentCustomMessageId.value = null
throw err
}
}
function mimoToggle(messageId: string, content: string, opts: MimoTtsOptions) {
if (currentCustomMessageId.value === messageId && isCustomPlaying.value) {
if (isCustomPaused.value) {
if (customAudio) {
customAudio.play()
}
isCustomPaused.value = false
} else {
if (customAudio) {
customAudio.pause()
}
isCustomPaused.value = true
}
} else {
stop(false)
if (customAudio) {
customAudio.pause()
customAudio = null
}
mimoPlay(messageId, content, opts)
}
}
// ─── Unified speak ──────────────────────────────────────────
function speak(messageId: string, text: string, options: SpeechOptions = {}) {
@@ -473,6 +621,10 @@ export function useSpeech() {
openaiPlay,
openaiToggle,
// MiMo TTS
mimoPlay,
mimoToggle,
// Browser WebSpeech (直接调用避免 Rolldown 树摇)
speakViaBrowser,
}