From 07209cd9506308931e45a2608ed51f24728cd301 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 20 Aug 2026 22:30:15 +0800 Subject: [PATCH 01/10] =?UTF-8?q?feat(tts):=20=E6=8E=A5=E9=80=9A=20IndexTT?= =?UTF-8?q?S-2.5=20=E4=B8=8A=E6=B8=B8=E9=87=87=E6=A0=B7=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E6=97=8F=E3=80=81=E9=9A=8F=E6=9C=BA=E7=A7=8D=E5=AD=90=E4=B8=8E?= =?UTF-8?q?=E5=8F=91=E9=9F=B3=E6=A0=87=E6=B3=A8=E6=8E=A7=E5=88=B6;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 循证核验上游源码(~/tools/index-tts HEAD 4f8792f)后,把三类此前隐式继承或完全 未使用的能力接通到配音管线,并全程保住存量缓存零失效。 采样参数族(temperature/top_p/top_k/length_penalty/repetition_penalty/ max_mel_tokens/interval_silence/text_normalization)此前全部隐式取上游默认, 任何调优都得改服务源码。现经 SynthesizeRequest 显式化并对齐 webui 取值域校验; /health 增加 supports_sampling_params / supports_seed / supports_text_normalization 三个能力位,客户端在旧服务上请求非默认值时硬失败——否则 Pydantic 会静默忽略未声明 字段,而摘要已按新参数变了,会得出「改了参数没效果」的假验证。不暴露 do_sample: 上游 infer_v2_5.py:780 用字面量 True 覆盖了弹出值,webui 的复选框是装饰性控件。 随机种子:上游 do_sample 恒 True 且全链路无种子,同句每次合成都是不同的 take。 实测同文本带 --seed 777 两次字节完全一致、不带则不同——这是所有参数 A/B 可信的 前提,故一并接通并提供 --seed-offset 作「换一条 take」的逃生口。 发音标注 <原文|读音>:上游有完整实现却零使用。用「故意互换多音字读音」的对照实验 证实拼音通道生效(MFCC-DTW 距离 0.133 对「汉字写法」组 vs 0.320 对基线,分离度 2.4×);CMU 音素通道判定随分析窗口翻转,记为未验证。新增 pron_marks.py 承载语法与 校验:正文孤立 < 会被上游正则粘连并吞掉正文、标注错必然读错(原字被丢弃)、 pinyin.vocab 在运行时从未被读取故非法拼音静默通过——三个失效模式全部前移为 ERROR。 build_narration.py 据此派生 text(剥离标注,供字幕与字数预算)与 ttsText(带标注, 供合成),标注自带原字故一处书写零副本。 缓存兼容:摘要沿用「未使用即省略」规则,全部参数取默认时逐字节不变。已对三集 604 句逐句比对新旧摘要——0 句变化;改任一采样参数则正确失效。 测试 46 → 74 项(+8 摘要不变量与区间校验、+20 标注解析与三个失效模式)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/scripts/build_narration.py | 56 ++++- media/pipeline/scripts/pron_marks.py | 172 +++++++++++++ media/pipeline/scripts/tts.py | 293 +++++++++++++++++++++- media/pipeline/scripts/tts_sample.py | 122 ++++++++- media/pipeline/scripts/tts_server.py | 164 +++++++++++- media/pipeline/tests/test_digest.py | 144 ++++++++++- media/pipeline/tests/test_pron_marks.py | 143 +++++++++++ 7 files changed, 1069 insertions(+), 25 deletions(-) create mode 100644 media/pipeline/scripts/pron_marks.py create mode 100644 media/pipeline/tests/test_pron_marks.py diff --git a/media/pipeline/scripts/build_narration.py b/media/pipeline/scripts/build_narration.py index cdee5ca2..cd90076c 100644 --- a/media/pipeline/scripts/build_narration.py +++ b/media/pipeline/scripts/build_narration.py @@ -1,9 +1,20 @@ #!/usr/bin/env python3 -"""从 narration.md 解析生成 narration.json(逐句:id/scene/text)——公共管线版本。 +"""从 narration.md 解析生成 narration.json(逐句:id/scene/text[/ttsText])——公共管线版本。 narration.md 是单一事实源;本脚本是纯派生转换,不做任何内容改写。 适用于任何 `media/*-video/` 科普视频工程(目录约定见 media/pipeline/README.md)。 +**发音标注的正交拆分**:逐字稿里可内联 `<原文|读音>` 标注(多音字/英文专名,语法见 +[pron_marks.py](./pron_marks.py))。本脚本据此派生两个字段: + + text 剥离标注后的**人读文本** —— 同时被字幕(captions.py)与时长预算 + (check_script.py)消费,因此绝不能含标注,否则标注会漏进 SRT/VTT + 并污染字数口径; + ttsText 原始带标注文本,**仅当该句含标注时才写入** —— tts.py 优先取它送合成。 + +未标注的句子不产生 ttsText 字段,取值与历史完全一致 ⇒ 存量缓存摘要不失效。 +标注本身携带原字,故一处书写即可派生两者,不存在两份副本漂移。 + 用法:uv run --no-project media/pipeline/scripts/build_narration.py --project media/<工程> 工程内薄包装等价于:uv run --no-project scripts/build_narration.py """ @@ -16,10 +27,18 @@ import sys from pathlib import Path +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from pron_marks import has_marks, load_vocab, strip_marks, validate + LINE_RE = re.compile(r"^- \[(?P[a-z0-9-]+)\]\s+(?P.+)$") SCENE_RE = re.compile(r"^## (?PP\d+)\b") FORMAT_DOC = "media/pipeline/README.md 第二节格式契约" +#: pinyin.vocab 在 index-tts checkout 内(不在本仓)。存在则用于 WARN 级「音节是否在表内」, +#: 缺失时格式类 ERROR 仍然生效(规则内联在 pron_marks.py,不依赖该文件)。 +PINYIN_VOCAB = Path("~/tools/index-tts/checkpoints/pinyin.vocab").expanduser() + def main() -> None: parser = argparse.ArgumentParser(description="narration.md → narration.json") @@ -34,9 +53,13 @@ def main() -> None: if not src.is_file(): sys.exit(f"narration.md 不存在: {src} —— 见 {FORMAT_DOC}") + vocab = load_vocab(PINYIN_VOCAB) scene = "" items: list[dict[str, str]] = [] seen: set[str] = set() + mark_errors: list[str] = [] + mark_warnings: list[str] = [] + marked = 0 for lineno, raw in enumerate(src.read_text(encoding="utf-8").splitlines(), 1): if m := SCENE_RE.match(raw): scene = m.group("scene") @@ -57,7 +80,31 @@ def main() -> None: f"句 id 必须以幕名小写为前缀(应为 {scene.lower()}-…)" ) seen.add(sid) - items.append({"id": sid, "scene": scene, "text": text}) + # 发音标注:先校验(标注错 = 必然读错,上游丢弃原字、无字形兜底), + # 再派生「人读 text」与「送合成 ttsText」 + errs, warns = validate(text, vocab) + mark_errors += [f"{src}:{lineno} 句 {sid} {e}" for e in errs] + mark_warnings += [f"{src}:{lineno} 句 {sid} {w}" for w in warns] + item: dict[str, str] = { + "id": sid, + "scene": scene, + "text": strip_marks(text), + } + if has_marks(text): + item["ttsText"] = text + marked += 1 + items.append(item) + + for w in mark_warnings: + print(f"WARN {w}", file=sys.stderr) + if mark_errors: + # 与「重复句 id」「幕前置句」同口径硬失败:绝不产出一份带坏标注的 narration.json, + # 否则会静默合成出读错音的整集(单槽位 mp3,事后只能靠听发现) + for e in mark_errors: + print(f"FAIL {e}", file=sys.stderr) + sys.exit( + f"发音标注校验失败({len(mark_errors)} 处)—— 语法见 media/pipeline/scripts/pron_marks.py" + ) dst.write_text( json.dumps(items, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" @@ -69,6 +116,11 @@ def main() -> None: print(f"句数: {len(items)} 总字数: {total_chars}") print(f"各幕句数: {per_scene}") print(f"估算时长(280字/分): {total_chars / 280:.1f} 分钟") + if marked: + print( + f"发音标注: {marked} 句带 ttsText(字数与字幕仍取剥离后的 text)" + + ("" if vocab else ";未找到 pinyin.vocab,已跳过「音节是否在表内」告警") + ) if __name__ == "__main__": diff --git a/media/pipeline/scripts/pron_marks.py b/media/pipeline/scripts/pron_marks.py new file mode 100644 index 00000000..93a50d26 --- /dev/null +++ b/media/pipeline/scripts/pron_marks.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""发音控制标记 `<原文|读音>` 的解析与校验——公共管线单一事实源。 + +IndexTTS-2.5 支持在文本里内联标注读音,用于**多音字**与**英文专名**的精确控制: + + 他在银<行|HANG2>里<行|XING2>走。 拼音通道(标记左侧含汉字) + 能做到。 CMU 音素通道(标记左侧纯 ASCII) + +上游实现(`~/tools/index-tts/indextts/infer_v2_5.py`,HEAD 4f8792f): + - 正则 `:38` = ``<([^|>\\n]+)\\|([^>\\n]+)>``,替换函数 `:52-72`,唯一调用点 `:714`; + - 通道由**标记左侧是否含汉字**二分(含汉字→SPECIAL_TOKEN_2 拼音;纯 ASCII→SPECIAL_TOKEN_1 + 音素),**与 `lang` 参数无关**;读音内容被强制 `upper()`; + - 标记天然**免疫**中文文本归一化(`utils/front.py:143-171` 先换成纯字母占位符、`:220` 还原), + 故不需要为保标记而关 `text_normalization`; + - 切段时作为原子块不可切断(`:410`),单条标记仅 +4~6 token,本仓单句远未触及段预算。 + +三个**必须由本模块拦住**的失效模式(全部实测确认): + 1. 上游正则的 group(1) 字符类 `[^|>\\n]+` **允许 `<`**,故正文里孤立的 `<`(如 `延迟<10ms`) + 会与后面的标记粘连、**吞掉两者之间的全部文字**,且吞掉的是正文、事后极难发现; + 2. 替换是整体 `re.sub`,`match.group(1)`(原字)被**完全丢弃** —— 标注错 = 必然读错, + 没有字形兜底; + 3. `checkpoints/pinyin.vocab` 在**运行时从未被任何代码读取**(全仓 grep 仅命中 README/docs), + 非法拼音 100% 静默通过。 + +拼音拼写规则(对 `checkpoints/pinyin.vocab` 1728 条逐条统计得出,2026-08-20 实测): + - 全大写、无分隔符、必须带声调数字 1–5,100% 匹配 ``^[A-Z]+[1-5]$``; + - 轻声用 5(394 条,是最大的一类); + - ü 一律写 **V** 且仅出现在 J/Q/X/L/N 声母后(65 条)。**``^[JQX]U`` 零命中** —— + 居/去/须 必须写 JV1/QV4/XV1,写成 JU1/QU4/XU1 即非法; + L/N 两种都合法且语义不同(LU=卢、LV=吕;NU=奴、NV=女); + Y 声母保持 U 写法(YU/YUAN/YUE/YUN); + - 儿化没有独立标记:只有 ER2/ER3/ER4/ER5(无 ER1,无独立 R); + - vocab **不是声韵调全叉积**,存在合法音节缺格(ANG3/ER1/KEI1 均不在表内),故 + 「不在 vocab」只作 WARN(大概率读错,需试听),不作 ERROR。 + +用法:本模块只做纯函数,无 IO、无依赖。`build_narration.py` 在生成 narration.json 时调用 +`validate()` 硬失败;`check_script.py` 在内容门里复用同一套规则。 +""" + +from __future__ import annotations + +import re + +#: **本仓的严格版**解析正则:与上游语义一致,但字符类额外禁掉 `<` `>` `|`。 +#: 上游那版允许 group(1) 含 `<`,正是「孤立 < 吞正文」的成因;我们用严格版解析, +#: 再把「上游会匹配而严格版不匹配」的残留 `<`/`>` 报成 ERROR,从而把该 bug 前移到 lint。 +PRON_MARK_RE = re.compile(r"<([^|<>\n]+)\|([^|<>\n]+)>") + +#: 单个拼音音节:全大写字母 + 声调 1–5 +PINYIN_SYLLABLE_RE = re.compile(r"^[A-Z]+[1-5]$") + +#: jqx + ü 必须写 V(vocab 中 ^[JQX]U 零命中) +PINYIN_JQXU_RE = re.compile(r"^[JQX]U") + +#: 汉字(用于判定走哪个通道——与上游 `:66` 的判据一致) +HAN_RE = re.compile(r"[一-鿿]") + +#: ARPAbet 音素集(CMU 词典 39 音素),元音可带重音数字 0/1/2 +_ARPABET = ( + "AA AE AH AO AW AY B CH D DH EH ER EY F G HH IH IY JH K " + "L M N NG OW OY P R S SH T TH UH UW V W Y Z ZH" +) +CMU_PHONEMES = frozenset(_ARPABET.split()) +CMU_TOKEN_RE = re.compile(r"^([A-Z]{1,2}|NG|CH|DH|HH|JH|SH|TH|ZH)([012])?$") + + +def has_marks(text: str) -> bool: + """文本中是否含(严格版可识别的)发音标注。""" + return bool(PRON_MARK_RE.search(text)) + + +def strip_marks(text: str) -> str: + """`他在银<行|HANG2>里走。` → `他在银行里走。` + + 标记本身携带原字(group(1)),故剥离即可还原**人读文本**——无需在逐字稿里 + 重复书写两份,也就不存在两份漂移的风险。这正是 narration.json 里 + `text`(人读/字幕/字数预算)与 `ttsText`(送合成)能共用一个书写面的原因。 + """ + return PRON_MARK_RE.sub(lambda m: m.group(1), text) + + +def _check_pinyin(reading: str) -> list[str]: + """拼音通道校验 → ERROR 消息列表(空 = 通过)。""" + errs: list[str] = [] + syllables = reading.split() + if not syllables: + return ["拼音标注为空"] + for syl in syllables: + if not PINYIN_SYLLABLE_RE.match(syl): + errs.append( + f"拼音音节 {syl!r} 非法:须全大写字母 + 声调 1–5(轻声用 5),如 HANG2" + ) + elif PINYIN_JQXU_RE.match(syl): + errs.append( + f"拼音音节 {syl!r} 非法:j/q/x + ü 必须写 V 而非 U" + f"(如 居=JV1、去=QV4、须=XV1)" + ) + return errs + + +def _check_cmu(reading: str) -> list[str]: + """CMU 音素通道校验 → ERROR 消息列表(空 = 通过)。""" + errs: list[str] = [] + # 官方示例用 ` . ` 分音节(``),点号在此仅作分隔、不是音素 + tokens = [t for t in reading.replace(".", " ").split() if t] + if not tokens: + return ["音素标注为空"] + for tok in tokens: + m = CMU_TOKEN_RE.match(tok) + if not m or m.group(1) not in CMU_PHONEMES: + errs.append( + f"音素 {tok!r} 不在 ARPAbet 集合内(元音可带重音 0/1/2),如 K L AO1 D" + ) + return errs + + +def validate( + text: str, vocab: frozenset[str] | None = None +) -> tuple[list[str], list[str]]: + """校验一句文本里的全部发音标注 → (errors, warnings)。 + + vocab:可选的合法拼音音节集合(`checkpoints/pinyin.vocab` 内容)。该文件在 + index-tts checkout 里、**不在本仓**,故缺失时只跳过「是否在表内」这一项 WARN, + 格式类 ERROR 始终生效(规则已内联在本模块,不依赖外部文件)。 + """ + errors: list[str] = [] + warnings: list[str] = [] + + # 先剥掉所有合法标记,残留的 `<` / `>` 即结构风险(含上游「孤立 < 吞正文」那一类) + residue = PRON_MARK_RE.sub("", text) + if "<" in residue or ">" in residue: + errors.append( + "正文含未成对的 `<` 或 `>`:上游解析正则允许 group(1) 内含 `<`," + "孤立的 `<` 会与后面的标记粘连并**吞掉之间的全部正文**" + "(如 `延迟<10ms` + 同句标记)。请改写为「小于」「大于」等文字" + ) + if "|" in residue: + errors.append("正文含孤立 `|`:会与相邻 `<`/`>` 组成意外标记,请去掉") + + for m in PRON_MARK_RE.finditer(text): + word, reading = m.group(1), m.group(2) + mark = m.group(0) + if reading != reading.strip(): + warnings.append(f"{mark}:读音首尾有空格(上游会 upper() 但不 strip)") + if reading != reading.upper(): + errors.append( + f"{mark}:读音须全大写(上游 `:65` 会强制 upper(),但小写写法易与" + f"「裸内联拼音」混淆——后者在 v2.5 会被全局 lower() 打回小写、训练形态不同)" + ) + continue + if HAN_RE.search(word): + errors.extend(f"{mark}:{e}" for e in _check_pinyin(reading)) + if vocab: + for syl in reading.split(): + if PINYIN_SYLLABLE_RE.match(syl) and syl not in vocab: + warnings.append( + f"{mark}:音节 {syl} 不在 pinyin.vocab(1728 条)内 —— " + f"vocab 存在合法缺格(如 ANG3/ER1),故仅告警,但请务必试听确认" + ) + else: + errors.extend(f"{mark}:{e}" for e in _check_cmu(reading)) + + return errors, warnings + + +def load_vocab(path) -> frozenset[str] | None: + """读取 pinyin.vocab(可选)。文件在 index-tts checkout 内,不存在则返回 None。""" + try: + with open(path, encoding="utf-8") as fh: + return frozenset(line.strip() for line in fh if line.strip()) + except OSError: + return None diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 4418ecb8..8492f7bd 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -60,7 +60,19 @@ AVG_SEC_PER_LINE = 4.2 # 三集每句音频均值 # IndexTTS 8 维情感向量顺序(indextts/infer_v2_5.py 固定):happy, angry, sad, afraid, -# disgusted, melancholic, surprised, calm。有效和(Σ分量×emo_alpha)须 ≤0.8(直调 infer 不自动归一,双端校验)。 +# disgusted, melancholic, surprised, calm。 +# +# 有效和护栏 Σ分量×emo_alpha ≤ 0.8 是**本管线自定的口径**,不是上游行为(2026-08-20 核验): +# 上游 infer() 从不归一化,normalize_emo_vec 全仓唯一调用点是 webui.py:665 的「自定义向量」 +# 分支,且那条的 0.8 作用在**已乘 emo_bias 的和**上、且在 alpha 之前。 +# emo_bias(infer_v2_5.py:493 硬编码)8 维严重不等权: +# sad/afraid=1.0 > happy/disgusted/melancholic=0.9375 > angry=0.875 > surprised=0.6875 > calm=0.5625 +# 后果:从社区/WebUI 抄来的 (vec, alpha) 经本仓复现实际**强 16%–33%**(含 calm 越重偏差越大, +# confident 档最失真),跨来源参数迁移必须重新试听定档。详见 INDEXTTS-2.5-ADVANCED.md §3.2。 +# +# 另注:alpha 恰好等于「替换掉本人语调的百分比」**仅当名义向量和 = 1.0** —— +# sunny/passionate 刚好是 1.00,而 lively(0.85)/confident(0.90)/positive(0.95) 不是, +# 其 alpha 跨预设不可直接比较。 EMO_KEYS = [ "happy", "angry", @@ -74,14 +86,21 @@ # 风格预设 —— 数值为初值,可实测试听后微调。 # 可选键 "beams":预设自带的束搜索宽度(缺省 1)。束宽会改变韵律稳定度,属风格的一部分, -# 故允许写进预设;命令行 --num-beams 显式给值时优先。注意束宽 3 使整集墙钟约 ×3.4 -# (长跑折算 RTF 13→45),若只想让关键句更稳,用 --steady 混合档而非整集升档。 +# 故允许写进预设;命令行 --num-beams 显式给值时优先。束宽代价见下方 sunny-steady 注释 +# (MPS 上近乎免费,CUDA 上近线性);机器忙时想只让关键句更稳,用 --steady 混合档。 +# 可选键 "sampling":预设自带的采样参数覆盖(见 SAMPLING_DEFAULTS)。当前所有预设都不带 —— +# 任何写进预设的值都会改缓存摘要 ⇒ 整集重录,故须先 A/B 拿到证据再定档。 STYLE_PRESETS: dict[str, dict] = { "neutral": {"label": "中性", "vec": None, "alpha": 1.0, "df": 1.0}, "passionate": { "label": "激情", # 高唤醒正价(happy 主载)+ 跳跃感(surprised)+ 少量 calm 锚定咬字; - # 有效和 1.00×0.7=0.70 ≤0.8;df 0.97 护密集技术句清晰度。 + # 有效和 1.00×0.7=0.70 ≤0.8。 + # ⚠️ 校准(2026-08-20):此处原注「df 0.97 护密集技术句清晰度」**方向写反了**。 + # duration_factor 作用于 S2M 的时间轴重采样(infer_v2_5.py:832 target_lengths), + # df<1 → 梅尔帧更少 → 语速更快 → 每个音素分到的时间更短、咬字更紧,密集技术句 + # 应当**更糊**而非更清。护清晰度的正确方向是 df>1。0.97 的实际效果是「略快」, + # 与本档「激情」的定位自洽,故数值保留、只更正因果表述。 "vec": [0.70, 0, 0, 0, 0, 0, 0.20, 0.10], "alpha": 0.7, "df": 0.97, @@ -121,8 +140,11 @@ # = sunny 同方向同强度同语速,只把束宽提到 3:GPT 段搜索更宽 → 韵律更收敛。 # 实测(同文本同样本):语调起伏 48.4 → 43.5、音节率 4.10 → 4.55,亮度基本不掉 # (质心 1245 → 1223)——是唯一「不牺牲明快度就让语气更稳」的旋钮。 - # 代价:GPT 段耗时约按束宽线性放大,**整集墙钟约 ×3.4**(189 句估算 2.9→9.9 小时, - # 见 VOICE-CLONING.md §4.3b)。想只让关键句变稳请用 `--steady` 混合档,别整集升档。 + # 代价高度依赖硬件,**不是无条件的「线性放大」**:束宽只作用于 T2S(S2M 入口的 + # codes 形状与束宽无关),而 MPS 上 T2S 只占三段耗时的 18–46%(2026-08-20 本机 + # 13 样本分段 profile:s2mel 反而占 45–73%)。整集实测 1→3 束仅 +4% + # (EP1 v3 两遍法 1.9h → 1.98h)。故:CUDA 上近线性放大,MPS 上近乎免费。 + # 机器忙/热节流时仍可能出现数倍差,此时用 `--steady` 混合档只升关键句。 "vec": [0.95, 0, 0, 0, 0, 0, 0.02, 0.03], "alpha": 0.35, "df": 0.95, @@ -131,11 +153,57 @@ } +# 上游自回归采样参数的默认值 —— 已知副本,锚点 indextts/infer_v2_5.py:731-739(HEAD 4f8792f); +# 与服务端 tts_server.py 的 SAMPLING_DEFAULTS 必须逐字一致(那里有机制说明)。 +# +# 本副本只有一个用途:判定「这一项是否被显式改过」,从而决定**要不要进缓存摘要**。 +# 摘要沿用「未使用即省略」规则(同 |beams=N),故全部取默认时摘要与历史逐字节相同 —— +# 这是已上线三集近 600 句缓存零失效的前提,由 tests/test_digest.py 黄金哈希钉死。 +SAMPLING_DEFAULTS: dict[str, float | int | bool | None] = { + "temperature": 0.8, + "top_p": 0.8, + "top_k": 30, + "length_penalty": 0.0, + "repetition_penalty": 10.0, + "max_mel_tokens": 1500, + "interval_silence": 200, + "text_normalization": True, + "seed": None, +} + +#: 采样参数的合法区间(对齐上游 webui.py:901-910 的滑杆),用于长跑前提前失败。 +#: max_mel_tokens 上限 1815 = config.yaml 的 gpt.max_mel_tokens(mel 位置嵌入容量 ≈36.2 s)。 +SAMPLING_RANGES: dict[str, tuple[float, float]] = { + "temperature": (0.1, 2.0), + "top_p": (0.0, 1.0), + "top_k": (0, 100), + "length_penalty": (-2.0, 2.0), + "repetition_penalty": (0.1, 20.0), + "max_mel_tokens": (50, 1815), + "interval_silence": (0, 2000), +} + + def tts_text(text: str) -> str: - """口播文本微调:破折号换为逗号停顿,避免 TTS 念成怪音。""" + """口播文本微调:破折号换为逗号停顿,避免 TTS 念成怪音。 + + 注意这是**唯一**的程序化文本预处理:数字/百分号/量词的读法由上游中文归一化 + (wetext)承担,多音字与英文专名读音由逐字稿里的发音标注 `<字|读音>` 承担。 + 归一化的已知陷阱(4 位年份与「年」之间不能有空格)由 check_script.py 的写稿 lint 拦。 + """ return text.replace("——", ",").replace("……", "。") +def synth_source_text(item: dict) -> str: + """取该句真正送去合成的文本:优先 `ttsText`(含发音标注),否则 `text`。 + + `narration.json` 的 `text` 是**人读文本**,同时被字幕(captions.py)与字数预算 + (check_script.py)消费;发音标注只能进 `ttsText`,否则会泄漏到 SRT/VTT 并污染预算。 + 未标注的句子没有 `ttsText` 字段 ⇒ 取值与历史完全一致 ⇒ 存量缓存不失效。 + """ + return item.get("ttsText") or item["text"] + + def mp3_duration(path: Path) -> float: """mutagen 实测 MP3 时长(两引擎共用)。""" from mutagen.mp3 import MP3 @@ -191,6 +259,63 @@ def resolve_style( return args.style, preset["vec"], alpha, df, beams +#: 采样参数的 CLI 名 → 属性名(argparse 把连字符转下划线)。text_normalization / seed 单独处理。 +SAMPLING_CLI = ( + "temperature", + "top_p", + "top_k", + "length_penalty", + "repetition_penalty", + "max_mel_tokens", + "interval_silence", +) + + +def resolve_sampling(args: argparse.Namespace) -> dict[str, float | int | bool]: + """收集所有**被显式改动过**的采样参数;全默认时返回空 dict(摘要因此不变)。 + + 优先级同 alpha/df/beams:命令行显式给值 > 风格预设的 `sampling` 键 > 上游默认。 + 预设自带采样参数是刻意留的扩展位——束宽已证明「属于风格的一部分」,length_penalty + 这类同样影响韵律的旋钮理应能随风格走;但**任何写进预设的值都会改摘要 ⇒ 整集重录**, + 故当前所有预设都不带 sampling,待 A/B 拿到证据后再定档。 + """ + preset_sampling: dict = ( + STYLE_PRESETS.get(args.style, {}).get("sampling", {}) if args.style else {} + ) + out: dict[str, float | int | bool] = {} + for key in SAMPLING_CLI: + cli_val = getattr(args, key, None) + val = cli_val if cli_val is not None else preset_sampling.get(key) + if val is None: + continue + lo, hi = SAMPLING_RANGES[key] + if not lo <= val <= hi: # NaN 比较恒 False,一并被拦 + raise ValueError(f"--{key.replace('_', '-')} 必须在 [{lo:g}, {hi:g}]") + if key == "top_k" and val == 1: + raise ValueError( + "--top-k 1 在束搜索下不安全(每束需保底 2 个候选):用 0 关闭或 ≥2" + ) + if val != SAMPLING_DEFAULTS[key]: + out[key] = val + if getattr(args, "no_text_normalization", False): + out["text_normalization"] = False + if getattr(args, "seed", None) is not None: + # --seed-offset 是「换一条 take」的逃生口:固定种子会把某句锁死在一条可能不佳的 + # 采样结果上,偏移一位即可换一条而仍然可复现。 + out["seed"] = int(args.seed) + int(getattr(args, "seed_offset", 0) or 0) + return out + + +def sampling_suffix(sampling: dict | None) -> str: + """采样参数 → 缓存摘要后缀。空 dict 返回空串(存量缓存零失效的关键)。 + + 键按字母序,值用 repr()(防 0.7 → 0.70 之类的表示漂移,与 alpha/df 同口径)。 + """ + if not sampling: + return "" + return "".join(f"|{k}={sampling[k]!r}" for k in sorted(sampling)) + + # ---------------- 混合档:整集低束宽 + 指定句高束宽 ---------------- # # 动机(实测):3 束把语调起伏收窄约 10–20%、听感更「稳/可信」,但短句 RTF 从 6–7 涨到 @@ -382,6 +507,7 @@ def http_synthesize( emo_ref: str | None = None, emo_text: str | None = None, headers_out: dict | None = None, + sampling: dict | None = None, ) -> tuple[bytes, str]: """POST /synthesize → (mp3 bytes, X-Audio-Format)。4xx 不可重试。 @@ -404,6 +530,8 @@ def http_synthesize( payload["emo_ref_path"] = emo_ref if emo_text: # 自然语言情感描述(服务端 QwenEmotion 转向量) payload["emo_text"] = emo_text + if sampling: # 只发被显式改过的采样参数,其余由服务端取上游默认 + payload.update(sampling) req = urllib.request.Request( f"{server}/synthesize", data=json.dumps(payload).encode(), @@ -436,14 +564,17 @@ def digest_indextts( num_beams: int = 1, emo_ref_sha1: str | None = None, emo_text: str | None = None, + sampling: dict | None = None, ) -> str: vec_str = ",".join(repr(x) for x in vec) if vec else "none" - # 束宽/情感来源改变合成结果,须入键;未使用时省略字段——沿用历史摘要格式,存量缓存不失效 + # 束宽/情感来源/采样参数改变合成结果,须入键;未使用时省略字段——沿用历史摘要格式, + # 存量缓存不失效。新增字段一律追加在**末尾**且默认省略,故摘要格式可持续扩展。 beams_part = "" if num_beams == 1 else f"|beams={num_beams}" emo_part = f"|emoref={emo_ref_sha1}" if emo_ref_sha1 else "" emo_part += f"|emotext={emo_text}" if emo_text else "" return hashlib.sha1( - f"indextts|{engine_tag}|{ref_sha1}|{lang}|{style}|{vec_str}|{alpha!r}|{df!r}|{text}{beams_part}{emo_part}".encode() + f"indextts|{engine_tag}|{ref_sha1}|{lang}|{style}|{vec_str}|{alpha!r}|{df!r}|{text}" + f"{beams_part}{emo_part}{sampling_suffix(sampling)}".encode() ).hexdigest() @@ -465,8 +596,9 @@ async def synth_indextts( emo_ref: str | None = None, emo_ref_sha1: str | None = None, emo_text: str | None = None, + sampling: dict | None = None, ) -> dict: - sid, text = item["id"], item["text"] + sid, text = item["id"], synth_source_text(item) mp3 = out_dir / f"{sid}.mp3" meta = out_dir / f"{sid}.sha" digest = digest_indextts( @@ -481,6 +613,7 @@ async def synth_indextts( num_beams, emo_ref_sha1, emo_text, + sampling, ) if ( @@ -508,6 +641,8 @@ async def synth_indextts( num_beams, emo_ref, emo_text, + None, # headers_out:管线主路径不需要回填响应头 + sampling, ) if fmt != "mp3": raise NonRetryableError( @@ -643,6 +778,84 @@ async def main() -> None: default="indextts", help="[indextts] 缓存标记;模型升级后自定义以失效旧缓存", ) + + # 采样参数族:上游经 **generation_kwargs 透传给 HF generate,此前全部隐式继承上游默认, + # 任何调优都得改服务源码。显式化后它们会**进缓存摘要**(仅在 ≠ 上游默认时),故改参必然 + # 重合成,不会出现「改了参数却命中旧缓存 ⇒ 误判无效果」这类假验证。 + # 缺省一律 None = 取风格预设的 sampling,再退到上游默认(见 resolve_sampling)。 + smp = parser.add_argument_group( + "indextts 采样参数(专家级,缺省即上游默认;改任一项都会失效该句缓存)" + ) + smp.add_argument( + "--temperature", + default=None, + type=float, + help="[indextts] 采样温度 0.1–2.0(上游默认 0.8)。收紧可降低长视频的句间韵律漂移," + "过紧会滑向单调播报", + ) + smp.add_argument( + "--top-p", + default=None, + type=float, + help="[indextts] 核采样 0–1(上游默认 0.8)", + ) + smp.add_argument( + "--top-k", + default=None, + type=int, + help="[indextts] top-k 0–100(上游默认 30;0=关闭,1 在束搜索下不安全故禁用)", + ) + smp.add_argument( + "--length-penalty", + default=None, + type=float, + help="[indextts] 束打分长度惩罚 -2–2(上游默认 0.0)。**0.0 不是中性**:打分不做长度" + "归一化而对数概率恒负,故系统性偏好更短假设,是 --num-beams>1 时吞尾/漏字的机制来源。" + "抬到 0.3–1.0 可缓解;仅束搜索打分时生效,--num-beams 1 下改它无效", + ) + smp.add_argument( + "--repetition-penalty", + default=None, + type=float, + help="[indextts] 重复惩罚 0.1–20(上游默认 10.0,自 v1 沿用且无测试支撑)。" + "作用在语义码头上、是对 logit 的符号相关缩放;有效强度依赖 logit 绝对尺度," + "故**与音色/情感向量耦合**,跨音色不可迁移调参结论", + ) + smp.add_argument( + "--max-mel-tokens", + default=None, + type=int, + help="[indextts] 生成上限 50–1815(上游默认 1500 ≈30 s;1815 为架构上限 ≈36.2 s)。" + "溢出后果不是音频被裁短,而是文本尾部根本没被念出。本仓单句远未触顶,通常不需要动", + ) + smp.add_argument( + "--interval-silence", + default=None, + type=int, + help="[indextts] 单请求内**分段之间**的静音毫秒(上游默认 200)。本管线逐句合成、" + "单句远低于分段预算,故默认不生效;句间停顿由 video/src/timing.json 的 sentenceGapSec 决定", + ) + smp.add_argument( + "--no-text-normalization", + action="store_true", + help="[indextts] 关闭上游中文文本归一化(v2.5 专属)。**通常不要用**:实测 %% / 小数 /" + "量词 / 月日 / 章节的读法本来就正确,关掉等于把全部读法责任推给逐字稿;" + "发音标注 <字|读音> 本身免疫归一化,不需要为保标记而关它", + ) + smp.add_argument( + "--seed", + default=None, + type=int, + help="[indextts] 随机种子。上游 do_sample 恒 True 且全链路无种子,同句每次合成都是" + "不同的 take;给定种子后逐句可复现——这是任何参数 A/B 可信的前提", + ) + smp.add_argument( + "--seed-offset", + default=0, + type=int, + help="[indextts] 与 --seed 相加(默认 0)。固定种子会把某句锁死在一条可能不佳的" + "采样结果上,偏移一位即可换一条 take 而仍然可复现", + ) args = parser.parse_args() args.server = args.server.rstrip( "/" @@ -658,10 +871,22 @@ async def main() -> None: ",".join(f"{x:g}" for x in p["vec"]) if p["vec"] else "—(不注入情感)" ) eff = (sum(p["vec"]) * p["alpha"]) if p["vec"] else 0.0 + smp_note = ( + "" + if not p.get("sampling") + else " 采样 " + + ",".join(f"{k}={v!r}" for k, v in sorted(p["sampling"].items())) + ) print( f"{name:<14} {p['label']:<6} {vec:<62} {p['alpha']:<5} " - f"{eff:<8.3g} {p['df']:<4} {p.get('beams', 1)}" + f"{eff:<8.3g} {p['df']:<4} {p.get('beams', 1)}{smp_note}" ) + print( + "\n采样参数(temperature/top_p/top_k/length_penalty/repetition_penalty/" + "max_mel_tokens/interval_silence)未在任何预设中覆盖," + "均取上游默认:" + + ", ".join(f"{k}={v!r}" for k, v in SAMPLING_DEFAULTS.items()) + ) return if args.engine == "edge": @@ -689,6 +914,17 @@ async def main() -> None: "--steady": args.steady, "--style": args.style != "neutral", "--lang": args.lang != "ZH", + # 采样参数族同属克隆专属:edge 不认这些旋钮,且两引擎摘要必然不同, + # 照跑同样会把整集克隆音频改写成 edge 预置音色 ⇒ 与上面同口径硬失败。 + "--temperature": args.temperature is not None, + "--top-p": args.top_p is not None, + "--top-k": args.top_k is not None, + "--length-penalty": args.length_penalty is not None, + "--repetition-penalty": args.repetition_penalty is not None, + "--max-mel-tokens": args.max_mel_tokens is not None, + "--interval-silence": args.interval_silence is not None, + "--no-text-normalization": args.no_text_normalization, + "--seed": args.seed is not None, }.items() if val ] @@ -704,6 +940,8 @@ async def main() -> None: "--steady-beams": args.steady_beams != 3, "--server": args.server != "http://127.0.0.1:8766", "--engine-tag": args.engine_tag != "indextts", + # --seed-offset 单独给值而没给 --seed 时无任何效果(见 resolve_sampling) + "--seed-offset": args.seed_offset != 0 and args.seed is None, }.items() if val ] @@ -758,6 +996,10 @@ async def main() -> None: style_name, vec, alpha, df, beams = resolve_style(args) except ValueError as e: parser.error(str(e)) + try: + sampling = resolve_sampling(args) + except ValueError as e: + parser.error(str(e)) if not args.ref: parser.error( "--engine indextts 需要 --ref 参考音色样本(见 " + MANUAL + " §三)" @@ -821,6 +1063,12 @@ async def main() -> None: if args.plan: # 计划模式:纯本地计算,不连服务 print( f">> 计划:{root.name} · 风格 {style_name} · alpha {alpha:g} · 语速 {df:g}" + + ( + "" + if not sampling + else " · 采样 " + + ",".join(f"{k}={sampling[k]!r}" for k in sorted(sampling)) + ) ) todo = {b: 0 for b in sorted(set(beams_of.values()))} cached = dict(todo) @@ -834,10 +1082,11 @@ async def main() -> None: df, args.lang, args.engine_tag, - i["text"], + synth_source_text(i), b, emo_ref_sha1, args.emo_text, + sampling, ) meta, mp3 = out_dir / f"{i['id']}.sha", out_dir / f"{i['id']}.mp3" hit = ( @@ -889,6 +1138,25 @@ async def main() -> None: "当前服务未加载 QwenEmotion:重启服务加 --use-qwen-emo,或改用 --emo-vector/--emo-ref,见 " + MANUAL ) + # 采样参数/种子对旧服务是**静默丢弃**(Pydantic 默认忽略未声明字段),而摘要这边已经 + # 按新参数变了 ⇒ 会产出「摘要说改过、音频其实没改」的假验证。故显式硬失败。 + sampling_only = {k: v for k, v in sampling.items() if k != "seed"} + if sampling_only and not health.get("supports_sampling_params"): + parser.error( + f"当前服务不支持采样参数({','.join(sorted(sampling_only))}):服务端代码过旧," + f"请用本仓当前 tts_server.py 重启服务,见 {MANUAL} §二" + ) + if "seed" in sampling and not health.get("supports_seed"): + parser.error( + f"当前服务不支持 --seed:服务端代码过旧,请用本仓当前 tts_server.py 重启服务,见 {MANUAL} §二" + ) + if sampling.get("text_normalization") is False and not health.get( + "supports_text_normalization" + ): + parser.error( + "当前服务为 IndexTTS-2(infer() 无 text_normalization 形参):" + "去掉 --no-text-normalization,或改用 v2.5 服务" + ) sem = asyncio.Semaphore(CONCURRENCY_INDEXTTS) results = await asyncio.gather( @@ -912,6 +1180,7 @@ async def main() -> None: emo_ref=emo_ref_path, emo_ref_sha1=emo_ref_sha1, emo_text=args.emo_text, + sampling=sampling, ) for i in items ) diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py index 9a2837ae..55e0ea59 100644 --- a/media/pipeline/scripts/tts_sample.py +++ b/media/pipeline/scripts/tts_sample.py @@ -34,11 +34,13 @@ from tts import ( # noqa: E402 - 必须在 sys.path 注入之后导入 MANUAL, + SAMPLING_CLI, STYLE_PRESETS, NonRetryableError, http_json, http_synthesize, mp3_duration, + resolve_sampling, resolve_style, server_launch_hint, tts_text, @@ -85,7 +87,10 @@ def build_jobs( def check_server( - server: str, need_duration_factor: bool, need_emo_text: bool = False + server: str, + need_duration_factor: bool, + need_emo_text: bool = False, + sampling: dict | None = None, ) -> None: """健康检查——服务未起时给出可直接粘贴的启动命令,避免等到合成阶段才失败。""" try: @@ -110,6 +115,29 @@ def check_server( "当前服务未加载 QwenEmotion,无法用 --emo-text:\n" " 重启服务时加 --use-qwen-emo(约 +1.5 GB 内存),或改用 --emo-vector / --emo-ref" ) + # 旧服务对未声明字段是**静默忽略**(Pydantic 默认行为),会让 A/B 得出「改了没效果」的 + # 错误结论。与 tts.py 同口径显式硬失败。 + sampling = sampling or {} + if {k for k in sampling if k != "seed"} and not health.get( + "supports_sampling_params" + ): + sys.exit( + "当前服务不支持采样参数(temperature/top_p/top_k/length_penalty/" + "repetition_penalty/max_mel_tokens/interval_silence):\n" + f" 服务端代码过旧,请用本仓当前 tts_server.py 重启服务:\n{server_launch_hint()}" + ) + if "seed" in sampling and not health.get("supports_seed"): + sys.exit( + "当前服务不支持 --seed:服务端代码过旧,请用本仓当前 tts_server.py 重启服务:\n" + f"{server_launch_hint()}" + ) + if sampling.get("text_normalization") is False and not health.get( + "supports_text_normalization" + ): + sys.exit( + "当前服务为 IndexTTS-2(infer() 无 text_normalization 形参):\n" + " 去掉 --no-text-normalization,或以 --indextts-version 2.5 重启服务" + ) def synthesize_one( @@ -121,6 +149,7 @@ def synthesize_one( beams: int, out_dir: Path, stem: str | None = None, + sampling: dict | None = None, ) -> dict: """合成一档并落盘 → {style, path, duration, wall, rtf}。失败即退出(小样无需容错累积)。""" out = out_dir / f"{stem or name}.mp3" @@ -141,6 +170,7 @@ def synthesize_one( args.emo_ref, args.emo_text, headers, + sampling, ) except NonRetryableError as e: sys.exit(f"[{name}] 请求被拒(4xx,重试无意义):{e}") @@ -253,6 +283,66 @@ def main() -> None: + MANUAL + " §4.3b", ) + # 采样参数族(与 tts.py 同名同区间,语义与机制说明见 tts.py 的 SAMPLING_DEFAULTS 注释)。 + # 小样是这些参数的**主战场**:它们全都需要 A/B 才能定档,而整集长跑一次数小时。 + smp = parser.add_argument_group("采样参数(专家级,缺省即上游默认)") + smp.add_argument( + "--temperature", + default=None, + type=float, + help="采样温度 0.1–2.0(上游默认 0.8)", + ) + smp.add_argument( + "--top-p", default=None, type=float, help="核采样 0–1(上游默认 0.8)" + ) + smp.add_argument( + "--top-k", default=None, type=int, help="top-k 0–100(上游默认 30;0=关闭)" + ) + smp.add_argument( + "--length-penalty", + default=None, + type=float, + help="束打分长度惩罚 -2–2(上游默认 0.0,**非中性**:系统性偏好更短假设," + "是 --num-beams>1 时吞尾/漏字的机制来源)。仅束搜索时生效", + ) + smp.add_argument( + "--repetition-penalty", + default=None, + type=float, + help="重复惩罚 0.1–20(上游默认 10.0)。有效强度依赖 logit 尺度,故与音色/情感耦合", + ) + smp.add_argument( + "--max-mel-tokens", + default=None, + type=int, + help="生成上限 50–1815(上游默认 1500 ≈30 s)。溢出表现为文本尾部未被念出", + ) + smp.add_argument( + "--interval-silence", + default=None, + type=int, + help="单请求内分段间静音毫秒(上游默认 200);单句试听通常不分段,故一般无效果", + ) + smp.add_argument( + "--no-text-normalization", + action="store_true", + help="关闭上游中文归一化(v2.5 专属)。通常不要用——%% / 小数 / 量词的读法本来就对," + "且发音标注 <字|读音> 免疫归一化,不需要为保标记而关它", + ) + smp.add_argument( + "--seed", + default=None, + type=int, + help="随机种子。上游 do_sample 恒 True 且无种子,同句每次都是不同 take;" + "**做任何参数 A/B 都应先固定种子**,否则听到的差异可能只是采样噪声", + ) + smp.add_argument( + "--seed-offset", + default=0, + type=int, + help="与 --seed 相加(默认 0),用于换一条 take", + ) + parser.add_argument("--server", default="http://127.0.0.1:8766", help="服务地址") parser.add_argument( "--label", @@ -342,6 +432,16 @@ def main() -> None: if not args.text: parser.error("试听文本为空") + try: + sampling = resolve_sampling(args) + except ValueError as e: + parser.error(str(e)) + # --all-styles 下 resolve_sampling 只取命令行值(args.style 仍是默认 neutral,其预设无 + # sampling)。将来若给某个预设加了 sampling,A/B 就会静默丢掉那一档的采样口径 —— 提前拦住。 + if args.all_styles and any(p.get("sampling") for p in STYLE_PRESETS.values()): + parser.error( + "有预设自带 sampling,--all-styles 无法逐档正确应用:请改用单档 --style 逐个 A/B" + ) try: jobs = build_jobs(args) except ValueError as e: # parse_emo_vector 的键名/权重错误 @@ -364,6 +464,16 @@ def main() -> None: if args.emo_text: print(f">> 情感描述:{args.emo_text}(服务端 QwenEmotion 转向量)") print(f">> 风格 {len(jobs)} 档 · lang={args.lang}") + if sampling: + print( + ">> 采样参数(非上游默认,会入缓存摘要):" + + ", ".join(f"{k}={sampling[k]!r}" for k in sorted(sampling)) + ) + if "seed" not in sampling: + print( + ">> 提示:未固定 --seed —— 上游 do_sample 恒 True,同句每次合成都是不同 take," + "档间差异可能只是采样噪声。做 A/B 请加 --seed" + ) for name, vec, alpha, df, beams in jobs: vec_str = ",".join(f"{x:g}" for x in vec) if vec else "—(不注入情感)" slow = "(束宽 3,约慢 3 倍)" if beams >= 3 else "" @@ -385,6 +495,7 @@ def main() -> None: args.server, need_duration_factor=any(df != 1.0 for _n, _v, _a, df, _b in jobs), need_emo_text=bool(args.emo_text), + sampling=sampling, ) out_dir = ( @@ -406,6 +517,7 @@ def main() -> None: stem=None if not args.label else (args.label if len(jobs) == 1 else f"{args.label}-{name}"), + sampling=sampling, ) for name, vec, alpha, df, beams in jobs ] @@ -431,6 +543,14 @@ def main() -> None: ): if val is not None: chosen += f" {flag} {val:g}" + # 采样参数同理必须回显:它们进缓存摘要,漏带一个就是另一套音频(且会静默命中/失效缓存) + for key in SAMPLING_CLI: + if key in sampling: + chosen += f" --{key.replace('_', '-')} {sampling[key]:g}" + if sampling.get("text_normalization") is False: + chosen += " --no-text-normalization" + if "seed" in sampling: + chosen += f" --seed {sampling['seed']}" print( f"\n下一步 · 选定风格后全量合成一集:\n" f" cd media/<工程> && uv run --no-project --with mutagen scripts/tts.py \\\n" diff --git a/media/pipeline/scripts/tts_server.py b/media/pipeline/scripts/tts_server.py index 65dec0ea..c45157d8 100644 --- a/media/pipeline/scripts/tts_server.py +++ b/media/pipeline/scripts/tts_server.py @@ -7,13 +7,18 @@ uv run --frozen --with fastapi --with uvicorn --with soundfile --with numpy --with lameenc \ python <本仓>/media/pipeline/scripts/tts_server.py --model-dir checkpoints --port 8766 - 端点: - GET /health —— 服务与模型元信息(version/device/dtype/encoder/supports_duration_factor/ - supports_emo_text) + GET /health —— 服务与模型元信息(version/device/dtype/encoder + 四个 supports_* 能力位) POST /synthesize —— JSON 请求合成,返回 MP3 bytes(X-Audio-Format 头) - 情感三来源(互斥,只能给一个): emo_vector —— 8 维显式向量(有效和 Σvec×alpha ≤ 0.8) emo_ref_path —— 情感参考音频:音色仍取 ref_path,语调/情绪迁移自这段录音(无合成味) emo_text —— 自然语言描述(需 --use-qwen-emo),服务端转向量并在 X-Emo-Vector 头回显 +- 采样参数族(temperature/top_p/top_k/length_penalty/repetition_penalty/max_mel_tokens): + 上游经 **generation_kwargs 透传给 HF generate,全部生效(唯一例外是 do_sample——上游 + infer_v2_5.py:780 用字面量 True 覆盖,故本服务不暴露它)。缺省一律取上游默认值, + 见 SAMPLING_DEFAULTS。 +- seed:上游全链路无种子且 do_sample 恒 True,同句每次合成韵律都不同;给 seed 即可复现, + 这是任何参数 A/B 可信的前提。 - 安全:仅监听 127.0.0.1,无鉴权,勿暴露公网;ref_path / emo_ref_path 为服务端本地绝对路径。 完整部署/排障手册见 media/pipeline/VOICE-CLONING.md。 @@ -70,6 +75,8 @@ def load_model( return tts, { "version": "2.5", "supports_duration_factor": True, + # v2 的 infer() 签名里没有 text_normalization(infer_v2.py 无该形参),v2.5 才有 + "supports_text_normalization": True, # 从对象实际状态派生:MPS 分支构造器内部强制 use_bf16=False(实际 fp32) "dtype_flag": "bf16" if getattr(tts, "use_bf16", use_bf16) else "fp32", } @@ -89,6 +96,7 @@ def load_model( return tts, { "version": "2", "supports_duration_factor": False, + "supports_text_normalization": False, "dtype_flag": "fp16" if getattr(tts, "use_fp16", use_fp16) else "fp32", } @@ -156,13 +164,39 @@ def encode_mp3(data: np.ndarray, sr: int) -> tuple[bytes, str]: EMO_LABELS = "happy,angry,sad,afraid,disgusted,melancholic,surprised,calm" +# 上游自回归采样参数的默认值 —— 已知副本,锚点 indextts/infer_v2_5.py:731-739(HEAD 4f8792f) +# 与 infer_v2.py:536-544 完全一致,两版共享同一组默认。客户端 tts.py 持有同一份副本用于 +# 「摘要按未使用即省略」判定,二者必须同步(见 tts.py SAMPLING_DEFAULTS 的同名注释)。 +# +# 三条口径提醒(写进注释而非文档,因为它们直接决定该不该动这些值): +# length_penalty=0.0 **不是中性**——束打分 score = sum_logprobs / len**0 = sum_logprobs, +# 对数概率恒负故越长越吃亏,即系统性偏好更短假设,是 num_beams>1 时吞尾/漏字的机制来源; +# 仅在束搜索打分时生效,num_beams=1 下改它无效。 +# repetition_penalty=10.0 作用在 8194 类语义码头上、是对 logit 的符号相关缩放(非概率硬禁), +# 故能取到远超文本 LM 常用 1.0-1.2 的值;但其有效强度依赖 logit 绝对尺度,因而与音色/情感 +# 向量耦合——跨音色迁移调参结论必须重新验证。 +# max_mel_tokens=1500 ≈ 30 s 音频(语义码率 50 Hz × 1.72 mel 帧/token,hop 256 @ 22050); +# 溢出后果不是音频被裁短,而是文本尾部根本没被念出(infer_v2_5.py:792-813)。 +SAMPLING_DEFAULTS: dict[str, float | int] = { + "temperature": 0.8, + "top_p": 0.8, + "top_k": 30, + "length_penalty": 0.0, + "repetition_penalty": 10.0, + "max_mel_tokens": 1500, + "interval_silence": 200, +} + class SynthesizeRequest(BaseModel): text: str ref_path: str emo_vector: list[float] | None = None # 情感参考音频:音色取自 ref_path,语调/情绪取自本字段(另一段录音),无合成味的风格迁移。 - # 上游 infer() 在 emo_vector 存在时会「静默丢弃」emo_audio_prompt,故本服务显式拒绝二者同传。 + # 本服务拒绝它与 emo_vector 同传 —— 但理由不是「上游会静默丢弃音频」(那是误读: + # infer_v2_5.py:611 的 `if emo_audio_prompt is None` 不成立,音频仍会经 merge_emovec 以 + # (1−Σw) 权重混进最终 emovec)。真实问题是 **emo_alpha 被消费两次**:先在 :605-608 缩放 + # 8 维向量,又在 :763 用作参考音频的隐空间插值系数,语义混乱且不可预测。 emo_ref_path: str | None = None # 自然语言情感描述(如「轻快爽朗、自信阳光」):服务端先用 QwenEmotion 转成 8 维向量, # 再按 ≤0.8 有效和规则缩放后当作 emo_vector 使用,并在 X-Emo-Vector 响应头回显供固化复用。 @@ -171,6 +205,24 @@ class SynthesizeRequest(BaseModel): duration_factor: float = 1.0 lang: str = "ZH" num_beams: int = 1 + # ---- 采样参数族:缺省即上游默认,取值域对齐 webui.py:901-910 的滑杆区间 ---- + temperature: float = SAMPLING_DEFAULTS["temperature"] + top_p: float = SAMPLING_DEFAULTS["top_p"] + top_k: int = SAMPLING_DEFAULTS["top_k"] + length_penalty: float = SAMPLING_DEFAULTS["length_penalty"] + repetition_penalty: float = SAMPLING_DEFAULTS["repetition_penalty"] + max_mel_tokens: int = SAMPLING_DEFAULTS["max_mel_tokens"] + # 段间静音(毫秒):仅作用于**单请求内**因超 max_text_tokens_per_segment 而被上游切开的分段 + # 之间。本管线逐句合成、单句远低于分段预算,故默认路径下不生效;句间停顿由时间轴常数 + # video/src/timing.json 的 sentenceGapSec 提供,二者不是同一件事。 + interval_silence: int = SAMPLING_DEFAULTS["interval_silence"] + # 中文文本归一化(数字/百分号/量词 → 口语读法)。保持 True:实测 % / 小数 / 量词 / 月日 / + # 章节的读法都正确,关掉会把全部读法责任推给逐字稿。发音标注 <字|读音> 由上游占位符机制 + # 保护、天然免疫归一化,故**不需要**为保标记而关它。v2.5 专属参数。 + text_normalization: bool = True + # 随机种子:上游 do_sample 恒 True 且全链路无种子,同句每次合成都是不同的 take。 + # 给定即可复现(transformers.set_seed 覆盖 random/numpy/torch)。None = 保持上游随机行为。 + seed: int | None = None @field_validator("emo_vector") @classmethod @@ -206,6 +258,64 @@ def _beams_ok(cls, v: int) -> int: raise ValueError("num_beams 必须在 [1, 5]") return v + @field_validator("temperature") + @classmethod + def _temp_ok(cls, v: float) -> float: + if not 0.1 <= v <= 2.0: # NaN 比较恒 False,一并被拦 + raise ValueError("temperature 必须在 [0.1, 2.0]") + return v + + @field_validator("top_p") + @classmethod + def _top_p_ok(cls, v: float) -> float: + if not 0.0 <= v <= 1.0: + raise ValueError("top_p 必须在 [0.0, 1.0]") + return v + + @field_validator("top_k") + @classmethod + def _top_k_ok(cls, v: int) -> int: + # 0 = 关闭 TopK warper(上游门控为 top_k != 0)。1 在 num_beams>1 下会踩到 + # multinomial 的非零元素数下界(min_tokens_to_keep = n_eos+1 = 2),故禁用 1。 + if v == 1: + raise ValueError( + "top_k=1 在束搜索下不安全(每束需保底 2 个候选):用 0 关闭或 ≥2" + ) + if not 0 <= v <= 100: + raise ValueError("top_k 必须在 [0, 100]") + return v + + @field_validator("length_penalty") + @classmethod + def _len_pen_ok(cls, v: float) -> float: + if not -2.0 <= v <= 2.0: + raise ValueError("length_penalty 必须在 [-2.0, 2.0]") + return v + + @field_validator("repetition_penalty") + @classmethod + def _rep_pen_ok(cls, v: float) -> float: + if not 0.1 <= v <= 20.0: + raise ValueError("repetition_penalty 必须在 [0.1, 20.0]") + return v + + @field_validator("max_mel_tokens") + @classmethod + def _max_mel_ok(cls, v: int) -> int: + # 上限 1815 = config.yaml 的 gpt.max_mel_tokens(mel 位置嵌入容量,≈36.2 s),越界即报错。 + if not 50 <= v <= 1815: + raise ValueError( + "max_mel_tokens 必须在 [50, 1815](1815 为架构上限 ≈36.2 s)" + ) + return v + + @field_validator("interval_silence") + @classmethod + def _interval_ok(cls, v: int) -> int: + if not 0 <= v <= 2000: + raise ValueError("interval_silence 必须在 [0, 2000] 毫秒") + return v + STATE: dict = {} @@ -215,7 +325,10 @@ def _qwen_vector_sync(tts, emo_text: str, alpha: float) -> list[float]: Qwen 每维 clamp 在 [0, 1.2] 但**不做和归一**,Σ 可能 >1;而上游混合式为 `emovec = Σ(w·基向量) + (1 - Σw)·参考音频情感`,Σw>1 会让参考音频项变负权重(发音劣化)。 - 故此处等比缩放(保留 Qwen 选定的「方向」,只压「强度」),保底留 ≥0.2 的自然情感残量。 + 故此处等比缩放(保留 Qwen 选定的「方向」,只压「强度」),使残留的本人语调 ≥0.2。 + + 注意上游 emo_text 路径**完全没有**这层保护:webui 只对「自定义向量」模式调 normalize_emo_vec, + emo_text 模式把 Qwen 输出直接交给 infer()。这个 0.8 是本管线补的,不是上游行为。 """ vec = list(tts.qwen_emo.inference(emo_text).values()) total = sum(vec) * alpha @@ -227,6 +340,11 @@ def _qwen_vector_sync(tts, emo_text: str, alpha: float) -> list[float]: def _infer_sync(tts, ref: Path, req: SynthesizeRequest, tmpdir: Path) -> Path: wav_path = tmpdir / "out.wav" + if req.seed is not None: + # 必须在 infer 之前、且在同一线程内设置:generate 的多项式采样读的是全局 RNG。 + from transformers import set_seed + + set_seed(req.seed) kwargs = dict( spk_audio_prompt=str(ref), text=req.text, @@ -234,15 +352,29 @@ def _infer_sync(tts, ref: Path, req: SynthesizeRequest, tmpdir: Path) -> Path: emo_vector=req.emo_vector, emo_audio_prompt=req.emo_ref_path, emo_alpha=req.emo_alpha, + # use_random=True 会为每个情感维度从 73 行原型里均匀乱抽,放弃「按你的 CAMPPlus 风格 + # 挑最像你的那行」这一步 —— 等于让陌生人来演这个情绪,直接掉克隆保真度。恒 False。 use_random=False, verbose=False, - # 束搜索宽度:上游默认 3;采样生成(do_sample=True)下 1 与 3 的听感差异可忽略, - # 但 GPT 段耗时约按束宽线性放大(MPS fp32 实测 RTF 40-58),管线长跑默认 1。 + # 束搜索宽度:上游默认 3,管线长跑默认 1。束宽只作用于 T2S(S2M 入口 codes 形状与 + # 束宽无关);代价高度依赖硬件——CUDA 上近线性放大,MPS 上近乎免费(整集实测 1→3 + # 仅 +4%,因 beam 扩张只把 batch 1→3 而 kernel 发射与逐步同步点不变)。 num_beams=req.num_beams, + interval_silence=req.interval_silence, + # 以下六项经 **generation_kwargs 透传到 HF generate,全部生效(唯一失效的 do_sample + # 被上游 infer_v2_5.py:780 用字面量 True 覆盖,故不暴露)。 + temperature=req.temperature, + top_p=req.top_p, + top_k=req.top_k, + length_penalty=req.length_penalty, + repetition_penalty=req.repetition_penalty, + max_mel_tokens=req.max_mel_tokens, ) if STATE["supports_duration_factor"]: kwargs["duration_factor"] = req.duration_factor kwargs["lang"] = req.lang + if STATE["supports_text_normalization"]: + kwargs["text_normalization"] = req.text_normalization tts.infer(**kwargs) return wav_path @@ -273,12 +405,14 @@ async def lifespan(app: FastAPI): dtype=meta["dtype_flag"], encoder=encoder, supports_duration_factor=meta["supports_duration_factor"], + supports_text_normalization=meta["supports_text_normalization"], supports_emo_text=getattr(tts, "qwen_emo", None) is not None, infer_lock=asyncio.Lock(), ) print( f">> 就绪:IndexTTS-{STATE['version']} device={STATE['device']} dtype={STATE['dtype']} " - f"encoder={encoder} emo_text={'on' if STATE['supports_emo_text'] else 'off'}" + f"encoder={encoder} emo_text={'on' if STATE['supports_emo_text'] else 'off'} " + f"sampling=on seed=on" ) yield STATE.clear() @@ -300,6 +434,11 @@ async def health(): "encoder": STATE.get("encoder"), "supports_duration_factor": STATE.get("supports_duration_factor"), "supports_emo_text": STATE.get("supports_emo_text", False), + "supports_text_normalization": STATE.get("supports_text_normalization", False), + # 采样参数族与 seed 由本服务自身实现(不依赖模型版本,v2/v2.5 的 generation_kwargs + # 默认值完全一致),故恒为 True。客户端据此在旧服务上对非默认取值硬失败。 + "supports_sampling_params": True, + "supports_seed": True, } @@ -333,6 +472,11 @@ async def synthesize(req: SynthesizeRequest): 400, "emo_text 需要 QwenEmotion:服务启动时加 --use-qwen-emo(约 +1.5 GB 内存)", ) + # 有效和护栏 Σvec×alpha ≤ 0.8 是**本管线自定的口径**,不是上游行为:上游 infer() 从不做 + # 归一(normalize_emo_vec 只被 webui.py:665 的自定义向量分支调用),且 webui 那条的 0.8 + # 作用在「已乘 emo_bias 的和」上、且在 alpha 之前。因此从社区/WebUI 抄来的 (vec, alpha) + # 在本服务上实际比原意强 16%–33%(含 calm 越重偏差越大)——跨来源参数迁移需重新试听定档。 + # 详见 media/pipeline/INDEXTTS-2.5-ADVANCED.md §3.2。 effective_sum = (sum(req.emo_vector) if req.emo_vector else 0.0) * req.emo_alpha if effective_sum > 0.8: # infer 内部以 alpha 缩放向量,有效和超界会产生负混合权重 raise HTTPException( @@ -344,6 +488,12 @@ async def synthesize(req: SynthesizeRequest): 400, "IndexTTS-2 不支持 duration_factor(v2.5 专属),请改用 v2.5 服务或去掉 --duration-factor", ) + if not req.text_normalization and not STATE["supports_text_normalization"]: + raise HTTPException( + 400, + "IndexTTS-2 的 infer() 没有 text_normalization 形参(v2.5 专属):" + "请改用 v2.5 服务,或去掉 --no-text-normalization", + ) derived: list[float] | None = None async with STATE["infer_lock"]: diff --git a/media/pipeline/tests/test_digest.py b/media/pipeline/tests/test_digest.py index b0fe0ea0..0c425a0a 100644 --- a/media/pipeline/tests/test_digest.py +++ b/media/pipeline/tests/test_digest.py @@ -1,17 +1,29 @@ """digest 黄金值——「TTS 缓存零失效」的守门人。 钉死 digest_indextts 的「未使用即省略」后缀规则:beams=1 不带 |beams=N、 -beams≥2 才带;emoref/emotext 同理。此前靠这条规则保住了已上线三集 596 句的 -缓存摘要 100% 不变;未来任何人改摘要公式,这里先红。 +beams≥2 才带;emoref/emotext/采样参数族同理。此前靠这条规则保住了已上线三集 +604 句的缓存摘要 100% 不变;未来任何人改摘要公式,这里先红。 + +新增字段一律追加在摘要**末尾**且默认省略,故格式可持续扩展而不动存量缓存。 """ from __future__ import annotations +import argparse import sys from pathlib import Path +import pytest + sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) -from tts import digest_edge, digest_indextts # noqa: E402 +from tts import ( # noqa: E402 + SAMPLING_DEFAULTS, + digest_edge, + digest_indextts, + resolve_sampling, + sampling_suffix, + synth_source_text, +) def test_digest_indextts_golden(): @@ -120,3 +132,129 @@ def test_digest_edge_golden(): import hashlib assert digest_edge("v", "r", "t") == hashlib.sha1(b"v|r|t").hexdigest() + + +# ---------------- 采样参数族:同一条「未使用即省略」规则 ---------------- + + +#: resolve_sampling 期望的 Namespace 形状(全 None = 全取上游默认) +_NS_BASE = { + "style": "neutral", + "temperature": None, + "top_p": None, + "top_k": None, + "length_penalty": None, + "repetition_penalty": None, + "max_mel_tokens": None, + "interval_silence": None, + "no_text_normalization": False, + "seed": None, + "seed_offset": 0, +} + + +def _ns(**kw) -> argparse.Namespace: + return argparse.Namespace(**{**_NS_BASE, **kw}) + + +def test_sampling_suffix_omitted_when_default(): + """空 sampling 必须产出空后缀 —— 这是存量三集 604 句缓存零失效的唯一依据。""" + assert sampling_suffix(None) == "" + assert sampling_suffix({}) == "" + vec = (0.95, 0, 0, 0, 0, 0, 0.02, 0.03) + args = ("3ed0d9d60d4b", "passionate", vec, 0.7, 0.97, "ZH", "indextts", "测试句。") + # 三种写法(不传 / 传 None / 传空 dict)必须与历史摘要逐字节相同 + base = digest_indextts(*args, 1, None, None) + assert digest_indextts(*args, 1, None, None, None) == base + assert digest_indextts(*args, 1, None, None, {}) == base + assert base == "b6ccdeea2d913069c87bb679d67043a602bddfcb", base + + +def test_sampling_suffix_key_order_is_stable(): + """键按字母序 —— 否则 dict 插入序不同就会算出不同摘要(同参数却重合成整集)。""" + a = sampling_suffix({"top_p": 0.7, "length_penalty": 0.8, "seed": 1}) + b = sampling_suffix({"seed": 1, "top_p": 0.7, "length_penalty": 0.8}) + assert a == b == "|length_penalty=0.8|seed=1|top_p=0.7" + + +def test_sampling_participates_in_digest(): + """任一采样参数改动都必须失效缓存,否则会得出「改了参数没效果」的假结论。""" + vec = (0.95, 0, 0, 0, 0, 0, 0.02, 0.03) + args = ("aa", "sunny", vec, 0.35, 0.95, "ZH", "indextts", "测试句。") + base = digest_indextts(*args, 1, None, None, {}) + seen = {base} + for key, val in ( + ("temperature", 0.6), + ("top_p", 0.7), + ("top_k", 0), + ("length_penalty", 0.8), + ("repetition_penalty", 2.0), + ("max_mel_tokens", 1815), + ("interval_silence", 0), + ("text_normalization", False), + ("seed", 1234), + ): + d = digest_indextts(*args, 1, None, None, {key: val}) + assert d != base, f"{key} 未参与摘要" + assert d not in seen, f"{key} 与其它参数摘要碰撞" + seen.add(d) + + +def test_sampling_suffix_uses_repr_not_str(): + """用 repr 而非 str:防 0.8 与 0.80 算出不同摘要(与 alpha/df 同口径)。""" + assert sampling_suffix({"length_penalty": 0.80}) == "|length_penalty=0.8" + assert sampling_suffix({"text_normalization": False}) == "|text_normalization=False" + assert sampling_suffix({"top_k": 0}) == "|top_k=0" + + +def test_resolve_sampling_drops_upstream_defaults(): + """显式传入与上游默认相同的值不得进摘要——否则「照抄 --list-styles 输出」会整集重录。""" + ns = _ns( + temperature=SAMPLING_DEFAULTS["temperature"], + length_penalty=SAMPLING_DEFAULTS["length_penalty"], + ) + assert resolve_sampling(ns) == {} + ns.length_penalty = 0.8 + assert resolve_sampling(ns) == {"length_penalty": 0.8} + + +def test_resolve_sampling_validates_ranges(): + for kw, frag in ( + ({"temperature": 0.05}, "temperature"), + ({"top_p": 1.5}, "top-p"), + ({"top_k": 1}, "束搜索下不安全"), + ({"length_penalty": 3.0}, "length-penalty"), + ({"repetition_penalty": 25.0}, "repetition-penalty"), + ({"max_mel_tokens": 1816}, "max-mel-tokens"), + ({"interval_silence": 3000}, "interval-silence"), + ): + with pytest.raises(ValueError, match=frag): + resolve_sampling(_ns(**kw)) + # top_k=0 是「关闭 TopK」的合法值,不能被上面的 1 规则误伤 + assert resolve_sampling(_ns(top_k=0)) == {"top_k": 0} + + +def test_resolve_sampling_seed_offset(): + """--seed-offset 是「换一条 take」的逃生口;单独给 offset 而无 seed 时不生效。""" + assert resolve_sampling(_ns(seed=1000, seed_offset=0)) == {"seed": 1000} + assert resolve_sampling(_ns(seed=1000, seed_offset=3)) == {"seed": 1003} + assert resolve_sampling(_ns(seed=None, seed_offset=3)) == {} + + +def test_synth_source_text_prefers_tts_text(): + """发音标注走 ttsText,字幕用的 text 不受影响;无标注句取值与历史完全一致。""" + assert ( + synth_source_text({"id": "p0-01", "text": "他在银行里走。"}) == "他在银行里走。" + ) + assert ( + synth_source_text( + { + "id": "p0-01", + "text": "他在银行里走。", + "ttsText": "他在银<行|HANG2>里走。", + } + ) + == "他在银<行|HANG2>里走。" + ) + # 空字符串 ttsText 视为未提供(防 build_narration 写出空字段导致合成空文本) + assert synth_source_text({"id": "x", "text": "正文", "ttsText": ""}) == "正文" diff --git a/media/pipeline/tests/test_pron_marks.py b/media/pipeline/tests/test_pron_marks.py new file mode 100644 index 00000000..4c5b5f8b --- /dev/null +++ b/media/pipeline/tests/test_pron_marks.py @@ -0,0 +1,143 @@ +"""发音标注 `<原文|读音>` 的解析与校验 —— 「标注错 = 必然读错」的守门人。 + +上游把标注整体 re.sub 掉、**丢弃原字**(infer_v2_5.py:52-72),且对非法标注既不报错 +也不忽略(`pinyin.vocab` 在运行时从未被读取),故错误标注 100% 静默产出错读音频。 +一集近 200 句、单槽位 mp3,事后只能靠听发现 —— 校验必须前移到生成阶段。 + +夹具取自 2026-08-20 对上游源码与 pinyin.vocab(1728 条)的实测。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from pron_marks import ( # noqa: E402 + has_marks, + strip_marks, + validate, +) + +VOCAB = frozenset({"HANG2", "XING2", "YIN2", "JV1", "QV4", "XV1", "ER2", "DE5"}) + + +# ---------------- 剥离:单一书写面派生「人读 text」 ---------------- + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("他在银<行|HANG2>里走。", "他在银行里走。"), + ("他在银<行|XING2>里<行|HANG2>走。", "他在银行里行走。"), + (" 能做到。", "Claude 能做到。"), + ("<银行|YIN2 HANG2>是机构。", "银行是机构。"), + ("没有标注的句子。", "没有标注的句子。"), + ], +) +def test_strip_marks_restores_human_text(raw, expected): + """标注自带原字 ⇒ 剥离即还原人读文本,无需在逐字稿写两份(不存在副本漂移)。""" + assert strip_marks(raw) == expected + + +def test_has_marks(): + assert has_marks("银<行|HANG2>") is True + assert has_marks("银行") is False + assert has_marks("延迟 < 10ms") is False # 孤立 < 不构成标注 + + +# ---------------- ERROR:三个静默失效模式 ---------------- + + +def test_stray_angle_bracket_is_error(): + """上游正则 group(1) 的 `[^|>\\n]+` 允许 `<`:孤立 `<` 会吞掉到下个标记之间的正文。""" + errs, _ = validate("当 a里走。") + assert errs, "孤立 < 与后续标记共存必须报 ERROR" + assert any("吞掉" in e for e in errs) + # 只有孤立 < 而无标记时同样报(避免「今天没吞、明天加个标注就吞」) + errs2, _ = validate("延迟<10ms 就够了。") + assert errs2 + + +def test_stray_pipe_is_error(): + errs, _ = validate("A|B 两种方案。") + assert any("孤立 `|`" in e for e in errs) + + +def test_second_pipe_inside_annotation(): + """`<行|XING2|HANG2>` 的第二个竖线会混进发音串 —— 严格版正则不匹配它,残留即报错。""" + errs, _ = validate("他在银<行|XING2|HANG2>里走。") + assert errs + + +@pytest.mark.parametrize( + "bad", + [ + "他在银<行|HANG>里走。", # 缺声调 + "他在银<行|SHENGDIAO9>里走。", # 声调越界 + "他在银<行|hang2>里走。", # 小写 + "他在银<行|HANG2!>里走。", # 非法字符 + "他在银<行|>里走。", # 空标注(严格版不匹配 → 残留 <> 报错) + ], +) +def test_illegal_pinyin_is_error(bad): + errs, _ = validate(bad) + assert errs, f"{bad!r} 应报 ERROR" + + +def test_jqx_u_must_be_written_v(): + """实测 pinyin.vocab 中 `^[JQX]U` 零命中:居/去/须 必须写 JV1/QV4/XV1。""" + errs, _ = validate("这个<居|JU1>然可以。") + assert any("必须写 V" in e for e in errs) + ok, _ = validate("这个<居|JV1>然可以。") + assert ok == [] + # L/N 声母两种都合法且语义不同(LU=卢、LV=吕),不得误伤 + assert validate("<卢|LU2>先生")[0] == [] + assert validate("<吕|LV3>先生")[0] == [] + + +def test_valid_marks_pass(): + for good in ( + "他在银<行|HANG2>里<行|XING2>走。", + "<银行|YIN2 HANG2>是机构。", # 多字词:空格分音节 + " 能做到。", # CMU 通道(左侧纯 ASCII) + " on.", # 官方示例的 ` . ` 分音节写法 + "轻声用五声:<的|DE5>。", + ): + errs, _ = validate(good) + assert errs == [], f"{good!r} 应通过,实得 {errs}" + + +def test_cmu_channel_rejects_non_arpabet(): + errs, _ = validate(" 能做到。") + assert any("ARPAbet" in e for e in errs) + + +def test_channel_chosen_by_left_side_not_lang(): + """通道由标记左侧是否含汉字二分(上游 :66),与 lang 无关 —— 写法陷阱的来源。 + + `` 左侧纯 ASCII ⇒ 走 CMU 通道,拼音会被当成音素而报错。 + 要走拼音通道,左侧必须至少含一个汉字。 + """ + errs, _ = validate(" 很强。") + assert errs, "左侧无汉字却写拼音应被 CMU 校验拦下" + assert validate("<爱|AI4>很强。")[0] == [] + + +# ---------------- WARN:vocab 缺格只告警不阻断 ---------------- + + +def test_vocab_miss_is_warning_not_error(): + """vocab 存在合法缺格(ANG3/ER1/KEI1 均不在 1728 条内),故只能 WARN。""" + errs, warns = validate("这<行|ANG3>不通。", VOCAB) + assert errs == [], "格式合法就不该 ERROR" + assert any("不在 pinyin.vocab" in w for w in warns) + # 不传 vocab 时跳过该项(该文件在 index-tts checkout 内,不在本仓) + assert validate("这<行|ANG3>不通。")[1] == [] + + +def test_in_vocab_no_warning(): + errs, warns = validate("他在银<行|HANG2>里走。", VOCAB) + assert errs == [] and warns == [] From 0a5b3763b82ecd0346aa6db065cc6dc598b03663 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 20 Aug 2026 22:30:50 +0800 Subject: [PATCH 02/10] =?UTF-8?q?fix(narration):=20=E4=BF=AE=E5=A4=8D=208?= =?UTF-8?q?=20=E5=8F=A5=E5=B9=B4=E4=BB=BD=E8=AF=BB=E6=B3=95=E3=80=81?= =?UTF-8?q?=E8=AF=BB=E6=B3=95=E9=99=B7=E9=98=B1=E6=88=90=E9=97=A8=E3=80=81?= =?UTF-8?q?=E5=8F=82=E8=80=83=E6=A0=B7=E6=9C=AC=E6=94=B6=E7=B4=A7=E5=88=B0?= =?UTF-8?q?=2015=20=E7=A7=92=E7=A1=AC=E4=B8=8A=E9=99=90;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 已上线三集共 8 句年份被念错:`2025 年` 读成「两千零二十五年」而非「二零二五年」。 根因是「排版约定 × 归一化规则」的隐式耦合——逐字稿有「数字与汉字间加空格」的排版 习惯,而 macOS 上实际的归一化引擎 wetext 的 date 规则要求数字与「年」字面相邻, 插入空格即回落到基数读法。实测空格敏感性矩阵里**只有 4 位年份这一条被击穿**: `6 月`/`88 页`/`47.6%`/`第 3 章`/`1.2 节` 加不加空格都正确。 修复面:三集 narration.md(唯一维护处)共 8 句去掉该空格并重跑 build,命中句为 EP1 p0-01/p0-11、EP2 p0-01/p0-12/p5-15/p5-22、EP3 p0-16/p2-06。归一化是幂等的, 故此类修复可逐句增量做,不需要关 text_normalization(关掉反而丢失 %/小数/量词 这些本来就正确的能力)。 check_script.py 新增 READING_TRAPS 内容门,把**实测确认会读错**的 7 类写法固化: 4 位年份带空格、三段版本号 2.5.1→「二.五点一」、连字符区间 3-5 倍→「三减五倍」、 ±3%→「百分之正负三」、10x→「十x」、整句无汉字→被 use_chinese 逐句嗅探路由到英文 归一化(IndexTTS 2.5→two point five)、1080P→「一千零八十P」(WARN)。每条 message 都带实测输出。反例组同等重要:调研初稿曾把 `0.5~1.0 秒` 与 `9:30` 也列为缺陷,实测 证明它们其实正确(零点五到一点零秒 / 九点三十分),凭直觉扩大清单会造成误伤。 prepare_ref.py 的 --duration 上限从 30 收到 15 并硬失败:上游 _load_and_cut_audio 对参考音频只取前 15 秒、静默丢弃尾部且 verbose 关闭时无日志,故 16–30 秒的样本会有 一半以上内容永不进模型。默认值同步 15→12(推荐区间 10–14,文献侧 speaker similarity 在 ~10 秒后饱和)。顺带更正两处归因:「保留原采样率」对模型无影响(上游无条件重采样 到 22.05 kHz),-3 dB 峰值归一的作用不是防止小音量削弱相似度(CAMPPlus 与 w2v-BERT 两条相似度主路径都有 CMVN、对全局增益免疫),真正伤相似度的是削波。 测试 74 → 88 项(+14 读法陷阱正反例)。三集内容门复跑 FAIL 0 / WARN 0。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .../script/narration.json | 4 +- .../script/narration.md | 4 +- media/pipeline/scripts/check_script.py | 85 +++++++++++++++++- media/pipeline/scripts/prepare_ref.py | 42 +++++++-- media/pipeline/tests/test_check_script.py | 86 +++++++++++++++++++ .../script/narration.json | 4 +- .../script/narration.md | 4 +- .../script/narration.json | 8 +- .../script/narration.md | 8 +- 9 files changed, 220 insertions(+), 25 deletions(-) diff --git a/media/experience-era-agents-video/script/narration.json b/media/experience-era-agents-video/script/narration.json index 0064e63d..f7e39158 100644 --- a/media/experience-era-agents-video/script/narration.json +++ b/media/experience-era-agents-video/script/narration.json @@ -2,7 +2,7 @@ { "id": "p0-01", "scene": "P0", - "text": "2025 年,强化学习领域两位泰斗,放出一篇宣言。" + "text": "2025年,强化学习领域两位泰斗,放出一篇宣言。" }, { "id": "p0-02", @@ -57,7 +57,7 @@ { "id": "p0-11", "scene": "P0", - "text": "2026 年 6 月,清华联合一家 AI 公司,发了一篇 88 页的综述。" + "text": "2026年 6 月,清华联合一家 AI 公司,发了一篇 88 页的综述。" }, { "id": "p0-12", diff --git a/media/experience-era-agents-video/script/narration.md b/media/experience-era-agents-video/script/narration.md index 9dba6007..e61411fc 100644 --- a/media/experience-era-agents-video/script/narration.md +++ b/media/experience-era-agents-video/script/narration.md @@ -10,7 +10,7 @@ > 画面:黑场,衬线金句卡打字机逐字打出;英文原文渐显在下方。 -- [p0-01] 2025 年,强化学习领域两位泰斗,放出一篇宣言。 +- [p0-01] 2025年,强化学习领域两位泰斗,放出一篇宣言。 - [p0-02] DeepMind 的大卫·希尔弗,和强化学习教父萨顿,联名放话: - [p0-02b] AI 的下一个时代,叫经验时代。 - [p0-03] 未来 AI 的进步,不再主要来自人类喂的静态数据, @@ -32,7 +32,7 @@ > 画面:论文封面卡:标题 + 机构行(清华大学 × Frontis.AI)+「88 页综述」。 -- [p0-11] 2026 年 6 月,清华联合一家 AI 公司,发了一篇 88 页的综述。 +- [p0-11] 2026年 6 月,清华联合一家 AI 公司,发了一篇 88 页的综述。 - [p0-12] 系统回答了这个问题:经验,到底怎么变成实力。 - [p0-13] 这期视频,我们把它讲透。 diff --git a/media/pipeline/scripts/check_script.py b/media/pipeline/scripts/check_script.py index 68719bde..e4e6f9ce 100644 --- a/media/pipeline/scripts/check_script.py +++ b/media/pipeline/scripts/check_script.py @@ -7,7 +7,9 @@ 2. 时长预算:narration.json 字数估算与 manifest 实测(若已合成)两个口径都对 pipeline.toml 的 target_minutes 负责——首次把「策划宣称/估算/实测」三处接上; 3. 幕间呼吸淡入淡出的不变式:2×sceneCrossFadeSec ≤ sentenceGap+sceneGap - (淡入淡出必须花在既有静默里,时间轴零位移的前提)。 + (淡入淡出必须花在既有静默里,时间轴零位移的前提); + 4. 读法陷阱:上游中文归一化实测会读错的写法(4 位年份带空格、三段版本号、 + 数字区间连字符、±、10x、整句无汉字…)——见 READING_TRAPS,每条都附实测输出。 可选 --check-scenes:从 video/src/scenes/*.tsx 提取 beatWindow/w('id','id') 调用,与分镜表互比(WARN-only,TSX 正则本质近似)。 @@ -131,6 +133,86 @@ def check_budget(root: Path, items: list[dict], cfg: dict, msgs: list[str]) -> N print(" 实测口径:manifest 未生成(跳过;合成后复跑本门)") +#: 读法陷阱:上游中文文本归一化(macOS 上是 wetext,`indextts/utils/front.py:116-142`) +#: 会把数字展开成口语读法,绝大多数写法都正确,但下列写法**实测读错**。 +#: +#: 每条都标注了 2026-08-20 在本机 index-tts venv 上直接跑 `TextNormalizer.normalize()` +#: 得到的真实输出——加规则前先跑探针拿证据,不要凭直觉列清单(本轮就有两条「疑似错误」 +#: 实测其实是对的:`0.5~1.0 秒`→`零点五到一点零秒`、`9:30`→`九点三十分`,故未列入)。 +#: +#: 归一化是**幂等**的:把读法预写成汉字后再过一遍结果不变,所以修复可逐句增量做。 +READING_TRAPS: tuple[tuple[str, str, str], ...] = ( + ( + r"\d{4}\s+年", + "FAIL", + "4 位年份与「年」之间有空格:`2026 年` 读成「两千零二十六年」(写成 `2026年` 才读「二零二六年」)", + ), + ( + r"\d+\.\d+\.\d+", + "FAIL", + "三段版本号:`2.5.1` 读成「二.五点一」(残留字面小数点)——改写为「二点五点一」", + ), + ( + r"\d\s*[-–—]\s*\d", + "FAIL", + "数字区间用连字符:`3-5 倍` 读成「三减五倍」——改写为「三到五倍」", + ), + ( + r"±", + "FAIL", + "正负号:`±3%` 读成「百分之正负三」(顺序颠倒)——改写为「正负百分之三」", + ), + ( + r"\d\s*[xX](?![A-Za-z])", + "FAIL", + "倍数用 x:`10x` 读成「十x」(裸字母)——改写为「十倍」", + ), + ( + r"\d{3,}\s*[A-Za-z]", + "WARN", + ( + "数字串+字母型号:`1080P` 读成「一千零八十P」(按基数读且裸字母)——" + "若要逐位读需改写为「一零八零 P」" + ), + ), + ( + r"≈", + "WARN", + "约等于号未被归一化展开(原样透传),大概率不发音——改写为「大约」", + ), +) +READING_TRAPS_COMPILED = tuple( + (re.compile(p), level, msg) for p, level, msg in READING_TRAPS +) + +#: 汉字。整句无汉字时上游按 `use_chinese()`(front.py:106-114)逐句嗅探路由到**英文** +#: normalizer:实测 `IndexTTS 2.5`→`IndexTTS two point five`、`RTF 0.2065`→ +#: `RTF oh point two oh six five`。逐字稿为字幕可读性把句子拆到 ≤43 字,反而**提高**了 +#: 出现纯 ASCII 短句的概率——两个既有约束的隐性冲突,故必须成门。 +HAN_RE = re.compile(r"[一-鿿]") + + +def check_reading_traps(items: list[dict], msgs: list[str]) -> None: + """逐句扫描已知会被读错的写法(上游归一化的实测行为)。""" + hits = 0 + for it in items: + text = it["text"] + for pattern, level, why in READING_TRAPS_COMPILED: + if m := pattern.search(text): + hits += 1 + (fail if level == "FAIL" else warn)( + msgs, f"句 {it['id']} 命中读法陷阱 {m.group(0)!r}:{why}" + ) + if not HAN_RE.search(text): + hits += 1 + fail( + msgs, + f"句 {it['id']} 整句无汉字({text!r}):上游按整句嗅探路由到英文归一化," + "数字会读成英文(`2.5`→`two point five`)——请并入相邻句或补中文", + ) + print(f" 读法陷阱:{len(items)} 句扫描,命中 {hits} 处") + + def check_fade_invariant(root: Path, msgs: list[str]) -> None: c = load_constants(root) budget = c["sentenceGapSec"] + c["sceneGapSec"] @@ -194,6 +276,7 @@ def main() -> None: fail(msgs, f"未能从 {board.name} 解析出任何 beat 行(格式变化?)") check_coverage(items, beats, msgs) check_budget(root, items, cfg, msgs) + check_reading_traps(items, msgs) check_fade_invariant(root, msgs) if args.check_scenes: check_scenes(root, beats, msgs) diff --git a/media/pipeline/scripts/prepare_ref.py b/media/pipeline/scripts/prepare_ref.py index f6a286cd..5a38404c 100644 --- a/media/pipeline/scripts/prepare_ref.py +++ b/media/pipeline/scripts/prepare_ref.py @@ -3,9 +3,26 @@ - 输入:任意 mp3/wav/flac 录音(如手机录音、长片段素材;m4a 不受 libsndfile 支持, 需先 `ffmpeg -i in.m4a out.wav`) -- 输出:media/pipeline/voices/<名字>.wav —— 16-bit PCM 单声道,保留原始采样率(IndexTTS 内部重采样) -- 动机:克隆参考音频建议 5–15 秒干净人声;过长样本(如 4 分钟录音)会拖慢每句合成的 - 条件提取,且质量并不更好。 +- 输出:media/pipeline/voices/<名字>.wav —— 16-bit PCM 单声道,保留原始采样率 +- 动机:克隆参考音频 **10–14 秒**干净人声(硬上限 15 秒,见下)。 + +**两条实测口径(2026-08-20 核验上游 ~/tools/index-tts,HEAD 4f8792f)**: + +1. **15 秒是硬上限,超出部分被静默丢弃**:`infer_v2_5.py:396-408` 的 `_load_and_cut_audio` + 对 spk 与 emo 两路都传 15,**保前段丢尾部**,且 `verbose=False` 时连截断日志都不打印。 + 故 `--duration 16..30` 会生成一份「看起来更长、实际有一半永不进模型」的样本—— + 本脚本因此把上限收到 15.0 并硬失败。文献侧也支持这个区间:speaker similarity + 随 prompt 时长上升后在 ~10 秒饱和(Voicebox / E2 TTS)。 +2. **「保留原始采样率」对模型无影响**:`infer_v2_5.py:398` 的 `librosa.load` 不传 `sr`, + 即**无条件重采样到 22050 Hz 单声道**;再降到 16 kHz 喂 CAMPPlus / w2v-BERT + (Nyquist 8 kHz)。写入的采样率只影响本仓文件的 sha1 与体积。录 96 kHz 无收益, + 但低码率 mp3(64 kbps 在 ~11 kHz 滚降)会在模型可见频带边缘留下人工痕迹。 + +样本时长的真实代价也不在「条件提取」——那一步按路径缓存、每个样本只算一次 +(`infer_v2_5.py:619-664`)。真正的逐句成本是 `ref_mel` 作为 CFM 前缀**每句都参与 +25 步扩散**(`:839-845`):12 秒参考 ≈ 1034 个梅尔帧,而一句 6 秒目标只有 ≈517 帧。 +本机实测(同文本同种子、交错 3 组)把参考从 12 秒换到 6 秒,`s2mel_time` 中位数 +21.81s → 10.02s(−54%)——但音色与语速也随之改变,**不可为提速缩短参考**。 用法:uv run --no-project --with soundfile media/pipeline/scripts/prepare_ref.py \ <源音频> [--start 10] [--duration 15] [--out media/pipeline/voices/me.wav] @@ -37,8 +54,8 @@ def main() -> int: parser.add_argument( "--duration", type=float, - default=15.0, - help="保留时长(秒,默认 15,建议 5–15)", + default=12.0, + help="保留时长(秒,默认 12,推荐 10–14;硬上限 15——上游超出即静默前截)", ) parser.add_argument( "--out", @@ -54,9 +71,12 @@ def main() -> int: if args.start < 0: print("--start 不能为负数", file=sys.stderr) return 1 - if not 3.0 <= args.duration <= 30.0: + if not 3.0 <= args.duration <= 15.0: + # 上限 15 不是偏好而是**硬约束**:上游 _load_and_cut_audio 只取前 15 秒、 + # 静默丢弃其余(infer_v2_5.py:396-408),故 >15 的样本会有一部分永不进模型。 print( - "--duration 建议 5–15 秒(允许 3–30),克隆效果与速度的平衡点", + "--duration 必须在 3–15 秒(推荐 10–14):上游硬截断 15 秒且保前段丢尾部," + "超出部分永不进模型且无任何日志", file=sys.stderr, ) return 1 @@ -78,7 +98,13 @@ def main() -> int: if clip.shape[1] > 1: # 立体声 → 单声道 clip = clip.mean(axis=1, keepdims=True) peak = float(np.max(np.abs(clip))) if len(clip) else 0.0 - if peak > 0: # 峰值归一到 -3dB,避免过小音量削弱克隆相似度 + # 峰值归一到 -3 dB。注意它**不是**为了「避免小音量削弱克隆相似度」——两条与相似度 + # 最直接相关的路径都对全局增益免疫:CAMPPlus 做 CMVN(infer_v2_5.py:647 feat-=mean), + # w2v-BERT 的 SeamlessM4TFeatureExtractor 默认 do_normalize_per_mel_bins=True 逐 bin + # 标准化。真实作用是稳定输出响度、并让无 CMVN 的 log-mel(ref_mel,CFM 前缀)落在 + # 训练分布的能量工作点上。真正会伤相似度的是**削波**——它产生的宽带谐波落在 8 kHz + # 以内,CMVN 抵消不掉,故增益上限封在 4×。 + if peak > 0: clip = clip * min(0.7 / peak, 4.0) out = ( diff --git a/media/pipeline/tests/test_check_script.py b/media/pipeline/tests/test_check_script.py index a3f3aa83..555bad50 100644 --- a/media/pipeline/tests/test_check_script.py +++ b/media/pipeline/tests/test_check_script.py @@ -6,6 +6,8 @@ import sys from pathlib import Path +import pytest + SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "check_script.py" @@ -125,3 +127,87 @@ def test_fade_invariant(project): rc, out = run_check(project) assert rc == 1 assert "淡入淡出" in out or "sceneCrossFadeSec" in out + + +# ---------------- 读法陷阱(上游中文归一化的实测错读写法)---------------- +# +# 每条断言对应一次在本机 index-tts venv 上直接跑 TextNormalizer.normalize() 的实测 +# (2026-08-20)。反例组同样重要:`0.5~1.0 秒`→`零点五到一点零秒`、`9:30`→`九点三十分` +# 实测**正确**,故不得报错——加规则前先跑探针,别凭直觉扩大清单。 + + +def write_narration(root: Path, texts: list[str]) -> None: + """按 BOARD_OK 的 4 句骨架(p0-01/02 + p1-01/02)写 narration.json。""" + import json as _json + + ids = ["p0-01", "p0-02", "p1-01", "p1-02"] + items = [ + {"id": i, "scene": "P0" if i.startswith("p0") else "P1", "text": t} + for i, t in zip(ids, texts, strict=True) + ] + (root / "script" / "narration.json").write_text( + _json.dumps(items, ensure_ascii=False), encoding="utf-8" + ) + + +BENIGN = "这是一句普通的中文口播。" + + +@pytest.mark.parametrize( + ("bad", "frag"), + [ + ("论文发表于 2026 年六月。", "两千零二十六"), # 4 位年份 + 空格 + ("升级到版本 2.5.1 之后。", "三段版本号"), + ("速度快了 3-5 倍。", "三减五"), + ("测量误差是 ±3 个点。", "正负"), + ("整体提速 10x 左右。", "十x"), + ], +) +def test_reading_trap_fails(project, bad, frag): + write_board(project, BOARD_OK) + write_config(project, CFG_OK) + write_narration(project, [bad, BENIGN, BENIGN, BENIGN]) + rc, out = run_check(project) + assert rc == 1, out + assert "读法陷阱" in out and frag in out + + +def test_sentence_without_han_fails(project): + """整句无汉字会被上游 use_chinese() 路由到英文归一化(2.5 → two point five)。""" + write_board(project, BOARD_OK) + write_config(project, CFG_OK) + write_narration(project, ["IndexTTS 2.5", BENIGN, BENIGN, BENIGN]) + rc, out = run_check(project) + assert rc == 1, out + assert "整句无汉字" in out + + +@pytest.mark.parametrize( + "ok", + [ + "论文发表于 2026年六月。", # 无空格才读「二零二六年」 + "耗时 0.5~1.0 秒。", # 实测 → 零点五到一点零秒(正确) + "上午 9:30 开始录制。", # 实测 → 九点三十分(正确) + "提升 16.2 个百分点。", # 实测正确 + "6 月 20 日发布。", # 实测正确 + "第 3 章第 1.2 节。", # 实测正确 + "AI 与 LLM 都能做到。", # 纯缩写原样透传 + ], +) +def test_reading_trap_no_false_positive(project, ok): + write_board(project, BOARD_OK) + write_config(project, CFG_OK) + write_narration(project, [ok, BENIGN, BENIGN, BENIGN]) + rc, out = run_check(project) + assert rc == 0, out + assert "命中 0 处" in out + + +def test_model_designator_warns_not_fails(project): + """`1080P` 读成「一千零八十P」——是缺陷但不阻断(型号写法多样,避免误伤)。""" + write_board(project, BOARD_OK) + write_config(project, CFG_OK) + write_narration(project, ["输出 1080P 视频。", BENIGN, BENIGN, BENIGN]) + rc, out = run_check(project) + assert rc == 0, out + assert "WARN" in out and "一千零八十" in out diff --git a/media/self-evolving-coding-agents-video/script/narration.json b/media/self-evolving-coding-agents-video/script/narration.json index 2494bed1..9245c465 100644 --- a/media/self-evolving-coding-agents-video/script/narration.json +++ b/media/self-evolving-coding-agents-video/script/narration.json @@ -77,7 +77,7 @@ { "id": "p0-16", "scene": "P0", - "text": "2026 年 8 月,两所大学发了一篇综述," + "text": "2026年 8 月,两所大学发了一篇综述," }, { "id": "p0-17", @@ -232,7 +232,7 @@ { "id": "p2-06", "scene": "P2", - "text": "2025 年有个系统真这么干了:给 AI 一套基础工具,让它编辑自己的代码," + "text": "2025年有个系统真这么干了:给 AI 一套基础工具,让它编辑自己的代码," }, { "id": "p2-07", diff --git a/media/self-evolving-coding-agents-video/script/narration.md b/media/self-evolving-coding-agents-video/script/narration.md index edc24771..6d354f7c 100644 --- a/media/self-evolving-coding-agents-video/script/narration.md +++ b/media/self-evolving-coding-agents-video/script/narration.md @@ -37,7 +37,7 @@ > 画面:论文封面卡:Self-Evolving Coding Agents: A Survey + 南京理工大学 × 南京大学 + arXiv:2608.03392。 -- [p0-16] 2026 年 8 月,两所大学发了一篇综述, +- [p0-16] 2026年 8 月,两所大学发了一篇综述, - [p0-17] 把这个领域第一次拼成了完整的地图。 - [p0-18] 我们把它讲透。 @@ -98,7 +98,7 @@ - [p2-03] 第一个,最狠的:改自己的框架。 - [p2-04] AI 智能体本身也是一套程序,有源码。 - [p2-05] 那能不能让 AI 自己改自己的源码? -- [p2-06] 2025 年有个系统真这么干了:给 AI 一套基础工具,让它编辑自己的代码, +- [p2-06] 2025年有个系统真这么干了:给 AI 一套基础工具,让它编辑自己的代码, - [p2-07] 改出新版本,跑基准测试,分数涨了就留下。 - [p2-07b] 更严格一点:不光看分数,还要看花的钱、跑的时间,三项一起达标才算数。 diff --git a/media/self-improving-agents-video/script/narration.json b/media/self-improving-agents-video/script/narration.json index 79ff2560..0cb41937 100644 --- a/media/self-improving-agents-video/script/narration.json +++ b/media/self-improving-agents-video/script/narration.json @@ -2,7 +2,7 @@ { "id": "p0-01", "scene": "P0", - "text": "1966 年,英国数学家 I. J. Good 写下一个大胆的预言。" + "text": "1966年,英国数学家 I. J. Good 写下一个大胆的预言。" }, { "id": "p0-02", @@ -52,7 +52,7 @@ { "id": "p0-12", "scene": "P0", - "text": "2026 年 7 月,一支横跨多所机构的团队,发表了一篇综述。" + "text": "2026年 7 月,一支横跨多所机构的团队,发表了一篇综述。" }, { "id": "p0-12a", @@ -1062,7 +1062,7 @@ { "id": "p5-15", "scene": "P5", - "text": "1966 年,I. J. Good 说,超智能机器,是人类需要做出的最后一项发明。" + "text": "1966年,I. J. Good 说,超智能机器,是人类需要做出的最后一项发明。" }, { "id": "p5-16", @@ -1097,7 +1097,7 @@ { "id": "p5-22", "scene": "P5", - "text": "这期视频的全部内容,来自这篇 2026 年的综述论文。" + "text": "这期视频的全部内容,来自这篇 2026年的综述论文。" }, { "id": "p5-22a", diff --git a/media/self-improving-agents-video/script/narration.md b/media/self-improving-agents-video/script/narration.md index 3217663f..6f83f2fe 100644 --- a/media/self-improving-agents-video/script/narration.md +++ b/media/self-improving-agents-video/script/narration.md @@ -10,7 +10,7 @@ > 画面:黑场,打字机逐字打出金句卡(衬线体),英文原文渐显在下方。 -- [p0-01] 1966 年,英国数学家 I. J. Good 写下一个大胆的预言。 +- [p0-01] 1966年,英国数学家 I. J. Good 写下一个大胆的预言。 - [p0-02] 第一台超智能机器,将是人类需要做出的最后一项发明。 - [p0-03] 因为从那之后,机器会自己发明更强的机器。 @@ -29,7 +29,7 @@ > 画面:论文封面卡片浮现:标题 + arXiv 编号 + 机构行;Schmidhuber 名字在作者名单末位高亮。 -- [p0-12] 2026 年 7 月,一支横跨多所机构的团队,发表了一篇综述。 +- [p0-12] 2026年 7 月,一支横跨多所机构的团队,发表了一篇综述。 - [p0-12a] 作者名单压轴的,是深度学习元老 Schmidhuber。 - [p0-12b] 他早在上世纪八十年代,就提过一个更疯的设想——片尾我们再说。 - [p0-13] 这篇论文,系统梳理了"AI 自我进化"这个领域的主要路线。 @@ -343,7 +343,7 @@ > 画面:金句卡首尾呼应——Good 的预言再次浮现,加上一行新字。 -- [p5-15] 1966 年,I. J. Good 说,超智能机器,是人类需要做出的最后一项发明。 +- [p5-15] 1966年,I. J. Good 说,超智能机器,是人类需要做出的最后一项发明。 - [p5-16] 六十年后,人类正在小心翼翼地, - [p5-17] 给这台机器装上第一节可以自己升级的零件。 - [p5-18] 同时,死死握住那道验证之门。 @@ -353,7 +353,7 @@ > 画面:门的线条由验证闸门图标汇聚而成。 -- [p5-22] 这期视频的全部内容,来自这篇 2026 年的综述论文。 +- [p5-22] 这期视频的全部内容,来自这篇 2026年的综述论文。 - [p5-22a] 这篇论文的作者们,还维护着一张会持续更新的研究地图, - [p5-22b] 截至今年八月,已经收录了三百一十二项工作: - [p5-22c] 改大脑的七十七项,改装备的一百七十六项,做评测的五十九项。 From f1c66c225ee9aebce8135a2ac055c137678f4a99 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 20 Aug 2026 22:31:35 +0800 Subject: [PATCH 03/10] =?UTF-8?q?docs(tts):=20=E6=96=B0=E5=A2=9E=20IndexTT?= =?UTF-8?q?S-2.5=20=E8=BF=9B=E9=98=B6=E7=94=A8=E6=B3=95=E5=BE=AA=E8=AF=81?= =?UTF-8?q?=E7=AF=87=EF=BC=8C=E6=A0=A1=E5=87=86=E6=97=A2=E6=9C=89=E7=BB=93?= =?UTF-8?q?=E8=AE=BA=E5=B9=B6=E6=B2=89=E6=B7=80=20ISSUE-164;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 media/pipeline/INDEXTTS-2.5-ADVANCED.md(九章)作为 VOICE-CLONING.md 的正交 补充:后者是「怎么用现有能力做完一集」的单一参考,本文回答「上游到底有什么能力、 机制为何、还能怎么更好」。全文不复制任何本仓参数值,只写上游事实(file:line 锚定 HEAD 4f8792f)与二者的映射;含两张 Mermaid(文本前端链路、情感融合)、16 项 ROI 排序路线图、迁移地雷表、9 条 IEEE 参考文献。 调研推翻的既有结论(逐条校准,均附实测或源码坐标): - Σvec×alpha≤0.8 是**本仓自创口径**而非上游行为——上游 infer() 从不归一化, normalize_emo_vec 全仓唯一调用点是 webui.py:665 的自定义向量分支,且那条的 0.8 作用在已乘 emo_bias 的和上、且在 alpha 之前。emo_bias 8 维严重不等权 (calm 仅 0.5625、surprised 0.6875)⇒ 从社区/WebUI 抄来的参数在本仓实际强 16–33%,跨来源迁移不可靠。另:alpha 等于「替换本人语调的百分比」仅当名义向量和 为 1.0,lively/confident/positive 不满足,其 alpha 跨预设不可比。 - 「alpha ≤0.8 推荐(官方建议)」把向量总和上限与 alpha 上限混为一谈——alpha 本身 只被 clamp 到 [0,1];实操该盯的是残差保留率 1−Σ(w·α)。 - 「df 0.97 护密集技术句清晰度」方向写反:df<1 = 更快 = 咬字更紧更糊,护清晰度应 df>1。duration_factor 的真实作用面是 S2M 时间轴重采样(常数 1.72 = 梅尔帧率 86.13 ÷ 语义帧率 50),不产生音高偏移、不消耗 token 预算。 - 「数字句贵在 token 数暴涨」归因错误:该句 token 仅 17→23,且文本 token 只进一次 prefill 不参与 AR 循环;真实原因是「短句 × 3 束」。 - 「束宽线性放大整集墙钟」在 MPS 上不成立:分段 profile 实测 s2mel 占 45–73%、 T2S 仅 18–46%,整集 1→3 束仅 +4%。正确口径是 CUDA 近线性、MPS 近乎免费。 - 本机跑的是 U-DiT 而非论文的 Zipformer 变体(config dit_type="DiT",全仓 grep zipformer 零命中)⇒ 论文 Table 4 的 S2M 0.017 与头条「RTF 提升 2.28×」不适用于 本机,别引用。IndexTTS2.5-RL 权重亦未公开发布(已核 GitHub/HF/ModelScope)。 - 参考音频「5–15 秒(上限 30s)」与「保留原采样率」两处均已更正(见前一提交); 长样本代价不在条件提取(按样本缓存只算一次)而在 ref_mel 作为 CFM 前缀每句都进 25 步扩散——交错 3 组实测 12s→6s 使 s2mel 中位降 54%,但音色语速随之变,不可为 提速缩短参考。 - tts_server 对「向量 + 情感音频」互斥的理由更正:上游并非静默丢弃音频,真实问题 是 emo_alpha 被消费两次。 - 方案对比表的 mlx 行更新:index-tts-2.5-mlx 0.1.1 已支持 2.5,但砍掉全部情感控制 与束搜索,不可直接替换。 配套:新增 PRON-GLOSSARY.md 易错字台账(含 9 个科普高频多音字候选清单,明确「确认 读错才标注、不预防性标注」);skills/03 补读法纪律表与标注规约;skills/07 决策树补 束宽口径与「A/B 前先固定 seed」一闸;docs/.agents/issue.md 记 ISSUE-164(年份空格 陷阱的表因/根因/处理/防范);knowledge-map 登记新文档。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/.agents/issue.md | 8 + docs/.agents/knowledge-map.md | 1 + media/pipeline/INDEXTTS-2.5-ADVANCED.md | 558 ++++++++++++++++++++++++ media/pipeline/PRON-GLOSSARY.md | 47 ++ media/pipeline/README.md | 1 + media/pipeline/VOICE-CLONING.md | 54 ++- media/pipeline/skills/03-narration.md | 37 ++ media/pipeline/skills/07-tts-voice.md | 10 +- media/pipeline/voices/README.md | 4 +- 9 files changed, 708 insertions(+), 12 deletions(-) create mode 100644 media/pipeline/INDEXTTS-2.5-ADVANCED.md create mode 100644 media/pipeline/PRON-GLOSSARY.md diff --git a/docs/.agents/issue.md b/docs/.agents/issue.md index fce739de..9fca41d6 100644 --- a/docs/.agents/issue.md +++ b/docs/.agents/issue.md @@ -3530,3 +3530,11 @@ R7 后浏览器对照 Section 2.1 区域发现两类正交缺陷: - **根因**:`{id}.mp3` 单槽位 + 摘要不含引擎标识——换引擎=每句摘要都「合法地」变了,逐句 sidecar 察觉不到「整集正在被换音色」;且硬化不对称:`--plan` 路径已按同一危害论证改为硬失败(tts.py:605-612 注释写明),**合成路径漏了同样的处理**。 - **处理方式**:带值克隆参数(--ref/--style/--lang/--emo-*/--duration-factor/--num-beams/--steady)在 edge 下 `parser.error` 硬失败(典型手型=「照抄文档打了 --ref/--style 却丢了 --engine」);`.engine` 音色签名标记作第二层(覆盖「一个参数都没打」的场景),不一致须 `--allow-voice-switch`。测试 `test_engine_guard.py` 六个用例锁定行为。 - **后续防范**:**破坏性默认值必须硬失败而非提示**——静默降级的输出会覆盖唯一产物槽位时尤甚;修一处同类风险要横向扫全部入口(plan/合成/编排三层当时只硬化了一层)。 + +## ISSUE-164 逐字稿「数字与汉字间加空格」的排版约定击穿中文归一化的年份规则,8 句成片读成「两千零二十六年」(2026-08-20) + +- **表因**:已上线三集共 **8 句**年份读错——`2025 年` 被念成「两千零二十五年」而非「二零二五年」,`1966 年` 念成「一千九百六十六年」而非「一九六六年」。命中句:EP1 `p0-01`/`p0-11`、EP2 `p0-01`/`p0-12`/`p5-15`/`p5-22`、EP3 `p0-16`/`p2-06`。 +- **根因**:**「排版约定 × TN 规则」的隐式耦合**,而非任何一方单独的缺陷。逐字稿有「数字与汉字之间加空格」的排版习惯(`88 页`/`15 分钟`/`16.2 个百分点`),这一约定对绝大多数场景无害;但 macOS 上实际的中文归一化引擎是 **wetext**(`~/tools/index-tts/indextts/utils/front.py:116-142` 按 platform 分叉,Linux 才走 `tn.chinese.normalizer`),其 date/year 规则要求**数字与「年」字面相邻**,插入空格后该规则失配、回落到 cardinal(基数)读法。实测空格敏感性矩阵里**只有 4 位年份这一条被击穿**:`6 月`/`20 日`/`88 页`/`47.6%`/`第 3 章`/`1.2 节`/`0.5~1.0 秒`/`9:30` 加不加空格结果一致且全部正确。另注:中文**不走** NeMo(`infer_v2_5.py:703-707` 是 if/elif,只有 ja/es 走 `nemo_tn`),此前若按 NeMo 排查等于查一条死路径。 +- **处理方式**:① 三集 `narration.md`(唯一维护处)共 8 句去掉数字与「年」之间的空格,重跑 `build_narration.py`;② `check_script.py` 新增 `READING_TRAPS` 成门,把**实测确认会读错**的 7 类写法固化为 FAIL/WARN(4 位年份带空格、三段版本号 `2.5.1`→「二.五点一」、连字符区间 `3-5 倍`→「三减五倍」、`±3%`→「百分之正负三」、`10x`→「十x」、整句无汉字→路由到英文归一化、`1080P`→「一千零八十P」WARN);③ 每条规则都在 `test_check_script.py` 里配正反例,**反例组同等重要**——本轮调研初稿把 `0.5~1.0 秒` 与 `9:30` 也列为错误,实测证明它们其实正确(`零点五到一点零秒`/`九点三十分`),凭直觉扩大清单会造成误伤。 +- **后续防范**:**给外部引擎的文本,任何排版约定都要过一遍该引擎的真实行为探针,不能凭直觉列禁写清单**。归一化是**幂等**的(预写成汉字读法后再过一遍结果不变),故此类修复可逐句增量做、无需一次性全量改写、也不需要关 `text_normalization` 开关(关掉反而会丢失 `%`/小数/量词这些**已经正确**的能力)。加规则前先跑探针拿到「错读证据」,再把证据写进规则消息里——`READING_TRAPS` 的每条 message 都带实测输出。 +- **同类问题影响与注意**:① 生产若迁 Linux,归一化引擎换成 `tn.chinese.normalizer`,**必须在 Linux 上重跑同一组空格矩阵**确认行为一致;② 修复会改写这 8 句音频(每处少 2–3 个音节、约 −0.4~0.7 s),**牵动 beat 时长与片尾渐黑窗口**(同族踩坑:渐黑窗口须用 beat 时长而非末句时长),故重合成须按集排期、`pipeline.py tts --plan` 确认只有这几句 miss、重渲后 `qa_frames.py --last-n 6 --check` 复检尾幕;③ 本轮同时发现上游有一整套发音标注能力(`<行|HANG2>`)可治多音字,已接通并成门,见 [INDEXTTS-2.5-ADVANCED.md](../../media/pipeline/INDEXTTS-2.5-ADVANCED.md) 与 [PRON-GLOSSARY.md](../../media/pipeline/PRON-GLOSSARY.md)。 diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 60acd5ba..d90f79d4 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -73,4 +73,5 @@ - [科普视频制作 Pipeline(公共基建)](../../media/pipeline/README.md) — 全仓可复用的论文→视频九阶段流水线:`pipeline.py` 单入口(status/doctor/build/check/tts/captions/render/qa/all)+ 每集 `pipeline.toml` 声明式配置 + `timing.json` 时序 SSOT(timing.ts 与 Python 共读,双语言镜像漂移结构性消灭);技能规格 skills/01–09(01–05 内容层、06 生产层实现、07 TTS、08 草渲 QA、09 终渲交付),真 Skill 挂载于 [.agent/skills/science-video-pipeline](../../.agent/skills/science-video-pipeline/SKILL.md)(纯路由壳);测试 `media/pipeline/tests/` 45 项(digest 黄金哈希守缓存零失效);工具:check_script(分镜覆盖性/时长预算双口径)、check_series(系列顺序五规则执法 [series.json](../../media/series.json))、captions(srt/vtt)、qa_frames --check(黑帧/字幕带侵入/冻帧/WCAG 对比度自动体检)、paper_extract(论文取证五子命令)、refs(样本指纹清单)。 - [自进化系列科普视频 · 三集作品](../../media/series.md) — 发布顺序《上线之后,AI 才开始上学》(金/青/紫,EP1,v3 已交付:内容校准 + sunny-steady 重配,成片 14:01)→《AI 如何自己变强?》(蓝/橙,EP2)→《会写代码的 AI,开始给自己写代码》(绿/洋红,EP3);系列纪律:口播永不携带他集标题与集数序号,顺序变更 TTS 代价恒为零;各集 research/paper-notes.md 为口播单一事实源、upgrade-*.md 为轮次审计记录。 - [IndexTTS 声音克隆手册](../../media/pipeline/VOICE-CLONING.md) — 双引擎 TTS 的克隆侧单一参考:部署(~/tools/index-tts 服务)/参考样本(prospect+prepare+refs 指纹门)/风格预设(sunny 明快阳光推荐位、sunny-steady 成片档 beams=3)/试听关卡(tts_sample + 领航片段)/缓存摘要与幂等/许可合规。 +- [IndexTTS-2.5 进阶用法(上游能力面 · 机制循证 · 提升路线图)](../../media/pipeline/INDEXTTS-2.5-ADVANCED.md) — 与操作手册正交的循证篇,锚定上游 HEAD `4f8792f` 逐条给出 file:line:能力矩阵(含 `do_sample` 在上游失效、`max_text_tokens_per_segment` 对本仓惰性等否定结论)/文本前端(中文走 wetext 而非 NeMo;实测读法矩阵——4 位年份带空格是**唯一**被击穿的规则,已成门,见 [ISSUE-164](./issue.md))/发音标注 `<行\|HANG2>`(DTW 对照证实拼音通道生效 2.4× 分离度,CMU 通道不可判定)/情感机制(`emo_bias` 8 维不等权、`normalize_emo_vec` 在 infer 内从不被调用 ⇒ `Σvec×alpha≤0.8` 是本仓自创口径,跨来源参数迁移偏差 16–33%;73 个「情感×说话人」原型解释「换选段则标定失效」)/采样参数族(`length_penalty=0.0` 非中性、`repetition_penalty=10` 与音色耦合、`max_mel_tokens`≈30s 且溢出表现为文本尾部未念出、无种子致 A/B 不可信)/参考音频(15s 硬截断保前丢后、恒重采样 22.05kHz、CMVN 使相似度对增益免疫、逐句成本来自 CFM 前缀)/性能(本机是 U-DiT 非论文 Zipformer ⇒ 论文 RTF 不可引用;分段 profile 实测 **s2mel 占 45–73%** 而非 T2S,束宽在 MPS 上近乎免费)/16 项 ROI 排序路线图与迁移地雷。配套:[发音标注台账](../../media/pipeline/PRON-GLOSSARY.md)、[pron_marks.py](../../media/pipeline/scripts/pron_marks.py)。 - [自进化 Agents Team 方案(Phase 3 记忆检索面已落地)](../concepts/design/self-evolving-agents.md) — 四层自进化架构:本次落地 `engine/evolution/` 子系统(GEPA proposer + 状态机 + decision 护栏)并在记忆检索权重面接通 propose→shadow→canary→promote/rollback 全闭环(迁移 0081 + evolution_inspector),默认全关灰度;agent/skill/knowledge 面、Phase 1 tool_invocations 遥测、eval 四表留后续 diff --git a/media/pipeline/INDEXTTS-2.5-ADVANCED.md b/media/pipeline/INDEXTTS-2.5-ADVANCED.md new file mode 100644 index 00000000..c23d2483 --- /dev/null +++ b/media/pipeline/INDEXTTS-2.5-ADVANCED.md @@ -0,0 +1,558 @@ +# IndexTTS-2.5 进阶用法:上游能力面 · 机制循证 · 配音质量提升路线图 + +> **文档定位与边界**:[VOICE-CLONING.md](./VOICE-CLONING.md) 是操作与参数的**单一参考**,回答 +> 「怎么用现有能力做完一集」(部署/样本/风格档/合成/缓存/排障)。本文回答另一个正交问题: +> **上游到底有什么能力、机制为何、还能怎么更好**。 +> +> **本文不复制任何本仓参数值**——风格预设数值一律以 `tts.py --list-styles` 与 +> [VOICE-CLONING.md](./VOICE-CLONING.md) §4.1 为准,可执行参数一律以各集 `pipeline.toml` 为准。 +> 本文只写「上游事实」(源码坐标 + 论文表号)与「二者的映射关系」。 +> +> **证据锚点**:上游为本机 clone `~/tools/index-tts`[[8]](#ref8),**HEAD `4f8792f`**;权重为 HF 公开发布的 +> `IndexTeam/IndexTTS-2.5` 基座(`gpt.pth` 字节数与 HF 发布版逐字节一致)。所有 `file:line` +> 均指该 HEAD;升级上游后需复核。实测数据一律标注日期与口径(机器空闲/有负载、单句/整集)。 + +## 目录 + +1. [能力矩阵:上游有什么 / 本管线用了什么](#一能力矩阵) +2. [文本前端:读法的真正控制点](#二文本前端读法的真正控制点) +3. [情感与音色解耦的数学机制](#三情感与音色解耦的数学机制) +4. [自回归采样参数族](#四自回归采样参数族) +5. [参考音频工程](#五参考音频工程) +6. [性能:本机口径与论文口径为何不可互推](#六性能本机口径与论文口径为何不可互推) +7. [提升路线图(ROI 排序)](#七提升路线图roi-排序) +8. [迁移地雷](#八迁移地雷) +9. [上游追踪与参考文献](#九上游追踪与参考文献) + +## 一、能力矩阵 + +`infer()` 的完整签名在 `indextts/infer_v2_5.py:505-508`。下表逐项对照上游能力与本管线现状。 + +| 能力 | 上游默认 | 本管线 | 说明 | +|---|---|---|---| +| `spk_audio_prompt` | 必填 | ✅ `--ref` | 硬截断 15 s,见 §5 | +| `text` | 必填 | ✅ | 预处理仅 2 条替换 + 发音标注,见 §2 | +| `lang` | 位置必填 | ✅ 恒 `ZH` | 只影响 4 件事,见 §2.1 | +| `emo_vector` (8 维) | `None` | ✅ `--style`/`--emo-vector` | 上游**不做归一化**,见 §3.2 | +| `emo_alpha` | `1.0` | ✅ `--emo-alpha` | 物理含义有前提,见 §3.1 | +| `emo_audio_prompt` | `None` | ✅ `--emo-ref` | 与向量互斥(理由见 §3.1) | +| `use_emo_text`/`emo_text` | `False`/`None` | ✅ `--emo-text` | 需服务端 `--use-qwen-emo` | +| `duration_factor` | `1.0` | ✅ `--duration-factor` | v2.5 专属;方向易搞反,见 §3.4 | +| `use_random` | `False` | 🔒 硬编码 `False` | 开启必掉保真度,见 §3.3 | +| `interval_silence` | `200` ms | ✅ `--interval-silence`(本轮接通) | 仅作用于**单请求内分段之间**,本管线逐句合成故默认不生效 | +| `max_text_tokens_per_segment` | `120` | ❌ 不暴露(刻意) | 对本仓完全惰性,见 §4.4 | +| `text_normalization` | `True` | ✅ `--no-text-normalization`(本轮接通) | **不要关**,见 §2.2 | +| `temperature`/`top_p`/`top_k` | `0.8`/`0.8`/`30` | ✅(本轮接通) | 束搜索下仍生效,见 §4.1 | +| `length_penalty` | `0.0` | ✅(本轮接通) | **0.0 不是中性**,见 §4.2 | +| `repetition_penalty` | `10.0` | ✅(本轮接通) | 与音色耦合、不可迁移,见 §4.3 | +| `max_mel_tokens` | `1500` | ✅(本轮接通) | ≈30 s 天花板,见 §4.4 | +| `do_sample` | `True` | ❌ 不暴露(**上游失效**) | `:780` 用字面量 `True` 覆盖 `:731` 弹出的值,webui 的复选框是装饰性控件 | +| 随机种子 | **无** | ✅ `--seed`(本轮接通) | A/B 可信的前提,见 §4.5 | +| `stream_return` / `more_segment_before` | `False` / `0` | ❌ 不暴露 | 流式与分段前瞻,长视频批处理无收益 | +| `use_bf16` / `use_cuda_kernel` / `use_deepspeed` / `use_accel` | — | ❌ Apple Silicon 不可达 | 见 §6、§8 | + +## 二、文本前端:读法的真正控制点 + +### 2.1 中文归一化链路(wetext,不是 NeMo) + +```mermaid +flowchart TD + A["narration.md 逐字稿
(含可选 <原文|读音> 标注)"] --> B["build_narration.py
剥离标注 → text / 保留 → ttsText"] + B --> C["tts.py tts_text()
—— 逗号 · 省略号 → 句号"] + C --> D["POST /synthesize"] + + subgraph UP["上游 infer_v2_5.py 文本管线(顺序即坑位)"] + direction TB + E["`:699` 构造 lang_prefix
'<|zh|> ' = 2 个 token"] + F["`:701` clean_pattern
标点半角化"] + G["`:703-707` **归一化分支**
zh/en → front.py TextNormalizer
ja/es → nemo_tn(zh 走不到)"] + H["`:711` 全局 text.lower()"] + I["`:714` 发音标注展开
→ <|SPECIAL_TOKEN_1/2|>"] + J["`:719` split_text_by_tokens
预算 118 token"] + K["`:723` tiktoken encode"] + E --> F --> G --> H --> I --> J --> K + end + + D --> E + K --> L["T2S 自回归 → 语义码"] + + style G fill:#7a2d2d,stroke:#f0a0a0,stroke-width:2px,color:#fff + style I fill:#1a3a5c,stroke:#8ab8e0,stroke-width:2px,color:#fff + style B fill:#1a4d3a,stroke:#7fd1a8,stroke-width:2px,color:#fff +``` + +三条容易踩空的事实: + +- **中文不走 NeMo**。`:703-707` 是 `if/elif`:`zh`/`zhen`/`en` 走 `front.py` 的 `TextNormalizer`, + 只有 `ja`/`es` 走 `nemo_tn`。本管线 `lang` 恒为 `ZH`,故 `nemo_tn.py` 是**死路径**—— + 按它排查等于调一个到不了的模块。macOS 上 `TextNormalizer` 的实际引擎是 **wetext** + (`front.py:116-142` 按 platform 分叉,Linux 才用 `tn.chinese.normalizer`)。 +- **`lang` 只影响 4 件事**:归一化分支选择、大小写折叠、是否跑日语 g2p、以及 `lang_prefix`/lang id。 + 它**不影响发音标注的通道选择**(§2.3)。合法取值 `ZH/EN/JA/AR/ES`(`webui.py:800-804`); + 代码里出现的 `zhen` 是历史死分支,tokenizer 未注册该语言,写它会让前缀退化成 7 个普通 token。 +- **`:711` 对 zh 无条件 `text.lower()`**:逐字稿里刻意大写缩写零收益。 + +### 2.2 实测读法矩阵:什么写法正确、什么写法错 + +2026-08-20 在本机 index-tts venv 直接调 `TextNormalizer.normalize()` 的输出(非推断): + +| 写法 | 归一化输出 | 判定 | +|---|---|---| +| `2026 年` | 两千零二十六 年 | ❌ **唯一被空格击穿的规则** | +| `2026年` | 二零二六年 | ✅ | +| `2.5.1` | 二.五点一 | ❌ 残留字面小数点 | +| `3-5 倍` | 三减五 倍 | ❌ 读成「减」 | +| `±3%` | 百分之正负三 | ❌ 顺序颠倒 | +| `10x` | 十x | ❌ 裸字母 | +| `1080P` | 一千零八十P | ⚠️ 按基数读 | +| `47.6%` → `71.3%` | 百分之四十七点六 → 百分之七十一点三 | ✅ | +| `16.2 个百分点` | 十六点二个百分点 | ✅ | +| `6 月 20 日` | 六月 二十日 | ✅ | +| `第 3 章第 1.2 节` | 第 三 章第 一点二 节 | ✅ | +| `0.5~1.0 秒` | 零点五到一点零秒 | ✅ | +| `9:30` | 九点三十分 | ✅ | +| `AI 与 LLM` | 原样透传 | ✅ | +| `IndexTTS 2.5`(整句无汉字) | IndexTTS **two point five** | ❌ 路由到英文 normalizer | + +最后一条的机制是 `use_chinese()`(`front.py:106-114`)**逐句嗅探**:整句无汉字即走英文 +归一化。逐字稿为字幕可读性把句子拆到 ≤43 字,反而**提高**了出现纯 ASCII 短句的概率—— +两个既有约束的隐性冲突。 + +已上线三集曾有 8 句年份读错,修复与成门记于 [issue.md ISSUE-164](../../docs/.agents/issue.md); +禁写清单是 [check_script.py](./scripts/check_script.py) 的 `READING_TRAPS`,写稿侧规约见 +[skills/03-narration.md](./skills/03-narration.md)。 + +**为什么不关 `text_normalization`**:关掉后链路上**再无任何后备读法模块**(`:703-708` +整块跳过),`%`/小数/量词/月日/章节这些**已经正确**的能力全部丢失,等于用大面积返工换一个 +小面积 bug。归一化又是**幂等**的(预写汉字读法后再过一遍结果不变),故正确策略是 +「保持开关 + 逐句预归一化」,可增量推进。 + +### 2.3 发音控制标记:唯一的精确读音手段 + +语法、校验规则与三个失效模式集中在 [scripts/pron_marks.py](./scripts/pron_marks.py) 的模块 +文档(那是单一事实源,本节不复制)。这里只记**机制与验证结论**: + +- 通道由**标记左侧是否含汉字**二分(`:66`),**与 `lang` 无关**。写法陷阱: + `` 左侧纯 ASCII ⇒ 被当成音素通道。 +- 标记**免疫归一化**:`front.py:178/220` 先换占位符再还原。故不需要为保标记而关归一化, + 且 `<行|HANG2>` 与 `2026年→二零二六年` 可在同句共存(已实测)。 +- **2026-08-20 A/B 验证(拼音通道,生效)**:把多音字读音**故意互换**,再与「用汉字写出 + 目标读音」的对照组比 MFCC-DTW 距离(同 ref、同 style、`--seed 20260820` 固定): + + | 档 | 文本 | 期望读音 | + |---|---|---| + | A 基线 | `他在银行里行走。` | yín háng lǐ xíng zǒu | + | B 标注 | `他在银<行\|XING2>里<行\|HANG2>走。` | yín xíng lǐ háng zǒu | + | C 汉字对照 | `他在银形里航走。` | yín xíng lǐ háng zǒu | + + 结果 `d(B,C)=0.133` ≪ `d(A,B)=0.320`、`d(A,C)=0.342` —— **分离度 2.4×,标注确实生效**。 + +- **CMU 音素通道:语法可用,模型响应未经证实**。同样设计(`` 对照 + `Cat`)的距离差仅 2%,且判定随分析窗口翻转(前 0.6 s「生效」、前 1.0 s「未生效」)—— + 差异只在首词、被共享句尾稀释。**结论:中文句内英文专名继续沿用「进角标不口播」的既有 + 纪律**;CMU 标注属未验证选项,用前必须人耳小样对比。 + +## 三、情感与音色解耦的数学机制 + +### 3.1 融合公式与 alpha 的物理含义 + +```mermaid +flowchart LR + subgraph REF["参考音频(12 s)"] + R1["CAMPPlus
192 维 style"] + R2["w2v-BERT L17
语义条件"] + R3["log-mel
CFM 前缀"] + end + subgraph PROTO["情感原型库(写死在权重里)"] + P1["spk_matrix feat1.pt
73 × 192"] + P2["emo_matrix feat2.pt
73 × 1280"] + end + subgraph MIX["情感融合(infer_v2_5.py:669-767)"] + M1["余弦最近邻
每类情感挑「最像你」的原型行"] + M2["emovec = Σ wᵢ·Bᵢ
+ (1 − Σwᵢ)·E_self"] + end + subgraph GPT["GPT 全局条件(model_v2_5.py:731)"] + G1["conds_latent
= spk_emb_proj(spk) **+** emovec
(同一 1280 维槽位相加)"] + end + R1 --> M1 + P1 --> M1 + M1 --> M2 + P2 --> M2 + R1 --> G1 + M2 --> G1 + R2 --> G1 + R3 --> CFM["S2M / CFM 25 步扩散"] + G1 --> T2S["T2S 自回归"] + + style M2 fill:#5c2d7a,stroke:#c99ae0,stroke-width:2px,color:#fff + style G1 fill:#7a2d2d,stroke:#f0a0a0,stroke-width:2px,color:#fff + style M1 fill:#1a3a5c,stroke:#8ab8e0,stroke-width:2px,color:#fff +``` + +`emovec = Σ(wᵢ·Bᵢ) + (1 − Σwᵢ)·E_self`(`:766-767`),其中 `wᵢ` 是 alpha 缩放后的分量。 +把它写成 `Σ(wᵢ°·α)·Bᵢ + (1 − α·Σwᵢ°)·E_self` 就能看清: + +> **alpha 恰好等于「把自己的语调替换成合成原型的百分比」,当且仅当名义向量和 Σwᵢ° = 1.0。** + +`sunny`/`passionate` 刚好是 1.00,故「alpha 0.35 留 65% 给本人真实语调」成立;而 +`lively`(0.85)/`confident`(0.90)/`positive`(0.95) 的名义和不是 1,其 alpha 被稀释成 +`α·Σw°`,**跨预设不可直接比较**。比 alpha 更该盯的是**残差保留率** `1 − Σ(w·α)`: +想保住 ≥60% 本人语调,就让 `Σ(w·α) ≤ 0.40`。这与实测「≥0.6 开始像另一个人、 +0.3–0.45 是自然与风格的平衡带」互相印证。 + +「注入越多越像别人」是**两级叠加**,不是单纯「挤占」:第一级是 `:767` 的**凸组合替换** +(Σw 的比例直接把 `E_self` 换成 73 个「他人 × 情感」实例的加权和);第二级是 +`model_v2_5.py:731` 的**同槽位求和污染**(`c + e` 共用一个 1280 维条件 token,e 的模长越大, +GPT 看到的条件方向越偏离说话人身份)。论文的 GRL 只保证 e 在**训练分布上**对音色不变, +无法保证「他人原型的加权和」对未见说话人也音色中性——`find_most_similar_cosine` +(`:901-907`)正是为弥补这一残差而存在的工程补丁。 + +`emo_audio_prompt` 与 `emo_vector` 被本服务显式拒绝同传。**理由不是**「上游会静默丢弃音频」 +(那是误读:`:611` 的 `if emo_audio_prompt is None` 不成立,音频仍会以 `(1−Σw)` 权重混进 +最终 emovec),而是 **`emo_alpha` 被消费两次**——先在 `:605-608` 缩放向量,又在 `:763` +用作参考音频的隐空间插值系数,语义混乱且不可预测。 + +### 3.2 emo_bias 的 8 维不等权,与本仓口径的显式差异 + +`emo_bias` 硬编码在 `infer_v2_5.py:493`(不在 `config.yaml`): + +| 维度 | sad / afraid | happy / disgusted / melancholic | angry | surprised | calm | +|---|---|---|---|---|---| +| bias | 1.0 | 0.9375 | 0.875 | 0.6875 | **0.5625** | + +即**同样填 0.5,`calm` 的实际强度只有 `sad` 的 56.25%、`surprised` 只有 68.75%**—— +这两个维度「每单位 Σw 预算最贵、见效最差」。 + +**关键事实**:`normalize_emo_vec`(含 bias 与 0.8 上限)**在 `infer()` 内从不被调用**。 +全仓唯一调用点是 `webui.py:665` 的「自定义向量」分支;`apply_bias=False` 那条分支 +**没有任何调用者**(死路径)。因此: + +- 本管线的 `Σvec×alpha ≤ 0.8` 是**我们自定的护栏**,不是上游行为(上游直调 `infer` 零归一化); +- 上游 webui 的 0.8 作用在**已乘 bias 的和**上、且**在 alpha 之前**; +- 上游 `emo_text` 路径**既无 bias 也无上限**(Qwen 每维只 clamp 到 `[0, 1.2]`,8 维理论可达 9.6, + 会让 `(1−Σw)` 变成大负数)。本服务的 `_qwen_vector_sync` 是**我们补的**修补。 + +**后果(按上游 webui 口径复算本仓 5 个非中性预设)**:实际注入比本仓口径**弱 15.8%–33.3%** +(`confident` 最失真,因其 `calm` 占比最重),且成分构成会移位。**从社区/WebUI/HF Space +抄来的任何 `(vec, alpha)` 数值,经本仓 API 复现时都不是原意——跨来源参数迁移当前不可靠, +必须重新试听定档。** + +「不采用 emo_bias」是一个**显式的设计选择**而非遗漏:bias 是上游为 WebUI 交互体验做的感知 +补偿,而本管线要的是可复现、可缓存、可折算的线性口径。代价就是上述迁移不可靠性。 + +### 3.3 73 个情感原型:为何「换选段则标定失效」是必然 + +情感原型不是「每种情感一个均值」,而是 **73 个「(情感, 具体说话人)」实例**: +`emo_num=[3,17,2,8,4,5,10,24]`(Σ=73),`feat2.pt` 为 `[73, 1280]`、`feat1.pt` 为 +`[73, 192]`(CAMPPlus 空间),二者按 `emo_num` 平行切分(`config.yaml:108-110`、`:245-253`)。 + +默认路径用**参考音频的 CAMPPlus style 做余弦最近邻**,为每个情感组挑「说话人风格最像你」 +的那一行(`:669-679`)。这就是 `voices/refs.toml` 那条警告的**代码级机制解释**: +换参考样本会**同时移动**基底 `E_self` 与原型基向量 `Bᵢ`,所以在某个选段上标定的 alpha +换段即失效。分工的正确表述是——**样本决定音色 + 韵律基线 + 情感空间的局部基底; +向量只决定在该基底上挤掉多少本人真实情感**。 + +附带边界:`happy` 只有 **3 个原型**。全押 happy 的「明快」天花板由这 3 位说话人的表达 +风格决定,靠加权重是加不出来的;能调的只有 `(1−Σw)` 那一侧(即换样本)。 + +`use_random=True` 的实现是**每个情感维度独立均匀随机抽一行原型**(`:668-673`),完全丢弃 +说话人匹配——等于「随机找个陌生人来演这个情绪」,且 24 个 calm 原型里抽中风格远离你的 +概率很高。这是它降低克隆保真度的直接机制,生产必须恒 `False`。 + +### 3.4 duration_factor 的真实作用面(方向勘误) + +`:832` `target_lengths = int(S_infer.shape[1] * 1.72 * duration_factor)`。常数 **1.72 不是语速 +系数**,而是帧率换算比:梅尔帧率 `22050/256 = 86.13` fps ÷ 语义特征帧率 `50` Hz = 1.7227 +(源码取 1.72,系统性缩短 0.15%;对一整集约累计 1.2 s)。 + +作用面是 **S2M 阶段的时间轴重采样**:GPT 已生成完的语义 token 序列(内容与数量固定不变)被 +`F.interpolate(mode='nearest')` 拉伸/压缩到目标梅尔帧数。因此: + +- 语速与总时长是同一件事、**无法解耦**,且是**整段均匀**伸缩(塞音爆破与元音稳态被同比压缩, + 而自然加速时元音压缩得多、辅音几乎不变——所以 `df≠1` 的失真是「非自然的均匀伸缩」); +- 不做信号域重采样、`f0_condition: false` ⇒ **不会出现「花栗鼠」式音高偏移**; +- **不消耗 mel token 预算**(`duration_factor` 只出现在 `target_lengths`)⇒ 放慢语速不会提高 + 溢出风险;真正决定溢出的是参考音色的内在语速。 + +**方向勘误**:`df<1` = 更快 = 每个音素分到的时间更短 = 咬字**更紧更糊**。护密集技术句 +清晰度的正确方向是 `df>1`(可试 1.03/1.05,建议上限 1.10)。本仓 `passionate` 档原注释 +「df 0.97 护清晰度」方向写反,已更正(数值本身与「激情=略快」自洽,故未改)。 + +**本管线永远运行在论文的「自由时长模式」**:v2.5 的 GPT 里没有论文[[2]](#ref2) +描述的时长嵌入 `p`(`W_num` 表),故 `duration_factor` 不是论文的时长控制,而是 S2M 阶段的 +事后时间缩放。论文宣称的 token 数精度对本管线不适用——**不存在「指定秒数直接生成」的能力**, +严格画面对齐只能靠事后 df 微调 + 逐句实测。 + +## 四、自回归采样参数族 + +### 4.1 七个生效参数 + 一个失效参数 + +`:731-739` 从 `**generation_kwargs` 弹出 8 项。**唯一失效的是 `do_sample`**:`:780` 传的是 +字面量 `True`,弹出的局部变量此后再无读取点(webui 的复选框对 v2.5 结果零影响)。 + +底层走仓库内 vendored 的 HF `generate`。`num_beams>1` + `do_sample=True` ⇒ **beam-sample** +(束搜索 + 多项式采样),而非纯 beam search;`top_p`/`top_k`/`temperature` 在束搜索下 +**仍然生效**(`_get_logits_processor` 只在 `do_sample` 为真时追加这三个 warper,而它恒为真)。 +生效顺序:RepetitionPenalty → Temperature → TopK → TopP。 + +`top_k` 在 `num_beams>1` 下的安全下界是 **2**(`min_tokens_to_keep = n_eos+1 = 2`);`top_k=1` +会踩到 multinomial 的非零元素数下界,本仓客户端与服务端均已禁用该值(`0` = 关闭 TopK,合法)。 + +### 4.2 `length_penalty=0.0` 不是中性 + +束打分 `score = sum_logprobs / (len ** 0) = sum_logprobs`。对数概率恒负 ⇒ 序列越长累加越负 +⇒ **系统性偏好更短的假设**。这是 `num_beams>1` 时「吞尾 / 漏字 / 收尾急」的直接机制来源, +而不是一个中性设置。webui 允许区间 `[-2.0, 2.0]`。 + +> ⚠️ **本轮未能证实「抬高 length_penalty 有收益」**。第一句长句上曾观测到 `beams=3` 时 +> `lp=0.0` 的 GPT 段耗时 180.5 s vs `lp=0.8` 的 19.3 s(4.9×,输出时长几乎相同),但在第二句 +> 长句上**没有复现**(13.2 s vs 14.1 s,基本相同),且同一批测量里 `s2mel_time` 在同等音频 +> 长度下从 18.3 s 跳到 33.1 s——而 `length_penalty` 根本不作用于 S2M。结论:那次 180 s 是 +> **机器噪声**(测量期间交换区仅剩 0.5–1.2 GB,另有一个工作区的常驻实例)。 +> `length_penalty>0` 仍是机制上有理据的候选项(尤其对 30–49 字的长句),但**必须在机器空闲、 +> 固定 `--seed`、n≥20 句的条件下重测**,判据是尾部字词完整率而非墙钟。 + +### 4.3 `repetition_penalty=10.0` 为何能成立、为何不可跨音色迁移 + +它作用在 **GPT 的语义码头(8194 类)** 上,不是文本词表;实现是对**原始 logit 的符号相关 +缩放**而非概率乘法。三条机制解释了为何能取到远超文本 LM 常用 `1.0–1.2` 的值: +(1) logit 接近 0 的 token 几乎不受影响(`0/10≈0`),强正 logit 只是被压回 0 附近而**不会被 +推到 -inf**——本质是软降权而非硬禁;(2) 出射词表宽达 8194,一句约 500 token 只触及极小 +一部分;(3) 惩罚之后还要过 temperature 与 `top_k` 重新归一化。 + +它**不是** Tortoise 血统(Tortoise/XTTS 默认 2.0);IndexTTS 自 v1[[1]](#ref1) 起自选 +10.0,在 v2、v2.5、TRT 后端一路沿用,仓库内**找不到任何选择依据**(无测试、无注释、无文档)。 + +**关键约束**:其有效强度**依赖 logit 的绝对尺度**,因而换音色、换 `emo_vector`(都会改变 +`conds_latent`)都会改变同一个 10.0 的实际惩罚力度——**它不是音色无关的旋钮,跨音色迁移 +调参结论必须重新验证**。 + +与 `length_penalty=0.0` 构成一对**方向相反**的失效模式压力:前者偏好短序列(吞尾/漏字), +后者压制码复用(抑制拖音,但也压制持续元音与自然停顿所需的稳态码)。上游把两者同时拉到 +极端,等于把「宁可短促、不要拖长」写进了默认口径——这解释了为何 IndexTTS 的典型抱怨是 +「吞字/收尾急」而非「拖音」。 + +### 4.4 溢出天花板:`max_mel_tokens ≈ 30 s`,且后果不是「裁短」 + +语义码率 **50 Hz**(两路独立佐证:`:832` 的 1.72 倍展开 ÷ 86.13 mel fps = 50.08; +w2v-BERT-2.0 的 `preprocessor_config.json` 中 `sampling_rate=16000` + `stride=2` = 20 ms 帧)。 +折算:`1500 × 1.72 × 256 / 22050 ≈ **29.95 s**`;架构上限 `gpt.max_mel_tokens=1815` ≈ 36.2 s。 + +> 命名陷阱:`max_mel_tokens` 在 v2.5 里是**语义码**数量,不是 mel 帧数。`config.yaml` 的 +> `gpt.mel_length_compression: 1024` 是 v1 遗留字段,v2.5 推理路径完全不用——用它折算会 +> 得到错误答案。 + +**溢出后果不是音频被裁短,而是文本尾部根本没被念出**:`:792-799` 的 WARN 触发条件是「返回 +序列末位不是 `stop_mel_token`」(束搜索到 `max_length` 仍无束自然收束),紧随的 `code_lens` +循环在找不到 stop token 时直接取全长,输出一段在上限处**突然断掉**的完整长度音频。 + +`max_text_tokens_per_segment=120` 对本仓**完全惰性**:段预算 = `min(120, 600-2) − len(lang_prefix)` += 118 字,而三集单行最长 49/43/38 字 ⇒ `split_text_by_tokens` 从不分段(`:430-431` 直接返回)。 +**明确记录为「不要动」**,以阻止未来在此参数上浪费实验轮次。这也意味着 `interval_silence` +在本管线默认不生效(句间停顿由 `video/src/timing.json` 的 `sentenceGapSec` 提供,二者不是 +同一件事)。 + +**若将来改为「多句合并成一次请求」以摊薄固定开销**,这两个参数会立刻同时变成活约束: +118 字 ≈ 30 s 天花板,余量仅 10–30%,且 1815 是硬上限 ⇒ **单次请求最多约 36 s 语音**。 + +### 4.5 确定性:为什么必须加种子 + +上游全链路**无任何随机种子设置**(`indextts/` 与 `webui.py` 中 `set_seed`/`manual_seed`/ +`random.seed` 均零命中),叠加 `do_sample` 恒 `True` + beam-multinomial 采样 ⇒ **同一句每次 +合成的韵律都不同**。此前是按句缓存掩盖了这一点,一旦 `--force` 重合成就换一条不同的 take。 + +**2026-08-20 实测**(同文本、`sunny`、8767 服务): + +| 条件 | 两次输出 sha256 | +|---|---| +| `--seed 777` × 2 | **完全一致**(`50680908557300b7…`) | +| 不给种子 × 2 | 不同(`5ac01e62…` / `f17dd01e…`) | + +MPS 上的算子非确定性并未破坏可复现性。种子在 `_infer_sync` 内、每次 infer 之前设置,故 +**逐句确定性不受合成顺序与断点续跑影响**。 + +这是**元收益**:所有后续参数 A/B 才第一次变得可信(否则听到的差异可能只是采样噪声), +`--force` 重合成可复现,QA 复听与成片可对齐。`--seed-offset` 是逃生口——固定种子会把某句 +锁死在一条可能不佳的 take 上,偏移一位即换一条而仍可复现。 + +## 五、参考音频工程 + +| 约束 | 事实 | 坐标 | +|---|---|---| +| 时长 | **硬截断 15 s,保前段丢尾部**,`verbose=False` 时无日志 | `:396-408`(spk `:626/:642`、emo `:685` 均传 15) | +| 采样率 | spk 路径 `librosa.load` 不传 `sr` ⇒ **恒 22050 Hz 单声道**;再降 16 kHz 喂 CAMPPlus/w2v-BERT(Nyquist 8 kHz) | `:398` | +| 双路差异 | emo 路径单次重采样到 16 k,spk 路径 22050 → 再 Resample 16 k(双重)⇒ 同一文件的 `base_vec` 与 `emo_vec` 数值略有差异 | `:685` vs `:626-628` | +| 静音/降噪 | 全链路**无** trim/VAD、无响度归一化、无降噪 | 全文 grep | +| 增益敏感性 | CAMPPlus 有 CMVN、w2v-BERT 有逐 bin CMVN ⇒ 两条**相似度主路径对全局增益免疫**;只有无 CMVN 的 `ref_mel` 受影响 | `:643-648`、`:281-289` | +| 逐句成本 | `ref_mel` 是 CFM 的**前缀 mel**,每句都进 25 步扩散;条件提取按路径缓存、每样本只算一次 | `:839-845` vs `:619-664` | + +三条推论: + +1. **录制时必须把最好的一段放在文件开头**。截断保前丢后 + 无 VAD ⇒ 文件头部每一秒静音/ + 清嗓/口水音都会占用 15 秒预算并作为低能量帧进入 `ref_mel`;30 秒录音里第 20 秒的黄金句 + 永远不会被模型看到。 +2. **采样率收益在 22.05 kHz 就封顶**。录 96 kHz 无收益;录 44.1/48 kHz 的价值只在于给 + 重采样留干净过渡带。反面风险是低码率 mp3(64 kbps 在 ~11 kHz 滚降,正好落在模型可见 + 频带边缘)。**削波是唯一真正伤相似度的电平问题**——它产生的宽带谐波落在 8 kHz 以内, + CMVN 抵消不掉。 +3. **12 秒是有依据的选择,不应为提速缩短**。硬上限 15 s;文献侧 speaker similarity 随 prompt + 时长上升后在 ~10 s 饱和[[6]](#ref6)[[7]](#ref7);WildSpoof + [[5]](#ref5) Table 2 显示事后增强让 UTMOS/DNSMOS 上升但 **SECS 下降** + (0.35→0.28)⇒ 保真度问题**事后无法弥补,只能录得干净**。故可用区间实为 `[10, 15)`, + 12 s 落在中部。 + +> **官方从未给出参考音频规格**。上游 README 与两篇论文[[2]](#ref2) +> [[3]](#ref3) 均无 prompt 时长/采样率/质量要求,也无相关消融。论文里的 **25 秒是 +> 训练侧** Segment Merging 的片段上限,把它当推理侧建议是误读。网络聚合站流传的 +> 「官方推荐 16–48 kHz」在上游全文 grep **不存在**。 + +### 已知缺陷:客户端内容寻址 vs 服务端路径寻址 + +上游按**路径字符串**缓存条件张量(`:619`、`:681`),本仓客户端按**内容 sha1** +(`tts.py` 的 `ref_sha1`)。**服务常驻期间原地覆盖同一个 `voices/*.wav`,会产出「sha1 是新的、 +音色是旧的」音频**——而这正好命中 §3.3 定式里「裁 3–4 份候选各跑小样」这个高频动作。 +规避:给每个候选用不同文件名(或内容寻址路径),不要原地覆盖。 + +## 六、性能:本机口径与论文口径为何不可互推 + +### 6.1 本机跑的不是论文里的快变体 + +`checkpoints/config.yaml` 是 `dit_type: "DiT"` + `uvit_skip_connection: true`(**U-DiT**), +全仓 grep `zipformer` **零命中**,`flow_matching.py:168-171` 对非 DiT 直接 `NotImplementedError`。 + +论文[[3]](#ref3) Table 4(NVIDIA A10 + Xeon 8350C): + +| 模型 | T2S RTF | S2M RTF | +|---|---|---| +| IndexTTS 2 | 0.232 | 0.078 | +| IndexTTS 2.5 (U-DiT) | 0.119 | **0.081** | +| IndexTTS 2.5 (Zipformer) | 0.119 | **0.017** | + +即 **S2M 的 4.8× 提速完全来自 Zipformer[[4]](#ref4) 这一个变量**(与 codec 降帧率无关), +而本机对应的是 `0.081` 那一列。**论文头条的「RTF 提升 2.28 倍」不适用于本机**;Zipformer 版 +s2mel 权重截至 2026-08-20 未公开发布。 + +### 6.2 分段 profile:S2M 主导,束宽在 MPS 上近乎免费 + +`infer()` 内建三段计时器 `gpt_gen_time`/`s2mel_time`/`bigvgan_time`,`:870-875` **无条件打印** +且不受 `verbose` 控制 ⇒ **服务端 stdout 天然就是 profile 源,零改动**。启动时 +`> .temp/tts-server.log 2>&1` 即可逐句取数。 + +**2026-08-20 实测**(M4 base / 24 GB / MPS fp32 / 机器有负载,13 个样本): + +| 音频时长 | gpt (T2S) | s2mel (S2M) | bigvgan | gpt 占比 | s2mel 占比 | +|---|---|---|---|---|---| +| 2.0–2.2 s | 2.0–5.5 s | 8.0–8.7 s | 0.9 s | 18–37% | **57–73%** | +| 4.1–4.3 s | 4.1–6.6 s | 9.5–11.0 s | 1.4–1.7 s | 25–37% | **55–65%** | +| 7.3–8.2 s | 13.2–19.3 s | 14.7–33.1 s | 2.8–6.0 s | 25–46% | **45–47%** | + +两条结论: + +- **S2M 是 MPS 上的主导段(45–73%),不是 T2S**。这与 CUDA/A10 画像(T2S 占 87.5%)相反。 + 合理机制:MPS 上 `use_cuda_kernel` 被强制 `False`,BigVGAN 的反锯齿激活退化为上百次分组 + `conv_transpose1d`/`conv1d`;而 CFM 每句都要在「参考 prompt + 目标」的完整拼接序列上跑 + 25 步欧拉、CFG=0.7 还把 batch 堆到 2。 +- **s2mel 有巨大的固定成分**:2 s 音频要 8.0 s、4.3 s 音频才 9.6 s ⇒ 约 7.3 s 固定 + 约 0.8 s + 每秒音频。这个固定成分正是 12 s 参考音频作为扩散前缀(1034 梅尔帧 vs 2 s 目标的 172 帧)。 + **交错 3 组复现**(同文本、`--seed 555`):参考 12 s → 6 s,`s2mel_time` 中位数 + **21.81 s → 10.02 s(−54%)**,逐对 34.07→11.81 / 21.81→10.02 / 19.87→9.48。 + ⚠️ 但音频时长也从 2.16 s 变成 1.78 s(音色与语速随之改变),故降幅含「目标更短」的贡献, + **不可为提速缩短参考**(§5 推论 3)。 +- **束宽只作用于 T2S**(S2M 入口的 `codes` 形状与束宽无关)。T2S 既然只占 18–46%, + 「按束宽线性放大整集墙钟」就不成立:整集实测 1→3 束仅 **+4%**。正确表述是 + **CUDA 上近线性,MPS 上近乎免费**。 + +### 6.3 未解口径冲突(最大收益空间所在) + +社区 MLX 移植 `index-tts-2.5-mlx`[[9]](#ref9) 0.1.1(2026-08-14)报 PyTorch-MPS 基线 RTF **1.11–1.17** +(M5 Pro),而本仓整集折算 **8.8–9.2**(M4 base)。硬件差(带宽 273+ vs 120 GB/s、GPU 核 +~20 vs 10)最多解释 2–3×,**剩余 3–4× 无解释**。候选变量:其 RTF 只计 synth(排除 load/clone)、 +fixture 仅 2.83 s、无 `emo_vector` 路径、参考时长未知、无逐句 HTTP + mp3 编码往返。 + +**这是应当最先关掉的口径问题**:同口径重算一次本机的数,才能决定后续是「调管线」还是 +「换栈」。若同口径下本机也落在 1.x,说明 9 是「含全链路 + 12 s 参考 + 逐句往返」的口径产物, +真正的空间在管线侧。 + +### 6.4 Apple Silicon 上不可用的加速面(逐条已核实) + +`use_bf16`(MPS 分支 `:106-109` 硬编码 `False`,注释称 bf16 在 MPS 上是开销)、 +`use_cuda_kernel`(同处置 `False`)、`use_deepspeed`(三条分支都以 `torch.cuda.is_available()` +为合取条件)、`use_accel`(要求 CUDA + flash_attn)、`backends/trt`(要求 NVIDIA GPU + +TensorRT-LLM)。仓库内**无 vLLM 后端**,README 只给外链 recipe。 + +`PYTORCH_ENABLE_MPS_FALLBACK` **未设置**且合成能跑通 ⇒ **热路径没有静默回落到 CPU 的算子**, +「fallback 拖慢」这条常见嫌疑可以排除。`kv_cache` 在 MPS 路径上确实启用(`:155` 硬编码 +`kv_cache=True`),但是「每步 `torch.cat` 增长」的动态缓存而非预分配 `StaticCache`。 + +**把 dtype 改成 fp16/bf16 的收益上限可算**:AR 常驻权重 1.93 GB → 0.96 GB,按 25 tok/s 折算 +最多省 0.20–0.30 个 RTF,相对总 RTF 8.8–9.2 即 **2–3%**;而 S2M+BigVGAN 段的 autocast 被 +`:826-827` 硬编码 `dtype=None`,低精度根本覆盖不到那两段。叠加本仓已记录的 NaN 失效模式, +**风险收益不对称,明确不做**。旁证:4090 上 2.5 的 bf16(0.2065)本身就不比 fp32(0.2060)快 +——说明 2.5 架构里已经没有对低精度友好的 compute-bound 块了。 + +## 七、提升路线图(ROI 排序) + +每项都给「依据 / 代价 / 验证方法」。**未验证的一律标注**,不要当成结论执行。 + +| # | 动作 | 依据 | 代价 | 验证方法 | 状态 | +|---|---|---|---|---|---| +| 1 | **读法陷阱成门 + 8 句年份修复** | 亲测 8 句读错;归一化幂等 | 改稿 8 行;重合成牵动 beat 与渐黑窗口 | `check_script.py` 读法陷阱门 + 正反例单测 | ✅ 已落地(重合成待排期) | +| 2 | **发音标注接通(多音字)** | A/B 分离度 2.4× 证实生效 | `text`/`ttsText` 拆分 + lint;标注句失效缓存 | DTW 对照 + 全链路集成验证 | ✅ 已落地 | +| 3 | **固定 `--seed`** | 亲测带种子字节一致、不带则不同 | 零 | 同句 ×2 比 sha256 | ✅ 已落地 | +| 4 | **同口径重算本机 RTF** | 与 MLX 报告差 3–4× 无解释(§6.3) | 一次单句实验 | 排除 load/clone,只计 synth,用 2.83 s fixture 对齐分母 | ⬜ 待做(**优先级最高的诊断**) | +| 5 | 分段 profile 常态化 | 内建计时器零改动可取(§6.2) | 服务重启时加重定向 | `grep gpt_gen_time\|s2mel_time\|bigvgan_time` | ✅ 本轮已用 | +| 6 | `length_penalty>0`(长句吞尾) | 机制明确(§4.2),但**本轮未复现收益** | 进缓存摘要 ⇒ 改档即重录 | 机器空闲 + 固定 seed + n≥20 长句,判据=尾部字词完整率 | ⬜ 待验证 | +| 7 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | 同上;且**与音色耦合**不可迁移 | 固定 seed + beams=1,30 句覆盖长/短/数字/英文,量化 durationSec/字符 与 f0 标准差 | ⬜ 待验证 | +| 8 | 预设名义向量归一到 Σvec°=1.0 | 让 alpha 跨预设可比(§3.1) | 改预设 ⇒ 整集重录 | `α_new = α_old × Σvec°_old` 折算后波形应近乎一致 | ⬜ 待验证 | +| 9 | 砍掉 `calm`/`surprised` 配料 | bias 只 0.5625/0.6875,占预算却不兑付表达力(§3.2) | 同上 | 三档 A/B 比 F0 中位/起伏/音节率/质心 | ⬜ 待验证 | +| 10 | 密集技术句改 `df>1` | 方向勘误(§3.4) | 拉长时长 ⇒ 牵动 beat | 5 句最糊的技术句跑 df ∈ {0.95,1.0,1.05,1.08},用 ASR 回转写 CER 作清晰度代理 | ⬜ 待验证 | +| 11 | 重录目标风格参考样本 | 换段落即 F0 +12~16%、起伏 +25~40%(VOICE-CLONING §3.3);好段落必须放开头(§5) | 一次录制 + 定档 | 纯克隆小样比对,合格线取现有最佳候选的九成 | ⬜ 待做 | +| 12 | `prospect_ref.py` 增保真度门 | 现公式 5 项全是「风格」、0 项「保真度」;谱质心把「亮」与「噪」混淆 | 改评分 ⇒ 历史排名口径失效 | 干净候选 + 人工注入 -45 dBFS 白噪,旧公式总分应上升(暴露缺陷) | ⬜ 待做 | +| 13 | 进程级分片并行(双实例) | 瓶颈是发射/同步而非带宽饱和 | **本机内存不允许**(实测起第二实例后交换区仅剩 0.5 GB) | 先测稳态 RSS 与 swap 余量 | ⛔ 本机受限 | +| 14 | 降 `diffusion_steps` / 关 CFG | s2mel 占 45–73%,步数 25→12 可省该段一半 | **直接动音质**;需改服务端 | 10 句 A/B,谱质心掉 >5% 或出现齿音即否决 | ⬜ 高风险待验证 | +| 15 | 迁 MLX / 换栈 | 见 §6.3 | 砍掉全部情感控制 ⇒ 7 档风格体系失效 | 先做 #4 的分母校准 | ⛔ 前置未满足 | +| 16 | 升级到 2.5-RL 权重 | 论文中文 WER 4.36→3.93、SS 77.10→77.92 | 整集重录 | — | ⛔ **权重未公开**(见下) | + +**IndexTTS2.5-RL 权重未公开发布**(2026-08-20 核验):GitHub Model Zoo 4 条无 RL 行; +HF `IndexTeam` 组织 14 个 repo 无 RL/GRPO 命名;全 Hub 搜索 97 个 IndexTTS repo,2.5 血统仅 +7 个且全是格式转换/量化;`IndexTeam/IndexTTS-2.5` 文件树只有一个 `gpt.pth`。**订阅 upstream +release 比自行复现 GRPO 经济得多**——同时也在等 Zipformer 版 s2mel 权重(§6.1)。 + +## 八、迁移地雷 + +| 场景 | 地雷 | 表现 | +|---|---|---| +| 迁 CUDA + 开 `--accel` | `model_v2_5.py:761-772` 的 accel 旁路**只认 `temperature`**,其余采样参数全部静默失效 | 「参数明明传了却毫无效果」 | +| 迁小显存 CUDA(<10 GB) | `low_vram` 触发,`:509` 把 >40 字符的行按标点**硬切并插 200 ms 静音** | 旁白行中间莫名多出停顿 | +| 迁 Linux | 中文归一化引擎从 wetext 换成 `tn.chinese.normalizer`(`front.py:130-142`) | 读法行为可能变化——**必须在 Linux 上重跑空格矩阵**(§2.2) | +| 换 MLX 栈 | 主动放弃 `emo_vector`/`emo_audio_prompt`/`emo_text` 与束搜索 | 本仓 7 档风格体系、alpha 标定、`sunny`/`sunny-steady` 双档全部失效 | +| 原地覆盖参考样本 | 上游按路径缓存条件张量 | 「sha1 是新的、音色是旧的」(§5 末) | +| 合并多句成单请求 | 118 字段预算与 1815 mel token 同时变成活约束 | 单请求上限约 36 s 语音;溢出表现为**文本尾部未被念出** | + +## 九、上游追踪与参考文献 + +**值得订阅而非自研的两件事**:Zipformer 版 s2mel 权重(S2M 0.081 → 0.017,§6.1)、 +IndexTTS2.5-RL 权重(§七 #16)。二者都无需本仓做任何工程。 + +本机 `~/tools/index-tts` 是 **depth-1 浅克隆**(`git log` 只有 1 条,不代表上游历史)。 +升级上游后须复核本文全部 `file:line` 锚点。 + +### 参考文献(IEEE) + +[1] B. Si et al., "IndexTTS: An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System," *arXiv preprint arXiv:2502.05512*, 2025. + +[2] B. Si et al., "IndexTTS-2: Breakthrough Emotionally Expressive and Duration-Controlled Auto-Regressive Zero-Shot Text-To-Speech," *arXiv preprint arXiv:2506.21619*, 2025. + +[3] Index Speech Team, "IndexTTS 2.5 Technical Report," *arXiv preprint arXiv:2601.03888*, 2026. + +[4] Z. Yao et al., "Zipformer: A faster and better encoder for automatic speech recognition," in *Proc. ICLR*, 2024. + +[5] WildSpoof Challenge submission, "On the role of speech enhancement for spoofed speech detection and synthesis," *arXiv preprint arXiv:2602.05770*, 2026. —— prompt 质量增强提升 UTMOS/DNSMOS 但**降低** SECS 的对照数据(Table 1–2)。 + +[6] M. Le et al., "Voicebox: Text-guided multilingual universal speech generation at scale," in *Proc. NeurIPS*, 2023. —— speaker similarity 随 prompt 时长饱和的基线。 + +[7] S. E. Eskimez et al., "E2 TTS: Embarrassingly easy fully non-autoregressive zero-shot TTS," in *Proc. IEEE SLT*, 2024. —— 含 "Impact of audio prompt length" 专节。 + +[8] 上游仓库与许可:[index-tts/index-tts](https://github.com/index-tts/index-tts)(本文锚点 HEAD `4f8792f`);模型按 [bilibili 模型使用许可协议](https://github.com/index-tts/index-tts/blob/main/LICENSE) 发布——个人/研究可用,**商用需联系 indexspeech@bilibili.com**。 + +[9] 社区 MLX 移植:[index-tts-2.5-mlx](https://pypi.org/project/index-tts-2.5-mlx/) 0.1.1(2026-08-14)——本文 §6.3 的 PyTorch-MPS 基线数据来源。 diff --git a/media/pipeline/PRON-GLOSSARY.md b/media/pipeline/PRON-GLOSSARY.md new file mode 100644 index 00000000..e742ab52 --- /dev/null +++ b/media/pipeline/PRON-GLOSSARY.md @@ -0,0 +1,47 @@ +# 易错字台账(发音标注复用表) + +> **用途**:把「这一集试听时听出来的读错字」沉淀成跨集可复用的标注,让发音修正从 +> 逐集 O(n) 重听变成写稿时 O(1) 查表。语法与校验规则见 +> [scripts/pron_marks.py](./scripts/pron_marks.py);写稿纪律见 +> [skills/03-narration.md](./skills/03-narration.md)。 + +## 怎么用 + +1. **写稿时**:新稿定稿前,对照下表把命中的词按「标注写法」列改写进 `narration.md`; +2. **试听时**:听出新的读错字,先用单句小样确认标注有效,再回填本表; +3. **校验**:`build_narration.py` 会硬失败拦非法标注(格式/通道/`^[JQX]U` 等), + `pinyin.vocab` 存在时还会告警「音节不在表内」。 + +## 台账 + +目前为空——三集成片尚未做过系统性读音复听(此前无标注能力)。下一集配音的试听关卡 +([skills/07-tts-voice.md](./skills/07-tts-voice.md) 第 3 闸)请边听边往下表记。 + +| 词 | 正确读音 | 标注写法 | 出处 | 记录日期 | +|---|---|---|---|---| +| _(待填)_ | | | | | + +## 候选清单(尚未验证,供复听时重点关注) + +科普题材的高频多音字,**未经实测确认模型是否读错**,仅作复听时的注意力清单—— +确认读错才写进上面的台账,不要预防性标注(标注错 = 必然读错,反而引入风险)。 + +| 词 | 易错点 | 若读错则标注 | +|---|---|---| +| 行(银行 / 行走 / 一行代码) | háng / xíng | `<行\|HANG2>` / `<行\|XING2>` | +| 模(模型 / 模具) | mó / mú | `<模\|MO2>` | +| 率(效率 / 率领) | lǜ / shuài | `<率\|LV4>`(注意 ü 写 V) | +| 差(误差 / 差不多) | chā / chà | `<差\|CHA1>` | +| 重(重复 / 重要) | chóng / zhòng | `<重\|CHONG2>` / `<重\|ZHONG4>` | +| 量(数量 / 量化) | liàng / liáng | `<量\|LIANG4>` | +| 卷(卷积 / 卷起) | juǎn / juàn | `<卷\|JVAN3>`(j+ü→JV) | +| 系(系统 / 关系) | xì | `<系\|XI4>` | +| 更(更新 / 更好) | gēng / gèng | `<更\|GENG1>` / `<更\|GENG4>` | + +## 边界 + +- **英文专名不进本表**:沿用「进角标不口播」的既有纪律。CMU 音素通道 + (``)语法可用但模型响应未经证实(2026-08-20 A/B 客观距离随 + 分析窗口翻转),用前必须人耳小样确认。 +- **数字/百分号/量词不进本表**:那是文本归一化的职责,禁写清单见 + [skills/03-narration.md](./skills/03-narration.md) 的读法纪律表。 diff --git a/media/pipeline/README.md b/media/pipeline/README.md index ac0bc26c..f4acbd7e 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -84,6 +84,7 @@ uv run --no-project media/pipeline/scripts/pipeline.py --project media/-vi | [scripts/qa_frames.py](./scripts/qa_frames.py) | 抽帧 QA(幕/句/`--last-n` 末 N 句)+ `--check` 四项自动体检 + `--check-theme` WCAG 对比度 | `uv run --no-project --with pillow --with numpy scripts/qa_frames.py out/draft.mp4 --last-n 6 --check`(工程根;视频路径按 CWD 解析,仓库根调用写全 `media/<工程>/out/draft.mp4`) | | [scripts/paper_extract.py](./scripts/paper_extract.py) | Stage ① 取证工具箱(§→页映射 / 分栏取文 / caption 收割 / 定点 find / 页面光栅化) | `uv run --no-project --with pymupdf media/pipeline/scripts/paper_extract.py "" find "原文措辞"` | | [scripts/refs.py](./scripts/refs.py) | 参考样本可复现清单(verify/rebuild;指纹在 [voices/refs.toml](./voices/refs.toml),只存哈希不存音频) | `uv run --no-project media/pipeline/scripts/refs.py verify` | +| [scripts/pron_marks.py](./scripts/pron_marks.py) | 发音标注 `<原文\|读音>` 的解析与校验(纯函数库,无 IO):多音字/英文专名的精确读音控制;被 `build_narration.py` 用于硬失败拦非法标注 | 库,不直接调用;语法与规则见其模块文档,台账见 [PRON-GLOSSARY.md](./PRON-GLOSSARY.md) | 中心脚本以 `--project <工程根>` 参数化;工程内 `scripts/*.py` 为薄包装(透传参数、保持原 CLI)。改造/迭代只改 `media/pipeline/scripts/`,验证门 = 受影响工程的 `narration.json` / `manifest.json` 字节级不变。 diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index 35890b47..0f2335c6 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -1,7 +1,8 @@ # 科普视频配音 · 声音克隆操作手册(IndexTTS-2.5) -> **文档定位**:本文是公共视频管线声音克隆能力(用自己的声音配音 + 轻快/自信/正能量等风格控制)的**单一参考**。 -> 管线总纲见 [README.md](./README.md);参考音色样本目录约定见 [voices/README.md](./voices/README.md)。 +> **文档定位**:本文是公共视频管线声音克隆能力(用自己的声音配音 + 轻快/自信/正能量等风格控制)的**单一参考**——回答「怎么用现有能力做完一集」。 +> 上游能力面、机制循证与提升路线图见进阶篇 [INDEXTTS-2.5-ADVANCED.md](./INDEXTTS-2.5-ADVANCED.md)(不复制本文参数值,只做映射与解释)。 +> 管线总纲见 [README.md](./README.md);参考音色样本目录约定见 [voices/README.md](./voices/README.md);读音标注台账见 [PRON-GLOSSARY.md](./PRON-GLOSSARY.md)。 ## 目录 @@ -101,7 +102,7 @@ uv run --frozen --with fastapi --with uvicorn --with soundfile --with numpy --wi | 项 | 要求 | |---|---| -| 时长 | **5–15 秒**(上限 30s) | +| 时长 | **10–14 秒**(**硬上限 15 秒**:上游 `infer_v2_5.py:396-408` 只取前 15 秒、静默丢弃尾部且无日志,故 >15s 的样本有一部分永不进模型;`prepare_ref.py` 已收紧到 15.0 并硬失败。文献侧 speaker similarity 在 ~10s 后饱和) | | 内容 | 自然说话,**与目标成片语速/语调一致**——韵律风格会被一并克隆,样本定基线、情感向量只能在基线上微调(实测见 3.3) | | 环境 | 安静房间、固定麦克风距离、无 BGM/混响/系统降噪痕迹 | | 说话人 | 仅本人一人 | @@ -154,7 +155,18 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec | **语调迁移** | `--emo-ref <另一段录音>` | 音色仍取 `--ref`,**语调/情绪整体迁移自这段录音** | 觉得向量注入「有合成味」时的首选;用本人一段本来就轻快的录音最自然 | | **自然语言** | `--emo-text "轻快爽朗、自信阳光"` | 服务端 QwenEmotion 把描述转成向量(需启动带 `--use-qwen-emo`) | 说不清参数、只说得清感觉时;推出的向量会回显,可再用 `--emo-vector` 固化 | -**为什么「少注入」往往更自然**:上游把情感嵌入按 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ) · 参考音频情感` 混合(`indextts/infer_v2_5.py`,`wᵢ` 为 alpha 缩放后的分量)。可见 **Σw 就是「合成情感」挤掉「本人真实情感」的比例**:Σw=0.7 时只剩 30% 是你自己的语调;Σw>1 更会让参考音频项变成**负权重**(发音劣化)——这正是本管线把有效和卡在 ≤0.8 的原因。听感偏假时,先把 `--emo-alpha` 往下调(0.3–0.45),而不是继续加权重。 +**为什么「少注入」往往更自然**:上游把情感嵌入按 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ) · 参考音频情感` 混合(`indextts/infer_v2_5.py:766-767`,`wᵢ` 为 alpha 缩放后的分量)。可见 **Σw 就是「合成情感」挤掉「本人真实情感」的比例**:Σw=0.7 时只剩 30% 是你自己的语调;Σw>1 更会让参考音频项变成**负权重**(发音劣化)。听感偏假时,先把 `--emo-alpha` 往下调(0.3–0.45),而不是继续加权重。 + +> ⚠️ **口径校准(2026-08-20 核验上游源码)**:本管线的有效和护栏 `Σvec×alpha ≤ 0.8` 是**我们自定的**, +> 不是上游行为。上游 `infer()` **从不做归一化**——`normalize_emo_vec` 全仓唯一调用点是 +> `webui.py:665` 的「自定义向量」分支,且那条的 0.8 作用在**已乘 `emo_bias` 的和**上、且在 +> alpha 之前。`emo_bias`(`infer_v2_5.py:493` 硬编码)8 维**严重不等权**: +> `sad/afraid=1.0 > happy/disgusted/melancholic=0.9375 > angry=0.875 > surprised=0.6875 > calm=0.5625`。 +> 后果:从社区/WebUI 抄来的 `(vec, alpha)` 在本管线上实际**强 16%–33%**(含 `calm` 越重偏差越大, +> `confident` 档最失真),**跨来源参数迁移必须重新试听定档**。 +> 另:`alpha` 恰好等于「替换掉本人语调的百分比」**仅当名义向量和 = 1.0** —— `sunny`/`passionate` +> 刚好是 1.00,`lively`(0.85)/`confident`(0.90)/`positive`(0.95) 不是,其 alpha 跨预设不可比。 +> 机制与数值推导见 [INDEXTTS-2.5-ADVANCED.md](./INDEXTTS-2.5-ADVANCED.md) §3。 ### 4.1 风格预设(--style) @@ -176,17 +188,43 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec ### 4.2 自定义向量(--emo-vector) -`--emo-vector "happy:0.6,calm:0.2"` 语法覆盖预设;与 `--style` 非默认值互斥。**各分量非负,且有效和(Σ分量×emo-alpha)≤ 0.8**(客户端与服务端双重校验;管线直调 `infer` 不做自动归一,超界会拒绝请求;如 `happy:1.0` 在 alpha=0.6 下有效和 0.6,可放行)。alpha ≤0.8 推荐(官方建议)。 +`--emo-vector "happy:0.6,calm:0.2"` 语法覆盖预设;与 `--style` 非默认值互斥。**各分量非负,且有效和(Σ分量×emo-alpha)≤ 0.8**(客户端与服务端双重校验;管线直调 `infer` 不做自动归一,超界会拒绝请求;如 `happy:1.0` 在 alpha=0.6 下有效和 0.6,可放行)。 + +`--emo-alpha` 本身只被上游 clamp 到 `[0, 1]`(`infer_v2_5.py:605`)——**0.8 是「向量总和」的上限,不是 alpha 的上限**,此前把两者混为一谈的「alpha ≤0.8 推荐(官方建议)」表述已更正;该 0.8 的来源见 §四 的口径校准块。实操上真正该盯的是**残差保留率** `1 − Σ(w·α)`:想保住 ≥60% 本人语调就让 `Σ(w·α) ≤ 0.40`,这与实测「≥0.6 开始像另一个人、0.3–0.45 是平衡带」(§4.4)互相印证。 ### 4.3 语速(--duration-factor) 0.5–2.0(>1 变慢、<1 变快)。仅 v2.5 支持;`--emo-vector` 模式默认 1.0(手动传 df 需服务为 v2.5)。 +**它改的是什么**:S2M 阶段的时间轴重采样(`infer_v2_5.py:832`,`F.interpolate(mode='nearest')` 把已生成完的语义 token 序列拉伸/压缩到目标梅尔帧数;常数 1.72 = 梅尔帧率 86.13 ÷ 语义特征帧率 50)。因此语速与总时长是同一件事、无法解耦,且**整段均匀**伸缩。它**不做**信号域重采样、`f0_condition: false`,故不会出现「花栗鼠」式音高偏移;也**不消耗** mel token 预算(放慢语速不会提高溢出风险)。 + +> ⚠️ **方向校准**:`df<1` = 更快 = 每个音素分到的时间更短 = 咬字**更紧更糊**。此前 +> `passionate` 档注释写的「df 0.97 护密集技术句清晰度」**方向是反的**——护清晰度的正确 +> 方向是 `df>1`(可试 1.03 / 1.05,上限建议 1.10,再大会出现拖腔)。数值本身与「激情=略快」 +> 的定位自洽,故预设未改,只更正因果表述。 + ### 4.3b 束搜索宽度(--num-beams,速度主旋钮) -GPT 声码段的束搜索宽度,**缺省随风格**(多数预设 1、`sunny-steady` 为 3;上游库内部默认 3)。采样生成(`do_sample=True`)下 1 与 3 的听感差异可忽略,但 GPT 段耗时约按束宽线性放大。**MPS fp32 实测**(2026-08-18,M3 系列;⚠️ 该轮数据采集于机器有其它负载时——2026-08-20 空闲复测整集 RTF≈9.2,见 §4.3b):每句墙钟 = GPT 束搜索 + 扩散声码 + BigVGAN,RTF(耗时/音频时长)约 40–58(beams=3,忙时口径);beams=1 下三集全量连续跑(596 句 / 40.2 分钟纯语音 / 8.5 小时墙钟)折算整集 RTF≈12–14;整集(约 180–230 句)约 2.5–3.5 小时,按句缓存可断点续跑。**束宽也是韵律稳定度旋钮**,不只是速度旋钮:3 束把语调起伏收窄约 10–20%,听感更"稳/可信"(见 §4.1)。三种用法按代价递增:单句重合成 `--num-beams 3`、关键句混合档 `--steady`(§5.2.1,推荐)、整集升档 `--style sunny-steady`。 +GPT 声码段的束搜索宽度,**缺省随风格**(多数预设 1、`sunny-steady` 为 3;上游库内部默认 3)。采样生成(`do_sample=True`)下 1 与 3 的听感差异可忽略。 + +> ⚠️ **「线性放大」这个说法在 MPS 上不成立**(2026-08-20 本机分段 profile 校准)。束宽只作用于 +> T2S(S2M 入口的 `codes` 形状与束宽无关),而 MPS 上 T2S 只占三段耗时的 **18–46%** —— +> 用服务端内建计时器实测 13 个样本:`s2mel` 反而占 **45–73%**、`bigvgan` 6–9%。整集口径 +> 1→3 束仅 **+4%**(EP1 v3 两遍法 1.9h → 1.98h)。正确表述是:**CUDA 上近线性放大, +> MPS 上近乎免费**。机器忙/热节流时仍会出现数倍差(下文 2026-08-18/19 那批就是忙时口径), +> 此时才需要 `--steady` 混合档。分段占比与瓶颈分析见 +> [INDEXTTS-2.5-ADVANCED.md](./INDEXTTS-2.5-ADVANCED.md) §6。**MPS fp32 实测**(2026-08-18,M3 系列;⚠️ 该轮数据采集于机器有其它负载时——2026-08-20 空闲复测整集 RTF≈9.2,见 §4.3b):每句墙钟 = GPT 束搜索 + 扩散声码 + BigVGAN,RTF(耗时/音频时长)约 40–58(beams=3,忙时口径);beams=1 下三集全量连续跑(596 句 / 40.2 分钟纯语音 / 8.5 小时墙钟)折算整集 RTF≈12–14;整集(约 180–230 句)约 2.5–3.5 小时,按句缓存可断点续跑。**束宽也是韵律稳定度旋钮**,不只是速度旋钮:3 束把语调起伏收窄约 10–20%,听感更"稳/可信"(见 §4.1)。三种用法按代价递增:单句重合成 `--num-beams 3`、关键句混合档 `--steady`(§5.2.1,推荐)、整集升档 `--style sunny-steady`。 + +**短句最贵、数字句更贵**(2026-08-19 实测,单句空闲口径):RTF 随句长下降——4–6 秒的短句 3 束 RTF 19.6–31.5、1 束 6.0–7.4;13–15 秒长句 3 束仅 8.9–13.8。固定开销(`ref_mel` 作为 CFM 前缀参与每句 25 步扩散、BigVGAN)被长音频摊薄了。**逐字稿为字幕可读性把句子都拆到 ≤43 字,正好落在最贵区间**,排期请按短句口径留余量。 -**短句最贵、数字句更贵**(2026-08-19 实测,单句空闲口径):RTF 随句长下降——4–6 秒的短句 3 束 RTF 19.6–31.5、1 束 6.0–7.4;13–15 秒长句 3 束仅 8.9–13.8。固定开销(条件提取、25 步扩散、BigVGAN)被长音频摊薄了。数字密集句最贵(`2026 年 6 月…88 页` 一句 5.87 秒音频烧了 185 秒,RTF 31.5),因为数字会被文本归一展开成口语形式、token 数暴涨。**逐字稿为字幕可读性把句子都拆到 ≤43 字,正好落在最贵区间**,排期请按短句口径留余量。 +> ⚠️ **归因校准**:此前把「`2026 年 6 月…88 页` 一句 5.87 秒音频烧了 185 秒(RTF 31.5)」 +> 归因于「数字被归一展开、token 数暴涨」——**这条因果是错的**。实测该句文本 token 仅 +> 17 → 23(1.35×),远低于 118 的分段预算(`segments_count` 恒为 1),且**文本 token 只作为 +> 前缀进一次 prefill、不参与 AR 循环**。真实原因就是「短句 × 3 束」:31.5 正是同段自述的 +> 「4–6 秒短句 3 束 RTF 19.6–31.5」区间上界。归一化的真实影响是二阶且温和的——音节变多 +> (两千零二十六 = 6 音节 vs 二零二六 = 4 音节)导致音频变长。 +> 顺带:那句的年份写法 `2026 年` 本身就会被读成「两千零二十六年」,已作为读法陷阱修复 +> 并成门(见 [skills/03-narration.md](./skills/03-narration.md) 读法纪律)。 ### 4.4 调参建议 @@ -407,7 +445,7 @@ cd video && pnpm run render:draft && pnpm run render # render 脚本定义在 | edge-tts | ❌ 仅预置 | 仅 rate | 无需部署 | 本管线默认引擎(零成本回退) | | **IndexTTS-2.5** | ✅ 单样本零样本 | ✅ 向量+强度+语速 | ✅ MPS fp32 | **主方案**;中英日西阿 | | IndexTTS-2 | ✅ | ✅ 向量(无语速) | ✅ fp16 成熟 | 服务端一键回退档(`--indextts-version 2`) | -| mlx-indextts(社区 MLX 移植) | ✅ | 仅 2.0 | ✅ 最省内存 | 不支持 2.5,需自行转换权重 | +| index-tts-2.5-mlx(社区 MLX 移植) | ✅ | ❌ 砍掉全部情感控制 | ✅ 最省内存 | 0.1.1(2026-08-14)**已支持 2.5**、自带 int8 GPT 权重、uvx 一键;但主动放弃 `emo_vector`/`emo_audio_prompt`/`emo_text` 与束搜索 ⇒ 本仓 7 档风格体系与 alpha 标定全部失效,不可直接替换(评估前置动作见进阶篇 §6) | | GPT-SoVITS | ✅ 微调最佳 | 依赖参考音频 | 推理可/训练差 | 需训练工作流,过重 | | CosyVoice 2 | ✅ 3–10s | instruct 指令 | ✅ | 克隆相似度略逊 | | 云端(Azure Custom Voice 等) | ✅ | ✅ | 无需 | 收费/审核/隐私,不采纳 | diff --git a/media/pipeline/skills/03-narration.md b/media/pipeline/skills/03-narration.md index fdc210fa..d48fe775 100644 --- a/media/pipeline/skills/03-narration.md +++ b/media/pipeline/skills/03-narration.md @@ -24,6 +24,43 @@ - 幕标题 `## P`;句 id 必须 `p-` 前缀、全片唯一(含字母后缀如 `p2-37b` 允许,用于事后插句)。 - 每句一个完整语义单元(一条字幕),长度 8–35 字为宜;TTS 微调规则:`——`→逗号停顿、`……`→句号。 +## 读法纪律(上游中文归一化的实测行为) + +数字/百分号/量词的读法由 IndexTTS 上游的中文文本归一化承担,**绝大多数写法都正确** +(`47.6%`→百分之四十七点六、`16.2 个百分点`、`6 月 20 日`、`第 3 章第 1.2 节`、 +`0.5~1.0 秒`→零点五到一点零秒、`9:30`→九点三十分 均实测正确)。但下列写法**实测读错**, +已由 [check_script.py](../scripts/check_script.py) 的 `READING_TRAPS` 成门拦截: + +| 禁写 | 会读成 | 改写为 | +|---|---|---| +| `2026 年`(4 位年份+空格) | 两千零二十六年 | `2026年`(去掉空格) | +| `2.5.1`(三段版本号) | 二.五点一 | 二点五点一 | +| `3-5 倍`(连字符区间) | 三减五倍 | 三到五倍 | +| `±3%` | 百分之正负三 | 正负百分之三 | +| `10x` | 十x | 十倍 | +| 整句无汉字(如 `IndexTTS 2.5`) | 走英文归一化 → two point five | 并入相邻句或补中文 | +| `1080P`(WARN) | 一千零八十P | 一零八零 P | + +归一化是**幂等**的(预写成汉字读法后再过一遍结果不变),故修复可逐句增量做。 + +## 发音标注(多音字 / 英文专名) + +需要强制读音时,在逐字稿里内联 `<原文|读音>`——语法与校验规则见 +[pron_marks.py](../scripts/pron_marks.py),`build_narration.py` 在生成 narration.json +时**硬失败**拦非法标注。标注自带原字,故 `text`(人读/字幕/字数)由剥离派生、 +`ttsText`(送合成)保留标注,**一处书写、零副本**。 + +- 拼音通道(标记左侧含汉字):`他在银<行|HANG2>里<行|XING2>走。` 全大写、声调 1–5、 + 轻声用 5;**ü 写 V 且 j/q/x 后必须写 V**(居=`JV1`、去=`QV4`、须=`XV1`;写 `JU1` 即非法); + 多字词空格分音节 `<银行|YIN2 HANG2>`。 +- ⚠️ **标注错 = 必然读错**:上游整体替换时丢弃原字,没有字形兜底。 +- ⚠️ 正文出现孤立 `<`(如「延迟<10ms」)会与后面的标记粘连并**吞掉之间的正文**, + 已成 ERROR 门;请改写为「小于」。 +- 英文专名**继续沿用「进角标不口播」的既有纪律**(下方写作纪律第 3 条)。CMU 音素通道 + (``)语法可用,但 2026-08-20 的 A/B 未能证实模型稳定响应 + (客观距离随分析窗口翻转),属**未验证选项**——若要用,必须先人耳小样对比。 +- 发现的读错字请沉淀到 [PRON-GLOSSARY.md](../PRON-GLOSSARY.md) 供跨集复用。 + ## 写作纪律 1. **事实回溯**:每个论文断言必须能在 paper-notes 找到对应条目;论文外内容须口播标明「论文之外多说一句」。 diff --git a/media/pipeline/skills/07-tts-voice.md b/media/pipeline/skills/07-tts-voice.md index 02edbf78..e2310f69 100644 --- a/media/pipeline/skills/07-tts-voice.md +++ b/media/pipeline/skills/07-tts-voice.md @@ -1,14 +1,20 @@ # Stage ⑥ TTS 配音:声音克隆决策树(skill 规格 · 07) > 目标读者:执行配音阶段的代理/操作者。**参数与实测数据一律以 [VOICE-CLONING.md](../VOICE-CLONING.md) §三–§六为准(链接非复制)**;本文件只承载操作顺序与决策点。 +> 上游能力面、机制循证与提升路线图见 [INDEXTTS-2.5-ADVANCED.md](../INDEXTTS-2.5-ADVANCED.md);读音标注台账见 [PRON-GLOSSARY.md](../PRON-GLOSSARY.md)。 ## 决策树(按序过闸,任何一闸不过不进长跑) 1. **样本就位?** `uv run --no-project media/pipeline/scripts/refs.py list` → 缺失则 `refs.py rebuild --name <样本>`(源录音是本人私有文件,路径记录在 `voices/refs.toml`)。 2. **指纹一致?** `refs.py verify --name <样本>` 必须全绿——sha1 与清单不符说明源文件/裁剪参数变了,**勿在未核验音色上烧 10 小时**;新样本须先过第 4 步试听再回填清单。 -3. **风格定档?** 首次/换风格必过小样 A/B:`tts_sample.py --ref <样本> --all-styles --play`;再用**领航片段**(本集最难的 6–8 句:长分句/枚举清单/带小数点数字)在该风格下整句合成试听。听完 `--cleanup` 或 `pipeline.py clean-samples`(生物特征)。 +3. **风格定档?** 首次/换风格必过小样 A/B:`tts_sample.py --ref <样本> --all-styles --play`;再用**领航片段**(本集最难的 6–8 句:长分句/枚举清单/带小数点数字/含英文专名/含多音字)在该风格下整句合成试听。**边听边记读错字**到 [PRON-GLOSSARY.md](../PRON-GLOSSARY.md),用 `<字|读音>` 标注修(写稿规约见 [skills/03](./03-narration.md))。听完 `--cleanup` 或 `pipeline.py clean-samples`(生物特征)。 4. **排期对账?** `pipeline.py tts --plan`(纯本地):待合成句数 × 束宽档 + 墙钟估算。ETA 与预期差 >15% 先查机器负载。 -5. **音色签名护栏**:与上次合成不一致会被 `.engine` 标记硬拦(显式 `--allow-voice-switch` 才放行)——这正是防「README 旧命令静默重录整集」的机制。 + 束宽代价**不是无条件线性**:MPS 上近乎免费(整集 1→3 束实测 +4%),CUDA 上近线性—— + `--plan` 的 3 束常量刻意保守,见 [ADVANCED §6.2](../INDEXTTS-2.5-ADVANCED.md)。 +5. **做任何参数 A/B 前先固定 `--seed`**:上游 `do_sample` 恒 True 且全链路无种子,同句每次 + 合成都是不同的 take,不固定种子听到的差异可能只是采样噪声(实测:带种子字节一致、 + 不带则不同)。 +6. **音色签名护栏**:与上次合成不一致会被 `.engine` 标记硬拦(显式 `--allow-voice-switch` 才放行)——这正是防「README 旧命令静默重录整集」的机制。 ## 两遍法(长片的既定工作法) diff --git a/media/pipeline/voices/README.md b/media/pipeline/voices/README.md index 94c36c0c..f50d4708 100644 --- a/media/pipeline/voices/README.md +++ b/media/pipeline/voices/README.md @@ -6,11 +6,11 @@ | 项 | 要求 | 原因 | |---|---|---| -| 时长 | **5–15 秒**(最长不超过 30s) | 过长不提升克隆质量,反而拖慢每句合成的条件提取 | +| 时长 | **10–14 秒**(**硬上限 15 秒**) | 上游只取前 15 秒、静默丢弃尾部(`infer_v2_5.py:396-408`,无日志);文献侧 speaker similarity 在 ~10 秒后饱和。逐句代价来自 `ref_mel` 作为 CFM 前缀参与每句 25 步扩散(**不是**条件提取——那步按样本缓存只算一次) | | 内容 | 自然说话,与目标成片语速/语调一致 | 克隆音色+韵律风格均取自样本 | | 环境 | 安静房间、单一麦克风距离、无背景音乐/混响/降噪痕迹 | 噪声会被一起克隆进音色 | | 说话人 | **仅本人一人** | 多人声混杂会污染音色 | -| 格式 | WAV 16-bit(≥22.05 kHz)优先;mp3/flac 可先经 `prepare_ref.py` 转换;m4a 需先 `ffmpeg -i in.m4a out.wav` | IndexTTS 内部会重采样,但干净源更稳 | +| 格式 | WAV 16-bit,录 44.1/48 kHz 即可;mp3/flac 可先经 `prepare_ref.py` 转换;m4a 需先 `ffmpeg -i in.m4a out.wav` | 上游 `librosa.load` 不传 `sr` ⇒ **无条件重采样到 22.05 kHz 单声道**,再降到 16 kHz 喂 CAMPPlus/w2v-BERT(Nyquist 8 kHz)。**录 96 kHz 无收益**;但低码率 mp3(64 kbps 在 ~11 kHz 滚降)会在模型可见频带边缘留痕。**削波**是唯一真正伤相似度的电平问题(CMVN 抵消不掉) | ## 使用方式 From 65e783c3921f15750140529ac407a7dd41238314 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 20 Aug 2026 22:57:12 +0800 Subject: [PATCH 04/10] =?UTF-8?q?docs(tts):=20=E5=90=8C=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=E9=87=8D=E7=AE=97=E6=9C=AC=E6=9C=BA=20RTF=EF=BC=8C3=E2=80=934?= =?UTF-8?q?=C3=97=20=E6=9C=AA=E8=A7=A3=E7=BC=BA=E5=8F=A3=E5=B7=B2=E5=8F=AF?= =?UTF-8?q?=E5=BD=92=E5=9B=A0=EF=BC=8C=E5=B9=B6=E6=B2=89=E6=B7=80=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E6=B5=8B=E9=87=8F=E5=8D=8F=E8=AE=AE;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 路线图 #4(此前标为优先级最高的诊断)已完成。按社区 MLX 移植 README 的确切口径 (RTF = synth ÷ 音频时长,load 与 clone 均排除;warm、3 次均值;该移植主动砍掉全部 情感控制)直调 infer() 重测本机,剥离 HTTP/mp3/情感向量/长跑降频等本仓口径差异: 对齐档 RTF = 4.37(音频 3.34s / synth 14.60s;gpt 2.75 / cfm 10.10 / vocoder 1.41) 缺口分解:管线开销 2.1×(整集 9.0 → 4.37)× 硬件 2–3×(M4 base 120GB/s·10 核 vs M5 Pro 273+GB/s·~20 核),残差仅 1.3–1.9×。旁证是分段占比:本机 cfm 占 70.8% (3.02× 实时),对方 int8 的 cfm 只占 35%(0.15× 实时)——而 cfm 成本正比于 「参考帧 + 目标帧」,我们用 12s 参考(1034 梅尔帧)而对方 benchmark 的参考长度未披露, 这是残差最合理的落点。**结论:换栈不被 RTF 数字支持**(且 MLX 要付出砍掉全部情感控制 的代价),真正的杠杆仍是 cfm 前缀长度——那是管线侧的事,但 §5 已论证不可为提速缩短参考。 同时沉淀一条比上述任何数字都重要的发现(新增 §6.4):**本机当前无法支撑性能 A/B**。 同一份 neutral 工作量在 6 次连续调用内从 14.49s 漂到 48.74s(3.4×);交错设计的逐对 比值摆动 0.75×/1.60×/0.75×,把 1.0 夹在中间,真实效应被漂移淹没。漂移有可识别的指纹 ——「不该变的段变了」:num_beams 不作用于 S2M 却让 cfm 从 10.10s 涨到 19.01s,6s 参考 的归一化 cfm 反而高于 12s。唯一可信的是冷起第一块(两次独立冷起测同一工作量得 14.60s 与 14.49s,差 0.8%)。测量期间交换区仅剩 0.8–1.5GB。 据此写入性能测量协议(先查 swap 余量、每次只测一个冷起块、绝不用顺序阶梯做参数归因、 交错前先过 A/A 复现性、判据优先用分段计时器),并把它作为路线图新 #6,成为 length_penalty 与 repetition_penalty 两项 A/B 的前置——二者此前标为「待验证」,现明确 标注受阻于测量环境而非缺乏方案。路线图编号相应顺延至 17 项。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/INDEXTTS-2.5-ADVANCED.md | 81 ++++++++++++++++++------- 1 file changed, 60 insertions(+), 21 deletions(-) diff --git a/media/pipeline/INDEXTTS-2.5-ADVANCED.md b/media/pipeline/INDEXTTS-2.5-ADVANCED.md index c23d2483..2959eafa 100644 --- a/media/pipeline/INDEXTTS-2.5-ADVANCED.md +++ b/media/pipeline/INDEXTTS-2.5-ADVANCED.md @@ -462,16 +462,54 @@ s2mel 权重截至 2026-08-20 未公开发布。 「按束宽线性放大整集墙钟」就不成立:整集实测 1→3 束仅 **+4%**。正确表述是 **CUDA 上近线性,MPS 上近乎免费**。 -### 6.3 未解口径冲突(最大收益空间所在) +### 6.3 同口径重算:3–4× 的「未解缺口」已基本解释完(2026-08-20) -社区 MLX 移植 `index-tts-2.5-mlx`[[9]](#ref9) 0.1.1(2026-08-14)报 PyTorch-MPS 基线 RTF **1.11–1.17** -(M5 Pro),而本仓整集折算 **8.8–9.2**(M4 base)。硬件差(带宽 273+ vs 120 GB/s、GPU 核 -~20 vs 10)最多解释 2–3×,**剩余 3–4× 无解释**。候选变量:其 RTF 只计 synth(排除 load/clone)、 -fixture 仅 2.83 s、无 `emo_vector` 路径、参考时长未知、无逐句 HTTP + mp3 编码往返。 +社区 MLX 移植 `index-tts-2.5-mlx`[[9]](#ref9) 0.1.1 报 PyTorch-MPS 基线 RTF +**1.11–1.17**(M5 Pro),而本仓整集折算 **8.8–9.2**(M4 base)——曾记为「剩余 3–4× 无解释」。 +按其 README 的确切口径(**RTF = synth ÷ 音频时长,`load`(权重加载) 与 `clone`(说话人嵌入) +均排除**;warm、3 次均值;该移植主动砍掉全部情感控制)重算本机: -**这是应当最先关掉的口径问题**:同口径重算一次本机的数,才能决定后续是「调管线」还是 -「换栈」。若同口径下本机也落在 1.x,说明 9 是「含全链路 + 12 s 参考 + 逐句往返」的口径产物, -真正的空间在管线侧。 +| 档 | 配置 | 音频 | synth 中位 | **RTF** | gpt / cfm / vocoder | +|---|---|---|---|---|---| +| **L1 对齐档** | 直调 `infer()`、无 HTTP/无 mp3、neutral 无情感向量、1 束、12 s 参考、预热后 | 3.34 s | 14.60 s | **4.37** | 2.75 / **10.10** / 1.41 | + +缺口分解: + +| 分项 | 倍数 | 依据 | +|---|---|---| +| 本仓管线开销(HTTP + mp3 编码 + 情感向量 + 长跑降频 + 逐句往返) | **2.1×** | 整集 9.0 → 对齐档 4.37 | +| 硬件(M4 base 120 GB/s / 10 GPU 核 vs M5 Pro 273+ GB/s / ~20 核) | 2–3×(估) | 带宽与核数比 | +| **残差** | **约 1.3–1.9×** | 4.37 ÷ 1.14 = 3.8×,扣掉硬件 | + +**结论:缺口不再是「无解释」的 3–4×,而是可归因的 2.1×(管线)× 2–3×(硬件),残差仅 +1.3–1.9×**——最可能来自对方未披露的 fixture 与**参考音频长度**。旁证是分段占比:本机 cfm +占 70.8%(10.10 s / 3.34 s 音频 = 3.02× 实时),而对方 int8 分段 cfm 仅 0.46 s / ~3 s 音频 += 0.15× 实时(占其总量 35%)。**cfm 成本正比于「参考帧 + 目标帧」**,我们用 12 s 参考 +(1034 梅尔帧)而其 benchmark 参考长度未披露——这是残差最合理的落点。 + +> **因此「换栈」不被 RTF 数字支持**:没有大块未解释的性能余量,而 MLX 移植要付出砍掉全部 +> 情感控制的代价(§七 #16)。真正的杠杆仍是 cfm 前缀长度,那是管线侧的事、不需要换栈; +> 但 §5 已论证**不可为提速缩短参考**(会改音色与语速)。 + +### 6.4 ⚠️ 本机当前无法支撑性能 A/B(这条比上面任何数字都重要) + +同一份 neutral 工作量在 **6 次连续调用**内从 14.49 s 漂到 **48.74 s(3.4×)**。交错设计 +(neutral / emo_vector 交替 3 轮)本意是让漂移对两组同等作用,结果逐对比值摆动 +**0.75× / 1.60× / 0.75×**,把 1.0 夹在中间——**任何真实效应都被漂移淹没**。 + +同一批顺序阶梯数据里有两条机制上讲不通的读数,可作漂移的独立证据: +`num_beams` 不作用于 S2M,却让 cfm 从 10.10 s 涨到 19.01 s;6 s 参考的归一化 cfm +(3.72 s/音频秒)反而**高于** 12 s 参考的(3.02)——与交错实验的结论相反。 + +**唯一可信的是「冷起第一块」**:两次独立冷起测同一工作量得 14.60 s 与 14.49 s,**相差 0.8%**。 +测量期间交换区仅剩 0.8–1.5 GB(37 GB 已用),叠加另一工作区的常驻服务实例。 + +**性能测量协议(今后做任何 §七 的 A/B 都必须遵守)**: +1. 先看 `sysctl vm.swapusage`——空闲 < 4 GB 就不要测; +2. 每次只测**一个冷起块**(重启进程 + 1 次预热 + 3 次计时),块间充分冷却; +3. **绝不用同一进程内的顺序阶梯做参数归因**;必须交错,且交错也要先验证 A/A 复现性 + (同配置跑两轮,比值应 ≈1.0,否则环境不合格、当轮数据作废); +4. 判据优先用**分段计时器**而非总墙钟(分段能暴露「不该变的段变了」这类漂移指纹)。 ### 6.4 Apple Silicon 上不可用的加速面(逐条已核实) @@ -499,19 +537,20 @@ TensorRT-LLM)。仓库内**无 vLLM 后端**,README 只给外链 recipe。 | 1 | **读法陷阱成门 + 8 句年份修复** | 亲测 8 句读错;归一化幂等 | 改稿 8 行;重合成牵动 beat 与渐黑窗口 | `check_script.py` 读法陷阱门 + 正反例单测 | ✅ 已落地(重合成待排期) | | 2 | **发音标注接通(多音字)** | A/B 分离度 2.4× 证实生效 | `text`/`ttsText` 拆分 + lint;标注句失效缓存 | DTW 对照 + 全链路集成验证 | ✅ 已落地 | | 3 | **固定 `--seed`** | 亲测带种子字节一致、不带则不同 | 零 | 同句 ×2 比 sha256 | ✅ 已落地 | -| 4 | **同口径重算本机 RTF** | 与 MLX 报告差 3–4× 无解释(§6.3) | 一次单句实验 | 排除 load/clone,只计 synth,用 2.83 s fixture 对齐分母 | ⬜ 待做(**优先级最高的诊断**) | -| 5 | 分段 profile 常态化 | 内建计时器零改动可取(§6.2) | 服务重启时加重定向 | `grep gpt_gen_time\|s2mel_time\|bigvgan_time` | ✅ 本轮已用 | -| 6 | `length_penalty>0`(长句吞尾) | 机制明确(§4.2),但**本轮未复现收益** | 进缓存摘要 ⇒ 改档即重录 | 机器空闲 + 固定 seed + n≥20 长句,判据=尾部字词完整率 | ⬜ 待验证 | -| 7 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | 同上;且**与音色耦合**不可迁移 | 固定 seed + beams=1,30 句覆盖长/短/数字/英文,量化 durationSec/字符 与 f0 标准差 | ⬜ 待验证 | -| 8 | 预设名义向量归一到 Σvec°=1.0 | 让 alpha 跨预设可比(§3.1) | 改预设 ⇒ 整集重录 | `α_new = α_old × Σvec°_old` 折算后波形应近乎一致 | ⬜ 待验证 | -| 9 | 砍掉 `calm`/`surprised` 配料 | bias 只 0.5625/0.6875,占预算却不兑付表达力(§3.2) | 同上 | 三档 A/B 比 F0 中位/起伏/音节率/质心 | ⬜ 待验证 | -| 10 | 密集技术句改 `df>1` | 方向勘误(§3.4) | 拉长时长 ⇒ 牵动 beat | 5 句最糊的技术句跑 df ∈ {0.95,1.0,1.05,1.08},用 ASR 回转写 CER 作清晰度代理 | ⬜ 待验证 | -| 11 | 重录目标风格参考样本 | 换段落即 F0 +12~16%、起伏 +25~40%(VOICE-CLONING §3.3);好段落必须放开头(§5) | 一次录制 + 定档 | 纯克隆小样比对,合格线取现有最佳候选的九成 | ⬜ 待做 | -| 12 | `prospect_ref.py` 增保真度门 | 现公式 5 项全是「风格」、0 项「保真度」;谱质心把「亮」与「噪」混淆 | 改评分 ⇒ 历史排名口径失效 | 干净候选 + 人工注入 -45 dBFS 白噪,旧公式总分应上升(暴露缺陷) | ⬜ 待做 | -| 13 | 进程级分片并行(双实例) | 瓶颈是发射/同步而非带宽饱和 | **本机内存不允许**(实测起第二实例后交换区仅剩 0.5 GB) | 先测稳态 RSS 与 swap 余量 | ⛔ 本机受限 | -| 14 | 降 `diffusion_steps` / 关 CFG | s2mel 占 45–73%,步数 25→12 可省该段一半 | **直接动音质**;需改服务端 | 10 句 A/B,谱质心掉 >5% 或出现齿音即否决 | ⬜ 高风险待验证 | -| 15 | 迁 MLX / 换栈 | 见 §6.3 | 砍掉全部情感控制 ⇒ 7 档风格体系失效 | 先做 #4 的分母校准 | ⛔ 前置未满足 | -| 16 | 升级到 2.5-RL 权重 | 论文中文 WER 4.36→3.93、SS 77.10→77.92 | 整集重录 | — | ⛔ **权重未公开**(见下) | +| 4 | **同口径重算本机 RTF** | 与 MLX 报告差 3–4× 无解释 | 一次单句实验 | 排除 load/clone,只计 synth | ✅ **已做**:对齐档 RTF 4.37,缺口分解为 2.1×(管线)× 2–3×(硬件),残差仅 1.3–1.9×(§6.3)⇒ **换栈不被数字支持** | +| 5 | 分段 profile 常态化 | 内建计时器零改动可取(§6.2) | 服务重启时加重定向 | `grep gpt_gen_time\|s2mel_time\|bigvgan_time` | ✅ 本轮已用;且它是识别热漂移的主要手段(§6.4) | +| 6 | **建立可用的性能测量环境** | 本机同一工作量 6 次连续调用漂 3.4×,A/B 不可做(§6.4) | 清内存/停其它实例,或换机 | A/A 复现性检查:同配置两轮比值须 ≈1.0 | ⬜ **待做(现为 #7/#8/#15 的前置)** | +| 7 | `length_penalty>0`(长句吞尾) | 机制明确(§4.2),但**本轮未复现收益** | 进缓存摘要 ⇒ 改档即重录 | 需先满足 #6;固定 seed + n≥20 长句,判据=尾部字词完整率(非墙钟) | ⬜ 待验证(受阻于 #6) | +| 8 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | 同上;且**与音色耦合**不可迁移 | 需先满足 #6;固定 seed + beams=1,30 句覆盖长/短/数字/英文 | ⬜ 待验证(受阻于 #6) | +| 9 | 预设名义向量归一到 Σvec°=1.0 | 让 alpha 跨预设可比(§3.1) | 改预设 ⇒ 整集重录 | `α_new = α_old × Σvec°_old` 折算后波形应近乎一致 | ⬜ 待验证 | +| 10 | 砍掉 `calm`/`surprised` 配料 | bias 只 0.5625/0.6875,占预算却不兑付表达力(§3.2) | 同上 | 三档 A/B 比 F0 中位/起伏/音节率/质心 | ⬜ 待验证 | +| 11 | 密集技术句改 `df>1` | 方向勘误(§3.4) | 拉长时长 ⇒ 牵动 beat | 5 句最糊的技术句跑 df ∈ {0.95,1.0,1.05,1.08},用 ASR 回转写 CER 作清晰度代理 | ⬜ 待验证 | +| 12 | 重录目标风格参考样本 | 换段落即 F0 +12~16%、起伏 +25~40%(VOICE-CLONING §3.3);好段落必须放开头(§5) | 一次录制 + 定档 | 纯克隆小样比对,合格线取现有最佳候选的九成 | ⬜ 待做 | +| 13 | `prospect_ref.py` 增保真度门 | 现公式 5 项全是「风格」、0 项「保真度」;谱质心把「亮」与「噪」混淆 | 改评分 ⇒ 历史排名口径失效 | 干净候选 + 人工注入 -45 dBFS 白噪,旧公式总分应上升(暴露缺陷) | ⬜ 待做 | +| 14 | 进程级分片并行(双实例) | 瓶颈是发射/同步而非带宽饱和 | **本机内存不允许**(实测起第二实例后交换区仅剩 0.5 GB) | 先测稳态 RSS 与 swap 余量 | ⛔ 本机受限 | +| 15 | 降 `diffusion_steps` / 关 CFG | s2mel 占 45–73%,步数 25→12 可省该段一半 | **直接动音质**;需改服务端 | 10 句 A/B,谱质心掉 >5% 或出现齿音即否决 | ⬜ 高风险待验证 | +| 16 | 迁 MLX / 换栈 | 见 §6.3 | 砍掉全部情感控制 ⇒ 7 档风格体系失效 | — | ⛔ **不推荐**:#4 已完成分母校准,缺口基本可归因,无大块性能余量支持换栈 | +| 17 | 升级到 2.5-RL 权重 | 论文中文 WER 4.36→3.93、SS 77.10→77.92 | 整集重录 | — | ⛔ **权重未公开**(见下) | **IndexTTS2.5-RL 权重未公开发布**(2026-08-20 核验):GitHub Model Zoo 4 条无 RL 行; HF `IndexTeam` 组织 14 个 repo 无 RL/GRPO 命名;全 Hub 搜索 97 个 IndexTTS repo,2.5 血统仅 From f1b6612ec967338bca7f176b15e25dd729d6d602 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Thu, 20 Aug 2026 23:42:04 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat(tts):=20=E6=96=B0=E5=A2=9E=20tts=5Fb?= =?UTF-8?q?ench=20=E6=B5=8B=E9=87=8F=E7=8E=AF=E5=A2=83=E4=BD=93=E6=A3=80?= =?UTF-8?q?=EF=BC=8C=E6=BC=82=E7=A7=BB=E5=AE=9A=E5=9B=A0=E4=B8=BA=E7=83=AD?= =?UTF-8?q?=E8=8A=82=E6=B5=81=E5=B9=B6=E7=BB=99=E5=87=BA=E5=8F=AF=E7=94=A8?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 路线图 #6(#7/#8 的前置)完成。此前记录「本机同一工作量 6 次连续调用漂 3.4×,任何 耗时 A/B 都不可信」,但未定因。新增 media/pipeline/scripts/tts_bench.py 逐条排除后, 三个候选只剩一个: 内存/换页 逐次换页增量最大 31 MB(8 连跑,可用内存 9.7 GB) → 排除 MPS 分配器累积 driver_allocated_memory 恒定 7.62/10.00 GB,+0.00 GB → 排除 进程泄漏 RSS 反而下降 1834 → 1130 MB → 排除 **热节流** 单调爬升 15.36→36.46s(2.37×,全部由 cfm 承担 10.61→27.14s),冷却后恢复 → 成因 **加 75 s 冷却间隔即合格**:无冷却 8 连跑极差 2.37×;75 s 冷却后稳定窗口极差 1.016×(CV 0.007)与 1.047×(CV 0.018)。稳态值 ≈15.2 s ⇒ RTF 4.5,与 §6.3 冷态 对齐档 4.37 相差 3%,反证那个数字可信。重要推论:**加内存或清 MPS 缓存都不会有帮助** (二者已被证伪),冷却是唯一有效手段,占空比约 5:1。 判据设计被实测纠正三次,均已写进代码注释与测试文档,以免重犯: 1. 最初用「随运行序的秩相关」当门 —— 秩相关**无标度**,稳态尾部 15.19/15.31/15.42 (极差仅 1.016×)会被判成「完美单调上升 +1.00」。改为量级判据(极差比/CV/相对漂移)。 2. 接着用「弃头部、判尾部」 —— 只能从头裁,遇到末次突然**变快**(15.15 → 13.35, 风扇起转)就永远裁不掉,把明显合格的环境判成不合格。改为**最长连续稳定窗口**。 3. 静态内存门被逐条否决:「可用内存 ≥10 GB」硬门误杀了换页仅 31 MB 的可用环境; 用命令行扫描找「其它实例」连续两次把调用方的包装 shell 误判成实例(其 argv 里含 python 与脚本名)。故静态指标全部降级为提示,实例检测改用精确的端口占用;判据只用 直接测量量(换页增量 + 稳定窗口复现性)。 工具能力:--check-only 前置体检;A/A 复现性判定(含逐次分段计时、RSS、MPS 分配器占用、 换页增量);--cooldown 冷却节奏;--empty-cache 用于验证分配器假说;--json 落盘读数。 判据由 tests/test_tts_bench.py 用本轮三组真实读数钉死(7 项,含三次纠正各自的回归用例)。 路线图相应更新:#6 标记完成,#7(length_penalty)与 #8(repetition_penalty)从 「受阻于 #6」改为「已可做」,并在 §6.5 写明必须遵守的六条测量协议。测试 88 → 95 项。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/INDEXTTS-2.5-ADVANCED.md | 53 ++- media/pipeline/README.md | 1 + media/pipeline/scripts/tts_bench.py | 471 ++++++++++++++++++++++++ media/pipeline/tests/test_tts_bench.py | 92 +++++ 4 files changed, 608 insertions(+), 9 deletions(-) create mode 100644 media/pipeline/scripts/tts_bench.py create mode 100644 media/pipeline/tests/test_tts_bench.py diff --git a/media/pipeline/INDEXTTS-2.5-ADVANCED.md b/media/pipeline/INDEXTTS-2.5-ADVANCED.md index 2959eafa..0f5c15f5 100644 --- a/media/pipeline/INDEXTTS-2.5-ADVANCED.md +++ b/media/pipeline/INDEXTTS-2.5-ADVANCED.md @@ -504,12 +504,47 @@ s2mel 权重截至 2026-08-20 未公开发布。 **唯一可信的是「冷起第一块」**:两次独立冷起测同一工作量得 14.60 s 与 14.49 s,**相差 0.8%**。 测量期间交换区仅剩 0.8–1.5 GB(37 GB 已用),叠加另一工作区的常驻服务实例。 +### 6.5 漂移已定因为**热节流**,且环境已可用(2026-08-20 · 路线图 #6) + +用 [scripts/tts_bench.py](./scripts/tts_bench.py) 逐条排除,三个候选里只剩一个: + +| 候选成因 | 判据 | 实测 | 结论 | +|---|---|---|---| +| 内存/换页 | 逐次换页增量 | 最大 **31 MB**(8 次连跑,可用内存 9.7 GB) | ❌ 排除 | +| MPS 分配器累积 | `torch.mps.driver_allocated_memory()` 变化 | 恒定 7.62 / 10.00 GB,**+0.00 GB** | ❌ 排除 | +| 进程内存泄漏 | RSS 走势 | 反而**下降** 1834 → 1130 MB | ❌ 排除 | +| **热节流** | 随运行序单调爬升、冷却后恢复 | 无冷却 8 连跑 15.36 → 36.46 s(**2.37×**,全部由 cfm 承担 10.61 → 27.14 s);加 75 s 冷却后收敛到稳态 | ✅ **成因** | + +**加 75 s 冷却间隔后环境合格**: + +| 运行 | 全量读数(synth 秒) | 稳定窗口 | 极差比 | CV | 相对漂移 | 判定 | +|---|---|---|---|---|---|---| +| 无冷却 ×8 | 15.4 / 15.5 / 16.8 / 20.6 / 28.4 / 36.5 / 35.4 / 31.1 | #1–#3 | 1.092× | 0.049 | +5.1% | ❌ | +| 75 s 冷却 ×6 | 24.5 / 18.6 / 15.2 / 15.2 / 15.3 / 15.4 | #3–#6 | **1.016×** | 0.007 | +1.2% | ✅ | +| 验收 ×6 | 15.5 / 15.9 / 15.3 / 15.3 / 15.2 / 13.4 | #1–#5 | **1.047×** | 0.018 | −2.6% | ✅ | + +> 稳态值 ≈ **15.2 s / 3.34 s 音频 = RTF 4.5**,与 §6.3 的对齐档 4.37 一致(差 3%)—— +> 说明那个数字取自冷态、是可信的。 + **性能测量协议(今后做任何 §七 的 A/B 都必须遵守)**: -1. 先看 `sysctl vm.swapusage`——空闲 < 4 GB 就不要测; -2. 每次只测**一个冷起块**(重启进程 + 1 次预热 + 3 次计时),块间充分冷却; -3. **绝不用同一进程内的顺序阶梯做参数归因**;必须交错,且交错也要先验证 A/A 复现性 - (同配置跑两轮,比值应 ≈1.0,否则环境不合格、当轮数据作废); -4. 判据优先用**分段计时器**而非总墙钟(分段能暴露「不该变的段变了」这类漂移指纹)。 + +1. **先跑 `tts_bench.py --check-only`**,再跑一轮 A/A(同配置连跑)确认环境合格; + **环境不合格时当轮数据作废**,不要拿去做参数归因。 +2. **必须给 `--cooldown`**(本机 75 s 起)。冷却是唯一有效的缓解手段——内存和分配器都已 + 被证伪,加内存/清缓存都不会有帮助。 +3. 判定取**最长连续稳定窗口**,不是「弃头部取尾部」:机器带余热开始时头几次偏慢,而 + 末次也可能因风扇起转而突然**变快**(实测 15.15 → 13.35),两端都要能裁。 +4. 门用**量级**(极差比 ≤1.15 / CV ≤0.06 / 相对漂移 ≤5%),**不要用秩相关**——它无标度, + 稳态尾部 15.19/15.31/15.42 会被判成「完美单调上升 +1.00」,是假阳性。 +5. 静态内存指标**只作提示**:曾把「可用内存 ≥10 GB」设成硬门,误杀了换页仅 31 MB 的 + 可用环境;也曾用命令行扫描找「其它实例」,连续两次把调用方的包装 shell 误判成实例。 + 真正的判据只有直接测量量(换页增量 + 稳定窗口复现性)。 +6. 判据优先看**分段计时器**:漂移有可识别的指纹——**「不该变的段变了」** + (`num_beams` 不作用于 S2M 却让 cfm 翻倍;6 s 参考的归一化 cfm 反而高于 12 s)。 + +判据本身由 [tests/test_tts_bench.py](./tests/test_tts_bench.py) 用上述三组真实读数钉死, +并记录了判据被现实纠正的三次(秩相关无标度 → 量级判据;只裁头部 → 最长稳定窗口; +静态内存硬门 → 降级为提示)。 ### 6.4 Apple Silicon 上不可用的加速面(逐条已核实) @@ -538,10 +573,10 @@ TensorRT-LLM)。仓库内**无 vLLM 后端**,README 只给外链 recipe。 | 2 | **发音标注接通(多音字)** | A/B 分离度 2.4× 证实生效 | `text`/`ttsText` 拆分 + lint;标注句失效缓存 | DTW 对照 + 全链路集成验证 | ✅ 已落地 | | 3 | **固定 `--seed`** | 亲测带种子字节一致、不带则不同 | 零 | 同句 ×2 比 sha256 | ✅ 已落地 | | 4 | **同口径重算本机 RTF** | 与 MLX 报告差 3–4× 无解释 | 一次单句实验 | 排除 load/clone,只计 synth | ✅ **已做**:对齐档 RTF 4.37,缺口分解为 2.1×(管线)× 2–3×(硬件),残差仅 1.3–1.9×(§6.3)⇒ **换栈不被数字支持** | -| 5 | 分段 profile 常态化 | 内建计时器零改动可取(§6.2) | 服务重启时加重定向 | `grep gpt_gen_time\|s2mel_time\|bigvgan_time` | ✅ 本轮已用;且它是识别热漂移的主要手段(§6.4) | -| 6 | **建立可用的性能测量环境** | 本机同一工作量 6 次连续调用漂 3.4×,A/B 不可做(§6.4) | 清内存/停其它实例,或换机 | A/A 复现性检查:同配置两轮比值须 ≈1.0 | ⬜ **待做(现为 #7/#8/#15 的前置)** | -| 7 | `length_penalty>0`(长句吞尾) | 机制明确(§4.2),但**本轮未复现收益** | 进缓存摘要 ⇒ 改档即重录 | 需先满足 #6;固定 seed + n≥20 长句,判据=尾部字词完整率(非墙钟) | ⬜ 待验证(受阻于 #6) | -| 8 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | 同上;且**与音色耦合**不可迁移 | 需先满足 #6;固定 seed + beams=1,30 句覆盖长/短/数字/英文 | ⬜ 待验证(受阻于 #6) | +| 5 | 分段 profile 常态化 | 内建计时器零改动可取(§6.2) | 服务重启时加重定向 | `grep gpt_gen_time\|s2mel_time\|bigvgan_time` | ✅ 本轮已用;且它是识别热漂移的主要手段(§6.4–6.5) | +| 6 | **建立可用的性能测量环境** | 本机连续调用漂 2.4–3.4×,A/B 不可做(§6.4–6.5) | 冷却节奏(75 s/次),耗时约 5:1 占空比 | `tts_bench.py` A/A 判定 + 判据单测 | ✅ **已做**:漂移定因为热节流(换页/分配器/泄漏均排除),加 75 s 冷却后极差比 1.016–1.047× 合格(§6.5) | +| 7 | `length_penalty>0`(长句吞尾) | 机制明确(§4.2),但**本轮未复现收益** | 进缓存摘要 ⇒ 改档即重录 | #6 已解除阻塞:按 §6.5 协议跑;固定 seed + n≥20 长句,判据=尾部字词完整率(非墙钟) | ⬜ **待验证(已可做)** | +| 8 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | 同上;且**与音色耦合**不可迁移 | #6 已解除阻塞:固定 seed + beams=1,30 句覆盖长/短/数字/英文 | ⬜ **待验证(已可做)** | | 9 | 预设名义向量归一到 Σvec°=1.0 | 让 alpha 跨预设可比(§3.1) | 改预设 ⇒ 整集重录 | `α_new = α_old × Σvec°_old` 折算后波形应近乎一致 | ⬜ 待验证 | | 10 | 砍掉 `calm`/`surprised` 配料 | bias 只 0.5625/0.6875,占预算却不兑付表达力(§3.2) | 同上 | 三档 A/B 比 F0 中位/起伏/音节率/质心 | ⬜ 待验证 | | 11 | 密集技术句改 `df>1` | 方向勘误(§3.4) | 拉长时长 ⇒ 牵动 beat | 5 句最糊的技术句跑 df ∈ {0.95,1.0,1.05,1.08},用 ASR 回转写 CER 作清晰度代理 | ⬜ 待验证 | diff --git a/media/pipeline/README.md b/media/pipeline/README.md index f4acbd7e..7798ebf8 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -85,6 +85,7 @@ uv run --no-project media/pipeline/scripts/pipeline.py --project media/-vi | [scripts/paper_extract.py](./scripts/paper_extract.py) | Stage ① 取证工具箱(§→页映射 / 分栏取文 / caption 收割 / 定点 find / 页面光栅化) | `uv run --no-project --with pymupdf media/pipeline/scripts/paper_extract.py "" find "原文措辞"` | | [scripts/refs.py](./scripts/refs.py) | 参考样本可复现清单(verify/rebuild;指纹在 [voices/refs.toml](./voices/refs.toml),只存哈希不存音频) | `uv run --no-project media/pipeline/scripts/refs.py verify` | | [scripts/pron_marks.py](./scripts/pron_marks.py) | 发音标注 `<原文\|读音>` 的解析与校验(纯函数库,无 IO):多音字/英文专名的精确读音控制;被 `build_narration.py` 用于硬失败拦非法标注 | 库,不直接调用;语法与规则见其模块文档,台账见 [PRON-GLOSSARY.md](./PRON-GLOSSARY.md) | +| [scripts/tts_bench.py](./scripts/tts_bench.py) | 合成耗时基准与**测量环境体检**(**运行于 index-tts 环境**,同 tts_server.py):A/A 复现性判定 + 分段计时 + 换页/分配器诊断。本机漂移已定因为热节流,做任何耗时 A/B 前先用它确认环境合格 | 在 `~/tools/index-tts` 内:`./.venv/bin/python <本仓>/media/pipeline/scripts/tts_bench.py --check-only`;A/A 见 [INDEXTTS-2.5-ADVANCED.md §6.5](./INDEXTTS-2.5-ADVANCED.md) | 中心脚本以 `--project <工程根>` 参数化;工程内 `scripts/*.py` 为薄包装(透传参数、保持原 CLI)。改造/迭代只改 `media/pipeline/scripts/`,验证门 = 受影响工程的 `narration.json` / `manifest.json` 字节级不变。 diff --git a/media/pipeline/scripts/tts_bench.py b/media/pipeline/scripts/tts_bench.py new file mode 100644 index 00000000..0915f87c --- /dev/null +++ b/media/pipeline/scripts/tts_bench.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""IndexTTS 合成耗时基准与**测量环境体检**——运行于 index-tts 工程环境内(同 tts_server.py)。 + +## 为什么需要这个工具 + +2026-08-20 的性能调研发现:本机(M4 base / 24 GB)同一份工作量在 **6 次连续 infer 调用** +内从 14.49 s 漂到 48.74 s(3.4×),导致任何耗时 A/B 都不可信——交错设计的逐对比值摆动 +0.75×/1.60×/0.75×,把 1.0 夹在中间。漂移有可识别的指纹:**「不该变的段变了」** +(`num_beams` 不作用于 S2M 却让 cfm 翻倍;6 s 参考的归一化 cfm 反而高于 12 s)。 + +所以在测任何参数之前,必须先证明**环境本身**能给出可复现的读数。本工具做两件事: + + 1. `--check-only`:前置门(交换区余量 / 可用内存 / 是否已有其它 IndexTTS 实例占用); + 2. A/A 复现性运行:同一配置连跑 N 次,报逐次耗时、分段耗时、RSS 与 MPS 分配器占用, + 并给出**环境是否合格**的判据(极差比、变异系数、随运行序的单调趋势)。 + +判据不看绝对速度,只看**同配置的可复现性**——环境不合格时任何 A/B 结论都是噪声。 + +## 用法(在 index-tts 根目录) + + cd ~/tools/index-tts + ./.venv/bin/python <本仓>/media/pipeline/scripts/tts_bench.py --check-only + ./.venv/bin/python <本仓>/media/pipeline/scripts/tts_bench.py \ + --ref <本仓>/media/pipeline/voices/me-bright.wav --runs 8 [--empty-cache] + +`--empty-cache` 在每次调用后清 MPS 缓存并 gc——用于判定漂移是否来自分配器累积。 +若开启后漂移消失,则长跑(整集 2 小时)也应在服务端逐句清理。 + +产物与完整方法论见 media/pipeline/INDEXTTS-2.5-ADVANCED.md §6.4。 +""" + +from __future__ import annotations + +import argparse +import contextlib +import gc +import io +import json +import os +import re +import statistics as st +import subprocess +import sys +import time +from pathlib import Path + +DEFAULT_TEXT = "自进化智能体会修改自己的代码。" +TIMER_RE = re.compile(r">> (gpt_gen_time|s2mel_time|bigvgan_time): ([\d.]+) seconds") + +#: 环境门阈值。 +#: +#: **静态内存指标一律只作告警**,唯一的硬门是「是否已有其它 IndexTTS 实例」(每个约 5 GB, +#: 机制上必然把 24 GB 机器推入换页)。理由是实测校准出来的: +#: - macOS 的 swapfile **弹性增长**(本轮它自己从 5 GB 涨到 63 GB 又缩回),故「swap 空闲少」 +#: 并不预示 OOM,把它当硬门会误杀; +#: - 可用内存 9.7 GB 时跑完 8 次调用,**换页增量最大仅 31 MB**——内存不是瓶颈;那一轮 +#: 2.37× 的漂移全部来自热节流。曾把硬门设在 10 GB,会错误拦住这类完全可用的环境。 +#: 内存只有在**导致换页**时才要紧,而换页是逐次直接测量的(MAX_SWAP_DELTA_MB), +#: 故让直接测量当判据、静态指标只提示。 +ADVISE_AVAIL_GB = 8.0 +WARN_SWAP_FREE_GB = 4.0 +#: 运行期间的换页增量才是漂移的**权威信号**:模型常驻不动时,稳定环境下应接近 0。 +#: 阈值按「一次调用换进/换出超过 200 MB 即视为在换页」取。 +MAX_SWAP_DELTA_MB = 200.0 + +#: A/A 合格判据:同配置连跑的极差比与变异系数。极差比 1.15 是经验值—— +#: 低于它时,#7/#8 那类预期收益 20%+ 的参数效应才可能从噪声里分辨出来。 +MAX_SPREAD_RATIO = 1.15 +MAX_CV = 0.06 +#: 相对漂移(后半中位 vs 前半中位)的上限。**趋势不能当门**——秩相关是无标度的, +#: 稳态尾部 15.19/15.31/15.42(极差仅 1.016×)会被判成「完美单调上升」+1.00, +#: 那是假阳性。量级才是判据;秩相关只留作诊断输出。 +MAX_REL_DRIFT = 0.05 + + +def _sysctl_swap() -> tuple[float, float]: + """→ (已用 GB, 空闲 GB)。""" + out = subprocess.run( + ["sysctl", "-n", "vm.swapusage"], capture_output=True, text=True, check=False + ).stdout + used = re.search(r"used = ([\d.]+)M", out) + free = re.search(r"free = ([\d.]+)M", out) + return ( + float(used.group(1)) / 1024 if used else -1.0, + float(free.group(1)) / 1024 if free else -1.0, + ) + + +def _vm_stat_raw() -> dict[str, int]: + """vm_stat 原始计数(页数或累计次数,**不乘页大小**)。""" + out = subprocess.run( + ["vm_stat"], capture_output=True, text=True, check=False + ).stdout + d: dict[str, int] = {} + for line in out.splitlines(): + m = re.match(r'^"?(.+?)"?:\s+(\d+)', line.strip()) + if m: + d[m.group(1)] = int(m.group(2)) + return d + + +def _vm_stat() -> dict[str, int]: + """页数 → 字节(仅对「Pages *」类指标有意义)。""" + return {k: v * 16384 for k, v in _vm_stat_raw().items()} + + +def _swap_counters() -> tuple[int, int]: + """→ (Swapins, Swapouts) 页数。运行期间的**增量**是漂移的权威信号。""" + vm = _vm_stat_raw() + return vm.get("Swapins", 0), vm.get("Swapouts", 0) + + +def _rss_mb(pid: int) -> float: + out = subprocess.run( + ["ps", "-o", "rss=", "-p", str(pid)], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + return float(out) / 1024 if out else -1.0 + + +def _busy_ports() -> list[str]: + """检测占用 IndexTTS 端口的进程 —— 每个常驻实例约 5 GB,是最常见的干扰源。 + + **刻意用端口而不是扫命令行**:`pgrep -f tts_bench.py` 会命中调用方的包装 shell + (其 argv 里含脚本名与 python 路径),实测连续两次误报「已有其它实例」而拦掉了完全 + 可用的环境。端口是精确判据,不受 argv 与祖先链推断的影响。 + """ + hits = [] + for port in (8766, 8767): + out = subprocess.run( + ["lsof", "-nP", f"-iTCP:{port}", "-sTCP:LISTEN"], + capture_output=True, + text=True, + check=False, + ).stdout.strip() + if out and len(out.splitlines()) > 1: + hits.append(f"{port}: {out.splitlines()[1].split()[0]}") + return hits + + +def check_env(*, strict: bool) -> bool: + """前置门:交换区余量 / 可用内存 / 其它实例。返回是否合格。""" + swap_used, swap_free = _sysctl_swap() + vm = _vm_stat() + free = vm.get("Pages free", 0) / 1e9 + inactive = vm.get("Pages inactive", 0) / 1e9 + purgeable = vm.get("Pages purgeable", 0) / 1e9 + avail = free + inactive + purgeable + others = _busy_ports() + + print(">> 测量环境体检") + print( + f" 交换区:已用 {swap_used:.1f} GB · 空闲 {swap_free:.1f} GB" + f"(告警线 <{WARN_SWAP_FREE_GB:g};swapfile 弹性增长,故不作硬门)" + ) + print( + f" 可用内存:{avail:.1f} GB" + f"(free {free:.1f} + inactive {inactive:.1f} + purgeable {purgeable:.1f},提示线 <{ADVISE_AVAIL_GB:g})" + ) + print( + f" IndexTTS 端口占用:{len(others)} 个" + + ("" if not others else f" ← {others}") + ) + + ok = True + if 0 <= swap_free < WARN_SWAP_FREE_GB: + print( + " ⚠️ 交换区余量低:系统可能已在积极换页。不作硬门(macOS 会自行扩容)," + "但运行期的换页增量若超阈值,判定会据此否决" + ) + if avail < ADVISE_AVAIL_GB: + print( + " ⚠️ 可用内存偏低:仅作提示。是否真的换页由运行期的换页增量直接判定" + "(实测 9.7 GB 可用时换页增量仅 31 MB,内存并非瓶颈)" + ) + if others: + print( + " ⚠️ 已有常驻 IndexTTS 服务在跑(约 5 GB/个):建议先停掉再测;" + "是否真的造成干扰由运行期换页增量与稳态复现性判定" + ) + print( + " ✅ 前置门通过 —— **静态指标一律只作提示**:本轮把它们逐条降级都是被实测打的" + "(10 GB 内存硬门误杀了换页仅 31 MB 的可用环境;命令行扫描连续两次把包装 shell " + "误判成实例)。真正的判据是运行期换页增量 + 稳态尾部复现性,都在下面直接测量。" + ) + return ok + + +def _rank(xs: list[float]) -> list[float]: + order = sorted(range(len(xs)), key=lambda i: xs[i]) + r = [0.0] * len(xs) + for pos, i in enumerate(order): + r[i] = float(pos) + return r + + +def _spearman(xs: list[float], ys: list[float]) -> float: + """秩相关(无 scipy 依赖)。用于检测「耗时随运行序单调上升」这一漂移指纹。""" + n = len(xs) + if n < 3: + return 0.0 + rx, ry = _rank(xs), _rank(ys) + mx, my = st.mean(rx), st.mean(ry) + num = sum((a - mx) * (b - my) for a, b in zip(rx, ry, strict=True)) + den = sum((a - mx) ** 2 for a in rx) ** 0.5 * sum((b - my) ** 2 for b in ry) ** 0.5 + return num / den if den else 0.0 + + +def _rel_drift(xs: list[float]) -> float: + """后半中位相对前半中位的漂移比例。**量级判据**,替代无标度的秩相关做门。""" + if len(xs) < 2: + return 0.0 + h = len(xs) // 2 + a, b = st.median(xs[:h]), st.median(xs[h:]) + return (b - a) / a if a else 0.0 + + +def stable_window(walls: list[float]) -> tuple[int, int, float, float, float]: + """找出**最长连续稳定窗口** → (起, 止含, 极差比, CV, 相对漂移)。 + + 为什么不是「弃掉头部取尾部」:本机漂移是热节流,机器带余热开始时头几次偏慢, + 看起来「裁头部」就够。但实测遇到过反例——一轮 6 次里 #1–#5 稳在 15.15–15.86 + (极差 1.047×),末次却突然**变快**到 13.35 s(风扇终于起转 / 后台任务结束), + 只裁头部的判据永远裁不掉尾部异常点,于是把一个明显合格的环境判成不合格。 + + 故改为在所有连续窗口里取最长的合格者:既容忍带余热的头部,也容忍单点异常的尾部。 + """ + n = len(walls) + best: tuple[int, int, float, float, float] | None = None + for i in range(n): + for j in range(i + 2, n): # 至少 3 个点 + w = walls[i : j + 1] + sp = max(w) / min(w) + cv = st.stdev(w) / st.mean(w) + dr = _rel_drift(w) + if sp <= MAX_SPREAD_RATIO and cv <= MAX_CV and abs(dr) <= MAX_REL_DRIFT: + cand = (i, j, sp, cv, dr) + if best is None or (j - i) > (best[1] - best[0]): + best = cand + if best: + return best + # 无合格窗口:回报最稳的 3 连窗口,供人判断差多少 + trip = min( + ( + ( + i, + i + 2, + max(walls[i : i + 3]) / min(walls[i : i + 3]), + st.stdev(walls[i : i + 3]) / st.mean(walls[i : i + 3]), + _rel_drift(walls[i : i + 3]), + ) + for i in range(n - 2) + ), + key=lambda t: t[3], + ) + return trip + + +def main() -> None: + ap = argparse.ArgumentParser(description="IndexTTS 合成耗时基准与测量环境体检") + ap.add_argument("--ref", default=None, help="参考音色样本(A/A 运行必需)") + ap.add_argument("--text", default=DEFAULT_TEXT, help="fixture 文本") + ap.add_argument( + "--runs", type=int, default=8, help="计时次数(另有 1 次预热,默认 8)" + ) + ap.add_argument("--num-beams", type=int, default=1, help="束宽(默认 1)") + ap.add_argument("--seed", type=int, default=4242, help="随机种子(固定才可复现)") + ap.add_argument( + "--emo-vector", + default=None, + help="8 维情感向量,逗号分隔(默认不注入,等价 neutral 纯克隆)", + ) + ap.add_argument("--emo-alpha", type=float, default=1.0, help="情感强度") + ap.add_argument( + "--empty-cache", + action="store_true", + help="每次调用后清 MPS 缓存 + gc —— 用于判定漂移是否来自分配器累积", + ) + ap.add_argument( + "--cooldown", + type=float, + default=0.0, + help="每次计时之间静置的秒数。本机漂移已确诊为**热节流**(换页≈0、MPS 分配器恒定," + "却随运行序单调爬升且全由 cfm 承担),故冷却间隔是唯一有效的缓解手段;" + "实测冷态下前 2 次极差比仅 1.01×,第 3 次起开始漂", + ) + ap.add_argument("--model-dir", default="checkpoints", help="模型目录") + ap.add_argument("--check-only", action="store_true", help="只跑环境门,不加载模型") + ap.add_argument( + "--force", action="store_true", help="环境门不合格也继续(结果仅供参考)" + ) + ap.add_argument("--json", default=None, help="把逐次读数写入 JSON") + args = ap.parse_args() + + env_ok = check_env(strict=not args.check_only) + if args.check_only: + sys.exit(0 if env_ok else 1) + if not env_ok and not args.force: + sys.exit("环境门未通过;确需继续请加 --force(结果不可用于参数归因)") + if not args.ref: + sys.exit("A/A 运行需要 --ref 参考音色样本") + ref = Path(args.ref).expanduser().resolve() + if not ref.is_file(): + sys.exit(f"参考样本不存在: {ref}") + + import soundfile as sf + import torch + from transformers import set_seed + + sys.path.insert(0, str(Path.cwd())) + from indextts.infer_v2_5 import IndexTTS2 + + emo = [float(x) for x in args.emo_vector.split(",")] if args.emo_vector else None + if emo is not None and len(emo) != 8: + sys.exit("--emo-vector 必须为 8 维") + + print("\n>> 加载模型(不计入耗时读数)…", flush=True) + t0 = time.perf_counter() + tts = IndexTTS2( + cfg_path=str(Path(args.model_dir) / "config.yaml"), + model_dir=args.model_dir, + use_bf16=True, # MPS 分支内部强制 False ⇒ 实际 fp32 + use_cuda_kernel=False, + use_deepspeed=False, + use_qwen_emo=False, + ) + print(f">> 加载 {time.perf_counter() - t0:.1f}s\n", flush=True) + + mps_ok = hasattr(torch, "mps") and torch.backends.mps.is_available() + tmp = Path(os.environ.get("TMPDIR", "/tmp")) / "tts_bench" + tmp.mkdir(parents=True, exist_ok=True) + pid = os.getpid() + + def one(i: int) -> dict: + si0, so0 = _swap_counters() + set_seed(args.seed) + buf = io.StringIO() + t = time.perf_counter() + with contextlib.redirect_stdout(buf): + tts.infer( + spk_audio_prompt=str(ref), + text=args.text, + output_path=str(tmp / f"b{i}.wav"), + lang="ZH", + emo_vector=emo, + emo_alpha=args.emo_alpha, + use_random=False, + verbose=False, + num_beams=args.num_beams, + ) + wall = time.perf_counter() - t + data, sr = sf.read(str(tmp / f"b{i}.wav")) + stg = {m.group(1): float(m.group(2)) for m in TIMER_RE.finditer(buf.getvalue())} + si1, so1 = _swap_counters() + rec = { + "i": i, + "audio": len(data) / sr, + "synth": wall, + "rtf": wall / (len(data) / sr), + **stg, + "rss_mb": _rss_mb(pid), + "swap_free_gb": _sysctl_swap()[1], + # 本次调用期间的换页量(MB)——稳定环境下应接近 0 + "swapin_mb": (si1 - si0) * 16384 / 1e6, + "swapout_mb": (so1 - so0) * 16384 / 1e6, + } + if mps_ok: + rec["mps_alloc_gb"] = torch.mps.current_allocated_memory() / 1e9 + rec["mps_driver_gb"] = torch.mps.driver_allocated_memory() / 1e9 + if args.empty_cache: + gc.collect() + if mps_ok: + torch.mps.empty_cache() + return rec + + one(-1) # 预热:填充上游按路径缓存的说话人条件(等价「排除 clone」) + print( + f">> A/A 复现性:同配置连跑 {args.runs} 次" + f"(束宽 {args.num_beams} · seed {args.seed} · " + f"{'注入情感' if emo else 'neutral'} · " + f"{'每次清 MPS 缓存' if args.empty_cache else '不清缓存'})" + ) + rows = [] + for i in range(args.runs): + if args.cooldown and i: + time.sleep(args.cooldown) + r = one(i) + rows.append(r) + extra = ( + f" mps {r.get('mps_alloc_gb', 0):.2f}/{r.get('mps_driver_gb', 0):.2f} GB" + if mps_ok + else "" + ) + print( + f" #{i + 1:<2} synth {r['synth']:6.2f}s RTF {r['rtf']:5.2f} " + f"cfm {r.get('s2mel_time', 0):6.2f}s RSS {r['rss_mb']:6.0f} MB " + f"换页 in/out {r['swapin_mb']:6.0f}/{r['swapout_mb']:6.0f} MB{extra}", + flush=True, + ) + + walls = [r["synth"] for r in rows] + spread = max(walls) / min(walls) + cv = st.stdev(walls) / st.mean(walls) if len(walls) > 1 else 0.0 + trend = _spearman([float(r["i"]) for r in rows], walls) + print(f"\n>> A/A 判定({len(walls)} 次,中位 {st.median(walls):.2f}s)") + print(f" 极差比 max/min = {spread:.2f}×(门槛 ≤{MAX_SPREAD_RATIO:g})") + print(f" 变异系数 CV = {cv:.3f}(门槛 ≤{MAX_CV:g})") + print( + f" 随运行序趋势 = {trend:+.2f}(秩相关,**仅作诊断**:它无标度," + "微小的单调上升也会给出 +1.00,故不作判据)" + ) + if mps_ok: + d = rows[-1].get("mps_driver_gb", 0) - rows[0].get("mps_driver_gb", 0) + print(f" MPS 驱动占用变化 = {d:+.2f} GB(>0 且随序增长=分配器累积)") + max_swap = max(max(r["swapin_mb"], r["swapout_mb"]) for r in rows) + print( + f" 单次最大换页量 = {max_swap:.0f} MB(门槛 ≤{MAX_SWAP_DELTA_MB:g};" + ">0 说明模型常驻被挤出内存,是漂移的直接成因)" + ) + i0, j0, t_sp, t_cv, t_dr = stable_window(walls) + tail = walls[i0 : j0 + 1] + print( + f"\n 稳定窗口:第 {i0 + 1}–{j0 + 1} 次(共 {len(tail)} 次," + f"弃头 {i0} / 弃尾 {len(walls) - j0 - 1}),中位 {st.median(tail):.2f}s" + ) + print( + f" 极差比 {t_sp:.3f}×(门槛 ≤{MAX_SPREAD_RATIO:g}) " + f"CV {t_cv:.3f}(≤{MAX_CV:g}) " + f"相对漂移 {t_dr:+.1%}(|·| ≤{MAX_REL_DRIFT:.0%})" + ) + verdict = ( + t_sp <= MAX_SPREAD_RATIO + and t_cv <= MAX_CV + and abs(t_dr) <= MAX_REL_DRIFT + and len(tail) >= 3 + and max_swap <= MAX_SWAP_DELTA_MB + ) + print( + f"\n ✅ 环境合格:可以做参数 A/B —— 判据取第 {i0 + 1}–{j0 + 1} 次的稳定窗口," + f"A/B 时每档都须按同样方式取窗口后再比较" + if verdict + else "\n ❌ 环境不合格:连稳态尾部都不满足判据(加大 --cooldown 或减少机器上的其它负载)" + ) + if args.json: + Path(args.json).write_text( + json.dumps( + { + "config": vars(args), + "rows": rows, + "spread": spread, + "cv": cv, + "trend": trend, + "window": [i0, j0], + "settled_spread": t_sp, + "settled_cv": t_cv, + "settled_rel_drift": t_dr, + "verdict": verdict, + }, + ensure_ascii=False, + indent=1, + ), + encoding="utf-8", + ) + print(f" 读数已写入 {args.json}") + sys.exit(0 if verdict else 1) + + +if __name__ == "__main__": + main() diff --git a/media/pipeline/tests/test_tts_bench.py b/media/pipeline/tests/test_tts_bench.py new file mode 100644 index 00000000..fec2ea14 --- /dev/null +++ b/media/pipeline/tests/test_tts_bench.py @@ -0,0 +1,92 @@ +"""tts_bench 的环境判据 —— 「耗时 A/B 是否可信」的守门人。 + +夹具是 2026-08-20 在本机(M4 base / 24 GB / MPS fp32)实测的三组真实读数。它们的价值 +不只是回归基线,更是**判据设计被现实纠正过三次**的记录: + + 1. 最初用「随运行序的秩相关」当门 —— 秩相关**无标度**,稳态尾部 15.19/15.31/15.42 + (极差仅 1.016×)会被判成「完美单调上升 +1.00」。改为量级判据(相对漂移)。 + 2. 接着用「弃掉头部、判定尾部」 —— 只能从头裁,遇到末次突然**变快**(15.15 → 13.35, + 风扇起转)就永远裁不掉,把明显合格的环境判成不合格。改为最长连续稳定窗口。 + 3. 静态内存门(可用内存 ≥10 GB、命令行扫描其它实例)被实测逐条否决:9.7 GB 可用时 + 换页增量仅 31 MB(内存不是瓶颈),而命令行扫描连续两次把调用方的包装 shell 误判 + 成实例。故静态指标全部降级为提示,判据只用直接测量量。 + +结论:漂移的真实成因是**热节流**(换页 ≈ 0、MPS 分配器占用恒定,却随运行序单调爬升且 +全部由 cfm 段承担),唯一有效缓解是冷却间隔。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from tts_bench import ( # noqa: E402 + MAX_CV, + MAX_REL_DRIFT, + MAX_SPREAD_RATIO, + _rel_drift, + stable_window, +) + +#: 无冷却 8 连跑:单调爬升 2.37×,热节流的教科书形态 +NO_COOLDOWN = [15.36, 15.51, 16.76, 20.61, 28.42, 36.46, 35.43, 31.10] +#: 75 s 冷却 6 次:前两次带上一轮余热,#3 起进入稳态 +COOLDOWN = [24.50, 18.60, 15.19, 15.19, 15.31, 15.42] +#: 验收 6 次:#1–#5 稳定,末次突然变快(13.35)——只裁头部的判据会在此误判 +ACCEPT = [15.50, 15.86, 15.34, 15.27, 15.15, 13.35] + + +def _ok(walls: list[float]) -> bool: + i, j, sp, cv, dr = stable_window(walls) + return ( + sp <= MAX_SPREAD_RATIO + and cv <= MAX_CV + and abs(dr) <= MAX_REL_DRIFT + and (j - i + 1) >= 3 + ) + + +def test_no_cooldown_is_rejected(): + """热节流下的单调爬升必须判不合格 —— 否则会把噪声当成参数效应。""" + assert not _ok(NO_COOLDOWN) + + +@pytest.mark.parametrize(("walls", "expect"), [(COOLDOWN, (2, 5)), (ACCEPT, (0, 4))]) +def test_stable_window_located(walls, expect): + """冷却节奏下应判合格,且窗口位置符合物理解释(余热在头 / 异常在尾)。""" + i, j, *_ = stable_window(walls) + assert (i, j) == expect + assert _ok(walls) + + +def test_window_tolerates_tail_outlier(): + """回归第 2 次纠正:末次变快不得导致误判(只裁头部的判据会在此失败)。""" + i, j, sp, _cv, _dr = stable_window(ACCEPT) + assert j == len(ACCEPT) - 2, "末次异常点应被排除在窗口外" + assert sp <= MAX_SPREAD_RATIO + + +def test_window_prefers_longest(): + """同为合格窗口时取最长 —— 点数越多,后续 A/B 的统计功效越高。""" + i, j, *_ = stable_window([15.2, 15.3, 15.25, 15.28, 15.22, 40.0]) + assert (i, j) == (0, 4) + + +def test_rel_drift_is_scale_aware(): + """回归第 1 次纠正:相对漂移必须有标度,微小单调上升不能被判成显著漂移。""" + # 稳态尾部:单调上升但幅度仅 1.5%,秩相关会给 +1.00,量级判据须放行 + assert abs(_rel_drift([15.19, 15.19, 15.31, 15.42])) <= MAX_REL_DRIFT + # 真实漂移:翻倍,必须拦下 + assert _rel_drift([15.0, 16.0, 28.0, 36.0]) > MAX_REL_DRIFT + + +def test_no_qualifying_window_returns_steadiest_triple(): + """无合格窗口时回报最稳的 3 连窗口,让人看到「差多少」而不是空手。""" + i, j, sp, cv, dr = stable_window(NO_COOLDOWN) + assert j - i == 2, "回退分支应给出 3 点窗口" + assert not (sp <= MAX_SPREAD_RATIO and cv <= MAX_CV and abs(dr) <= MAX_REL_DRIFT), ( + "回退窗口本身不应满足判据" + ) From e9c9524756a02f242f42e08b7db01ba1b561075d Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 21 Aug 2026 00:16:59 +0800 Subject: [PATCH 06/10] =?UTF-8?q?feat(tts):=20=E8=B7=AF=E7=BA=BF=E5=9B=BE?= =?UTF-8?q?=20#9/#13=20=E8=90=BD=E5=9C=B0=E3=80=81#10=E2=80=93#12=20?= =?UTF-8?q?=E5=B0=B1=E4=BD=8D=EF=BC=8C=E5=B9=B6=E6=8E=A5=E9=80=9A=20ASR=20?= =?UTF-8?q?=E5=9B=9E=E8=BD=AC=E5=86=99=E5=88=A4=E6=8D=AE;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## #9 预设名义向量归一到 Σvec=1.0(已做) 只有 Σvec=1.0 时 emo_alpha 才等于「替换掉本人语调的百分比」,否则被稀释成 α·Σvec、 跨预设不可比。把 lively/confident/positive 的名义向量归一,alpha 反向缩放 (α_new = α_old × Σvec_old)。`--list-styles` 的「有效注入」列现在恒等于 alpha。 精确说法:有效注入 w=vec×alpha 在数学上不变,但上游把 w 截断到 4 位小数 (infer_v2_5.py:608 用 int() 截断而非四舍五入),而 0.33/0.455 恰在截断边界上, 故 21 个分量里有 3 个差 1e-4(相对 0.03%)。**不写成「逐项完全一致」——那是过度断言。** 这三档未被任何已上线剧集使用(三集用 sunny-steady / passionate),缓存零影响; 生产档逐字不变由 test_production_presets_untouched 钉死。 ## #13 prospect_ref 保真度门(已做) 原评分 5 项全是风格指标、0 项保真度,而保真度损伤事后无法弥补(WildSpoof arXiv:2602.05770 Table 2:事后增强提升 UTMOS 却让 SECS 0.35→0.28)。新增只否决不加权 的一组:削波、底噪绝对电平、动态范围(SNR 代理)、DC 偏置、有效带宽(识别低码率转码)。 同时修掉两个自相加强的旧缺陷:静音占比从相对阈改绝对阈(相对阈在有底噪的录音上会 系统性低估静音、扣分项失效);谱质心从全带改 300–5000 Hz 限带(全带会把「嗓音明亮」 与「有嘶声」记成同一信号,与上一条叠加使「脏但亮」的段落双重虚高)。 验收:成片在用的 me-1@180s 段**被放行** ✅,且正确标出更脏的窗口(底噪 −46 dB / 动态 29 dB);超 15s 与 5 kHz 带限的反例均被拦。 开发中被测试抓出并修掉的两个自造 bug: - `bandwidth_khz` 用 np.convolve(..., "same") 平滑对数谱,**两端少算抽头且不归一化**, 把空频段的 −240 dB 抬到接近 0 dB,使带宽恒报 Nyquist(5 kHz 带限信号被测成 16 kHz)。 改为按实际抽头数归一化。 - 底噪指标的隐含前提没写明:它是「帧 RMS 第 10 百分位」,窗口内**没有停顿**时测到的是 轻声段而非底噪(恒幅正弦上给出 −13.6 dBFS 的假警报)。改为先检查是否有足够安静帧, 不足 5% 时记 None 并旗标「底噪不可估」——宁可说测不了,不给错的数。 ## #10 / #11 候选档就位(新增而非改动生产档 ⇒ 缓存零影响) `sunny-pure`(happy 单载,砍掉有效强度仅 0.5625/0.6875 的 calm/surprised 配料)与 `sunny-clear`(= sunny-steady 但 df 1.05,护术语密集句清晰度——df 方向勘误见 §3.4)。 新增 §7.1 写清定档动作,并明确「放弃也是合法结论」:若 A/B 差异落在噪声内,标注 「实测无差异、维持现档」比强行改预设(整集重录 2 小时)更划算。 ## #12 工具就绪(录音须本人操作) `prospect_ref.py --accept`:整段评估候选样本,对**保真度**下硬结论、风格指标只作参考 (风格的真正判据是纯克隆小样而非样本本身),并给出合格线与下一步命令。 ## #14 / #15 关闭为「不做」 #14 进程级分片并行:#6 已定因为热节流而非吞吐受限,两个进程只会更快撞上同一个热墙。 #15 降 diffusion_steps / 关 CFG:二者是 infer() **函数体内的局部字面量**(:829-830), 既非参数也非模块常量 ⇒ 无法传入、无法 monkeypatch,只能改上游源码——那是 glossary.yaml 被否决的同一模式(不受本仓版本控制、换机即静默失效)。已记入迁移地雷表。 ## ASR 回转写判据(并据此更正一条结论) tts_bench 新增成对 A/B 模式(逐句交替先后顺序以抵消热漂移)与 whisper 回转写判据 (CER + **尾部覆盖率**,后者是 #7「吞尾」的直接判据且不受热漂移影响)。注意 whisper.transcribe(路径) 会 shell 调 ffmpeg 而本机 PATH 上没有,须先 librosa.load 到 16 kHz 再喂 numpy 数组。 **据此更正**:CMU 音素通道此前记为「不可判定」,实为**生效**——ASR 转写显示基线念 「Cloud」而标注 `` 档念成「看」(kàn≈kæt)。根因是 MFCC-DTW 在「差异只 集中在首词、句尾大段共享」时分辨力不足(只差 2%、判定随分析窗口翻转)。同时拼音通道 拿到了文本级铁证:标注档与「汉字对照」档转写**完全相同**、都不同于基线。 教训写进文档:验证发音类改动优先用回转写,声学距离只作辅助。 测试 95 → 110 项(+11 保真度门与带宽判据、+4 预设不变量)。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- docs/.agents/knowledge-map.md | 2 +- media/pipeline/INDEXTTS-2.5-ADVANCED.md | 85 +++++--- media/pipeline/PRON-GLOSSARY.md | 6 +- media/pipeline/README.md | 4 +- media/pipeline/VOICE-CLONING.md | 20 +- media/pipeline/scripts/prospect_ref.py | 233 +++++++++++++++++++++- media/pipeline/scripts/tts.py | 58 +++++- media/pipeline/scripts/tts_bench.py | 199 ++++++++++++++++++ media/pipeline/skills/03-narration.md | 8 +- media/pipeline/tests/test_digest.py | 62 ++++++ media/pipeline/tests/test_prospect_ref.py | 188 +++++++++++++++++ 11 files changed, 818 insertions(+), 47 deletions(-) create mode 100644 media/pipeline/tests/test_prospect_ref.py diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index d90f79d4..c2e62f3d 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -73,5 +73,5 @@ - [科普视频制作 Pipeline(公共基建)](../../media/pipeline/README.md) — 全仓可复用的论文→视频九阶段流水线:`pipeline.py` 单入口(status/doctor/build/check/tts/captions/render/qa/all)+ 每集 `pipeline.toml` 声明式配置 + `timing.json` 时序 SSOT(timing.ts 与 Python 共读,双语言镜像漂移结构性消灭);技能规格 skills/01–09(01–05 内容层、06 生产层实现、07 TTS、08 草渲 QA、09 终渲交付),真 Skill 挂载于 [.agent/skills/science-video-pipeline](../../.agent/skills/science-video-pipeline/SKILL.md)(纯路由壳);测试 `media/pipeline/tests/` 45 项(digest 黄金哈希守缓存零失效);工具:check_script(分镜覆盖性/时长预算双口径)、check_series(系列顺序五规则执法 [series.json](../../media/series.json))、captions(srt/vtt)、qa_frames --check(黑帧/字幕带侵入/冻帧/WCAG 对比度自动体检)、paper_extract(论文取证五子命令)、refs(样本指纹清单)。 - [自进化系列科普视频 · 三集作品](../../media/series.md) — 发布顺序《上线之后,AI 才开始上学》(金/青/紫,EP1,v3 已交付:内容校准 + sunny-steady 重配,成片 14:01)→《AI 如何自己变强?》(蓝/橙,EP2)→《会写代码的 AI,开始给自己写代码》(绿/洋红,EP3);系列纪律:口播永不携带他集标题与集数序号,顺序变更 TTS 代价恒为零;各集 research/paper-notes.md 为口播单一事实源、upgrade-*.md 为轮次审计记录。 - [IndexTTS 声音克隆手册](../../media/pipeline/VOICE-CLONING.md) — 双引擎 TTS 的克隆侧单一参考:部署(~/tools/index-tts 服务)/参考样本(prospect+prepare+refs 指纹门)/风格预设(sunny 明快阳光推荐位、sunny-steady 成片档 beams=3)/试听关卡(tts_sample + 领航片段)/缓存摘要与幂等/许可合规。 -- [IndexTTS-2.5 进阶用法(上游能力面 · 机制循证 · 提升路线图)](../../media/pipeline/INDEXTTS-2.5-ADVANCED.md) — 与操作手册正交的循证篇,锚定上游 HEAD `4f8792f` 逐条给出 file:line:能力矩阵(含 `do_sample` 在上游失效、`max_text_tokens_per_segment` 对本仓惰性等否定结论)/文本前端(中文走 wetext 而非 NeMo;实测读法矩阵——4 位年份带空格是**唯一**被击穿的规则,已成门,见 [ISSUE-164](./issue.md))/发音标注 `<行\|HANG2>`(DTW 对照证实拼音通道生效 2.4× 分离度,CMU 通道不可判定)/情感机制(`emo_bias` 8 维不等权、`normalize_emo_vec` 在 infer 内从不被调用 ⇒ `Σvec×alpha≤0.8` 是本仓自创口径,跨来源参数迁移偏差 16–33%;73 个「情感×说话人」原型解释「换选段则标定失效」)/采样参数族(`length_penalty=0.0` 非中性、`repetition_penalty=10` 与音色耦合、`max_mel_tokens`≈30s 且溢出表现为文本尾部未念出、无种子致 A/B 不可信)/参考音频(15s 硬截断保前丢后、恒重采样 22.05kHz、CMVN 使相似度对增益免疫、逐句成本来自 CFM 前缀)/性能(本机是 U-DiT 非论文 Zipformer ⇒ 论文 RTF 不可引用;分段 profile 实测 **s2mel 占 45–73%** 而非 T2S,束宽在 MPS 上近乎免费)/16 项 ROI 排序路线图与迁移地雷。配套:[发音标注台账](../../media/pipeline/PRON-GLOSSARY.md)、[pron_marks.py](../../media/pipeline/scripts/pron_marks.py)。 +- [IndexTTS-2.5 进阶用法(上游能力面 · 机制循证 · 提升路线图)](../../media/pipeline/INDEXTTS-2.5-ADVANCED.md) — 与操作手册正交的循证篇,锚定上游 HEAD `4f8792f` 逐条给出 file:line:能力矩阵(含 `do_sample` 在上游失效、`max_text_tokens_per_segment` 对本仓惰性等否定结论)/文本前端(中文走 wetext 而非 NeMo;实测读法矩阵——4 位年份带空格是**唯一**被击穿的规则,已成门,见 [ISSUE-164](./issue.md))/发音标注 `<行\|HANG2>`(DTW 对照证实拼音通道生效 2.4× 分离度,CMU 通道不可判定)/情感机制(`emo_bias` 8 维不等权、`normalize_emo_vec` 在 infer 内从不被调用 ⇒ `Σvec×alpha≤0.8` 是本仓自创口径,跨来源参数迁移偏差 16–33%;73 个「情感×说话人」原型解释「换选段则标定失效」)/采样参数族(`length_penalty=0.0` 非中性、`repetition_penalty=10` 与音色耦合、`max_mel_tokens`≈30s 且溢出表现为文本尾部未念出、无种子致 A/B 不可信)/参考音频(15s 硬截断保前丢后、恒重采样 22.05kHz、CMVN 使相似度对增益免疫、逐句成本来自 CFM 前缀)/性能(本机是 U-DiT 非论文 Zipformer ⇒ 论文 RTF 不可引用;分段 profile 实测 **s2mel 占 45–73%** 而非 T2S,束宽在 MPS 上近乎免费)/16 项 ROI 排序路线图与迁移地雷。配套工具:[发音标注台账](../../media/pipeline/PRON-GLOSSARY.md)、[pron_marks.py](../../media/pipeline/scripts/pron_marks.py)(标注解析/校验,拦「孤立 < 吞正文」等三类静默失效)、[tts_bench.py](../../media/pipeline/scripts/tts_bench.py)(耗时基准与**测量环境体检**:漂移已定因为热节流,加 75s 冷却后 A/A 极差比 1.016–1.047× 合格;含成对 A/B 模式与 whisper 回转写判据)、[prospect_ref.py --accept](../../media/pipeline/scripts/prospect_ref.py)(参考样本保真度验收:削波/底噪/动态/带宽/超 15s)。 - [自进化 Agents Team 方案(Phase 3 记忆检索面已落地)](../concepts/design/self-evolving-agents.md) — 四层自进化架构:本次落地 `engine/evolution/` 子系统(GEPA proposer + 状态机 + decision 护栏)并在记忆检索权重面接通 propose→shadow→canary→promote/rollback 全闭环(迁移 0081 + evolution_inspector),默认全关灰度;agent/skill/knowledge 面、Phase 1 tool_invocations 遥测、eval 四表留后续 diff --git a/media/pipeline/INDEXTTS-2.5-ADVANCED.md b/media/pipeline/INDEXTTS-2.5-ADVANCED.md index 0f5c15f5..0cebd10e 100644 --- a/media/pipeline/INDEXTTS-2.5-ADVANCED.md +++ b/media/pipeline/INDEXTTS-2.5-ADVANCED.md @@ -136,21 +136,29 @@ flowchart TD `` 左侧纯 ASCII ⇒ 被当成音素通道。 - 标记**免疫归一化**:`front.py:178/220` 先换占位符再还原。故不需要为保标记而关归一化, 且 `<行|HANG2>` 与 `2026年→二零二六年` 可在同句共存(已实测)。 -- **2026-08-20 A/B 验证(拼音通道,生效)**:把多音字读音**故意互换**,再与「用汉字写出 - 目标读音」的对照组比 MFCC-DTW 距离(同 ref、同 style、`--seed 20260820` 固定): - - | 档 | 文本 | 期望读音 | - |---|---|---| - | A 基线 | `他在银行里行走。` | yín háng lǐ xíng zǒu | - | B 标注 | `他在银<行\|XING2>里<行\|HANG2>走。` | yín xíng lǐ háng zǒu | - | C 汉字对照 | `他在银形里航走。` | yín xíng lǐ háng zǒu | - - 结果 `d(B,C)=0.133` ≪ `d(A,B)=0.320`、`d(A,C)=0.342` —— **分离度 2.4×,标注确实生效**。 - -- **CMU 音素通道:语法可用,模型响应未经证实**。同样设计(`` 对照 - `Cat`)的距离差仅 2%,且判定随分析窗口翻转(前 0.6 s「生效」、前 1.0 s「未生效」)—— - 差异只在首词、被共享句尾稀释。**结论:中文句内英文专名继续沿用「进角标不口播」的既有 - 纪律**;CMU 标注属未验证选项,用前必须人耳小样对比。 +- **2026-08-20 验证:两个通道都生效**。设计是「把读音**故意改错**,再与『直接把目标读音 + 写成字』的对照组比」——若标注生效,B 应与 C 一致而与基线 A 不同。同 ref、同 style、 + `--seed` 固定。判据用 **whisper-small 回转写**(比声学距离直接得多,见下方口径说明): + + | 通道 | 档 | 输入文本 | ASR 转写 | + |---|---|---|---| + | 拼音 | A 基线 | `他在银行里行走。` | 他在**银行**里 行走 | + | 拼音 | B 标注(故意互换) | `他在银<行\|XING2>里<行\|HANG2>走。` | 他在**隐形**里行走 | + | 拼音 | C 汉字对照 | `他在银形里航走。` | 他在**隐形**里 行走 | + | CMU | A 基线 | `Claude 能做到。` | **Cloud** 能做到 | + | CMU | B 标注 | ` 能做到。` | **看** 能做到 | + | CMU | C 对照 | `Cat 能做到。` | **Cat** 能做到 | + + 拼音通道:**B 与 C 转写完全相同、且都与 A 不同**(银行 yín háng → 隐形 yín xíng)—— + 文本级铁证。CMU 通道:B 既不同于 A(Claude→Cloud),其转写「看」(kàn) 与目标音 + /kæt/ 一致,说明**标注方向确实生效**;但 ASR 把它转成汉字而把对照组 C 转成 "Cat", + 提示 CMU 通道产出的是**带中文口音**的英文——可用,但用于成片前仍需人耳确认音色是否可接受。 + + > **口径更正**:本轮先用 MFCC-DTW 做判据,拼音通道得到 2.4× 分离度(`d(B,C)=0.133` vs + > `d(A,B)=0.320`)足以定论;但 CMU 通道的差距仅 2% 且判定随分析窗口翻转(前 0.6 s + > 「生效」、前 1.0 s「未生效」),当时据此错判为「不可判定」。**根因是 DTW 对「差异只 + > 集中在首词、句尾大段共享」这类情形分辨力不足**——整句距离被共享部分稀释。改用 ASR + > 后一目了然。教训:验证发音类改动优先用**回转写**,声学距离只作辅助。 ## 三、情感与音色解耦的数学机制 @@ -577,15 +585,45 @@ TensorRT-LLM)。仓库内**无 vLLM 后端**,README 只给外链 recipe。 | 6 | **建立可用的性能测量环境** | 本机连续调用漂 2.4–3.4×,A/B 不可做(§6.4–6.5) | 冷却节奏(75 s/次),耗时约 5:1 占空比 | `tts_bench.py` A/A 判定 + 判据单测 | ✅ **已做**:漂移定因为热节流(换页/分配器/泄漏均排除),加 75 s 冷却后极差比 1.016–1.047× 合格(§6.5) | | 7 | `length_penalty>0`(长句吞尾) | 机制明确(§4.2),但**本轮未复现收益** | 进缓存摘要 ⇒ 改档即重录 | #6 已解除阻塞:按 §6.5 协议跑;固定 seed + n≥20 长句,判据=尾部字词完整率(非墙钟) | ⬜ **待验证(已可做)** | | 8 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | 同上;且**与音色耦合**不可迁移 | #6 已解除阻塞:固定 seed + beams=1,30 句覆盖长/短/数字/英文 | ⬜ **待验证(已可做)** | -| 9 | 预设名义向量归一到 Σvec°=1.0 | 让 alpha 跨预设可比(§3.1) | 改预设 ⇒ 整集重录 | `α_new = α_old × Σvec°_old` 折算后波形应近乎一致 | ⬜ 待验证 | -| 10 | 砍掉 `calm`/`surprised` 配料 | bias 只 0.5625/0.6875,占预算却不兑付表达力(§3.2) | 同上 | 三档 A/B 比 F0 中位/起伏/音节率/质心 | ⬜ 待验证 | -| 11 | 密集技术句改 `df>1` | 方向勘误(§3.4) | 拉长时长 ⇒ 牵动 beat | 5 句最糊的技术句跑 df ∈ {0.95,1.0,1.05,1.08},用 ASR 回转写 CER 作清晰度代理 | ⬜ 待验证 | -| 12 | 重录目标风格参考样本 | 换段落即 F0 +12~16%、起伏 +25~40%(VOICE-CLONING §3.3);好段落必须放开头(§5) | 一次录制 + 定档 | 纯克隆小样比对,合格线取现有最佳候选的九成 | ⬜ 待做 | -| 13 | `prospect_ref.py` 增保真度门 | 现公式 5 项全是「风格」、0 项「保真度」;谱质心把「亮」与「噪」混淆 | 改评分 ⇒ 历史排名口径失效 | 干净候选 + 人工注入 -45 dBFS 白噪,旧公式总分应上升(暴露缺陷) | ⬜ 待做 | -| 14 | 进程级分片并行(双实例) | 瓶颈是发射/同步而非带宽饱和 | **本机内存不允许**(实测起第二实例后交换区仅剩 0.5 GB) | 先测稳态 RSS 与 swap 余量 | ⛔ 本机受限 | -| 15 | 降 `diffusion_steps` / 关 CFG | s2mel 占 45–73%,步数 25→12 可省该段一半 | **直接动音质**;需改服务端 | 10 句 A/B,谱质心掉 >5% 或出现齿音即否决 | ⬜ 高风险待验证 | +| 9 | 预设名义向量归一到 Σvec°=1.0 | 让 alpha 跨预设可比(§3.1) | 零——只动 `lively`/`confident`/`positive`,三集**都不用**这三档 | 有效注入 `w=vec×alpha` 在上游 4 位量化的 1 单位内一致(单测钉死) | ✅ **已做** | +| 10 | 砍掉 `calm`/`surprised` 配料 | bias 只 0.5625/0.6875,占预算却不兑付表达力(§3.2) | 零(**新增** `sunny-pure` 候选档,不动生产档) | 待 A/B:比 F0 中位/起伏/音节率/限带质心 + 人耳 | 🟡 **候选档已就位**,待定档 | +| 11 | 密集技术句改 `df>1` | 方向勘误(§3.4) | 零(**新增** `sunny-clear` 候选档 df=1.05);定档后须重渲(牵动 beat) | 待 A/B:ASR 回转写 CER 作清晰度代理(ASR 通路已就绪,见 §2.3) | 🟡 **候选档已就位**,待定档 | +| 12 | 重录目标风格参考样本 | 换段落即 F0 +12~16%、起伏 +25~40%(VOICE-CLONING §3.3);好段落必须放开头(§5) | 一次录制(**须本人操作**) | 工具已就绪:`prospect_ref.py --accept` 判保真度(削波/底噪/动态/带宽/超 15s),再跑纯克隆小样比风格(合格线 F0 ≥155 Hz、起伏 ≥34) | 🟡 **工具就绪,待录音** | +| 13 | `prospect_ref.py` 增保真度门 | 现公式 5 项全是「风格」、0 项「保真度」;谱质心把「亮」与「噪」混淆 | 排名口径变更(质心改限带、静音改绝对阈) | 成片在用的 180s 段**被放行** ✅,且正确标出更脏的窗口(底噪 −46 dB/动态 29 dB);超 15s 与 5 kHz 带限反例均被拦 | ✅ **已做** | +| 14 | 进程级分片并行(双实例) | 原假设「瓶颈是发射/同步开销」 | — | — | ⛔ **不做**:#6 已定因为**热节流**而非吞吐受限(换页/分配器/泄漏均排除)。两个进程只会更快撞上同一个热墙——单实例都已在 6 次调用内漂 2.4× | +| 15 | 降 `diffusion_steps` / 关 CFG | s2mel 占 45–73%,步数 25→12 本可省该段一半 | — | — | ⛔ **不可做**:二者是 `infer()` **函数体内的局部字面量**(`:829-830`),既非参数也非模块常量 ⇒ 无法传入、无法 monkeypatch,只能改上游源码——那是 `glossary.yaml` 被否决的同一模式(不受本仓版本控制、换机即静默失效) | | 16 | 迁 MLX / 换栈 | 见 §6.3 | 砍掉全部情感控制 ⇒ 7 档风格体系失效 | — | ⛔ **不推荐**:#4 已完成分母校准,缺口基本可归因,无大块性能余量支持换栈 | -| 17 | 升级到 2.5-RL 权重 | 论文中文 WER 4.36→3.93、SS 77.10→77.92 | 整集重录 | — | ⛔ **权重未公开**(见下) | +| 17 | 升级到 2.5-RL 权重 | 论文中文 WER 4.36→3.93、SS 77.10→77.92 | 整集重录 | — | ⛔ **权重未公开**(见下);订阅 upstream release 即可,无需本仓工程 | + +### 7.1 候选档怎么定档(#10 / #11 的收尾动作) + +`sunny-pure`(#10)与 `sunny-clear`(#11)已作为**新增**预设就位,对三集缓存零影响。定档 +需要的是证据,不是再改代码: + +```bash +# 0) 先确认测量环境合格(§6.5)——否则听感之外的任何数字都不可归因 +cd ~/tools/index-tts +./.venv/bin/python <本仓>/media/pipeline/scripts/tts_bench.py --check-only + +# 1) 客观面:同一批句子在两档之间成对 A/B(逐句交替顺序以抵消热漂移) +# #10 比「表达力密度」——F0 中位/起伏/音节率/限带质心;#11 比清晰度——ASR 回转写 CER +./.venv/bin/python <本仓>/media/pipeline/scripts/tts_bench.py \ + --ref <本仓>/media/pipeline/voices/me-bright.wav \ + --texts <混合句集.txt> --ab-param duration_factor --ab-values 0.95,1.05 \ + --num-beams 3 --cooldown 60 --json .temp/ab-df.json + +# 2) 主观面:小样 A/B + 人耳(客观指标只能排除明显更差的,选不出「更好听」) +uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-bright.wav --style sunny-pure --seed 4242 --play +``` + +定档后要做的三件事:把选定值写进各集 `pipeline.toml`(**不是**写进散文文档——ISSUE-161 的 +教训);重跑 `pipeline.py tts --plan` 确认待合成句数;重渲让时间轴重算(`sunny-clear` 的 +df=1.05 会拉长时长,牵动 beat 与片尾渐黑窗口)。 + +**放弃也是合法结论**:候选档不必然要被采纳。若 A/B 显示差异落在测量噪声内,直接在本表 +标注「实测无差异,维持现档」并保留候选档供将来复查——那比强行改预设(整集重录 2 小时) +更划算。 **IndexTTS2.5-RL 权重未公开发布**(2026-08-20 核验):GitHub Model Zoo 4 条无 RL 行; HF `IndexTeam` 组织 14 个 repo 无 RL/GRPO 命名;全 Hub 搜索 97 个 IndexTTS repo,2.5 血统仅 @@ -602,6 +640,7 @@ release 比自行复现 GRPO 经济得多**——同时也在等 Zipformer 版 s | 换 MLX 栈 | 主动放弃 `emo_vector`/`emo_audio_prompt`/`emo_text` 与束搜索 | 本仓 7 档风格体系、alpha 标定、`sunny`/`sunny-steady` 双档全部失效 | | 原地覆盖参考样本 | 上游按路径缓存条件张量 | 「sha1 是新的、音色是旧的」(§5 末) | | 合并多句成单请求 | 118 字段预算与 1815 mel token 同时变成活约束 | 单请求上限约 36 s 语音;溢出表现为**文本尾部未被念出** | +| 想调 S2M 扩散步数 / CFG | 二者是 `infer()` 体内的**局部字面量**(`:829-830`),不在签名里、也不是模块常量 | 无法从外部传入或 monkeypatch;只能改上游源码,而那份改动不受本仓版本控制、换机即静默失效(同 `glossary.yaml` 被否决的理由) | ## 九、上游追踪与参考文献 diff --git a/media/pipeline/PRON-GLOSSARY.md b/media/pipeline/PRON-GLOSSARY.md index e742ab52..f5113043 100644 --- a/media/pipeline/PRON-GLOSSARY.md +++ b/media/pipeline/PRON-GLOSSARY.md @@ -40,8 +40,8 @@ ## 边界 -- **英文专名不进本表**:沿用「进角标不口播」的既有纪律。CMU 音素通道 - (``)语法可用但模型响应未经证实(2026-08-20 A/B 客观距离随 - 分析窗口翻转),用前必须人耳小样确认。 +- **英文专名默认不进本表**:内容层沿用「进角标不口播」。CMU 音素通道 + (``)已用 ASR 回转写证实生效(2026-08-20),但产出**带中文口音** + 的英文,用于成片前须人耳确认;确需口播的专名可记入本表并注明「CMU」。 - **数字/百分号/量词不进本表**:那是文本归一化的职责,禁写清单见 [skills/03-narration.md](./skills/03-narration.md) 的读法纪律表。 diff --git a/media/pipeline/README.md b/media/pipeline/README.md index 7798ebf8..67bd53b6 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -74,8 +74,8 @@ uv run --no-project media/pipeline/scripts/pipeline.py --project media/-vi | [scripts/tts.py](./scripts/tts.py) | 逐句配音合成 + 时长 manifest(幂等,双引擎:edge 预置音色 / indextts 声音克隆;风格推荐位 sunny 明快阳光,`--steady` 混合档让关键句单独升束宽,`--plan` 预演排期) | `uv run --no-project --with edge-tts --with mutagen scripts/tts.py`(克隆模式免 edge-tts,见 [VOICE-CLONING.md](./VOICE-CLONING.md)) | | [scripts/tts_server.py](./scripts/tts_server.py) | IndexTTS 推理服务(声音克隆后端,**运行于 index-tts 环境**,非本仓) | 在 `~/tools/index-tts` 内启动,见 [VOICE-CLONING.md §二](./VOICE-CLONING.md) | | [scripts/tts_sample.py](./scripts/tts_sample.py) | 单句声音小样试听(直调 IndexTTS 服务合成一句话 + 全风格 A/B,定稿风格前的必经关口) | 无工程薄包装,从仓库根调用:`uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py --ref <样本.wav> --all-styles --play`,见 [VOICE-CLONING.md §5.1](./VOICE-CLONING.md) | -| [scripts/prepare_ref.py](./scripts/prepare_ref.py) | 参考音色样本裁剪/规范化(长录音 → 5–15s 干净 WAV) | 无工程薄包装(与具体工程无关),从仓库根调用:`uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prepare_ref.py <源音频>` | -| [scripts/prospect_ref.py](./scripts/prospect_ref.py) | 参考样本选段勘探(按 F0/起伏/音节率/谱质心筛「更亮更轻快」的候选起点,喂给 prepare_ref.py) | 无工程薄包装,从仓库根调用:`uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospect_ref.py <源音频…>`,见 [VOICE-CLONING.md §3.2](./VOICE-CLONING.md) | +| [scripts/prepare_ref.py](./scripts/prepare_ref.py) | 参考音色样本裁剪/规范化(长录音 → **10–14s** 干净 WAV;硬上限 15s——上游超出即静默前截) | 无工程薄包装(与具体工程无关),从仓库根调用:`uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prepare_ref.py <源音频>` | +| [scripts/prospect_ref.py](./scripts/prospect_ref.py) | 参考样本选段勘探(按 F0/起伏/音节率/限带质心筛「更亮更轻快」的候选起点)+ `--accept` **保真度验收**(削波/底噪/动态/有效带宽/超 15s,与风格分正交;损伤事后无法弥补故只否决不加权) | 无工程薄包装,从仓库根调用:`uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospect_ref.py <源音频…>`,见 [VOICE-CLONING.md §3.2](./VOICE-CLONING.md) | | [scripts/pipeline.py](./scripts/pipeline.py) | **单入口编排**(上表) | `uv run --no-project media/pipeline/scripts/pipeline.py --project media/<工程> tts --plan` | | [scripts/timeline.py](./scripts/timeline.py) | 时间轴 Python 侧实现(与 timing.ts 同构,直读 timing.json) | 被 qa_frames/captions/check_script 复用 | | [scripts/check_script.py](./scripts/check_script.py) | ④⑤ 内容门:beat 覆盖性 / 时长预算双口径 / SceneFade 不变式 / `--check-scenes` 分镜↔代码互比 | `uv run --no-project scripts/check_script.py --check-scenes` | diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index 0f2335c6..437ee5a0 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -18,7 +18,7 @@ ## 一、总览与架构 -**能力**:用一段 5–15 秒的本人录音作为参考音色,零样本(zero-shot)克隆出本人音色,逐句合成整集配音;并通过情感向量注入轻快、自信、正能量等风格。整集要跑数小时,故**定稿前先用单句小样试听择优**(§5.1),再全量合成(§5.2)。 +**能力**:用一段 **10–14 秒**(硬上限 15 秒)的本人录音作为参考音色,零样本(zero-shot)克隆出本人音色,逐句合成整集配音;并通过情感向量注入轻快、自信、正能量等风格。整集要跑数小时,故**定稿前先用单句小样试听择优**(§5.1),再全量合成(§5.2)。 **架构**(管线脚本轻依赖 与 重型推理环境 完全解耦): @@ -129,6 +129,16 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec 分高只代表「不小声、不平、不慢」,**不代表段落好**(成片在用的 180s 段综合分仅排 157/275);真正的判据是人声干净、单说话人、语句完整、语速语调贴近目标成片——只能靠试听定夺。 +**保真度与风格是正交的两件事**(2026-08-20 新增):上面那套评分只回答「亮不亮、快不快」,完全不看音质;而保真度损伤**事后无法弥补**(事后增强能提升 UTMOS 却降低说话人相似度)。故 `prospect_ref.py` 另有一组只做否决、不进加权的指标——削波、底噪绝对电平、动态范围(SNR 代理)、DC 偏置、有效带宽(识别低码率转码)——在候选表里以「保真旗标」列呈现。**新录的样本请先过验收模式**: + +```bash +uv run --no-project --with soundfile --with numpy \ + media/pipeline/scripts/prospect_ref.py --accept <候选1.wav> [<候选2.wav> …] +# 整段评估(不滑窗):保真度硬结论 + 风格参考值 + 超 15s / 低带宽告警 +``` + +通过保真度的候选再各跑一次 `--style neutral` 纯克隆小样比风格(合格线针对**小样**:F0 中位 ≥155 Hz 且起伏 ≥34,即「换段落所能拿到的最好水平」的九成)。 + ### 3.3 样本决定基线:换段落比调参数更管用(实测) 若合成结果「不够轻快/不够阳光」,**先怀疑样本,再怀疑向量**。2026-08-19 在同一位说话人的 4 段录音上做 12s 滑窗勘探(指标:F0 中位=音高、F0 四分位距=语调起伏、音节率=语速、谱质心=明亮度),并对每个候选样本做**纯克隆**(`--style neutral`,不注入任何情感)小样: @@ -180,6 +190,12 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec | confident 自信 | 沉稳有力 | calm=.65, happy=.25 | 0.7 | 0.63 | 1.05 | 1 | | positive 正能量 | 昂扬向上 | happy=.75, calm=.20 | 0.7 | 0.665 | 1.0 | 1 | +> **两个候选档(2026-08-20 新增,尚未定档)**:`sunny-pure`(happy 单载,砍掉 sunny 里 +> 有效强度仅 0.5625/0.6875 的 calm/surprised 配料)与 `sunny-clear`(= `sunny-steady` +> 但 df 1.05,护术语密集句的清晰度——df 方向勘误见 §4.3)。二者是**新增**而非改动生产档, +> 故对已上线三集的缓存零影响;定档前须按 +> [INDEXTTS-2.5-ADVANCED.md §6.5](./INDEXTTS-2.5-ADVANCED.md) 的测量协议做 A/B + 人耳确认。 + 预设可自带**束宽**(`STYLE_PRESETS` 的可选键 `beams`,缺省 1)——束宽改变韵律稳定度,属风格的一部分;命令行 `--num-beams` 显式给值时优先(故其 argparse 默认值是 `None` 而非 `1`,否则无法区分"没给"与"给了 1")。`--list-styles` 会打印全部七档的向量/alpha/有效注入/语速/束宽。 > **`sunny-steady` 的来历**:与 `sunny` 同方向同强度同语速,只把束宽 1→3。同文本同样本实测:语调起伏 **48.4 → 43.5**(更收敛、更"稳")、音节率 4.10 → 4.55,而亮度基本不掉(谱质心 1245 → 1223)——是目前唯一"不牺牲明快度就让语气更可信"的旋钮。代价是 GPT 段耗时按束宽放大:单句墙钟由 20–35 秒变为 **56–131 秒**(同机同参两次实测的区间,受机器负载影响大),整集排期须按 §4.3b 的 3 束口径乘上去。 @@ -426,7 +442,7 @@ cd video && pnpm run render:draft && pnpm run render # render 脚本定义在 | `/health` 报 `supports_duration_factor=false` | 服务为 IndexTTS-2 | 语速控制需 v2.5:重启服务 `--indextts-version 2.5` | | `/health` 报 `supports_emo_text=false`,`--emo-text` 被拒 | 服务未加载 QwenEmotion | 带 `--use-qwen-emo` 重启;若报缺 `model.safetensors` 见 §2.4 补权重 | | 报「情感来源互斥,只能给一个」 | 同时给了 `--emo-vector`/`--emo-ref`/`--emo-text` 中的两个以上 | 三者择一(上游遇「向量+音频」会静默丢弃音频,故本管线显式拒绝,见 §四) | -| 生成音色「不像我」 | 样本质量问题 | 按 §三 重录/重裁:换更干净段落、保证单说话人、5–15s | +| 生成音色「不像我」 | 样本质量问题 | 按 §三 重录/重裁:换更干净段落、保证单说话人、10–14s(先跑 `prospect_ref.py --accept` 过保真度) | | 长句合成失败 | 超时(HTTP_TIMEOUT=600s) | 重跑(缓存续传);超长句在逐字稿层面拆句 | | edge 模式失败 | 网络 | 与历史行为一致(重试 4 次后报错) | diff --git a/media/pipeline/scripts/prospect_ref.py b/media/pipeline/scripts/prospect_ref.py index a21f76b9..5138e6e6 100644 --- a/media/pipeline/scripts/prospect_ref.py +++ b/media/pipeline/scripts/prospect_ref.py @@ -10,6 +10,11 @@ 音节率 —— 语速("轻快"的主因),能量包络峰计数的粗代理 谱质心 —— 明亮度/爽朗感 RMS / 静音占比 / 发声占比 —— 响度与停顿,用于排除大段留白 +- **保真度门**(2026-08-20 新增,与风格分**正交**):风格分只回答「亮不亮、快不快」, + 完全不看音质。而文献侧的结论是保真度问题**事后无法弥补**——WildSpoof(arXiv:2602.05770) + Table 2 显示事后增强能提升 UTMOS/DNSMOS 却让 SECS 从 0.35 掉到 0.28。故新增一组 + 只做否决、不进加权的指标:削波、底噪绝对电平、动态范围(SNR 代理)、DC 偏置、 + 低码率转码痕迹。 - 输出:按综合分排序的候选起点,可直接喂给 prepare_ref.py 的 --start。 用法(仓库根): @@ -35,6 +40,30 @@ VOICED_AC = 0.35 # 自相关归一峰值阈:判定该帧是否为浊音 MANUAL = "media/pipeline/VOICE-CLONING.md" +#: 保真度阈值。**只做告警与旁注,不进加权分、不硬失败**——阈值是跨录音设备的绝对值, +#: 先观察一轮真实录音的分布再决定是否升级为硬门(否则容易把风格问题误判成音质问题)。 +CLIP_LEVEL = 0.999 # |x| ≥ 此值视为削波样本 +MAX_CLIP_SAMPLES = 0 # 削波样本数上限:削波谐波落在 8 kHz 内,CMVN 抵消不掉 +MAX_NOISE_DBFS = -50.0 # 底噪(帧 RMS 第 10 百分位)上限 +MIN_DYNAMIC_DB = 35.0 # 浊音中位 − 底噪,SNR 代理下限 +MAX_DC_OFFSET = 1e-3 # |均值| 上限 +#: 绝对静音阈(dBFS)。原实现用**相对**阈(帧 RMS < 0.1×窗口 RMS),在有稳定底噪的 +#: 录音上会系统性低估静音——底噪把帧 RMS 抬到阈值之上,停顿不被计为静音、扣分项失效。 +SILENCE_DBFS = -45.0 +#: 限带谱质心的频带。原实现用全带质心,会把「嗓音明亮」与「有嘶声/底噪」记成同一个信号 +#: (噪声底恰恰抬高质心),叠加上面的相对静音阈,使「脏但亮」的段落获得双重虚高。 +CENTROID_BAND = (300.0, 5000.0) + +#: `--accept` 模式的风格合格线(路线图 #12「重录目标风格参考样本」的验收判据)。 +#: 取自 VOICE-CLONING.md §3.3 的候选实测:现有最佳候选 me-1@28s 的**纯克隆小样** +#: 达到 F0 中位 163.3 Hz / 起伏 35.2,合格线取其九成——即「重录的样本至少要能达到 +#: 换段落所能拿到的最好水平的九成」,否则不如直接换段落。 +#: ⚠️ 阈值是对**纯克隆小样**(--style neutral 合成结果)而非样本本身;样本侧指标只作参考, +#: 因为克隆会压缩起伏(实测样本 35.2 → 小样 34.1 一类),二者不同尺度。 +ACCEPT_SAMPLE_F0 = 150.0 # 样本 F0 中位下限(参考值,不否决) +ACCEPT_CLONE_F0 = 155.0 # 纯克隆小样 F0 中位下限 +ACCEPT_CLONE_IQR = 34.0 # 纯克隆小样 F0 起伏下限 + def framed(x: np.ndarray, frame: int, hop: int) -> np.ndarray: n = 1 + max(0, (len(x) - frame) // hop) @@ -43,6 +72,86 @@ def framed(x: np.ndarray, frame: int, hop: int) -> np.ndarray: return x[np.arange(frame)[None, :] + hop * np.arange(n)[:, None]] +def _dbfs(v: float) -> float: + return 20.0 * float(np.log10(max(v, 1e-12))) + + +def fidelity_stats( + seg: np.ndarray, frame_rms: np.ndarray, voiced_rms: np.ndarray +) -> dict: + """保真度指标(只否决、不加权)。→ dict,含 `fid_flags` 人读旗标列表。 + + **底噪只在窗口内确实存在停顿时才可估**:指标定义是「帧 RMS 第 10 百分位」,若整个 + 窗口都在说话(连读、无气口),第 10 百分位测到的是**轻声段**而非底噪,会给出虚高的 + 假警报(合成的恒幅正弦上实测 −13.6 dBFS)。故先检查有没有足够的「疑似静音」帧 + (低于 `SILENCE_DBFS + 10 dB` 的宽松线);不足 5% 时把 `noise_db`/`dyn_db` 记为 + None 并旗标「底噪不可估」,交由人耳判断——宁可说「测不了」,不要给一个错的数。 + """ + clip = int(np.sum(np.abs(seg) >= CLIP_LEVEL)) + dc = float(abs(np.mean(seg))) if len(seg) else 0.0 + flags = [] + if clip > MAX_CLIP_SAMPLES: + flags.append(f"削波{clip}") + if dc > MAX_DC_OFFSET: + flags.append(f"DC{dc:.1e}") + + quiet_thr = 10.0 ** ((SILENCE_DBFS + 10.0) / 20.0) + if len(frame_rms) == 0 or float(np.mean(frame_rms < quiet_thr)) < 0.05: + flags.append("底噪不可估(无停顿)") + return { + "clip": clip, + "noise_db": None, + "dyn_db": None, + "dc": dc, + "fid_flags": flags, + } + + noise = _dbfs(float(np.percentile(frame_rms, 10))) + speech = _dbfs(float(np.median(voiced_rms))) if len(voiced_rms) else -120.0 + dyn = speech - noise + if noise > MAX_NOISE_DBFS: + flags.append(f"底噪{noise:.0f}dB") + if dyn < MIN_DYNAMIC_DB: + flags.append(f"动态{dyn:.0f}dB") + return { + "clip": clip, + "noise_db": noise, + "dyn_db": dyn, + "dc": dc, + "fid_flags": flags, + } + + +def bandwidth_khz(x: np.ndarray, sr: int, floor_db: float = -50.0) -> float: + """有效带宽(kHz):谱包络仍高于「峰值 + floor_db」的最高频率。 + + 用于识别低码率转码——64 kbps mp3 在 ~11 kHz 处断崖,正落在模型可见频带 + (重采样到 22.05 kHz 后 Nyquist 11.025 kHz)边缘,会在那里留下人工痕迹。 + + **不能用「累积能量 99% 分位」**:语音能量本就集中在 3 kHz 以下,那样算出来 + 任何正常语音都是约 3 kHz,会对每个窗口误报(本轮踩过)。判据必须是**相对自身 + 峰值的断崖位置**,而不是能量占比。 + """ + n = min(len(x), sr * 8) # 前 8 秒足够 + if n < 2048: + return 0.0 + mag = np.abs(np.fft.rfft(x[:n] * np.hanning(n))) + freqs = np.fft.rfftfreq(n, 1 / sr) + # 对数谱做宽平滑,压掉谐波梳状结构,只看包络趋势。 + # **必须按实际抽头数归一化**:np.convolve(..., "same") 在两端少算抽头且不补偿, + # 会把空频段的 −240 dB 抬到接近 0 dB,使顶端恒在阈值之上、带宽恒报 Nyquist + # (本轮踩过:5 kHz 带限信号被测成 16 kHz)。 + k = max(1, len(mag) // 200) + db = 20 * np.log10(mag + 1e-12) + ker = np.ones(k) + env = np.convolve(db, ker, mode="same") / np.convolve( + np.ones_like(db), ker, mode="same" + ) + thr = float(env.max()) + floor_db + above = np.flatnonzero(env > thr) + return float(freqs[above[-1]]) / 1000.0 if len(above) else 0.0 + + def frame_features(x: np.ndarray, sr: int) -> dict: """帧级 F0 / 谱质心 / RMS(整段算一次,供各窗口聚合)。""" F = framed(x, FRAME, HOP) @@ -68,9 +177,12 @@ def frame_features(x: np.ndarray, sr: int) -> dict: voiced = seg[np.arange(len(seg)), best] > VOICED_AC f0[s : s + len(blk)] = np.where(voiced, sr / np.maximum(best + lag_lo, 1), 0.0) mag = np.abs(np.fft.rfft(blk, n=FRAME)) - den = mag.sum(axis=1) + # 限带质心:只在 300–5000 Hz 内算,切断「亮」与「噪」的混淆(见 CENTROID_BAND) + band = (freqs >= CENTROID_BAND[0]) & (freqs <= CENTROID_BAND[1]) + bm = mag[:, band] + den = bm.sum(axis=1) den[den == 0] = 1.0 - centroid[s : s + len(blk)] = (mag * freqs).sum(axis=1) / den + centroid[s : s + len(blk)] = (bm * freqs[band]).sum(axis=1) / den return {"f0": f0, "centroid": centroid, "rms": rms, "gate": gate} @@ -98,6 +210,10 @@ def window_stats(feat: dict, x: np.ndarray, sr: int, start: int, window: int) -> return {} rms = float(np.sqrt(np.mean(seg**2))) frame_rms = feat["rms"][fs:fe] + # 静音判定改用**绝对**阈(相对阈在有底噪的录音上会失效,见 SILENCE_DBFS) + sil_thr = 10.0 ** (SILENCE_DBFS / 20.0) + voiced_mask = (f0 > 0) & gate + fid = fidelity_stats(seg, frame_rms, frame_rms[voiced_mask]) return { "start": start, "f0_med": float(np.median(voiced)), @@ -105,11 +221,87 @@ def window_stats(feat: dict, x: np.ndarray, sr: int, start: int, window: int) -> "syl": syllable_rate(seg, sr), "cen": float(np.mean(feat["centroid"][fs:fe][gate])) if np.any(gate) else 0.0, "rms": rms, - "sil": float(np.mean(frame_rms < 0.1 * rms)) if len(frame_rms) else 1.0, - "voiced": float(np.mean((f0 > 0) & gate)), + "sil": float(np.mean(frame_rms < sil_thr)) if len(frame_rms) else 1.0, + "voiced": float(np.mean(voiced_mask)), + **fid, } +def accept_mode(sources: list[str]) -> int: + """验收模式:整段评估候选样本,输出保真度判定与风格参考值。 + + 与滑窗勘探的分工:勘探是「从长录音里找候选」,验收是「判断这一段能不能用」。 + 验收只对**保真度**下硬结论(削波/底噪/动态/转码——这些事后无法弥补),风格指标 + 仅作参考并给出下一步指引,因为风格的真正判据是纯克隆小样而不是样本本身。 + """ + bad = 0 + for src in sources: + p = Path(src).expanduser() + if not p.is_file(): + print(f"❌ 不存在:{p}", file=sys.stderr) + bad += 1 + continue + x, sr = sf.read(str(p), dtype="float32", always_2d=True) + x = x.mean(axis=1) + dur = len(x) / sr + feat = frame_features(x, sr) + if not feat: + print(f"❌ {p.name}: 音频过短") + bad += 1 + continue + st = window_stats(feat, x, sr, 0, int(dur)) + if not st: + print(f"❌ {p.name}: 无有效浊音帧(全静音?)") + bad += 1 + continue + bw = bandwidth_khz(x, sr) + nyq = sr / 2000.0 + flags = list(st["fid_flags"]) + if bw < min(0.7 * nyq, 12.0): + flags.append(f"带宽{bw:.1f}k") + # 上游硬截断 15 秒且保前段丢尾部(infer_v2_5.py:396-408) + if dur > 15.0: + flags.append(f"超 15s(后 {dur - 15:.1f}s 永不进模型)") + ok = not flags + bad += 0 if ok else 1 + print( + f"\n{'✅' if ok else '❌'} {p.name} {dur:.1f}s · {sr} Hz · 有效带宽 {bw:.1f} kHz" + ) + print( + " 保真度:削波 {} · 底噪 {} · 动态 {} · DC {:.1e}".format( + st["clip"], + "不可估(窗口内无停顿)" + if st["noise_db"] is None + else f"{st['noise_db']:.0f} dBFS(≤{MAX_NOISE_DBFS:g})", + "不可估" + if st["dyn_db"] is None + else f"{st['dyn_db']:.0f} dB(≥{MIN_DYNAMIC_DB:g})", + st["dc"], + ) + ) + print( + f" 风格参考:F0 中位 {st['f0_med']:.1f} Hz · 起伏 {st['f0_iqr']:.1f}" + f" · 音节率 {st['syl']:.2f} · 限带质心 {st['cen']:.0f} Hz" + f" · 静音 {st['sil']:.2f} · 发声 {st['voiced']:.2f}" + ) + if flags: + print(f" ⚠️ {'、'.join(flags)} —— 这类损伤事后无法弥补,请重录而非后期修") + elif st["f0_med"] < ACCEPT_SAMPLE_F0: + print( + f" 提示:样本 F0 中位低于 {ACCEPT_SAMPLE_F0:g} Hz,克隆音大概率偏闷;" + "若目标是「明快」建议重录得更亮一些" + ) + print( + f"\n下一步:对**通过保真度**的候选各跑一次纯克隆小样,比风格而非比样本:\n" + f" uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \\\n" + f" --ref <裁剪后的样本.wav> --style neutral --seed 4242 --label <名字>\n" + f"合格线(对**小样**,非样本):F0 中位 ≥{ACCEPT_CLONE_F0:g} Hz 且起伏 ≥{ACCEPT_CLONE_IQR:g}" + f"——即达到「换段落所能拿到的最好水平」的九成(见 {MANUAL} §3.3);\n" + f"未达标就重录,别靠加情感向量补(注入越多越像别人,见 INDEXTTS-2.5-ADVANCED.md §3)。" + ) + return 1 if bad else 0 + + def main() -> int: parser = argparse.ArgumentParser( description="参考样本选段勘探(更亮/更轻快的候选起点)" @@ -124,9 +316,19 @@ def main() -> int: parser.add_argument( "--top", type=int, default=4, help="每个文件列出的候选数(默认 4)" ) + parser.add_argument( + "--accept", + action="store_true", + help="验收模式:把每个输入当作**一段完整的候选样本**整体评估(不滑窗)," + "打印风格指标 + 保真度旗标 + 合格判定。用于路线图 #12「重录目标风格参考样本」:" + "录 2–3 个版本,各跑一次本模式筛掉音质不合格的,再对通过的跑纯克隆小样比风格", + ) args = parser.parse_args() win, step = int(args.window), max(1, int(args.step)) + if args.accept: + return accept_mode(args.sources) + rows: list[dict] = [] for src in args.sources: p = Path(src).expanduser() @@ -140,11 +342,23 @@ def main() -> int: print(f"跳过(短于 {win}s):{p.name} {dur:.1f}s", file=sys.stderr) continue feat = frame_features(x, sr) + roll = bandwidth_khz(x, sr) + nyq = sr / 2000.0 + # 低码率转码痕迹:标称 sr 高,但有效带宽在远低于 Nyquist 处就断了。 + # 判据取「带宽 < 0.7×Nyquist 且 < 12 kHz」——12 kHz 以上的截断对模型不可见 + # (spk 路径无条件重采样到 22.05 kHz ⇒ Nyquist 11.025 kHz),无需告警。 + transcoded = roll < min(0.7 * nyq, 12.0) for s in range(0, int(dur) - win + 1, step): st = window_stats(feat, x, sr, s, win) if st: + if transcoded: + st["fid_flags"] = [*st["fid_flags"], f"带宽{roll:.1f}k"] rows.append({**st, "file": p.name, "path": str(p)}) - print(f"# {p.name}: {dur:.0f}s sr={sr}", file=sys.stderr) + print( + f"# {p.name}: {dur:.0f}s sr={sr} 有效带宽 {roll:.1f} kHz" + + ("(疑似低码率转码)" if transcoded else ""), + file=sys.stderr, + ) if not rows: print("无有效窗口(音频过短或全为静音)", file=sys.stderr) @@ -172,6 +386,7 @@ def main() -> int: print( f"\n{'file':<16}{'--start':>9}{'分':>7}{'F0中位':>8}{'F0起伏':>8}" f"{'音节率':>8}{'质心Hz':>8}{'RMS':>7}{'静音':>7}{'发声':>7}" + f"{'底噪dB':>8}{'动态dB':>8} 保真旗标" ) picked: dict[str, list[int]] = {} for r in sorted(rows, key=lambda r: -r["score"]): @@ -187,13 +402,19 @@ def main() -> int: f"{r['file']:<16}{r['start']:9d}{r['score']:7.2f}{r['f0_med']:8.1f}" f"{r['f0_iqr']:8.1f}{r['syl']:8.2f}{r['cen']:8.0f}{r['rms']:7.3f}" f"{r['sil']:7.2f}{r['voiced']:7.2f}" + f"{' n/a' if r['noise_db'] is None else format(r['noise_db'], '8.0f')}" + f"{' n/a' if r['dyn_db'] is None else format(r['dyn_db'], '8.0f')}" + + (" ✅" if not r["fid_flags"] else " ⚠️ " + ",".join(r["fid_flags"])) ) print( f"\n下一步:挑 3–4 个候选各裁一份,再各跑一次 `--style neutral` 小样比对(见 {MANUAL} §3.3):\n" f" uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prepare_ref.py \\\n" f" <源音频> --start <上表 --start> --duration {win:g} --out media/pipeline/voices/<名字>.wav\n" - "分高只代表「不小声、不平、不慢」,**不代表段落好**——务必 afplay 试听确认人声干净、单说话人、语句完整。" + "分高只代表「不小声、不平、不慢」,**不代表段落好**——务必 afplay 试听确认人声干净、单说话人、语句完整。\n" + "保真旗标与风格分**正交**:⚠️ 的段落即使分高也别用(削波/底噪/动态不足/低码率转码的\n" + "损伤事后无法弥补——事后增强会提升 UTMOS 却降低说话人相似度)。旗标目前只告警不否决,\n" + "阈值见脚本顶部常量;观察一轮真实录音的分布后再决定是否升级为硬门。" ) return 0 diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 8492f7bd..c4126e52 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -105,22 +105,40 @@ "alpha": 0.7, "df": 0.97, }, + # 以下三档的名义向量已归一到 Σvec=1.0(2026-08-20):只有 Σvec=1.0 时 alpha 才等于 + # 「替换掉本人语调的百分比」,否则它被稀释成 α·Σvec、**跨预设不可比**。 + # alpha 同步反向缩放(α_new = α_old × Σvec_old),故有效注入 w=vec×alpha 在数学上不变。 + # **精确说法**:上游把 w 截断到 4 位小数(infer_v2_5.py:608 用 int() 截断而非四舍五入), + # 而 0.33/0.455 一类值恰落在截断边界上,故 21 个分量里有 3 个出现 1e-4 的差异 + # (0.33→0.3299、0.455→0.4549、0.5249→0.525)。相对幅度 0.03%,远低于任何听感阈; + # 且这三档**未被任何已上线剧集使用**(三集用 sunny-steady / passionate),即便真有 + # 差异也无影响。不要把它写成「逐项完全一致」——那是过度断言。 + # 由 tests/test_digest.py::test_preset_normalization_preserves_injection 钉死 1e-4 界。 "lively": { "label": "轻快", - "vec": [0.55, 0, 0, 0, 0, 0, 0.15, 0.15], - "alpha": 0.6, + "vec": [ + 0.6470588, + 0, + 0, + 0, + 0, + 0, + 0.1764706, + 0.1764706, + ], # 原 .55/.15/.15 ÷ 0.85 + "alpha": 0.51, # 原 0.6 × 0.85 "df": 0.95, }, "confident": { "label": "自信", - "vec": [0.25, 0, 0, 0, 0, 0, 0, 0.65], - "alpha": 0.7, + "vec": [0.2777778, 0, 0, 0, 0, 0, 0, 0.7222222], # 原 .25/.65 ÷ 0.90 + "alpha": 0.63, # 原 0.7 × 0.90 "df": 1.05, }, "positive": { "label": "正能量", - "vec": [0.75, 0, 0, 0, 0, 0, 0, 0.2], - "alpha": 0.7, + "vec": [0.7894737, 0, 0, 0, 0, 0, 0, 0.2105263], # 原 .75/.20 ÷ 0.95 + "alpha": 0.665, # 原 0.7 × 0.95 "df": 1.0, }, "sunny": { @@ -150,6 +168,30 @@ "df": 0.95, "beams": 3, }, + # ── 以下两档是**候选**,尚未定档:新增而非改动生产档,故对三集缓存零影响。 + # 定档前必须按 INDEXTTS-2.5-ADVANCED.md §6.5 的测量协议做 A/B + 人耳确认。 + "sunny-pure": { + "label": "明快纯载", + # 候选(路线图 #10):砍掉 sunny 里的配料维度。依据是上游 emo_bias + # (infer_v2_5.py:493)8 维不等权——surprised 只有 0.6875、calm 只有 0.5625, + # 它们既占 Σw 预算(等量挤掉本人语调)又只兑付 69%/56% 的表达力。sunny 里的 + # surprised=0.02(加 bias 后 0.0138、占比 1.5%)基本是装饰。 + # 同时这是唯一使 Σvec=1.0 与「happy 单载」同时成立的写法 ⇒ alpha 语义最干净。 + "vec": [1.0, 0, 0, 0, 0, 0, 0, 0], + "alpha": 0.35, + "df": 0.95, + }, + "sunny-clear": { + "label": "明快清晰", + # 候选(路线图 #11):= sunny-steady 但 df 1.05。依据是 df 方向的勘误—— + # df<1 = 更快 = 每音素分到的时间更短 = 咬字更紧更糊,护术语密集句的清晰度 + # 正确方向是 df>1(§3.4)。明快感仍由 happy/alpha 承担,与语速正交。 + # 代价:拉长时长 ⇒ 牵动 beat 与片尾渐黑窗口,定档后须重渲。 + "vec": [0.95, 0, 0, 0, 0, 0, 0.02, 0.03], + "alpha": 0.35, + "df": 1.05, + "beams": 3, + }, } @@ -697,7 +739,9 @@ async def main() -> None: idx = parser.add_argument_group("indextts 声音克隆") idx.add_argument( - "--ref", default=None, help="[indextts] 参考音色样本路径(建议 5–15s 干净人声)" + "--ref", + default=None, + help="[indextts] 参考音色样本路径(建议 10–14s 干净人声;硬上限 15s,上游超出即静默前截)", ) idx.add_argument( "--expect-ref-sha1", diff --git a/media/pipeline/scripts/tts_bench.py b/media/pipeline/scripts/tts_bench.py index 0915f87c..b2249a31 100644 --- a/media/pipeline/scripts/tts_bench.py +++ b/media/pipeline/scripts/tts_bench.py @@ -26,6 +26,21 @@ `--empty-cache` 在每次调用后清 MPS 缓存并 gc——用于判定漂移是否来自分配器累积。 若开启后漂移消失,则长跑(整集 2 小时)也应在服务端逐句清理。 +## 成对 A/B 模式(`--ab-param`) + + ./.venv/bin/python <本仓>/media/pipeline/scripts/tts_bench.py \ + --ref <样本.wav> --texts <每行一句的文本文件> \ + --ab-param length_penalty --ab-values 0.0,0.8 --num-beams 3 --cooldown 60 + +对每句**成对**跑两个取值,并**逐句交替先后顺序**——热漂移随时间单调,交替后它对两组的 +影响相互抵消,故即使环境仍有残余漂移,逐对比值依然可用。这是本机唯一可靠的参数归因方式。 + +判据除耗时外还含 **ASR 回转写**(whisper,若可用): + `cer` 整句字错率 + `tail_cov` **尾部覆盖率**——参考文本后 20% 的字符被转写命中的比例。这是 #7 + (`length_penalty=0.0` 系统性偏好短假设 ⇒ 吞尾)的直接判据, + 比总墙钟稳健得多,且对残余热漂移天然不敏感。 + 产物与完整方法论见 media/pipeline/INDEXTTS-2.5-ADVANCED.md §6.4。 """ @@ -258,6 +273,45 @@ def stable_window(walls: list[float]) -> tuple[int, int, float, float, float]: return trip +def _strip_punct(t: str) -> str: + return "".join(c for c in t if c.isalnum()) + + +def asr_metrics(model, wav: Path, ref: str) -> dict: + """whisper 回转写 → {hyp, cer, tail_cov}。model 为 None 时返回空 dict。 + + `tail_cov` 是参考文本**后 20%** 字符的命中比例:用 difflib 对齐 ref 与 hyp,统计 + 落在尾部区间内的匹配字符占该区间长度的比例。吞尾会让它显著下降,而它不受机器 + 热漂移影响——这正是 #7 需要的判据(对比总墙钟:那个会被漂移淹没)。 + """ + if model is None: + return {} + import difflib + + import librosa + + # **不能传路径**:whisper 内部会 shell 调 ffmpeg 解码,而本机 PATH 上没有 ffmpeg + # (仓库用的是 Remotion 内置那份)。直接喂 16 kHz float32 数组即可绕开。 + audio, _ = librosa.load(str(wav), sr=16000, mono=True) + hyp = model.transcribe(audio, language="zh", fp16=False)["text"] + r, h = _strip_punct(ref), _strip_punct(hyp) + if not r: + return {"hyp": hyp} + sm = difflib.SequenceMatcher(None, r, h, autojunk=False) + matched = sum(b.size for b in sm.get_matching_blocks()) + tail_start = int(len(r) * 0.8) + tail_hit = sum( + max(0, min(b.a + b.size, len(r)) - max(b.a, tail_start)) + for b in sm.get_matching_blocks() + ) + tail_len = len(r) - tail_start + return { + "hyp": hyp.strip(), + "cer": 1.0 - matched / len(r), + "tail_cov": (tail_hit / tail_len) if tail_len else 1.0, + } + + def main() -> None: ap = argparse.ArgumentParser(description="IndexTTS 合成耗时基准与测量环境体检") ap.add_argument("--ref", default=None, help="参考音色样本(A/A 运行必需)") @@ -292,6 +346,19 @@ def main() -> None: "--force", action="store_true", help="环境门不合格也继续(结果仅供参考)" ) ap.add_argument("--json", default=None, help="把逐次读数写入 JSON") + ap.add_argument("--texts", default=None, help="A/B 模式:每行一句的文本文件") + ap.add_argument( + "--ab-param", + default=None, + help="A/B 模式:要对比的参数名(如 length_penalty / repetition_penalty / " + "temperature / duration_factor / num_beams)", + ) + ap.add_argument("--ab-values", default=None, help="A/B 模式:两个取值,逗号分隔") + ap.add_argument( + "--asr", + default="small", + help="whisper 模型名(A/B 模式用于回转写判据);none = 关闭", + ) args = ap.parse_args() env_ok = check_env(strict=not args.check_only) @@ -375,6 +442,138 @@ def one(i: int) -> dict: torch.mps.empty_cache() return rec + # ---------------- 成对 A/B 模式 ---------------- + if args.ab_param: + if not (args.texts and args.ab_values): + sys.exit("--ab-param 需同时给 --texts 与 --ab-values") + vals = [float(v) for v in args.ab_values.split(",")] + if len(vals) != 2: + sys.exit("--ab-values 需给正好两个取值") + texts = [ + ln.strip() + for ln in Path(args.texts).read_text(encoding="utf-8").splitlines() + if ln.strip() + ] + model = None + if args.asr != "none": + try: + import whisper + + print(f">> 加载 whisper {args.asr}(ASR 判据)…", flush=True) + model = whisper.load_model(args.asr) + except Exception as e: # noqa: BLE001 - ASR 不可用时降级为纯耗时判据 + print(f">> whisper 不可用({e}),仅用耗时判据", file=sys.stderr) + + def ab_call(txt: str, val: float, tag: str) -> dict: + kw: dict = { + "spk_audio_prompt": str(ref), + "text": txt, + "output_path": str(tmp / f"{tag}.wav"), + "lang": "ZH", + "emo_vector": emo, + "emo_alpha": args.emo_alpha, + "use_random": False, + "verbose": False, + "num_beams": args.num_beams, + } + if args.ab_param == "num_beams": + kw["num_beams"] = int(val) + else: + kw[args.ab_param] = val + set_seed(args.seed) + buf = io.StringIO() + t = time.perf_counter() + with contextlib.redirect_stdout(buf): + tts.infer(**kw) + wall = time.perf_counter() - t + data, sr = sf.read(str(tmp / f"{tag}.wav")) + dur = len(data) / sr + stg = { + m.group(1): float(m.group(2)) for m in TIMER_RE.finditer(buf.getvalue()) + } + return { + "text": txt, + "value": val, + "audio": dur, + "synth": wall, + "sec_per_char": dur / max(1, len(_strip_punct(txt))), + **stg, + **asr_metrics(model, tmp / f"{tag}.wav", txt), + } + + print( + f"\n>> 成对 A/B:{args.ab_param} ∈ {vals},{len(texts)} 句," + f"束宽 {args.num_beams},seed {args.seed},冷却 {args.cooldown:g}s\n" + f" 逐句**交替先后顺序**以抵消热漂移" + ) + ab_rows: list[dict] = [] + for n, txt in enumerate(texts): + order = vals if n % 2 == 0 else vals[::-1] + for v in order: + if args.cooldown and ab_rows: + time.sleep(args.cooldown) + r = ab_call(txt, v, f"ab{n}_{v}") + ab_rows.append({**r, "idx": n}) + cer = ( + f" CER {r['cer']:.3f} 尾覆盖 {r['tail_cov']:.2f}" + if "cer" in r + else "" + ) + print( + f" [{n + 1:>2}/{len(texts)}] {args.ab_param}={v:<5g} " + f"音频 {r['audio']:5.2f}s synth {r['synth']:6.2f}s " + f"秒/字 {r['sec_per_char']:.3f}{cer}", + flush=True, + ) + print(f"\n>> A/B 汇总({args.ab_param})") + for v in vals: + g = [r for r in ab_rows if r["value"] == v] + line = ( + f" {args.ab_param}={v:<5g} n={len(g)} " + f"音频中位 {st.median([r['audio'] for r in g]):5.2f}s " + f"秒/字中位 {st.median([r['sec_per_char'] for r in g]):.3f} " + f"synth 中位 {st.median([r['synth'] for r in g]):6.2f}s" + ) + if "cer" in g[0]: + line += ( + f" CER 中位 {st.median([r['cer'] for r in g]):.3f}" + f" 尾覆盖中位 {st.median([r['tail_cov'] for r in g]):.3f}" + f" 尾覆盖<0.9 的句数 {sum(1 for r in g if r['tail_cov'] < 0.9)}" + ) + print(line) + # 逐句配对差(同句同种子,只差被测参数 ⇒ 配对差比组间中位更可信) + pairs = [] + for n in range(len(texts)): + g = {r["value"]: r for r in ab_rows if r["idx"] == n} + if len(g) == 2: + pairs.append((g[vals[0]], g[vals[1]])) + if pairs: + print(f"\n 逐句配对({len(pairs)} 对,B 相对 A):") + for key, label in ( + ("sec_per_char", "秒/字"), + ("tail_cov", "尾覆盖"), + ("cer", "CER"), + ): + if key not in pairs[0][0]: + continue + d = [b[key] - a[key] for a, b in pairs] + wins = sum(1 for x in d if x > 0) + print( + f" Δ{label:<6} 中位 {st.median(d):+.4f} " + f"B 更大的句数 {wins}/{len(d)}" + ) + if args.json: + Path(args.json).write_text( + json.dumps( + {"config": vars(args), "rows": ab_rows}, + ensure_ascii=False, + indent=1, + ), + encoding="utf-8", + ) + print(f"\n 读数已写入 {args.json}") + return + one(-1) # 预热:填充上游按路径缓存的说话人条件(等价「排除 clone」) print( f">> A/A 复现性:同配置连跑 {args.runs} 次" diff --git a/media/pipeline/skills/03-narration.md b/media/pipeline/skills/03-narration.md index d48fe775..9bc943a9 100644 --- a/media/pipeline/skills/03-narration.md +++ b/media/pipeline/skills/03-narration.md @@ -56,9 +56,11 @@ - ⚠️ **标注错 = 必然读错**:上游整体替换时丢弃原字,没有字形兜底。 - ⚠️ 正文出现孤立 `<`(如「延迟<10ms」)会与后面的标记粘连并**吞掉之间的正文**, 已成 ERROR 门;请改写为「小于」。 -- 英文专名**继续沿用「进角标不口播」的既有纪律**(下方写作纪律第 3 条)。CMU 音素通道 - (``)语法可用,但 2026-08-20 的 A/B 未能证实模型稳定响应 - (客观距离随分析窗口翻转),属**未验证选项**——若要用,必须先人耳小样对比。 +- 英文专名**默认仍走「进角标不口播」**(下方写作纪律第 3 条)——这是内容层的选择,与 + 技术可行性无关。若确需口播某个英文专名,CMU 音素通道(``,左侧纯 + ASCII、右侧 ARPAbet 大写)**已于 2026-08-20 用 ASR 回转写证实生效**;但它产出的是 + **带中文口音**的英文(对照实验里 ASR 把标注档转成汉字、把直写英文档转成原词), + 故成片前须人耳确认。 - 发现的读错字请沉淀到 [PRON-GLOSSARY.md](../PRON-GLOSSARY.md) 供跨集复用。 ## 写作纪律 diff --git a/media/pipeline/tests/test_digest.py b/media/pipeline/tests/test_digest.py index 0c425a0a..532f2fcf 100644 --- a/media/pipeline/tests/test_digest.py +++ b/media/pipeline/tests/test_digest.py @@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) from tts import ( # noqa: E402 SAMPLING_DEFAULTS, + STYLE_PRESETS, digest_edge, digest_indextts, resolve_sampling, @@ -258,3 +259,64 @@ def test_synth_source_text_prefers_tts_text(): ) # 空字符串 ttsText 视为未提供(防 build_narration 写出空字段导致合成空文本) assert synth_source_text({"id": "x", "text": "正文", "ttsText": ""}) == "正文" + + +# ---------------- 风格预设:名义向量归一化的不变量 ---------------- + + +def _upstream_trunc(vec, alpha): + """复刻上游 infer_v2_5.py:608 的量化:int(x*alpha*10000)/10000(**截断**非四舍五入)。""" + return [int(x * alpha * 10000) / 10000 for x in vec] + + +def test_preset_nominal_sums_are_one(): + """只有 Σvec=1.0 时 alpha 才等于「替换掉本人语调的百分比」,否则跨预设不可比。""" + for name, p in STYLE_PRESETS.items(): + if p["vec"] is None: + continue + assert abs(sum(p["vec"]) - 1.0) < 1e-6, f"{name} 的名义向量和不是 1.0" + + +def test_preset_normalization_preserves_injection(): + """归一化前后的**有效注入**必须在上游量化的 1 个单位(1e-4)内一致。 + + 不能断言「逐项完全一致」:上游用 int() 截断,而 0.33 / 0.455 恰在截断边界上, + 故 lively/confident/positive 各有一个分量差 1e-4(相对 0.03%,远低于听感阈)。 + 这三档未被任何已上线剧集使用,即便真有差异也无影响。 + """ + # (预设名, 归一化前的 vec, 归一化前的 alpha) + before = { + "lively": ([0.55, 0, 0, 0, 0, 0, 0.15, 0.15], 0.6), + "confident": ([0.25, 0, 0, 0, 0, 0, 0, 0.65], 0.7), + "positive": ([0.75, 0, 0, 0, 0, 0, 0, 0.2], 0.7), + "passionate": ([0.70, 0, 0, 0, 0, 0, 0.20, 0.10], 0.7), + "sunny": ([0.95, 0, 0, 0, 0, 0, 0.02, 0.03], 0.35), + } + for name, (old_vec, old_alpha) in before.items(): + p = STYLE_PRESETS[name] + w_old = _upstream_trunc(old_vec, old_alpha) + w_new = _upstream_trunc(p["vec"], p["alpha"]) + for a, b in zip(w_old, w_new, strict=True): + assert abs(a - b) <= 1e-4, f"{name}: 有效注入偏移 {abs(a - b):.2e} > 1e-4" + + +def test_production_presets_untouched(): + """已上线三集用的两档必须逐字不变 —— 改它们即整集重录(ISSUE-161 的教训)。""" + assert STYLE_PRESETS["passionate"]["vec"] == [0.70, 0, 0, 0, 0, 0, 0.20, 0.10] + assert STYLE_PRESETS["passionate"]["alpha"] == 0.7 + assert STYLE_PRESETS["passionate"]["df"] == 0.97 + for name in ("sunny", "sunny-steady"): + assert STYLE_PRESETS[name]["vec"] == [0.95, 0, 0, 0, 0, 0, 0.02, 0.03] + assert STYLE_PRESETS[name]["alpha"] == 0.35 + assert STYLE_PRESETS[name]["df"] == 0.95 + assert STYLE_PRESETS["sunny-steady"]["beams"] == 3 + + +def test_candidate_presets_are_additive_only(): + """#10/#11 的候选档是**新增**,不得改动任何生产档的数值。""" + assert set(STYLE_PRESETS) >= {"sunny-pure", "sunny-clear"} + assert STYLE_PRESETS["sunny-pure"]["vec"] == [1.0, 0, 0, 0, 0, 0, 0, 0] + assert STYLE_PRESETS["sunny-clear"]["df"] == 1.05, "候选 #11 的要点是 df>1" + # 候选档未定档前不得带 sampling 覆盖(那会改摘要) + for name in ("sunny-pure", "sunny-clear"): + assert "sampling" not in STYLE_PRESETS[name] diff --git a/media/pipeline/tests/test_prospect_ref.py b/media/pipeline/tests/test_prospect_ref.py new file mode 100644 index 00000000..d98f6de3 --- /dev/null +++ b/media/pipeline/tests/test_prospect_ref.py @@ -0,0 +1,188 @@ +"""prospect_ref 的保真度门 —— 「风格分看不见的音质问题」的守门人。 + +背景:原评分公式 5 项全是**风格**指标(音高/起伏/音节率/质心/响度),0 项保真度。而 +文献侧的结论是保真度损伤**事后无法弥补**——WildSpoof(arXiv:2602.05770)Table 2 显示 +事后增强能提升 UTMOS/DNSMOS,却让说话人相似度 SECS 从 0.35 掉到 0.28。故新增一组只做 +否决、不进加权的指标。 + +同时修掉原公式的两个自相加强的缺陷: + 1. 静音占比用**相对**阈(帧 RMS < 0.1×窗口 RMS),在有稳定底噪的录音上会系统性低估 + 静音——底噪把帧 RMS 抬到阈值之上,停顿不被计为静音、扣分项失效。改绝对阈。 + 2. 谱质心用**全带**,把「嗓音明亮」与「有嘶声/底噪」记成同一个信号(噪声底恰恰抬高 + 质心)。叠加上一条,使「脏但亮」的段落获得双重虚高。改为 300–5000 Hz 限带。 + +用合成信号驱动(已知 F0 / 已知削波 / 已知带限),不依赖任何录音文件。 +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +from prospect_ref import ( # noqa: E402 + CLIP_LEVEL, + MAX_NOISE_DBFS, + MIN_DYNAMIC_DB, + bandwidth_khz, + fidelity_stats, + frame_features, + window_stats, +) + +SR = 32000 + + +def _voice( + dur: float = 3.0, + f0: float = 160.0, + amp: float = 0.3, + pauses: bool = True, + broadband: float = 0.002, +) -> np.ndarray: + """合成「浊音」:基频 + 若干谐波,幅度包络模拟音节,并插入真实停顿。 + + `pauses=True` 很关键:底噪指标定义为「帧 RMS 第 10 百分位」,**前提是窗口里有气口**。 + 早期夹具用了不落零的连续包络,于是第 10 百分位测到的是「轻声段」而非底噪, + 给出 −36 dBFS 的假警报——那是夹具不真实,不是代码错。 + """ + t = np.arange(int(SR * dur)) / SR + sig = sum(amp / (k + 1) * np.sin(2 * np.pi * f0 * (k + 1) * t) for k in range(6)) + env = 0.5 + 0.5 * np.sin(2 * np.pi * 4.5 * t) # 4.5 音节/秒 + x = sig * env + if broadband: + # 真实语音有摩擦音与房间本底,全频带都有低幅能量。纯谐波和**没有任何高频内容** + # (实测有效带宽仅 2.74 kHz),带限类测试在那种夹具上无从谈起。 + x = x + broadband * np.random.default_rng(7).standard_normal(len(x)) + if pauses: # 每秒插入 200 ms 静音,模拟气口 + for k in range(int(dur)): + x[int((k + 0.8) * SR) : int((k + 1.0) * SR)] = 0.0 + return x.astype(np.float32) + + +def _frames(x: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + f = frame_features(x, SR) + voiced = (f["f0"] > 0) & f["gate"] + return f["rms"], f["rms"][voiced] + + +# ---------------- 保真度指标 ---------------- + + +def test_clean_voice_passes(): + x = _voice() + fid = fidelity_stats(x, *_frames(x)) + assert fid["clip"] == 0 + assert fid["noise_db"] <= MAX_NOISE_DBFS + assert fid["dyn_db"] >= MIN_DYNAMIC_DB + assert fid["fid_flags"] == [] + + +def test_clipping_is_flagged(): + x = _voice(amp=0.9) + x = np.clip(x * 3.0, -1.0, 1.0).astype(np.float32) # 强行削波 + fid = fidelity_stats(x, *_frames(x)) + assert fid["clip"] > 0 + assert any("削波" in f for f in fid["fid_flags"]) + assert np.max(np.abs(x)) >= CLIP_LEVEL + + +def test_noise_floor_is_flagged(): + """叠加 -40 dBFS 底噪:底噪超阈 + 动态range 塌缩,两项都该报。""" + rng = np.random.default_rng(0) + x = _voice() + (0.01 * rng.standard_normal(int(SR * 3.0))).astype(np.float32) + fid = fidelity_stats(x, *_frames(x)) + assert fid["noise_db"] > MAX_NOISE_DBFS + assert any("底噪" in f for f in fid["fid_flags"]) + + +def test_dc_offset_is_flagged(): + x = (_voice() + 0.02).astype(np.float32) + fid = fidelity_stats(x, *_frames(x)) + assert any("DC" in f for f in fid["fid_flags"]) + + +# ---------------- 有效带宽(低码率转码识别) ---------------- + + +def test_bandwidth_of_broadband_signal_is_near_nyquist(): + """白噪的有效带宽应接近 Nyquist —— 若接近 3 kHz 说明判据又退化成「能量占比」。""" + rng = np.random.default_rng(1) + x = (0.1 * rng.standard_normal(SR * 2)).astype(np.float32) + assert bandwidth_khz(x, SR) > 0.9 * (SR / 2000.0) + + +def test_bandwidth_detects_band_limiting(): + """5 kHz 带限信号必须被测出约 5 kHz。""" + x = _voice(dur=2.0) + X = np.fft.rfft(x) + X[np.fft.rfftfreq(len(x), 1 / SR) > 5000] = 0 + y = np.fft.irfft(X, len(x)).astype(np.float32) + bw = bandwidth_khz(y, SR) + assert 4.0 <= bw <= 6.0, bw + + +def test_bandwidth_not_confused_by_speech_spectral_tilt(): + """回归:语音能量集中在 3 kHz 以下,但**有效带宽不是 3 kHz**。 + + 最初用「累积能量 99% 分位」,对每个窗口都误报「滚降 2.9 kHz」——因为那衡量的是 + 能量占比而非断崖位置。判据必须是相对自身峰值的落点。 + """ + x = _voice(dur=2.0) # 谐波集中在 <1 kHz,但含全频带低幅本底(如真实录音) + assert bandwidth_khz(x, SR) > 8.0 + + +# ---------------- 修掉的两个旧缺陷 ---------------- + + +def test_noise_floor_not_estimable_without_pauses(): + """无停顿的窗口不该给出底噪读数 —— 宁可说「测不了」,不要给一个错的数。 + + 夹具用**恒幅**正弦:只有它才真的没有安静帧。带包络的合成音即使不插停顿,包络落零处 + 仍产生安静帧(实测 11.3% 的帧低于 −35 dBFS),会被判为可估。 + """ + t = np.arange(int(SR * 2.0)) / SR + x = (0.3 * np.sin(2 * np.pi * 160.0 * t)).astype(np.float32) + fid = fidelity_stats(x, *_frames(x)) + assert fid["noise_db"] is None and fid["dyn_db"] is None + assert any("不可估" in f for f in fid["fid_flags"]) + + +def test_silence_uses_absolute_threshold(): + """相对阈的缺陷:叠加底噪后停顿不再被计为静音。绝对阈须不受底噪影响。""" + t = np.arange(int(SR * 4.0)) / SR + speech = _voice(dur=4.0, pauses=False) + speech[(t > 1.0) & (t < 3.0)] = 0.0 # 中间 2 秒真静音(占 50%) + rng = np.random.default_rng(2) + noisy = (speech + 0.003 * rng.standard_normal(len(speech))).astype(np.float32) + + def sil_of(x): + f = frame_features(x, SR) + st = window_stats(f, x, SR, 0, 4) + return st["sil"] if st else None + + clean_sil, noisy_sil = sil_of(speech), sil_of(noisy) + assert clean_sil is not None and noisy_sil is not None + assert clean_sil > 0.3, f"干净信号的静音占比应显著(实得 {clean_sil})" + # 绝对阈下,-50 dBFS 量级的底噪不该把静音占比打到 0 + assert noisy_sil > 0.2, f"绝对阈应不受底噪掩盖(实得 {noisy_sil})" + + +@pytest.mark.parametrize("hiss_amp", [0.0, 0.02]) +def test_bandlimited_centroid_resists_hiss(hiss_amp): + """限带质心应对高频嘶声不敏感 —— 否则「脏但亮」的段落会虚高。""" + x = _voice(dur=2.0) + if hiss_amp: + rng = np.random.default_rng(3) + hf = rng.standard_normal(len(x)) + X = np.fft.rfft(hf) + X[np.fft.rfftfreq(len(x), 1 / SR) < 8000] = 0 # 只留 8 kHz 以上嘶声 + x = (x + hiss_amp * np.fft.irfft(X, len(x))).astype(np.float32) + f = frame_features(x, SR) + st = window_stats(f, x, SR, 0, 2) + assert st, "应有有效浊音帧" + # 限带上限 5 kHz ⇒ 8 kHz 以上的嘶声不可能把质心推高到 5 kHz 以上 + assert st["cen"] <= 5000.0 From 6c8559bc87c4ff109dc8af06b468cff4860fe54f Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 21 Aug 2026 01:25:33 +0800 Subject: [PATCH 07/10] =?UTF-8?q?docs(tts):=20=E8=B7=AF=E7=BA=BF=E5=9B=BE?= =?UTF-8?q?=20#7/#8=20=E9=87=87=E6=A0=B7=E5=8F=82=E6=95=B0=20A/B=20?= =?UTF-8?q?=E8=B7=91=E5=AE=8C=EF=BC=8C=E4=B8=A4=E9=A1=B9=E5=9D=87=E5=AE=9A?= =?UTF-8?q?=E8=AE=BA=E4=B8=BA=E3=80=8C=E7=BB=B4=E6=8C=81=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E3=80=8D;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 §6.5 的测量协议(固定 seed、冷却节奏、逐句交替先后顺序以抵消热漂移)跑完两项参数 A/B,判据用 whisper 回转写的**尾部覆盖率**与 CER 而非墙钟——后者会被热漂移淹没, 而 ASR 判据不受热影响。 ## #7 length_penalty:机制成立,但在本工作负载上**实际惰性** | 对比 | n | Δ尾覆盖中位 | 尾覆盖改善句数 | |---|---|---|---| | 0.0 vs 0.8(35–49 字长句) | 8 对 | +0.0000 | **0/8** | | −2.0 vs +2.0(全区间极值) | 3 对 | +0.0000 | **0/3** | 8 对里 7 对输出**逐项完全相同**(时长到 0.01s、CER 到 3 位、尾覆盖);即使拉到区间两端, 可测效应也只有秒/字 +0.4%。代码路径已核实确实到达 BeamSearchScorer (transformers_generation_utils.py:2232),**不是**像 do_sample 那样被静默忽略。 机制解释:length_penalty 只在比较**长度不同**的已完成假设时起作用;3 条束在同一长度收束 时除数 len^lp 是公共因子、排序不变。本仓旁白 35–49 字,束间长度差异不足以让它咬合。 **同时作废上一版的记载**:此前记「某长句 lp=0 的 GPT 段 180.5s vs lp=0.8 的 19.3s(4.9×)」 并据此推测有大收益。那是热漂移伪影(同批数据里 s2mel 在同等音频长度下从 18.3 跳到 33.1s,而 lp 根本不作用于 S2M)。本轮在合格环境下用 11 对样本得到零效应,可以定论。 ## #8 repetition_penalty:10.0 落在稳定平台区,上游选择事后成立 | 对比 | n | CER 中位 | 尾覆盖中位 | 逐对差异 | |---|---|---|---|---| | 10.0 vs 3.5(8 句混合长度) | 8 对 | 0.060 / 0.060 | 0.929 / 0.929 | **8/8 逐项相同** | | 1.0 vs 20.0(全区间极值) | 3 对 | 0.095 / 0.057 | 0.889 / 1.000 | Δ尾覆盖 +0.111 | **极值证伪是这一项的关键**:只做 10 vs 3.5 会得到「零效应」,却无法区分「真惰性」与 「参数没到达模型」。拉到 1.0(关惩罚)vs 20.0(上限)后差异立刻显现 ⇒ 参数确实生效, 3.5–10 只是一段平台。两端都不如 10.0:rp=1 中位更差;rp=20 中位虽好但方差极大—— 同批第 1 句崩到 CER 0.395 / 尾覆盖 0.56(惩罚过强会压掉韵尾所需的稳态码)。 结论「维持 10.0」同时给上游那个无据可查的默认值补上了事后依据:它处在既避开「无惩罚 导致拖音/漏字」又避开「过强压掉韵尾」的中间带。极值组 n=3,只支撑定性结论;且该结论 与音色绑定(惩罚强度依赖 logit 绝对尺度),换参考样本需重测。 两项都不改预设——改档即整集重录,而实测收益为零。 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/INDEXTTS-2.5-ADVANCED.md | 72 ++++++++++++++++++++----- 1 file changed, 58 insertions(+), 14 deletions(-) diff --git a/media/pipeline/INDEXTTS-2.5-ADVANCED.md b/media/pipeline/INDEXTTS-2.5-ADVANCED.md index 0cebd10e..09abc7c7 100644 --- a/media/pipeline/INDEXTTS-2.5-ADVANCED.md +++ b/media/pipeline/INDEXTTS-2.5-ADVANCED.md @@ -43,8 +43,8 @@ | `max_text_tokens_per_segment` | `120` | ❌ 不暴露(刻意) | 对本仓完全惰性,见 §4.4 | | `text_normalization` | `True` | ✅ `--no-text-normalization`(本轮接通) | **不要关**,见 §2.2 | | `temperature`/`top_p`/`top_k` | `0.8`/`0.8`/`30` | ✅(本轮接通) | 束搜索下仍生效,见 §4.1 | -| `length_penalty` | `0.0` | ✅(本轮接通) | **0.0 不是中性**,见 §4.2 | -| `repetition_penalty` | `10.0` | ✅(本轮接通) | 与音色耦合、不可迁移,见 §4.3 | +| `length_penalty` | `0.0` | ✅(本轮接通) | 数学上非中性,但**实测在本工作负载惰性**(11 对样本零效应),见 §4.2 | +| `repetition_penalty` | `10.0` | ✅(本轮接通) | 与音色耦合、不可迁移;**实测 10.0 落在稳定平台区、两端更差**,见 §4.3 | | `max_mel_tokens` | `1500` | ✅(本轮接通) | ≈30 s 天花板,见 §4.4 | | `do_sample` | `True` | ❌ 不暴露(**上游失效**) | `:780` 用字面量 `True` 覆盖 `:731` 弹出的值,webui 的复选框是装饰性控件 | | 随机种子 | **无** | ✅ `--seed`(本轮接通) | A/B 可信的前提,见 §4.5 | @@ -308,17 +308,38 @@ GPT 看到的条件方向越偏离说话人身份)。论文的 GRL 只保证 e ### 4.2 `length_penalty=0.0` 不是中性 -束打分 `score = sum_logprobs / (len ** 0) = sum_logprobs`。对数概率恒负 ⇒ 序列越长累加越负 -⇒ **系统性偏好更短的假设**。这是 `num_beams>1` 时「吞尾 / 漏字 / 收尾急」的直接机制来源, -而不是一个中性设置。webui 允许区间 `[-2.0, 2.0]`。 +束打分 `score = sum_logprobs / (len ** lp)`。`lp=0` 时除数恒为 1、**完全不做长度归一化**; +而对数概率恒负 ⇒ 序列越长累加越负 ⇒ 数学上偏好更短的假设。webui 允许区间 `[-2.0, 2.0]`。 -> ⚠️ **本轮未能证实「抬高 length_penalty 有收益」**。第一句长句上曾观测到 `beams=3` 时 -> `lp=0.0` 的 GPT 段耗时 180.5 s vs `lp=0.8` 的 19.3 s(4.9×,输出时长几乎相同),但在第二句 -> 长句上**没有复现**(13.2 s vs 14.1 s,基本相同),且同一批测量里 `s2mel_time` 在同等音频 -> 长度下从 18.3 s 跳到 33.1 s——而 `length_penalty` 根本不作用于 S2M。结论:那次 180 s 是 -> **机器噪声**(测量期间交换区仅剩 0.5–1.2 GB,另有一个工作区的常驻实例)。 -> `length_penalty>0` 仍是机制上有理据的候选项(尤其对 30–49 字的长句),但**必须在机器空闲、 -> 固定 `--seed`、n≥20 句的条件下重测**,判据是尾部字词完整率而非墙钟。 +代码路径已核实**不是被忽略**(与 `do_sample` 不同):`length_penalty` → `hf_generate_kwargs` +→ HF `generation_config` → `BeamSearchScorer(length_penalty=…)`(`transformers_generation_utils.py:2232`)。 + +> ### ⚠️ 实测结论:机制成立,但在本工作负载上**实际惰性**(2026-08-21,路线图 #7) +> +> 按 §6.5 协议成对 A/B(逐句交替先后顺序、固定 `--seed 4242`、`--cooldown 60`、`beams=3`), +> 判据用 whisper 回转写的**尾部覆盖率**(参考文本后 20% 字符的命中比例)而非墙钟—— +> 后者会被热漂移淹没。 +> +> | 对比 | n | 音频中位 | 秒/字中位 | Δ尾覆盖中位 | 尾覆盖改善的句数 | +> |---|---|---|---|---|---| +> | `lp=0.0` vs `0.8`(35–49 字长句) | 8 对 | 7.46 / 7.46 s | 0.228 / 0.228 | **+0.0000** | **0/8** | +> | `lp=-2.0` vs `+2.0`(全区间极值) | 3 对 | 8.90 / 8.90 s | 0.214 / 0.217 | **+0.0000** | **0/3** | +> +> 8 对里 7 对的输出**逐项完全相同**(时长到 0.01 s、CER 到 3 位小数、尾覆盖)。即使把 lp 拉到 +> 全区间两端,可测效应也只有**秒/字 +0.4%**(方向与机制一致:`lp>0` 略偏长),而尾部覆盖率 +> **一句都没有改善**。两档的「尾覆盖 <0.9 的句数」都是 3——那 3 句是 ASR 伪影,不是吞尾。 +> +> **为什么惰性**:`length_penalty` 只在比较**长度不同**的已完成假设时起作用;若 3 条束都在 +> 同一长度收束,除数 `len^lp` 就是公共因子、排序不变。本仓旁白单句 35–49 字,束间长度差异 +> 太小,不足以让它咬合。 +> +> **前一版本的记载已作废**:曾观测到某长句 `lp=0` 的 GPT 段 180.5 s vs `lp=0.8` 的 19.3 s +> (4.9×),据此推测「lp>0 有大收益」。第二句长句未复现(13.2 vs 14.1 s),且同批数据里 +> `s2mel_time` 在同等音频长度下从 18.3 跳到 33.1 s——而 lp 根本不作用于 S2M。那是**热漂移** +> (成因见 §6.5)。本轮在合格环境下用 11 对配对样本得到零效应,可以定论。 +> +> **操作结论**:维持上游默认 `0.0`,不要为它改预设(改档即整集重录)。若日后改为「多句合并 +> 成单次请求」(§4.4),束间长度差异会显著变大,届时值得重测。 ### 4.3 `repetition_penalty=10.0` 为何能成立、为何不可跨音色迁移 @@ -335,6 +356,29 @@ GPT 看到的条件方向越偏离说话人身份)。论文的 GRL 只保证 e `conds_latent`)都会改变同一个 10.0 的实际惩罚力度——**它不是音色无关的旋钮,跨音色迁移 调参结论必须重新验证**。 +> ### 实测结论:10.0 落在一个**稳定平台区**,上游的选择事后成立(2026-08-21,路线图 #8) +> +> 按 §6.5 协议成对 A/B(固定 `--seed`、`--cooldown`、`beams=1`、逐句交替顺序),ASR 回转写判据: +> +> | 对比 | n | CER 中位 | 尾覆盖中位 | 尾覆盖<0.9 句数 | 逐对差异 | +> |---|---|---|---|---|---| +> | `10.0` vs `3.5`(8 句混合长度) | 8 对 | 0.060 / 0.060 | 0.929 / 0.929 | 4 / 4 | **8/8 逐项完全相同** | +> | `1.0` vs `20.0`(全区间极值) | 3 对 | 0.095 / **0.057** | 0.889 / **1.000** | 2 / 1 | Δ尾覆盖中位 **+0.111** | +> +> **极值证伪很关键**:若只做 10 vs 3.5,会得到「零效应」而无法区分「真惰性」与「参数没到达 +> 模型」。拉到 `1.0`(等于关闭惩罚)vs `20.0`(上限)后差异立刻显现——参数确实生效, +> 3.5–10 只是一段**平台**。 +> +> 两端的表现都不如 10.0: +> - **`rp=1.0`(无惩罚)中位更差**(CER 0.095 vs 0.057、尾覆盖 0.889 vs 1.000); +> - **`rp=20.0` 中位虽好但方差极大**——同一批里第 1 句崩到 CER **0.395**、尾覆盖 **0.56** +> (惩罚过强会把韵尾所需的稳态码一起压掉)。 +> +> **操作结论:维持上游默认 10.0,不要动它。** 这也算给「上游自 v1 沿用 10.0 却无任何测试 +> 支撑」补上了事后依据:它处在一个既避开「无惩罚导致的拖音/漏字」又避开「过强惩罚压掉 +> 韵尾」的中间带。⚠️ 极值组 n=3,只够支撑「两端更差」这一定性结论,不足以精确定位平台边界; +> 且如上所述该结论**与音色绑定**,换参考样本后需重测。 + 与 `length_penalty=0.0` 构成一对**方向相反**的失效模式压力:前者偏好短序列(吞尾/漏字), 后者压制码复用(抑制拖音,但也压制持续元音与自然停顿所需的稳态码)。上游把两者同时拉到 极端,等于把「宁可短促、不要拖长」写进了默认口径——这解释了为何 IndexTTS 的典型抱怨是 @@ -583,8 +627,8 @@ TensorRT-LLM)。仓库内**无 vLLM 后端**,README 只给外链 recipe。 | 4 | **同口径重算本机 RTF** | 与 MLX 报告差 3–4× 无解释 | 一次单句实验 | 排除 load/clone,只计 synth | ✅ **已做**:对齐档 RTF 4.37,缺口分解为 2.1×(管线)× 2–3×(硬件),残差仅 1.3–1.9×(§6.3)⇒ **换栈不被数字支持** | | 5 | 分段 profile 常态化 | 内建计时器零改动可取(§6.2) | 服务重启时加重定向 | `grep gpt_gen_time\|s2mel_time\|bigvgan_time` | ✅ 本轮已用;且它是识别热漂移的主要手段(§6.4–6.5) | | 6 | **建立可用的性能测量环境** | 本机连续调用漂 2.4–3.4×,A/B 不可做(§6.4–6.5) | 冷却节奏(75 s/次),耗时约 5:1 占空比 | `tts_bench.py` A/A 判定 + 判据单测 | ✅ **已做**:漂移定因为热节流(换页/分配器/泄漏均排除),加 75 s 冷却后极差比 1.016–1.047× 合格(§6.5) | -| 7 | `length_penalty>0`(长句吞尾) | 机制明确(§4.2),但**本轮未复现收益** | 进缓存摘要 ⇒ 改档即重录 | #6 已解除阻塞:按 §6.5 协议跑;固定 seed + n≥20 长句,判据=尾部字词完整率(非墙钟) | ⬜ **待验证(已可做)** | -| 8 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | 同上;且**与音色耦合**不可迁移 | #6 已解除阻塞:固定 seed + beams=1,30 句覆盖长/短/数字/英文 | ⬜ **待验证(已可做)** | +| 7 | `length_penalty>0`(长句吞尾) | 机制成立(§4.2)但**实测在本工作负载上惰性** | — | 11 对配对样本(含 −2/+2 全区间极值):尾覆盖改善 **0/11 句**,秒/字仅 +0.4% | ✅ **已做 → 维持默认 0.0**:束间长度差异太小,除数成公共因子。若日后合并多句成单请求需重测 | +| 8 | `repetition_penalty` 定向扫描 | 上游自 v1 沿用 10.0 且无任何测试支撑(§4.3) | — | 11 对样本:3.5–10 是**稳定平台**(8/8 逐项相同);极值两端更差(`1.0` 尾覆盖 0.889、`20.0` 有单句崩到 0.56) | ✅ **已做 → 维持默认 10.0**:上游选择事后成立,它处在避开「无惩罚拖音」与「过强压掉韵尾」的中间带 | | 9 | 预设名义向量归一到 Σvec°=1.0 | 让 alpha 跨预设可比(§3.1) | 零——只动 `lively`/`confident`/`positive`,三集**都不用**这三档 | 有效注入 `w=vec×alpha` 在上游 4 位量化的 1 单位内一致(单测钉死) | ✅ **已做** | | 10 | 砍掉 `calm`/`surprised` 配料 | bias 只 0.5625/0.6875,占预算却不兑付表达力(§3.2) | 零(**新增** `sunny-pure` 候选档,不动生产档) | 待 A/B:比 F0 中位/起伏/音节率/限带质心 + 人耳 | 🟡 **候选档已就位**,待定档 | | 11 | 密集技术句改 `df>1` | 方向勘误(§3.4) | 零(**新增** `sunny-clear` 候选档 df=1.05);定档后须重渲(牵动 beat) | 待 A/B:ASR 回转写 CER 作清晰度代理(ASR 通路已就绪,见 §2.3) | 🟡 **候选档已就位**,待定档 | From 0f2a9406ab77864da1771e5a8ae4007606ba2f26 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 21 Aug 2026 08:34:04 +0800 Subject: [PATCH 08/10] =?UTF-8?q?fix(tts):=20tts=5Fbench=20stable=5Fwindow?= =?UTF-8?q?=20=E5=9C=A8=20runs<3=20=E6=97=B6=E5=B4=A9=E6=BA=83=E7=9A=84?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8E=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/scripts/tts_bench.py | 12 ++++++++++++ media/pipeline/tests/test_tts_bench.py | 16 ++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/media/pipeline/scripts/tts_bench.py b/media/pipeline/scripts/tts_bench.py index b2249a31..5ed6fe45 100644 --- a/media/pipeline/scripts/tts_bench.py +++ b/media/pipeline/scripts/tts_bench.py @@ -241,8 +241,20 @@ def stable_window(walls: list[float]) -> tuple[int, int, float, float, float]: 只裁头部的判据永远裁不掉尾部异常点,于是把一个明显合格的环境判成不合格。 故改为在所有连续窗口里取最长的合格者:既容忍带余热的头部,也容忍单点异常的尾部。 + + n < 3(--runs 1/2)时无任何 3 连窗口,直接返回退化结果(极差比按两点/一点算, + CV 记 0)——不崩:读数打印与 --json 落盘必须先于判定完成,否则整轮测量丢失。 """ n = len(walls) + if n < 3: + w = walls or [0.0] + return ( + 0, + n - 1, + max(w) / min(w) if w and min(w) > 0 else 0.0, + 0.0, + _rel_drift(walls), + ) best: tuple[int, int, float, float, float] | None = None for i in range(n): for j in range(i + 2, n): # 至少 3 个点 diff --git a/media/pipeline/tests/test_tts_bench.py b/media/pipeline/tests/test_tts_bench.py index fec2ea14..da038494 100644 --- a/media/pipeline/tests/test_tts_bench.py +++ b/media/pipeline/tests/test_tts_bench.py @@ -90,3 +90,19 @@ def test_no_qualifying_window_returns_steadiest_triple(): assert not (sp <= MAX_SPREAD_RATIO and cv <= MAX_CV and abs(dr) <= MAX_REL_DRIFT), ( "回退窗口本身不应满足判据" ) + + +@pytest.mark.parametrize("walls", [[15.0], [15.0, 15.2]], ids=["runs=1", "runs=2"]) +def test_fewer_than_three_runs_does_not_crash(walls): + """`--runs 1/2` 时无任何 3 连窗口——不得在回退分支抛 ValueError 崩掉: + 读数打印与 --json 落盘发生在判定之后,一崩整轮测量丢失。判据须判不合格 + (len(tail) >= 3 是必要条件),退出码 1 引导加 --runs 重跑。 + """ + i, j, sp, cv, dr = stable_window(walls) + assert 0 <= i <= j < len(walls) + assert not ( + sp <= MAX_SPREAD_RATIO + and cv <= MAX_CV + and abs(dr) <= MAX_REL_DRIFT + and (j - i + 1) >= 3 + ), "不足 3 点不得判合格" From 439323d1ad7350c99f839abdc962d6ac3c143dee Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 21 Aug 2026 08:34:15 +0800 Subject: [PATCH 09/10] =?UTF-8?q?refactor(tts):=20=E9=87=87=E6=A0=B7?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E5=80=BC=E6=94=B6=E6=95=9B=E4=B8=BA=20tts.py?= =?UTF-8?q?=20=E5=8D=95=E4=B8=80=E4=BA=8B=E5=AE=9E=E6=BA=90=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=8F=8C=E5=89=AF=E6=9C=AC=E6=BC=82=E7=A7=BB?= =?UTF-8?q?=E9=A3=8E=E9=99=A9;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/scripts/tts.py | 13 +++++++++++-- media/pipeline/scripts/tts_server.py | 24 ++++++++++++------------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index c4126e52..91e22b53 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -196,12 +196,18 @@ # 上游自回归采样参数的默认值 —— 已知副本,锚点 indextts/infer_v2_5.py:731-739(HEAD 4f8792f); -# 与服务端 tts_server.py 的 SAMPLING_DEFAULTS 必须逐字一致(那里有机制说明)。 +# 与 infer_v2.py:536-544 完全一致,两版共享同一组默认。 +# +# **分层 SSOT**:`SAMPLING_PASSTHROUGH_DEFAULTS`(7 个经 **generation_kwargs 透传给 HF +# generate 的参数)是唯一的数据副本,服务端 tts_server.py 运行时从本模块导入它(那个进程 +# 不受本仓版本控制约束之外的依赖影响——导入是纯常量读取);本字典在其上追加两个 +# **非透传**键:text_normalization(v2.5 infer() 的独立形参)与 seed(本服务自己 set_seed), +# 它们不进 SAMPLING_RANGES/SAMPLING_CLI 的透传校验路径。 # # 本副本只有一个用途:判定「这一项是否被显式改过」,从而决定**要不要进缓存摘要**。 # 摘要沿用「未使用即省略」规则(同 |beams=N),故全部取默认时摘要与历史逐字节相同 —— # 这是已上线三集近 600 句缓存零失效的前提,由 tests/test_digest.py 黄金哈希钉死。 -SAMPLING_DEFAULTS: dict[str, float | int | bool | None] = { +SAMPLING_PASSTHROUGH_DEFAULTS: dict[str, float | int] = { "temperature": 0.8, "top_p": 0.8, "top_k": 30, @@ -209,6 +215,9 @@ "repetition_penalty": 10.0, "max_mel_tokens": 1500, "interval_silence": 200, +} +SAMPLING_DEFAULTS: dict[str, float | int | bool | None] = { + **SAMPLING_PASSTHROUGH_DEFAULTS, "text_normalization": True, "seed": None, } diff --git a/media/pipeline/scripts/tts_server.py b/media/pipeline/scripts/tts_server.py index c45157d8..75365a8c 100644 --- a/media/pipeline/scripts/tts_server.py +++ b/media/pipeline/scripts/tts_server.py @@ -41,6 +41,11 @@ from fastapi import FastAPI, HTTPException, Response from pydantic import BaseModel, field_validator +# 客户端 tts.py 与本服务分属两个运行环境(本仓轻依赖 vs index-tts venv),但服务启动 +# 脚本就是从本仓拷贝/引用这份 tts_server.py —— 采样默认值等共享常量以 tts.py 为 SSOT。 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from tts import SAMPLING_PASSTHROUGH_DEFAULTS as SAMPLING_DEFAULTS # noqa: E402 + def ensure_indextts_import(index_tts_root: Path) -> None: """优先依赖 venv 已安装的 indextts;仅源码未安装时把 checkout 根目录塞进 sys.path 兜底。""" @@ -164,9 +169,13 @@ def encode_mp3(data: np.ndarray, sr: int) -> tuple[bytes, str]: EMO_LABELS = "happy,angry,sad,afraid,disgusted,melancholic,surprised,calm" -# 上游自回归采样参数的默认值 —— 已知副本,锚点 indextts/infer_v2_5.py:731-739(HEAD 4f8792f) -# 与 infer_v2.py:536-544 完全一致,两版共享同一组默认。客户端 tts.py 持有同一份副本用于 -# 「摘要按未使用即省略」判定,二者必须同步(见 tts.py SAMPLING_DEFAULTS 的同名注释)。 +# 上游自回归采样参数的默认值 —— SSOT 在客户端 tts.py(SAMPLING_PASSTHROUGH_DEFAULTS, +# 锚点 indextts/infer_v2_5.py:731-739(HEAD 4f8792f),与 infer_v2.py:536-544 完全一致), +# 本文件经文件头 import 引用(别名 SAMPLING_DEFAULTS 供下方请求模型取默认值)。 +# 此前这里持有一份手抄副本、靠注释约束「逐字一致」——两份 7/9 键字典已经漂移过一次 +# (服务端缺 text_normalization/seed 两键),现改为运行时导入,改一处两端同步。 +# 注意 SSOT 只覆盖 7 个**透传 generation_kwargs** 的键;text_normalization(v2.5 独立形参) +# 与 seed(本服务自行 set_seed)不在此列,由下方请求模型单独定义。 # # 三条口径提醒(写进注释而非文档,因为它们直接决定该不该动这些值): # length_penalty=0.0 **不是中性**——束打分 score = sum_logprobs / len**0 = sum_logprobs, @@ -177,15 +186,6 @@ def encode_mp3(data: np.ndarray, sr: int) -> tuple[bytes, str]: # 向量耦合——跨音色迁移调参结论必须重新验证。 # max_mel_tokens=1500 ≈ 30 s 音频(语义码率 50 Hz × 1.72 mel 帧/token,hop 256 @ 22050); # 溢出后果不是音频被裁短,而是文本尾部根本没被念出(infer_v2_5.py:792-813)。 -SAMPLING_DEFAULTS: dict[str, float | int] = { - "temperature": 0.8, - "top_p": 0.8, - "top_k": 30, - "length_penalty": 0.0, - "repetition_penalty": 10.0, - "max_mel_tokens": 1500, - "interval_silence": 200, -} class SynthesizeRequest(BaseModel): From 527a4f14d99ca8e306b8aaf0ab096c95e6374bf8 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Fri, 21 Aug 2026 08:34:20 +0800 Subject: [PATCH 10/10] =?UTF-8?q?docs(tts):=20=E5=B9=B4=E4=BB=BD=E8=AF=BB?= =?UTF-8?q?=E6=B3=95=E9=99=B7=E9=98=B1=E8=A1=A5=E3=80=8C=E7=A9=BA=E6=A0=BC?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E6=95=8F=E6=84=9F=E3=80=8D=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E7=BB=93=E8=AE=BA=EF=BC=8C=E9=98=B2=E8=AF=AF=E8=A7=84=E8=8C=83?= =?UTF-8?q?=E5=8C=96=E5=B7=B2=E6=AD=A3=E7=A1=AE=E7=9A=84=E5=86=99=E6=B3=95?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- media/pipeline/scripts/check_script.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/media/pipeline/scripts/check_script.py b/media/pipeline/scripts/check_script.py index e4e6f9ce..3c67eb1a 100644 --- a/media/pipeline/scripts/check_script.py +++ b/media/pipeline/scripts/check_script.py @@ -146,6 +146,9 @@ def check_budget(root: Path, items: list[dict], cfg: dict, msgs: list[str]) -> N r"\d{4}\s+年", "FAIL", "4 位年份与「年」之间有空格:`2026 年` 读成「两千零二十六年」(写成 `2026年` 才读「二零二六年」)", + # ⚠️ 空格位置敏感(2026-08-21 探针复核):错读只发生在空格**夹在年份与「年」之间**; + # 空格在年份**前**(`这篇 2026年的`)读法正确(「二零二六年」),门也不触发——不要 + # 把这类句子「顺手规范化」成年份前无空格以外的别的形态。 ), ( r"\d+\.\d+\.\d+", @@ -182,7 +185,7 @@ def check_budget(root: Path, items: list[dict], cfg: dict, msgs: list[str]) -> N ), ) READING_TRAPS_COMPILED = tuple( - (re.compile(p), level, msg) for p, level, msg in READING_TRAPS + (re.compile(p), level, msg) for p, level, msg, *_ in READING_TRAPS ) #: 汉字。整句无汉字时上游按 `use_chinese()`(front.py:106-114)逐句嗅探路由到**英文**