InariNGT-Ofiicial/dsh-anime-voice--plugins-tts-train-pipeline ↗★ 8
dsh-tts-train-pipeline
将角色语料训练为GPT-SoVITS音色 适合需要构建自定义TTS语音合成模型、进行配音创作的用户。
安裝
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:InariNGT-Ofiicial/dsh-anime-voice#ace605e5c8ae845f3bda66d3ff1bb59606e4f41e&path:plugins/tts-train-pipeline說明文件
閱讀完整 README ↗dsh-tts-train-pipeline
把角色语料做成 GPT-SoVITS TTS 音色(.pth + .ckpt)的 DSH 插件。
它是 dsh-train-pipeline(DDSP / SVC 音色)的TTS 对偶,不是它的升级版。
两者做的是不同的东西,不能互相替代:
| dsh-train-pipeline(DDSP/SVC) | 本插件(GPT-SoVITS/TTS) | |
|---|---|---|
| 输入 | 只要音频 | 音频 + 台词(必须逐条对齐) |
| 产物 | 音色转换模型 | 能「念任意文本」的 TTS 音色 |
| 超长音频 | 按能量谷切开 | 绝不切分,整条丢弃 |
| 监督信号 | 无文本 | 文本 → 音素 → 语义 token |
★ 不可逆:TTS 不能反向用于 SVC,SVC 也不能反向用于 TTS。 想两个都要,就两条链路各跑一次,共用同一份解包语料。
1. 安装
dsh plugin add
dsh 启动时自动加载 cordis.patch.yml。路径默认全空,由 src/config.js 自动发现;
发现失败时跑 tts_doctor,会给出可直接粘的配置片段。
三种给配置的方式,优先级从高到低:
- 工具参数(
tts_build({ expName, ... })之类) /tts.local.json(本机覆盖层,不进 git)cordis.patch.yml/ profile 的cordis.patch.yml- 环境变量
DSH_GSV_ROOT/DSH_TTS_ROOT/DSH_COVER_ROOT
前置依赖
| 依赖 | 用途 | 缺了会怎样 |
|---|---|---|
GPT-SoVITS 工程(含 webui.py) | 训练与推理的全部脚本与底模 | 什么都做不了 |
工程内的 .venv(torch 需匹配 GPU) | 跑预处理/训练/推理 | 什么都做不了 |
| ffmpeg + ffprobe | 转码、裁静音、探时长 | 出料(tts_build)失败 |
| pyyaml | 生成 s1 训练配置 | s1 不能训 |
| faster-whisper + large-v3 缓存 | 仅 ASR 兜底 | 无台词语料没法补标 |
★ torch 与 GPU 算力必须匹配:Blackwell(sm_120,50 系)配 cu126 会报
no kernel image is available,必须 cu128。tts_doctor 会直接判死这一条。
2. 十四个工具
| 工具 | 做什么 |
|---|---|
tts_doctor | 环境体检:工程/底模/CUDA/ffmpeg/ASR 缓存/磁盘/已注册音色 |
tts_plan | 六段流程 + 5 个决策点 + 红线(开训前先读) |
tts_script | 从剧本反编译产物抽「说话人 + 台词 + 语音」★ 首选入口 |
tts_scan | 盘语料:语音数 / 带台词数 / 两者都有 / 时长 / 可训判定 |
tts_build | 出料:32kHz 单声道 wav + list.txt,带尾音体检 tail_stats |
tts_asr | 兜底:无台词语料才用,faster-whisper 回标 |
tts_patch_text | 日语文本「消 UNK」补丁(幂等 / 可回滚 / 打前校验符号表) |
tts_preprocess | 四步预处理(含 v2Pro 专属 2-get-sv) |
tts_run | 训练:s2(出 .pth)→ s1(出 .ckpt) |
tts_status | 进程存活 / 日志尾 / epoch·loss / 权重是否真落盘 |
tts_stop | 终止训练(taskkill /T /F) |
tts_infer | 合成试听(可带 ASR 回读核对) |
tts_register | 把权重写进音色清单 |
tts_overview | 总览:数据集 / 权重 / 音色 / 最近任务 |
标准链路:
tts_doctor → tts_plan → tts_script → tts_scan → tts_build
→ tts_preprocess → tts_run → tts_status → tts_infer → tts_register
3. 核心立场:原文优先,ASR 兜底
语料本身带台词时(剧本、字幕、标注表),那是引擎级的无损数据; ASR 是「重新听写一遍」——会丢标点、认错人名、写错汉字。日语里「はし」和「橋」听感相同, ASR 写错汉字,模型就学错发音。
实测同一部 galgame(KrKrZ 引擎):
| 取法 | 抽到条数 | 备注 |
|---|---|---|
在 lines 里找文本 | 102 条 | 只能捞到选项菜单 |
在 scene.texts 里找 | 3135 条 | 说话人齐全,语音命中率 100% |
| faster-whisper 回标 | —— | 完全不需要 |
所以 tts_asr 只在「真的没有任何文本」时才用。
KrKrZ 剧本结构(tts_script 凭什么能抽到)
scene.texts[i][0] → 说话人
scene.texts[i][2][0][1] → 日文原文 ← 日文语音要的是这一列
scene.texts[i][2][2][1] → 官方简中 ← 给人看的,不能拿去训日文语音
scene.texts[i][3][0].voice → 语音 ID
lines 里只有指令流(bgm / 立绘 / env),台词在场景级 texts 里被索引引用——
这就是为什么在 lines 里找文本只能捞到菜单。
4. 会在哪里踩坑(都是实测出来的)
4.1 裁静音:stop_periods=1 会截断句子
ffmpeg 的 silenceremove 里,正的 stop_periods 不是「从末尾裁」,
而是在「音频开头之后遇到的第一段静音」处就停止写入——也就是句内第一个停顿。
实测 yos001_002.ogg(4.20s):
| 写法 | 输出时长 |
|---|---|
| 不裁 | 4.20s |
start_periods=1 + stop_periods=1 | 0.66s ← 只剩开头两个字 |
start_periods=1(只裁头) | 4.04s |
start_periods=1 → areverse → start_periods=1 → areverse | 3.81s ⚠️ 句子保住了,但尾音被削(见下) |
后果不只是音质:被截短的句子会小于 minSec 而被整条丢弃。
修复前 630 条只出 28 条(1.96 分钟),修复后出 383 条(27.18 分钟)。
⚠️ 但 areverse 方案也不是终解(2026-10-24 更正,此前这里标的是「✅ 正确」)。
areverse + silenceremove(start_periods=1) 并不是「切在阈值上」,而是把尾部一段真实语音一起削掉。
实测 yos001_001.ogg:
| 时长 | 尾部静音 | 收尾电平 | |
|---|---|---|---|
| 原声 ogg | 3.04s | 82ms | −42.6dB |
走 areverse 方案 | 2.64s | 0ms | +0.5dB |
即每条都停在满电平。换 −55dB 阈值、start_duration=0 也一样。用这种数据训出来的模型会学成
「句子该在满电平处直接断」,推理时吞末字——芳乃线 383 条实测末三字完整念出率只有 33%、PER 0.197。
现在的做法是只裁头 + 尾部保留原衰减 + 兜 0.16s 静音底(tts_build 默认):末三字 33% → 93%、
PER 0.197 → 0.018。同一批语料、同一套训练配置,差别只在音频怎么切。
tts_build 现在输出 tail_stats:收尾电平中位(真人自然收尾约 −24dB)、尾部静音中位、
以及收尾 > −12dB 的占比;后者超过三成直接判「句尾被切断」。trimTail=true 可以复现旧数据集,
但会打警告——那是已知缺陷,只用于和历史模型对齐。
4.2 默认 minSec 是 2.0,不是官方建议的 3.0
galgame / 番剧单句语音中位只有约 3 秒。实测芳乃 630 条:
| minSec | 可用条数 | 丢弃(过短) |
|---|---|---|
| 1.5 | 155 / 200 抽样 | 42 |
| 2.0 | 136 / 200 抽样 | 61 |
| 3.0 | 99 / 200 抽样 | 98 |
tts_build 会把时长直方图写进 build.json,并在丢弃量远大于出料量时给出建议。
素材充足时可以调回 3.0。
4.3 v2Pro / v2ProPlus 漏了 2-get-sv = 静默训废
上游 webui.py 里有一句 if "Pro" in version,命中时才多跑一步 2-get-sv.py,
产出 7-sv_cn/.pt(说话人向量)。
缺了它不会报错。module/data_utils.py 是这么写的:
try:
spec, wav = self.get_audio(...)
ssl = torch.load(f"{self.path4}/{audiopath}.pt")
if self.is_v2Pro:
sv_emb = torch.load(f"{self.path7}/{audiopath}.pt") # ← 这里抛异常
except:
spec = torch.zeros(1025, 100) # ← spec / ssl / wav 一起变全零
ssl = torch.zeros(1, 768, 100)
...
print("load audio or ssl error!!!!!!")
spec、ssl、wav 会一起被换成全零继续训。 训练能跑完、能出权重、能推理,
但音色是废的。所以 tts_preprocess 把它当固定步骤,tts_run 的前置校验把它当硬阻断。
4.4 日文语料的 3-bert 是空目录,这是正常的
1-get-text.py 里:
if os.path.exists(path_bert) == False and lan == "zh":
bert_feature = get_bert_feature(...)
只在 lan == "zh" 时才抽 BERT 特征。日文语料没有 3-bert 文件不影响训练。
别看到空目录就去"修"它。
4.5 后台训练必须 PYTHONUNBUFFERED=1
Python 对非 tty 的 stdout 是块缓冲(默认 8KB)。重定向到日志文件时:
- 训练跑了几分钟,日志还是 0 字节 → 看起来像没启动
- 崩溃 / 被 abort 时缓冲区不会刷 → 真正的报错直接丢失
src/execution.js 的 pythonEnv() 统一加了这个变量。
4.6 预处理产物「已存在就跳过」
1-get-text.py 第 47 行:if os.path.exists(txt_path) == False:。
换数据集不先重置 opt_dir,就会拿旧台词去训新音频——最难查的一类错。
tts_preprocess 的 force=true 用改名而非删除来重置(移到 _stale_/):
既强制重跑,又留了回滚余地。
4.7 训练进程的 cwd 必须是工程根
s2 的 save_weight_dir、s1 的 half_weights_save_dir / train_semantic_path
全是相对路径。换个 cwd 就会写到别处去,而且不报错。
4.8 list.txt 的格式是硬契约
wav名|说话人|语言|文本
- 必须正好 4 段,多一段少一段都会抛异常后跳过整句
- 文本里的
|会被替换成全角逗号(否则解包错位) - 语言必须在白名单里:
zh/ja/en/ko/yue,不在白名单会被静默跳过
tts_build 出料后会核对行数,tts_preprocess 会比对「文本特征行数 vs list 行数」,
不一致时明确提示「有句子被跳过」。
4.9 超长音频只能整条丢弃
SVC 可以按能量谷切开;TTS 不行——切了音频和文本就对不上了,监督信号错位比少几条数据更糟。
所以 tts_build 里 maxSec 之外的样本直接丢弃并计数(build.json 里有 rejected_long 样本)。
4.10 epoch 数小于保存间隔 = 退出码 0 但一个权重都没有
tts_run 的门禁看的是权重文件真的出现(glob _e*_s*.pth),不是退出码。
4.11 s2 存权重前必须存在 logs_s2_/
s2_train.py 的 save_checkpoint 会用 shutil.move 写到
/logs_s2_/G_.pth,但它自己不建这个目录——
建目录的动作在上游 webui.py 第 517 行(s1 的在第 611 行,不过 s1 自己会 mkdir)。
命令行直跑会在保存权重那一刻抛 FileNotFoundError,整轮训练白跑:
FileNotFoundError: [WinError 3] 系统找不到指定的路径。
: '1792411894.14.pth' -> 'logs/yoshino/logs_s2_v2ProPlus/G_233333333333.pth'
tts_run 在启动前统一 mkdir -p 四个目录(logs_s2_ / logs_s1 /
SoVITS_weights_ / GPT_weights_),并在输出里列出新建了哪些。
4.12 ref-lang 给错 = 合成出静音
推理时 prompt_lang 必须与参考音频的真实语言一致。给它 zh 而参考音频是日文时,
模型拿到的语义 prompt 是错的,实测输出塌成 0.68 秒、peak=0.003 的静音,
而且不报任何错。
同一段日文参考音频、同一句日文目标文本,只改 prompt_lang:
| prompt_lang | 时长 | peak | rms |
|---|---|---|---|
zh(错) | 0.68s | 0.0027 | 0.00018 |
ja(对) | 4.84s | 0.3032 | 0.0420 |
所以 tts_infer 的 refLang 默认是 auto:先按参考台词的字符构成判(假名→ja、
谚文→ko、汉字→zh、拉丁→en),判不出才退到目标语言。输出里会写明「参考语言: ja(自动判定)」。
对照实验的做法值得记住:先拿官方底模跑同一句。底模也是静音 → 问题在推理路径; 底模正常而自训权重静音 → 问题在训练。这一步能省掉几小时的瞎猜。
4.13 ctranslate2 找不到 cuBLAS 时自动降 CPU
本机 torch 是 cu128(cublas64_12.dll 在 torch/lib 下),但 ctranslate2 不会去那里找,
ASR 会报:
RuntimeError: Library cublas64_12.dll is not found or cannot be loaded
★ 这个错误是惰性的:WhisperModel(...) 构造可能成功,到第一次 transcribe() 才抛。
所以降级逻辑必须包住转写调用,只包构造会漏。asr_label.py 会把设备降到 CPU + int8
并在 deviceDowngraded 里如实标出。ASR 本来只是兜底,慢一点可以接受,报错不行。
4.14 训练日志里 tqdm 的 \r 会让「一行」长达上百 KB
tts_status 读日志尾部时必须同时按 \r 切分并限制单行长度,
否则整个进度条被当成一行甩到界面上,真正要看的信息全被淹没。
4.15 欠拟合的权重会「念到一半跳字」,先调采样再谈加训
轮数不足时,temperature=1.0 / top_k=15 的默认采样会在长句中间整段跳字。
同一句、同一权重、只改采样(芳乃 2 轮权重,实测 ASR 回读):
| 采样 | 回读片段 | avg_logprob |
|---|---|---|
t=1.0, k=15 | …ちらやね桃色の美しい…(中间整段丢失) | -0.254 |
t=0.6, k=5 | …綺麗に透き通った風を食べ、桃色の…(基本完整) | -0.105 |
t=0.6, k=5, split=cut0 | 同上,句尾更完整 | -0.090 |
所以乱读先降温度,别急着加训。但采样只能缓解,不能扩展模型没学过的句式: 训练语料全是 galgame 短句时,文学性长句的句尾(如「〜できます」)仍会被吞。 那种情况只能加 epoch 或加语料。
4.16 cut5 分句会把句尾切丢,整段朗读用 cut0
text_split_method=cut5 按标点切小句,独立合成后拼接 —— 好处是长文本不崩,
代价是句间韵律不连贯、句尾容易被截。十几秒以内的单段散文用 cut0(不切)
更连贯;超过约 15 秒再回 cut5。
4.17 显存被别的进程占着时,训练会慢 6 倍且不报错
8GB 卡上实测:独占时 0.8~1.9 s/it,与另一个占 1.9GB 显存的任务并存时升到 7.3 s/it,
显存显示 7869/8151 MiB —— 溢到共享内存了,但训练不会报错,只会静静地变慢。
开训前先看 nvidia-smi;batch_size 调小救不了这种情况(激活省下的还不够抵消溢出),
要么等对方结束,要么换卡。
4.18 跨语言不是免费的:纯单语语料训出来的音色念不了外语
芳乃的语料是纯日文(383 条 galgame 台词)。用她念同一段中文散文,实测:
| 语言 | 结果 | avg_logprob |
|---|---|---|
| 日文 | 完整念出,仅句尾少「ます」 | -0.10 |
中文 cut0 | 只念出前半句就停(5.58s / 应约 10s) | -0.136 |
中文 cut5 | 两段都念完,但音全错(冰糖→兵团、晨光→成员) | -0.418 |
v2ProPlus 底模本身是多语言的,但 s2 微调会把音素→声学的映射拉向训练语言。
框架对、音全错是最容易蒙混过关的失败:ASR 回读还能对上「结构」,人耳一听就废。
所以:要哪个语言的音色,就用哪个语言的语料训。真要多语言,得在 list.txt 里混入多语种
(lang 列填对应标记),而不是训完日文再指望它念中文。
4.19 ★ 跨语言的正解:s2 自训拿音色,s1 用底模拿发音
4.18 那个「音全错」并不是 s2 的锅。三组对照(同一句中文散文、同一参考音频、
全部 t=0.6/k=5/cut5):
| 组合 | ASR 回读 | avg_logprob |
|---|---|---|
| A 底模 s2 + 底模 s1(零样本) | 我们即使没有足够的冰糖,也能尝到清晨的风,引下桃色的晨光… | -0.138 |
| B 自训 s2 + 底模 s1 | 我们即使没有足够的冰糖,也能尝到清晨的风,饮下桃色的晨光… | -0.146 |
| C 自训 s2 + 自训 s1 | 我们即使没有足够的兵团也能成为赢下逃赛的成员… | -0.419 |
病根在 s1,不在 s2。 s2 学的是「语义 token → 声学」,语义 token 本身语言无关;
s1 学的是「音素 → 语义 token」,音素是分语言的。芳乃的 s1 只见过日文音素,
于是把中文音素映射到了错的语义位置 —— 换成底模 s1v3 立刻正常。
而且换掉自训 s1 之后日文也变好了(不只是没退化):
| 同一段日文散文 | avg_logprob | 句尾 |
|---|---|---|
| 自训 s1(2 轮) | -0.10 | 「〜ことができ」缺「ます」 |
| 底模 s1 | -0.057 | 完整念出「〜ことができます」 |
结论:s1 训得少(几轮)是纯负收益 —— 灾难性遗忘把底模的多语言能力削掉了, 母语也没学到精。要么 s1 训够(几十轮 + 足够语料),要么干脆不训 s1。
工具层对应:tts_infer 的 gptMode(trained / base),tts_register 可把它记进音色清单,
之后推理默认沿用。跨语言音色请注册成 base。
4.20 音色清单曾按「名字」查,但调用方传的是「实验名」
findVoice(cfg, expName) 只比对 v.name,而用户给音色起的名字通常是中文(「芳乃」),
实验名是 yoshino —— 于是永远查不到,且不报错,只是静默退化成「没有注册记录」。
表现是:注册表里明明写了参考音频,推理却说「未给参考台词(音色更不稳)」。
已改成 name 与 expName 双向匹配。教训:按名字查表时,先确认调用方给的是哪一列;
查不到要能看出来,不能静默降级。
4.21 ★ ASR 是筛子,不是裁判
用 ASR 回读判定「念对了没有」,最大的风险是把裁判的错误当成事实。
先看一个真实翻车:判定曾写成「只要开头 6 个字对得上就算一致」——
const same = norm(asr.text).includes(norm(args.text).slice(0, 6)); // ← 假阳性
于是一句音全错的输出「我们即使没有足够的兵团也能成为赢下逃赛的成员…」 因为开头 6 字与原文相同,被判成「✅ 内容基本一致」。前缀匹配对系统性错误完全失明。
现在的判定在 src/asr-gate.js:全文字错率 CER + avg_logprob 双条件,两个都过才算数。
配套 test/asr-gate.mjs 用真实回读样本做回归(14 项断言)。
但换掉阈值还不够,必须先确认这个裁判本身可信。 问法: 「如果 ASR 对某个词天生不敏感,那念对的版本是不是也会被判错?」
实测(同一段中文,三档 beam):
| 组 | beam=1 | beam=5 | beam=10 |
|---|---|---|---|
| 底模 s1(念对了) | 冰糖 · -0.140 | 冰糖 · -0.138 | 冰糖 · -0.138 |
| 自训 s1 | 兵团 · -0.416 | 兵团 · -0.419 | 兵团 · -0.417 |
- 「ASR 认不出冰糖」被证伪:同样的词,念对的版本三档都稳定认对。
- 自训 s1 三档稳定是「兵团」,说明不是解码偶然,音频里确实没有 /tang/。
- 用日文 ASR 交叉听:念对的中文能被转写出「足りない冰糖でも…」(汉字原样保留); 音错的那版被转写成「ご視聴ありがとうございました」—— 这是 whisper 听不懂时的典型幻觉。
结论的口径要收窄:能说的是「音频里没有发出 /tang/ 这个音」, 不能说「它念了兵团这个词」——那是把 ASR 的判读当成了 TTS 的事实。 人耳听到底是「带口音的冰糖」还是「别的音」,自动指标准不了,最终裁判是人耳。
另一个反直觉结论:日文的字符级 CER 几乎没有信息量。 ASR 输出「私たち / 綺麗 / 透き通った / 食べ」,原文是「わたしたち / きれい / すきとおった / たべ」—— 同音不同表记在字符级全算错。实测:念对了 CER 就有 30.5%,句尾少两个「ます」是 37.3%, 只差 7 个百分点。所以日文阈值放宽到 0.45,只用来拦「大面积离谱」。
所以「多次生成 + ASR 门」到底能不能救跨语言?
- 治随机性失败(跳字、截断、句尾被吞)——有效,换个 seed 能捞到好的。
- 治系统性失败(音素映射错)——无效。自训 s1 那版三档 beam 输出完全一致,
说明这个错是稳定的;多采样只会在同一个错误分布里打转。
跨语言必须换配置(
gptMode=base),不是换运气。
4.22 ★「读法对、语气不对」:s1 管节奏,s2 管音色,而 cut5 在偷偷加长停顿
4.19 解决的是「音念错了」。但换成底模 s1 之后,中文读音正常了, 语气结构仍然是日语的 —— 用户的原话是「读法没问题,但语气结构是日语的、有训读色彩」。 这是两个不同层面的问题,4.19 的方案管不到这一层。
(1) 先纠正一个误判:s2 不负责节奏
同一段 44 字中文,固定参考音频与采样参数,只换 s1 / s2:
| 组合 | 时长 |
|---|---|
| 底模 s1 + 底模 s2 | 12.18 s |
| 底模 s1 + 自训 s2 | 12.18 s |
| 自训 s1 + 底模 s2 | 11.42 s |
| 自训 s1 + 自训 s2 | 11.42 s |
时长由 s1 决定,与 s2 完全无关(s2 换了,时长精确到 0.00 不变)。 s1 是「文本音素 → 语义 token」的模型,它输出的 token 序列长度直接决定每个字的 时长分配与停顿位置 —— 也就是断句、重音、语气结构都归它管。 自训 s1 只见过 27 分钟日语,它给出的节奏因而少 6.2%。
所以「跨语言」要分两件事说:发音准不准看 s1 选没选对(4.19), 语气像不像这门语言也要看 s1。s2 只负责「用什么嗓子」。
(2) 真正让长文本「一顿一顿」的,是分段静音的叠加
cut5 把长文本按标点切句分别合成,每段音频尾部自带约 0.9 秒静音
(模型从参考音频那里学来的「说完留白」),上游再在段间插入
fragment_interval(默认 0.3 s)。两者叠加,实测:
| 总长 | 停顿数 | 停顿总长 | 静音占比 | 停顿序列 | |
|---|---|---|---|---|---|
| 修复前 | 12.18 s | 5 | 5.26 s | 43% | 0.56 / 1.20 / 1.26 / 1.04 / 1.20 |
| 修复后 | 9.36 s | 5 | 2.62 s | 28% | 0.88 / 0.26 / 0.36 / 0.24 / 0.88 |
12 秒里有 5.3 秒是静音,每个句间停顿 1.2 秒 —— 听感就是逐句往外蹦; 再叠加「每句独立合成 ⇒ 每句语调都是从零起、到零收」,就非常像外国人念汉文。
修法在 py/gsv_infer.py:用 return_fragment=True 拿分段,逐段
trim_head / trim_tail 掉留白,再按 --gap(默认 0.22 s)拼回去。
不要用上游的 fragment_interval 直接调小 —— 它只减少插入的那 0.3 s,
段尾那 0.9 s 仍在。
(3) 音色:底模零样本已经够,fine-tune 的增益远小于它的代价
用 ERES2NetV2 说话人向量做跨语言相似度,先校准两个基准:
| 对照 | 余弦相似度 |
|---|---|
| 同说话人(芳乃语料内部真实录音) | 0.784(0.700~0.866) |
| 异说话人(CosyVoice 中文女声) | 0.473 |
| 底模 s2 + 芳乃参考(完全没训,zero-shot) | 0.797 |
| 自训 s2 e8(776 步) | 0.833 |
底模零样本的 0.797 已经落在「同说话人」区间内,fine-tune 只再涨 0.036。 也就是说:为了 4.5% 的音色相似度,付出了日语化的代价,这笔交易并不划算。 (这不否定 fine-tune 的另一个价值:音色稳定性 —— 零样本对参考音频敏感, 换一条参考音色会飘。要稳定 + 中文韵律,得让 s2 见过中文,见 (4)。)
(4) 试过但不成立的路:s2 权重线性插值
既然自训 s2 是从底模初始化的,直觉上可以插值 θ(α)=(1-α)θ_base+αθ_ft
在「音色像」和「调域完整」之间滑。实测不单调:
| α | 0.00 | 0.30 | 0.50 | 0.70 | 1.00 |
|---|---|---|---|---|---|
| F0 跨度中位数(半音) | 10.66 | 9.24 | 9.58 | 10.10 | 8.58 |
差异量级和「换一段文本」的波动(同一权重 7.0~12.9)相当,插值没有可用的中间态。
VITS 的音色与韵律纠缠在同一批参数里,参数平均不是语义平均。
(顺带:enc_q.* 共 103 个参数只在训练用,上游 savee() 会剥掉,
插值时按「底模原值带过」处理,不能当成 key 不一致报错。)
(5) 方法论教训:F0 统计量判不出「像不像这门语言」
一开始想用 F0 的跨度 / 标准差 / 双峰度来量化「日语腔」。用 16 条日文语料 与 6 条中文语料做池化基准,两者几乎重合(跨度 11.07 vs 11.10,标准差 3.56 vs 3.48)。 换成「音节时长离散度」才有区分(0.458 vs 0.627),但对合成音频又不稳定。
原因是:这些统计量对「句间 1.2 秒大停顿」「每句语调重新起头」完全不敏感, 而后者恰恰是人耳第一时间听出来的东西。教训是 —— 先想清楚「人耳到底听到了什么」,再去挑指标;指标挑错了,实验做得再干净也是空的。 (另外:单条 4 秒音频的 F0 跨度方差极大,任何结论都必须多文本重复测量取中位数, 否则会得出 α=0.7 突然回弹这种假象。)
工具层对应
gptMode默认改为auto:读数据集list.txt的主语言, 与目标语言不一致就自动用底模 s1,并在渲染里说明判据(s1 选择: 训练语料 ja → 目标 zh,跨语言,用底模 s1)。 仍然可以强制trained/base。gsv_infer.py新增--gap(段间静音,默认 0.22)与--no-trim(排障用), 分段首尾静音裁剪默认开启。test/f0_prosody.py(韵律量化)、test/soup_s2.py(权重插值)、test/prosody_matrix.py(热切换权重做重复测量)留作回归与复现用。
还没解决的
底模 s2 的中文韵律好但音色会飘,自训 s2 音色稳但韵律日语化。 要同时拿到,必须让 s2 在训练时见过中文 —— 即「芳乃日文语料 + 中文语料」混合训练 s2, 或者先拿音色再用中文语料低 lr 纠一遍。本机目前没有可用的中文单说话人语料, 这一步未做。
4.23 ★「音节读得太快」:speed_factor 是 latent 插值,不是重采样
4.22 修完停顿之后,剩下的反馈是:停顿对了,但每个字的音节本身还是短。
量化后才看清:同一段 46 字中文,含停顿总时长 9.36s ⇒ 4.92 字/秒。
中文散文朗读是 3.84.2,新闻播报约 5.0 —— 也就是这个模型一直用播音速度念散文,
自然听着赶。而「有声段」只占 92%(停顿已经裁好),所以问题不在停顿,在音节时长
(0.143 s/字,正常约 0.190.22)。
根源
s2 的时长分布完全来自它的训练语料。芳乃的语料是 galgame 台词,日语语速本来就比中文快 (mora 约 0.12~0.14 s),模型把这个节奏原样搬到中文上。
关键判断:speed_factor 到底会不会变调?
一开始我以为会。因为 TTS.py 里有这么一段:
upsample_rate = math.prod(self.vits_model.upsample_rates) * (2 / speed_factor)
改采样率 = 重采样 = 变调,看起来铁证如山。但这段 upsample_rate 是拿去做切片边界计算的
(audio_frag_idx = pred_semantic.shape[0] * 2 * upsample_rate),不是采样率参数。
真正起作用的是 module/models.py:259:
if speed != 1:
y = F.interpolate(y, size=int(y.shape[-1] / speed) + 1, mode="linear")
作用在 latent 序列上,改的是序列长度,波形采样率不变 ⇒ 纯时长缩放,不改音高。
实测验证(同一文本、同一权重、同一 seed,只改 speed):
| speed | 总长 | s/字 | 字/秒 | F0 中位 | ΔF0 |
|---|---|---|---|---|---|
| 1.00 | 9.36s | 0.203 | 4.92 | 227.8Hz | 0 |
| 0.85 | 10.93s | 0.238 | 4.21 | 223.9Hz | −1.7% |
| 0.75 | 12.22s | 0.266 | 3.77 | 225.2Hz | −1.1% |
| 0.65 | 13.96s | 0.303 | 3.30 | 222.6Hz | −2.3% |
| 0.55 | 16.13s | 0.351 | 2.85 | 222.6Hz | −2.3% |
F0 偏移在 −1~−3%,和 pyin 本身的帧间抖动同一量级 ⇒ 没有变调。 时长与 speed 成严格反比(9.36 / 0.85 = 11.01 ≈ 10.93),这是纯缩放的另一个佐证。
教训:
speed这个词在同一个文件里可能指两件事。 判断「会不会变调」要一直追到 真正改变波形的那一行,而不是看到采样率变量就下结论。
工具层对应
src/config.js新增LANG_SPEED与resolveSpeed(lang, explicit):zh 0.85 · en 0.9 · ja 1.0 · ko 1.0 · yue 0.85。 日文不调,因为训练语料就是日文,1.0 才是它的自然语速。tts_infer的 `s