MiMo TTS Studio 的核心交互就是”选音色 → 输入台词 → 合成”。音色选择不是简单的一个 <select> 下拉框——它有 4 种完全不同的模式,每种模式的参数复杂度和用户心智模型都不同。本文从 UX 设计和代码实现两个角度拆解。
Q1:4 种音色模式分别是什么?各自解决什么问题?
A:
1 2 3 4 5 6 7 8 9 10 11
| ┌─────────────────────────────────────────────────────┐ │ 音色设计面板 (VoiceDesignPanel) │ │ │ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ │ 标准模式 │ │ 音色设计 │ │ 克隆模式 │ │ 导演模式 │ │ │ │ Standard│ │ VoiceDesign│ │ VoiceClone│ │ Director│ │ │ └─────────┘ └──────────┘ └──────────┘ └─────────┘ │ │ │ │ 简单选预制 参数化调音色 上传参考音频 结构化指令 │ │ → 新手友好 → 进阶定制 → 声音复刻 → 精细控制 │ └─────────────────────────────────────────────────────┘
|
| 模式 |
用户操作 |
后端对应 |
适用人群 |
| 标准模式 |
从预置列表选一个音色 + 可选方言标签 |
mimo-v2.5-tts |
大多数用户 |
| 音色设计 |
用滑块调整性别/年龄/音色/情绪/节奏 |
mimo-v2.5-tts-voicedesign |
想要独特声音的用户 |
| 克隆模式 |
上传一段参考音频(wav/mp3) |
mimo-v2.5-tts-voiceclone |
有特定声音样本的用户 |
| 导演模式 |
填写【角色】+【场景】+【指导】三段式 Prompt |
同音色设计(Prompt 组装) |
专业配音/精细控制 |
Q2:标准模式是怎么实现的?方言标签怎么注入的?
A: 标准模式是最简单的——本质就是一个音色下拉列表 + 方言标签多选:
1 2 3 4 5 6 7 8 9 10 11 12
| const standardVoices = [ { label: '冰糖', value: '冰糖', gender: 'female' }, { label: '茉莉', value: '茉莉', gender: 'female' }, { label: '苏打', value: '苏打', gender: 'male' }, { label: '白桦', value: '白桦', gender: 'male' }, { label: 'Mia', value: 'Mia', gender: 'female' }, ];
const dialectStyleTags = ['东北话', '四川话', '河南话', '粤语'];
|
方言标签的注入时机在生成时(而非选择时):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
| async function generateLine(charId, lineId) { const ch = ; const line = ;
const dialectTags = ['东北话', '四川话', '河南话', '粤语']; const activeDialect = (ch.styleTags || []).find(t => dialectTags.includes(t));
let finalText = line.text; if (activeDialect) { finalText = `(${activeDialect})${finalText}`; }
const allTags = (ch.styleTags || []).filter(t => !dialectTags.includes(t)); const dialectHint = activeDialect ? `,用${activeDialect}口音朗读` : ''; const styleDesc = (allTags.join(' ') + dialectHint).trim();
return await doGenerateTTS(outputPath, filename, { customText: finalText, styleDescription: styleDesc, }); }
|
为什么不在前端 UI 里直接拼接?因为:
- 方言前缀是发给 TTS 引擎看的(让模型知道用什么口音读),不应该保存到原始台词文本里
- 风格描述是另一个字段,需要把方言和其他风格标签分开组装
- 这样做的好处是
script.txt 里存的是干净原文,方便后续编辑
Q3:音色设计模式(滑块组合 Prompt)是怎么做的?
A: 这是 4 种模式里最有意思的设计。核心思路:
前端滑块 → 数值化参数 → 拼接成自然语言 Prompt → 发给 TTS API
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| <!-- VoiceDesignBasic.vue — 核心模板 --> <template> <div class="voice-design-basic"> <!-- 性别 --> <n-slider v-model:value="params.gender" :min="0" :max="1" :step="0.1"> <template #thumb>{{ params.gender < 0.5 ? '👩 女' : '👨 男' }}</template> </n-slider>
<!-- 年龄感 --> <n-slider v-model:value="params.age" :min="0" :max="1" step="0.1"> <template #prefix>年龄</template> <template #suffix>{{ ageLabel }}</template> <!-- 少年/青年/中年/老年 --> </n-slider>
<!-- 音色(明亮↔深沉) --> <n-slider v-model:value="params.timbre" :min="0" :max="1" step="0.1" />
<!-- 情绪强度 --> <n-select v-model:value="params.emotion" :options="emotionOptions" /> <!-- 选项:平静/温柔/兴奋/悲伤/愤怒/惊讶 -->
<!-- 语速 / 节奏 --> <n-slider v-model:value="params.pace" :min="0.5" :max="2.0" step="0.1" /> </div> </template>
|
关键转换逻辑——数值到自然语言的映射:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| export function useVoiceDesignParams() { const params = reactive({ gender: 0.5, age: 0.5, timbre: 0.5, emotion: 'neutral', pace: 1.0, });
const ageLabels = ['少年', '青年', '中年', '中老年', '老年'];
function buildPrompt() { const parts = [];
parts.push(params.gender < 0.5 ? '女性声音' : '男性声音');
const ageIdx = Math.floor(params.age * (ageLabels.length - 1)); parts.push(`${ageLabels[ageIdx]}的声音`);
if (params.timbre < 0.33) parts.push('音色明亮清脆'); else if (params.timbre < 0.66) parts.push('音色自然温和'); else parts.push('音色低沉厚重');
if (params.emotion !== 'neutral') { const emotionMap = { gentle: '温柔地', excited: '兴奋地', sad: '悲伤地', angry: '愤怒地', surprised: '惊讶地' }; parts.push(emotionMap[params.emotion] || ''); }
if (params.pace < 0.8) parts.push('语速较慢,一字一顿'); else if (params.pace > 1.3) parts.push('语速较快,流畅连贯');
return parts.filter(Boolean).join(','); }
return { params, buildPrompt }; }
|
这个设计的精妙之处在于:把连续的滑块值映射成了 TTS 模型能理解的自然语言描述。用户不需要懂 Prompt Engineering,拖滑块就能得到不错的效果。
Q4:克隆模式怎么上传和处理参考音频?
A: 克隆模式的流程涉及几个关键步骤:
1 2 3 4 5 6 7 8 9
| 用户选择本地 wav 文件 ↓ 前端读取文件为 Base64(通过 IPC 调主进程) ↓ Base64 拼成 Data URI(data:audio/wav;base64,...) ↓ 随请求体发送给 MiMo voiceclone API ↓ MiMo 返回模仿该音频音色的合成语音
|
文件读取与编码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| async function doGenerateTTS(outputPath, outputFilename, options = {}) { if (options.useVoiceClone || ttsMode.value === 'voiceclone') { const cloneVoicePath = options.cloneVoicePath || ch?.cloneVoicePath || '';
if (!cloneVoicePath) { return { success: false, error: '需要先选择参考音频文件' }; }
if (ipc?.invoke) { const base64 = await readFileAsBase64(cloneVoicePath); if (!base64) { return { success: false, error: '无法读取参考音频文件' }; } cloneVoicePayload = { cloneVoiceBase64: base64, cloneVoiceMime: options.cloneVoiceMime || 'audio/wav' }; } else { return { success: false, error: '克隆模式需要 Electron 环境' }; } }
}
|
主进程侧的安全检查
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| if (useVoiceClone) { actualModel = 'mimo-v2.5-tts-voiceclone';
let cloneVoiceDataUri = params.cloneVoiceDataUri || ''; if (!cloneVoiceDataUri) { const cloneVoiceBase64 = (params.cloneVoiceBase64 || '').trim(); cloneVoiceDataUri = `data:${cloneVoiceMime};base64,${cloneVoiceBase64}`; }
if (!cloneVoiceDataUri.startsWith('data:audio/')) { throw new Error('cloneVoiceDataUri must start with data:audio/'); }
audioConfig.voice = cloneVoiceDataUri; }
|
参考音频的持久化
1 2 3 4 5 6 7 8 9
| function createDefaultCharacter(name) { return { voiceSource: 'voiceclone', cloneVoicePath: '', cloneVoiceMime: 'audio/wav', }; }
|
参考音频不内嵌到 JSON 里(太大了),而是只存路径。实际使用时再通过 IPC 读取。
Q5:导演模式的三段式结构是怎么设计的?
A: 导演模式是我认为最有 AIGC 产品味道的设计——它把”音色描述”这件事从”调参”变成了”编剧”:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| <!-- VoiceDesignDirector.vue --> <template> <div class="director-mode"> <div class="director-section"> <label>【角色】</label> <n-input v-model:value="role" type="textarea" placeholder="例:一位30岁的女性书店老板,说话轻柔但有主见" /> </div>
<div class="director-section"> <label>【场景】</label> <n-input v-model:value="scene" type="textarea" placeholder="例:安静的午后书店里,她在向顾客推荐一本书" /> </div>
<div class="director-section"> <label>【指导】</label> <n-input v-model:value="guide" type="textarea" placeholder="例:读到书名时稍微放慢,带着一丝欣赏的语气" /> </div> </div> </template>
|
三段式 Prompt 的组装
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function getFullStyleDesc() { let desc = voiceDesignDesc.value.trim();
const configStore = useConfigStore(); if (configStore.directorMode) { const dm = configStore.directorMode; const parts = []; if (dm.role?.trim()) parts.push('【角色】' + dm.role.trim()); if (dm.scene?.trim()) parts.push('【场景】' + dm.scene.trim()); if (dm.guide?.trim()) parts.push('【指导】\n' + dm.guide.trim());
const directorPrompt = parts.join('\n\n'); if (directorPrompt) { desc = desc ? desc + '\n\n' + directorPrompt : directorPrompt; } } return desc; }
|
最终发送给 API 的 userMessage 长这样:
1 2 3 4 5 6 7
| 【角色】一位30岁的女性书店老板,说话轻柔但有主见
【场景】安静的午后书店里,她在向顾客推荐一本书
【指导】 读到书名时稍微放慢,带着一丝欣赏的语气。 遇到顾客犹豫时,语气转为鼓励和耐心。
|
为什么这种设计比纯滑块好?
| 维度 |
滑块模式(音色设计) |
导演模式 |
| 学习成本 |
中等(需理解每个滑块含义) |
低(按直觉填空即可) |
| 表现力上限 |
受限于预设维度 |
几乎无限(自然语言) |
| 可复现性 |
高(相同参数=相同结果) |
中(语义相似但不完全一致) |
| 适合人群 |
技术用户、追求精确控制 |
内容创作者、非技术用户 |
两种模式可以叠加使用——先在音色设计模式下调出一个基础音色,再用导演模式微调表演方式。
Q6:四种模式之间怎么切换?状态如何同步?
A: 切换的核心在于 ttsMode 状态 + 角色配置的 voiceSource 字段联动:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function syncFromCharacter() { const charStore = useCharacterStore(); const ch = charStore.currentCharacter; if (!ch) return;
voiceId.value = ch.voiceId; speed.value = ch.speed; styleDescription.value = ch.styleTags.join(' '); voiceDesignDesc.value = ch.voiceDesignPrompt;
if (ch.voiceSource === 'voicedesign') { ttsMode.value = 'voicedesign'; } else if (ch.voiceSource === 'voiceclone') { ttsMode.value = 'voiceclone'; } else { ttsMode.value = 'standard'; } }
|
切换时的数据流向:
1 2 3 4 5 6 7 8 9 10 11
| 用户点击 "克隆模式" Tab ↓ ttsMode.value = 'voiceclone' ↓ 用户上传参考音频 → 存到 ch.cloneVoicePath ↓ 用户点 "保存角色" → ch.voiceSource = 'voiceclone' ↓ saveCharacterToDisk(ch) → 写入 voice.json ↓ 下次打开该角色 → syncFromCharacter() → 自动恢复到克隆模式 Tab
|
VoiceDesignPanel 的 Tab 切换 UI
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <!-- VoiceDesignPanel.vue --> <template> <div class="voice-design-panel"> <n-tabs v-model:value="activeTab" type="line"> <n-tab-pane name="standard" tab="标准"> <VoiceDesignStandard /> </n-tab-pane> <n-tab-pane name="voicedesign" tab="音色设计"> <VoiceDesignBasic /> </n-tab-pane> <n-tab-pane name="voiceclone" tab="克隆"> <VoiceDesignClone /> </n-tab-pane> <n-tab-pane name="director" tab="导演"> <VoiceDesignDirector /> </n-tab-pane> </n-tabs> </div> </template>
|
Q7:验证逻辑怎么做?比如克隆模式没选参考音频就点生成了?
A: 在生成之前有一层 validateCloneConfig 校验:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| function validateCloneConfig(charOrId = null) { const charStore = useCharacterStore(); const ch = typeof charOrId === 'string' ? charStore.characters.find(c => c.id === charOrId) : (charOrId || charStore.currentCharacter);
if (!ch || ch.voiceSource !== 'voiceclone') { return { ok: true }; }
if (!ch.cloneVoicePath) { return { ok: false, error: `角色「${ch.name}」使用克隆音色,但未配置参考音频` }; } return { ok: true }; }
async function generateLine(charId, lineId) { const ch = ; const cloneValidation = validateCloneConfig(ch); if (!cloneValidation.ok) { line.status = 'error'; await charStore.saveCharacterToDisk(ch); return { success: false, error: cloneValidation.error }; } }
|
同样,试听按钮也有独立的禁用判断:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| function getPreviewDisableReason(options = {}) { const providerStatus = getProviderStatus(); if (!providerStatus.ready) return providerStatus.message;
if (options.designTab === 'clone') { const clonePath = options.cloneVoicePath || ch?.cloneVoicePath || ''; if (!clonePath) return '克隆模式需要先选择参考音频'; }
if (options.designTab === 'standard') { const hasText = !!(options.previewText || '').trim(); const hasStyle = !!styleTags.filter(t => !dialectStyleTags.includes(t) ).length; if (!hasText && !hasStyle) return '请输入试听文本'; }
return ''; }
|
返回的不是 boolean 而是 原因字符串——可以直接展示给用户作为按钮的 tooltip 或禁用提示。
总结
| 模式 |
复杂度 |
实现要点 |
代码位置 |
| 标准 |
⭐ |
下拉选音色 + 方言标签 + 文本前缀注入 |
ttsStore.generateLine() |
| 音色设计 |
⭐⭐ |
滑块→自然语言 Prompt 映射 |
useVoiceDesignParams.buildPrompt() |
| 克隆 |
⭐⭐⭐ |
本地文件→Base64→Data URI→API 发送 |
ttsStore.doGenerateTTS() + MimoProvider._buildRequestPayload() |
| 导演 |
⭐⭐ |
三段式结构化 Prompt 组装 |
ttsStore.getFullStyleDesc() + VoiceDesignDirector.vue |
4 种模式共享同一个 doGenerateTTS() 入口,只是传入的 useVoiceDesign / useVoiceClone / styleDescription 参数不同。后端不需要知道前端用的是哪个 Tab ——这就是好的抽象带来的解耦。
下一篇深入 Pinia Store 设计 —— 6 个 Store 各管什么、怎么协作。