小米 MiMo TTS 批量抽卡:给游戏台词自动配音的一次工程实践

御坂スバル Lv1

写在前面

给后室题材的项目做播报台词,一共 31 条,风格是”老旧通讯设备里传出的通告”。用小米 MiMo 的语音克隆模型配上参考音频,音色基本一把就对了——问题全出在细节上:

  • 感叹号和句号后面没有停顿,几个短句糊成一片;
  • 同一条台词,有时候干净利落,有时候一个字一个字往外挤;
  • 偶尔直接跑飞,一句话合成出 18 秒。

我一开始的直觉是”后处理修一修”,于是接 ffmpeg 剪静音、轻微提速。结果越修越糟——剪静音把句子之间的停顿也一起剪了,最大停顿从 0.41 秒被压到 0.10 秒,听起来反而更黏。

最后一版换了思路:不做任何音频后处理,改成”抽卡”。抽一次,检测这一张合不合格,不合格就再抽,抽到合格的为止。

项目地址:github.com/msksbr515/mimo-tts-gacha,MIT 许可。

下面按”为什么抽卡 / 接口怎么调 / 停顿怎么插 / 怎么判拖沓 / 工程化怎么收拾”的顺序记录一遍。

一、为什么是”抽卡”

同一句话、同样的参考音频、同样的参数,每次出来的结果都不一样。我测了同一条 Warning! Sanity low.:

采样 时长 静音占比 最长停顿
第 1 次 1.76 s 26% 0.21 s
第 2 次 2.40 s 28% 0.51 s
第 3 次 2.56 s 43% 0.41 s
跑飞的一次 7.68 s — —

最长的一次见过 18.56 秒——五个词的台词。

这说明没有哪个固定参数能让它每次都乖。既然修不好,那就筛选:把”合格”定义成一组可测量的指标,不合格就重新请求。

代价是请求数变多。所以后面所有的设计——并发、限速、检测阈值——本质上都是在控制”重抽的成本”。

二、接口结构

接口是 OpenAI 兼容的,用官方 openai 库直接调就行,不需要额外的 SDK。有一点要特别注意:风格提示和台词分别放在 user 和 assistant 消息里,而不是拼成一段 prompt。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
completion = client.chat.completions.create(
model="mimo-v2.5-tts-voiceclone",
messages=[
# user 消息 = 风格 / 角色提示词
{"role": "user", "content": prompt},
# assistant 消息 = 真正要朗读的文本
{"role": "assistant", "content": text},
],
audio={
"format": "wav",
# 参考音频整段 base64 成 data URL,只编码一次复用
"voice": f"data:audio/mpeg;base64,{voice_base64}",
},
timeout=60,
)
audio_bytes = base64.b64decode(completion.choices[0].message.audio.data)

整条流水线长这样:

flowchart LR
    A["台词 txt"] --> B["句末标点后插停顿标记"]
    B --> C["调接口抽一次"]
    C --> D{"拖沓检测"}
    D -->|"达标"| E["落盘 output 时间戳目录"]
    D -->|"不达标"| F{"还有重抽次数"}
    F -->|"有"| C
    F -->|"没有"| G["丢弃并记 FAIL"]
    E --> H["logs 日志与报告"]
    G --> H

波形与检测出的停顿

▲ 脚本自己生成的一条音频:蓝色是波形,浅蓝阴影是它识别出的停顿

三、停顿:不要靠标点,靠标记

TTS 对句末标点的停顿很不稳定,尤其参考音频本身节奏偏快时,句号和感叹号听起来是黏在一起的。

按官方文档的音频标签用法,想要停就插一对方括号 []:

1
2
Warning! Sanity low.
→ Warning![] Sanity low.

听起来简单,但”插在哪”是个坑。第一版我天真地按”所有句号都插”,结果:

原文 错误结果 问题
This is M.E.G. Base Alpha. 在 M.E.G. 后面也插了 缩写被当成句号
J. Smith said hi. 在 J. 后面插了 姓名首字母
It costs 3.5 dollars. 在 3.5 后面插了 小数
no space.Here 插了 句点后没有空格

后来定的判定原则只有一条:ASCII 标点后面必须跟空格,才算真正的句子边界。

输入 输出 原因
Ready. Go Ready.[] Go 句点后跟空格,句子边界
M.E.G. Base 不插入 词内含点,属缩写
J. Smith 不插入 单个大写字母,属姓名首字母
Mr. Lee、e.g. this 不插入 缩写词表
Costs 3.5 units 不插入 句点后是数字
no space.Here 不插入 句点后无空格
"Stop!" Next "Stop!"[] Next 跳过收尾引号后是空格
你好。再见 你好。[]再见 全角标点不要求空格
行尾的 . ! 不插入 避免尾部多余静音

还有一个细节:引号包住的句末标点。"Stop!" Next 里的 ! 后面紧跟的是引号,不是空格,如果只看”下一个字符”就会漏掉。所以要先跳过收尾引号(" ' ) ] } )),再看跳过之后是不是空格,标记插在引号外面。

一个大坑:别做音频后处理

这是我走的最大的弯路。为了压掉”词与词之间拖沓”,我接了 ffmpeg 的 silenceremove,把所有超过 0.10 秒的静音剪短。

实测一组对比:

阶段 时长 静音占比 最长停顿
接口原始输出 3.36 s 31% 0.41 s
silenceremove 之后 2.45 s 11% 0.10 s

最长停顿从 0.41 秒变成 0.10 秒。看起来”更干脆”,但你辛苦用 [] 插出来的句子停顿,也在这一步被抹平了——这就是”感叹号句号没停顿”的根本原因。

所以最终版把所有后处理都删了:接口返回什么就存什么,质量交给”抽卡”来保证。

四、怎么判断”拖沓”

要筛选就得有指标。我读 PCM 算四个量(10 毫秒一帧、每帧抽 1/5 采样,毫秒级,不依赖任何外部工具):

指标 含义 判为拖沓的阈值
时长 总长 > 词数 × 0.9 + 3 秒
静音占比 静音帧 / 总帧 > 0.55
最长静音 连续静音的最大值 > 0.90 秒
语速 词数 / 有效语音时长 < 90 wpm

其中三个量可以直接写成式子(下文 N 为词数、D 为总时长、S 为静音总时长、T 为有效语音时长):

silence=SDwpm=60NTTmax⁡=0.9N+3\begin{aligned} \mathrm{silence} &= \frac{S}{D} \\ \mathrm{wpm} &= \frac{60N}{T} \\ T_{\max} &= 0.9N + 3 \end{aligned}

四项判据合起来就是”合格”的定义:静音占比不超过 0.55、最长停顿不超过 0.9 秒、语速不低于 90 wpm,且总时长不超过上面那个上限。

阈值不是拍脑袋定的。我先量了 6 条自己觉得正常的样本:

指标 正常范围 取值理由
静音占比 17% – 43% 阈值取 0.55,留足余量
最长停顿 0.21 – 0.54 s 阈值取 0.90 s,不误杀正常换气
语速 133 – 186 wpm 阈值取 90 wpm,只抓明显拖沓的

有一个坑值得单独说:短句的语速毫无意义。Warning! Overheating. 只有两个词,塞进 1.9 秒的有效语音里,算出来 63 wpm——按 90 的门槛会被判”太慢”,于是每条都要白抽两次。

这就是为什么语速判定加了词数下限:

1
2
if words >= quality.min_words and wpm < quality.min_wpm:
reasons.append(f"语速 {wpm:.0f}wpm")

min_words 默认 6,短句只看静音和停顿,不看语速。

五、不合格就丢弃,不要”将就”

早期版本的做法是”重抽几次,留其中最不拖的一版”。听起来稳妥,实际是自欺欺人——最不拖的一版仍然拖,只是没那么明显,最后混在 output/ 里,等到剪辑的时候才发现一堆残次品。

现在的规则很干脆:

  • 中途某次合格 → 立即保存,停止重抽;
  • 抽满 5 次全不合格 → 丢弃,不生成文件,记一条 FAIL,进程退出码为 1。

因为文件没有落盘,下次运行会自然而然地重新抽它——断点续跑跳过的是”已存在的文件”,不存在的自然会重来。这比额外维护一个”失败队列”简单得多。

日志长这样:

1
2
3
time    status  line  file                            duration  silence  max_gap  wpm  retries  note
13:10:21 OK 1 0001_This_is_Level_4.wav 2.40 28% 0.51 105 0
13:10:22 FAIL 3 0003_Warning_Overheating.wav 5 重抽 5 次仍太拖沓(静音 81%,最长停顿 2.50s),已丢弃不保存

六、目录与续跑:时间戳子目录

第一版把所有音频平铺在 output/ 里,跑几轮之后就分不清哪批是哪批了。现在每次运行单独一个目录:

1
2
3
4
5
6
output/
├─ 2026_10_25_12_12_45/
│ ├─ 0001_xxx.wav
│ └─ 0002_xxx.wav
└─ 2026_10_26_09_30_12/
└─ 0001_xxx.wav

这里有个容易踩的坑:换了新目录之后,”跳过已存在”的判断就失效了——新目录永远是空的,于是每次运行都会全量重跑,白白烧额度。

解决办法是启动时扫一遍输出根目录(含所有历史子目录),收集已存在的文件名作为索引:

1
2
3
4
5
6
7
def collect_existing(root: Path) -> set[str]:
"""收集输出根目录下(含历次时间戳子目录)已存在的文件名。"""
names = set()
for entry in root.rglob("*"):
if entry.is_file():
names.add(entry.name)
return names

于是”时间戳分目录”和”断点续跑”可以同时成立。

七、并发与限流:429 是反馈,不是敌人

官方对 TTS 模型的限制是 RPM 100 / TPM 10M,按”账号 + 模型 + 所有 Key 合计”计算;此外还有账号级并发上限,超出直接返回 429。

我一开始直接开 4 路并发,结果第一批就吃掉一串 429,还丢了一条。

现在的做法是自适应:

机制 做法
令牌桶 按 rpm 控制整体速率,从源头不撞限流
起步并发 从 2 路开始,不冒进
渐进放大 连续成功 5 次,加 1 路,直到 concurrency 上限
遇 429 并发立即减半 + 全局冷却,优先采用响应头 Retry-After
重试范围 只重试 408 / 429 / 5xx 和连接类错误,其余 4xx 直接失败

concurrency 只是上限,实际并发由反馈决定——你不需要猜账号能吃几路。

八、留痕:日志 + 报告

跑批最烦的是”我记得跑过,但不知道哪天跑的、失败的是哪几条”。所以每次运行留两份东西在 logs/:

文件 内容
run_<时间戳>.log TSV 明细,一行一条任务,可直接用 Excel 打开
report_<时间戳>.md Markdown 报告,给人和给 AI 看都行

报告结构:

章节 内容
1. 概况 开始时间、用时、输入文件、输出目录、模型、停顿配置、风格提示
2. 统计 成功 / 跳过 / 失败条数,触发重抽的条目数,重抽总次数
3. 成功条目 逐行列出行号、文件名、时长、静音、最长停顿、语速、重抽次数
4. 失败条目 行号、文本片段、失败原因
5. 本次配置 当次使用的阈值、并发、限速、超时

只记成功与失败,跳过的不记——跳过是正常状态,记进去只会把日志灌满。

九、工程化:从 700 行脚本到 10 个模块

第一版是一个 700 行的 main.py,改到后面自己都不敢动。现在拆成包:

模块 职责
cli.py 解析命令行、装配、返回退出码
config.py 读 .env 与 config.yaml,校验成不可变配置对象
text.py 读文本、整条目、生成文件名
pause.py 停顿标记判定与插入
quality.py 音频统计与拖沓判定
api.py 接口调用、错误分类、退避
ratelimit.py 令牌桶、自适应并发、硬超时
runlog.py TSV 日志
report.py Markdown 报告
runner.py 批处理流程与汇总

两个原则:

  1. 配置全部外置到 config.yaml,脚本里不写任何默认业务参数;
  2. 密钥走 .env,config.yaml 里留空,优先级是”环境变量 > .env > 配置文件”。

配置文件长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
paths:
input: input.txt
output: output
timestamped: true # 每次运行落在 output/<时间戳>/ 下

pause:
marker: "[]"
scope: sentence # sentence 只句末;clause 连逗号后也插

quality:
retry: 5 # 判拖沓后的重抽次数
max_silence: 0.55
max_gap: 0.90
min_wpm: 90
min_words: 6

request:
concurrency: 6
rpm: 100
timeout: 60

依赖也很干净——只用 openai 和 pyyaml,不锁版本,不列它们的传递依赖:

1
2
3
# requirements.txt
openai
pyyaml

十、踩过的坑

1. 一次等了一千多秒

某次卡住,日志停了很久才报错。原因是重试叠了两层:SDK 自带的 max_retries=2(每次超时 120 秒)× 我自己的 4 次重试,最坏情况要等十几分钟。

修法两条:

  • 关掉 SDK 的自动重试:OpenAI(..., max_retries=0);
  • 自己加一层墙钟超时:请求放进子线程,主线程 join(60),到点就放弃:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def call_with_deadline(func, seconds: float):
outcome = {}
def runner():
try:
outcome["value"] = func()
except BaseException as exc:
outcome["error"] = exc
thread = threading.Thread(target=runner, daemon=True)
thread.start()
thread.join(seconds)
if thread.is_alive():
raise TimeoutError(f"请求超过 {seconds:g}s 未返回")
if "error" in outcome:
raise outcome["error"]
return outcome.get("value")

超时之后 1 秒就重发——挂起多半是服务端丢了连接,等它没意义。

2. [停顿] 被念了出来

我一开始想当然地用了 [停顿] 这种”看着就像标记”的写法,结果模型老老实实把”停顿”两个字念了出来。

正确的做法是用空方括号 []——文档里的音频标签就是这么用的。

3. Windows 终端中文乱码

在 cmd 里好好的,一放进管道(IDE 输出面板、重定向、Git Bash)就花屏。

根因是 Python 在管道里退回系统区域编码(GBK)。但不能无脑设成 UTF-8——真控制台里 Python 走的是宽字符 API,改编码反而会让 cmd 的提示变乱码。

所以要先判断输出目标:

1
2
handle = msvcrt.get_osfhandle(stream.fileno())
is_console = bool(ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode)))

是真控制台就只放宽错误处理,是管道就固定 UTF-8。

4. 报告里的表格被撑破

Markdown 报告里,台词片段如果自带 |,整张表格就错位了。写报告时要把竖线转义掉(我用的是替换成全角斜线),顺便截断超长文本。

结语

这套东西现在的日常用法就一行:

1
py main.py 新台词.txt

跑完看 logs/report_<时间戳>.md,成功的条目直接进游戏,失败的补跑一次。

回头看,最有价值的三个决定都不是写代码,而是想清楚该做什么、不该做什么:

  1. 不做音频后处理——所有”修一下”的冲动,最后都在破坏停顿;
  2. 不合格就丢弃——不留残次品,问题留到下一轮解决,而不是藏在目录里;
  3. 把”抽卡”的代价控制在可接受范围——阈值定得保守一点、重抽次数有限、并发自适应,让它跑得久但不烧钱。

项目:github.com/msksbr515/mimo-tts-gacha(MIT)
欢迎 issue 和 PR,尤其欢迎”你这条阈值设错了”这种反馈——quality 那组参数是按我的样本调的,别人的素材大概率要重新标定。

  • 标题: 小米 MiMo TTS 批量抽卡:给游戏台词自动配音的一次工程实践
  • 作者: 御坂スバル
  • 创建于 : 2026-10-25 13:00:00
  • 更新于 : 2026-10-25 15:13:57
  • 链接: https://msksbr.com/2026/10/25/mimo-tts-gacha/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论