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
// ttsStore.js — 预置音色列表
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
// generateLine() — 实际合成台词时处理方言
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();

// 发送给 TTS 引擎
return await doGenerateTTS(outputPath, filename, {
customText: finalText, // 带方言前缀的文本
styleDescription: styleDesc, // 含方言口音提示的风格描述
});
}

为什么不在前端 UI 里直接拼接?因为:

  1. 方言前缀是发给 TTS 引擎看的(让模型知道用什么口音读),不应该保存到原始台词文本里
  2. 风格描述是另一个字段,需要把方言和其他风格标签分开组装
  3. 这样做的好处是 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
// useVoiceDesignParams.js — 组合式函数
export function useVoiceDesignParams() {
const params = reactive({
gender: 0.5, // 0=女 1=男
age: 0.5, // 0=少年 1=老年
timbre: 0.5, // 0=明亮 1=深沉
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
// ttsStore.js — 克隆模式的处理
async function doGenerateTTS(outputPath, outputFilename, options = {}) {
// 处理克隆音色的参考音频
if (options.useVoiceClone || ttsMode.value === 'voiceclone') {
const cloneVoicePath = options.cloneVoicePath || ch?.cloneVoicePath || '';

if (!cloneVoicePath) {
return { success: false, error: '需要先选择参考音频文件' };
}

// 通过 IPC 让主进程读取文件并转为 base64
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
// service/tts.js — MimoProvider 构建克隆请求
if (useVoiceClone) {
actualModel = 'mimo-v2.5-tts-voiceclone';

let cloneVoiceDataUri = params.cloneVoiceDataUri || '';
if (!cloneVoiceDataUri) {
// 从 base64 手动拼接 Data URI
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; // 放入请求体的 audio 字段
}

参考音频的持久化

1
2
3
4
5
6
7
8
9
// characterStore.js — 角色数据结构中的克隆信息
function createDefaultCharacter(name) {
return {
// ...
voiceSource: 'voiceclone', // 标记使用克隆模式
cloneVoicePath: '', // 参考音频的磁盘路径
cloneVoiceMime: 'audio/wav', // MIME 类型
};
}

参考音频不内嵌到 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
// ttsStore.js — getFullStyleDesc()
function getFullStyleDesc() {
let desc = voiceDesignDesc.value.trim(); // 来自音色设计模式的 Prompt

// 如果开启了导演模式,追加三段式内容
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
// ttsStore.js — 从角色配置同步到 UI 状态
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;

// 根据 voiceSource 决定显示哪个 Tab
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
// ttsStore.js
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 };
}

// 在 generateLine() 中调用
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 各管什么、怎么协作。