InariNGT-Ofiicial/dsh-anime-voice--plugins-tts-train-pipeline ↗★ 8

dsh-tts-train-pipeline

DSH GPT-SoVITS TTS training pipeline: turn a character corpus (with lines or ASR-labelled) into a GPT-SoVITS voice (.pth + .ckpt). The TTS counterpart of dsh-train-pipeline (which makes DDSP/SVC voices). 适合需要构建自定义TTS语音合成模型、进行配音创作的用户。

Package
dsh-tts-train-pipeline
Compatibility
Unverified
Cordis peer range
>=4.0.1
Version
0.2.0
Last updated
Oct 25, 2026

Install

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:InariNGT-Ofiicial/dsh-anime-voice#ace605e5c8ae845f3bda66d3ff1bb59606e4f41e&path:plugins/tts-train-pipeline

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,会给出可直接粘的配置片段。

三种给配置的方式,优先级从高到低:

  1. 工具参数(tts_build({ expName, ... }) 之类)
  2. /tts.local.json(本机覆盖层,不进 git)
  3. cordis.patch.yml / profile 的 cordis.patch.yml
  4. 环境变量 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=10.66s ← 只剩开头两个字
start_periods=1(只裁头)4.04s
start_periods=1 → areverse → start_periods=1 → areverse3.81s ⚠️ 句子保住了,但尾音被削(见下)

后果不只是音质:被截短的句子会小于 minSec 而被整条丢弃。 修复前 630 条只出 28 条(1.96 分钟),修复后出 383 条(27.18 分钟)。

⚠️ 但 areverse 方案也不是终解(2026-10-24 更正,此前这里标的是「✅ 正确」)。 areverse + silenceremove(start_periods=1) 并不是「切在阈值上」,而是把尾部一段真实语音一起削掉。 实测 yos001_001.ogg:

时长尾部静音收尾电平
原声 ogg3.04s82ms−42.6dB
走 areverse 方案2.64s0ms+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.5155 / 200 抽样42
2.0136 / 200 抽样61
3.099 / 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时长peakrms
zh(错)0.68s0.00270.00018
ja(对)4.84s0.30320.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=1beam=5beam=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 + 底模 s212.18 s
底模 s1 + 自训 s212.18 s
自训 s1 + 底模 s211.42 s
自训 s1 + 自训 s211.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 s55.26 s43%0.56 / 1.20 / 1.26 / 1.04 / 1.20
修复后9.36 s52.62 s28%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.000.300.500.701.00
F0 跨度中位数(半音)10.669.249.5810.108.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.009.36s0.2034.92227.8Hz0
0.8510.93s0.2384.21223.9Hz−1.7%
0.7512.22s0.2663.77225.2Hz−1.1%
0.6513.96s0.3033.30222.6Hz−2.3%
0.5516.13s0.3512.85222.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