From dc78f445acfcae4c29d509a8bd9b05a9ffe46fc9 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 09:52:29 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(tts):=20=E6=96=B0=E5=A2=9E=E5=8D=95?= =?UTF-8?q?=E5=8F=A5=E5=A3=B0=E9=9F=B3=E5=B0=8F=E6=A0=B7=E8=AF=95=E5=90=AC?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=EF=BC=8C=E5=A3=B0=E9=9F=B3=E5=85=8B=E9=9A=86?= =?UTF-8?q?=E6=89=8B=E5=86=8C=E8=A1=A5=E9=BD=90=E8=AF=95=E5=90=AC=E7=AB=A0?= =?UTF-8?q?=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 media/pipeline/scripts/tts_sample.py:直调 IndexTTS 服务合成单句小样, 不需要 narration.json 与视频工程。支持 --style 单档 / --all-styles 五风格 A/B / --dry-run 秒级核参 / --play 顺序试听;风格预设、口播文本预处理与 HTTP 契约全部 复用 tts.py(单一事实源),故小样与成片走完全相同的合成路径、听感可直接外推。 产物落 .temp/voice-samples/(已被根 .gitignore 忽略;含本人音色,脚本提示试听后清理)。 前置校验与 tts.py 同口径(样本存在性、情感有效和 ≤0.8、v2.5 语速支持、参数互斥), 避免等到两分钟合成结束才报错。 VOICE-CLONING.md 原 §五「逐集使用」扩写为「小样试听与逐集合成」:5.1 小样试听 (四步操作 + 开关速查 + 实测耗时 + 纯 HTTP curl 直调附录)兑现原文里「小样试听」 的空承诺,5.2 全量合成、5.3 合成后重渲染;§3.2 增补滑窗 RMS/静音占比选段辅助, 并把裁剪示例改为已上线三集成片的同源样本(--start 180 --duration 12,sha1 3ed0d9d60d4b,由音频缓存 sidecar 摘要反查确认);§一 架构图补试听客户端节点, §4.4 指向 5.1。六~九章编号不动,6 处外部 §七/§八 引用保持有效。 实测(2026-08-19 · M3 · MPS fp32 · num_beams=1 · 34 字文本):单档暖机后 6.0–6.9s 音频 / 20–22s 墙钟(RTF 3.2–3.4;首档含暖机 47.0s),五风格 A/B 全跑 2.2 分钟;已注明该口径与 §4.3b 整集折算 RTF≈12–14 不可互推。文档中的 curl recipe 与 RMS 选段片段均逐字执行验证(200 / X-Audio-Format: mp3 / 4xx detail 可 cat 读出), 另跑通七条负例(样本缺失、服务不可达、有效和超界、参数互斥、未知情感键、缺 mutagen)。 🤖 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/README.md | 1 + media/pipeline/VOICE-CLONING.md | 134 +++++++++-- media/pipeline/scripts/tts_sample.py | 329 +++++++++++++++++++++++++++ 3 files changed, 448 insertions(+), 16 deletions(-) create mode 100644 media/pipeline/scripts/tts_sample.py diff --git a/media/pipeline/README.md b/media/pipeline/README.md index 78c939d2..10c0df59 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -56,6 +56,7 @@ media/-video/ | [scripts/build_narration.py](./scripts/build_narration.py) | narration.md → narration.json + 时长估算 | `uv run --no-project scripts/build_narration.py` | | [scripts/tts.py](./scripts/tts.py) | 逐句配音合成 + 时长 manifest(幂等,双引擎:edge 预置音色 / indextts 声音克隆,风格含 passionate 激情 / lively 轻快等) | `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/qa_frames.py](./scripts/qa_frames.py) | 按句 id 抽帧视觉 QA | `uv run --no-project scripts/qa_frames.py out/draft.mp4 --scene P2` | diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index 3a2d9d99..5fa18116 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -9,7 +9,7 @@ 2. [一次性部署(index-tts + 模型)](#二一次性部署index-tts--模型) 3. [参考音色样本](#三参考音色样本) 4. [风格与参数](#四风格与参数) -5. [逐集使用](#五逐集使用) +5. [小样试听与逐集合成](#五小样试听与逐集合成) 6. [缓存与幂等](#六缓存与幂等) 7. [故障排查](#七故障排查) 8. [许可与合规](#八许可与合规) @@ -17,7 +17,7 @@ ## 一、总览与架构 -**能力**:用一段 5–15 秒的本人录音作为参考音色,零样本(zero-shot)克隆出本人音色,逐句合成整集配音;并通过情感向量注入轻快、自信、正能量等风格。 +**能力**:用一段 5–15 秒的本人录音作为参考音色,零样本(zero-shot)克隆出本人音色,逐句合成整集配音;并通过情感向量注入轻快、自信、正能量等风格。整集要跑数小时,故**定稿前先用单句小样试听择优**(§5.1),再全量合成(§5.2)。 **架构**(管线脚本轻依赖 与 重型推理环境 完全解耦): @@ -25,6 +25,7 @@ flowchart LR subgraph 管线侧["本仓 media/pipeline(轻依赖)"] A["tts.py
--engine indextts"] -->|"HTTP 127.0.0.1:8766
逐句 POST /synthesize"| B + S["tts_sample.py
单句小样试听"] -.->|"单次 POST /synthesize"| B end subgraph 推理侧["~/tools/index-tts(重依赖:torch/indextts)"] B["tts_server.py
FastAPI + IndexTTS-2.5"] --> C["模型常驻内存
MPS 串行推理"] @@ -33,6 +34,7 @@ flowchart LR end B -->|"MP3 bytes
X-Audio-Format 头"| A A --> F["{id}.mp3 + manifest.json
Remotion 时间轴自动重算"] + S -.-> G[".temp/voice-samples/{风格}.mp3
afplay 试听择优"] ``` **契约不变**:无论哪个引擎,输出仍是 `<工程>/video/public/audio/{id}.mp3` 与 `manifest.json`(`durationSec` 为实测时长),下游(Remotion 场景、字幕、抽帧 QA)零改动。 @@ -106,13 +108,33 @@ uv run --frozen --with fastapi --with uvicorn --with soundfile --with numpy --wi ### 3.2 长录音裁剪(prepare_ref.py) ```bash -# 从长录音截取 [8s, 22s) 共 14s,归一化峰值、转 16-bit 单声道 WAV,输出到 voices/ +# 从长录音截取 [180s, 192s) 共 12s,归一化峰值、转 16-bit 单声道 WAV,输出到 voices/ uv run --no-project --with soundfile --with numpy \ - media/pipeline/scripts/prepare_ref.py ~/Documents/dify/me-1.mp3 --start 8 --duration 14 -# → media/pipeline/voices/me-1.wav + media/pipeline/scripts/prepare_ref.py ~/Documents/dify/me-1.mp3 --start 180 --duration 12 +# → media/pipeline/voices/me-1.wav(此段即已上线三集成片所用样本,sha1 3ed0d9d60d4b) ``` -裁剪段须试听确认:该段人声干净、无背景音乐、语句完整。样本 SHA1 参与缓存摘要(见 §六),替换样本自动失效缓存。 +裁剪段须试听确认(`afplay media/pipeline/voices/me-1.wav`):该段人声干净、无背景音乐、语句完整。样本 SHA1 参与缓存摘要(见 §六),替换样本自动失效缓存。 + +**选段辅助**——长录音里挑哪一段?按滑窗扫响度与停顿,先筛出候选起点再逐个试听: + +```bash +uv run --no-project --with soundfile --with numpy python - <<'PY' +import numpy as np, soundfile as sf +x, sr = sf.read("/Users/<你>/Documents/dify/me-1.mp3", dtype="float32", always_2d=True) +x, W, rows = x.mean(axis=1), 12, [] # W = 目标样本时长(秒) +for s in range(0, int(len(x) / sr) - W + 1): + seg = x[s * sr : (s + W) * sr] + frames = seg[: len(seg) // (sr // 50) * (sr // 50)].reshape(-1, sr // 50) # 20ms 帧 + rms = float(np.sqrt(np.mean(seg**2))) + sil = float(np.mean(np.sqrt(np.mean(frames**2, axis=1)) < 0.1 * rms)) # 静音占比 + rows.append((rms, s, sil)) +for rms, s, sil in sorted(rows, reverse=True)[:8]: + print(f"--start {s:<4d} rms={rms:.4f} 静音占比={sil:.2f}") +PY +``` + +响度高只代表「不太小声」,**不代表段落好**(成片所用的 180s 段在 253 个候选窗口中 RMS 仅排 67);真正的判据是人声干净、单说话人、语句完整、语速语调贴近目标成片——只能靠试听定夺。 ## 四、风格与参数 @@ -140,29 +162,109 @@ GPT 声码段的束搜索宽度,默认 **1**(上游库内部默认 3)。 ### 4.4 调参建议 -风格向量是 8 维情感空间中的方向+强度,首次使用建议:固定一句文本,`--style` 各档合成一次试听对比;同风格微调用 `--emo-alpha 0.5`(更含蓄)或 `--duration-factor 0.92`(更紧凑)。**先跑 3 句小样确认,再全量合成**。科普长视频推荐 `passionate`(充满激情与轻快:高唤醒正价 happy 主载 + surprised 跳跃感 + 少量 calm 锚定咬字);数字/术语密集的段落若嫌糊,可 `--duration-factor 1.0` 重跑该集。 +风格向量是 8 维情感空间中的方向+强度,首次使用建议:固定一句文本,`--style` 各档合成一次试听对比(一条命令跑完五档,见 §5.1 的 `--all-styles`);同风格微调用 `--emo-alpha 0.5`(更含蓄)或 `--duration-factor 0.92`(更紧凑)。**先跑小样确认,再全量合成**。科普长视频推荐 `passionate`(充满激情与轻快:高唤醒正价 happy 主载 + surprised 跳跃感 + 少量 calm 锚定咬字);数字/术语密集的段落若嫌糊,可 `--duration-factor 1.0` 重跑该集。 -## 五、逐集使用 +## 五、小样试听与逐集合成 + +### 5.1 小样试听(单句直调服务,不需要工程) + +全量一集要跑 2.5–3.5 小时,而「克隆出的音色像不像我」「哪档风格适合本集」用**一句话**就能判定——所以**定稿风格前必须先听小样**。[scripts/tts_sample.py](./scripts/tts_sample.py) 直调 IndexTTS 服务合成单句,无需 `narration.json`、无需视频工程;它复用 `tts.py` 的风格预设与口播文本预处理(单一事实源),故小样与成片走**完全相同**的合成路径,听感可直接外推。 ```bash -# 0) 确认服务在线 +# 1) 生成参考样本(已有可跳过;本人长录音截取一段干净人声) +uv run --no-project --with soundfile --with numpy \ + media/pipeline/scripts/prepare_ref.py ~/Documents/dify/me-1.mp3 --start 180 --duration 12 +# → media/pipeline/voices/me-1.wav(12.0s · 32 kHz · 单声道 · 16-bit · sha1 3ed0d9d60d4b) + +# 2) 确认服务在线(未启动见 §2.3) curl -s http://127.0.0.1:8766/health -# 1) 全量合成(工程内薄包装等价) -cd media/<工程> +# 3) 单档试听:合成后立即播放 +uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-1.wav --style passionate --play + +# 4) 五风格 A/B:neutral→passionate→lively→confident→positive 各一遍,顺序试听择优 +uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-1.wav --all-styles --play +``` + +> **`--start 180 --duration 12` 就是已上线三集成片所用的同源样本**:该段裁剪结果的 `sha1` 前 12 位为 `3ed0d9d60d4b`,与三集音频缓存 sidecar 摘要中的 `ref_sha1` 一致,直接复用即可听到与成片完全一致的音色。想换段落见 §3.2。 + +**常用开关** + +| 开关 | 作用 | 默认 | +|---|---|---| +| `--text` / `--text-file` | 试听文本(建议 20–40 字,带数字/术语更易暴露咬字问题) | 内置一句科普文本 | +| `--style` / `--all-styles` | 单档 / 全部预设 A/B(`--all-styles` 逐档取预设自带 alpha 与语速,故与下一行三参数互斥) | `neutral` | +| `--emo-vector` `--emo-alpha` `--duration-factor` | 手动调参,语义与取值范围同 §四 | 随风格 | +| `--num-beams` | 束宽;质量敏感的单句可试 `3`(耗时约按束宽线性放大,见 §4.3b) | `1` | +| `--dry-run` | 只解析并打印各档向量/alpha/语速,不连服务(秒级核参,改风格后先跑这个) | 关 | +| `--play` / `--out-dir` | 合成后 `afplay` 顺序试听 / 产物目录 | 关 / `.temp/voice-samples/` | + +**耗时实测**(2026-08-19 · M3 系列 · MPS fp32 · `num_beams=1` · 上述 34 字文本,机器空闲) + +| 环节 | 音频时长 | 墙钟 | RTF | +|---|---|---|---| +| 单档 · 首档(含服务暖机) | 6.86s | 47.0s | 6.8 | +| 单档 · 暖机后 | 6.0–6.9s | 20–22s | 3.2–3.4 | +| 五风格 A/B 全跑 | 合计 32.3s | **2.2 分钟** | — | + +> **口径提醒**:小样 RTF(≈3.3)与 §4.3b 整集折算 RTF(≈12–14)测的不是同一件事——前者是暖机后、机器空闲、单句;后者含数小时长跑的降频、机器争用与逐句开销。**小样耗时不可线性外推到整集**,整集排期仍按 §4.3b。 + +**注意事项** + +- 参考样本路径由客户端解析为**绝对路径**后传给服务端(服务与客户端可分处不同 checkout,本机即如此),但文件须对**服务进程**可见; +- 小样文本会经与成片相同的预处理(`——`→`,`、`……`→`。`);改用下方 curl 直调则**不做**该替换,破折号会念出怪音; +- 风格/样本一改,整集时长随之改变 → 定稿后必须重跑草渲让时间轴重算(见 5.3、§六); +- 产物落 `.temp/voice-samples/`(已被根 `.gitignore` 忽略)。**小样内含本人音色,属生物特征信息,试听后请 `rm -rf .temp/voice-samples`**。 + +#### 附:纯 HTTP 直调(协议级排障 / 无 Python 环境时) + +服务只暴露 `/health` 与 `/synthesize` 两个端点,一条 `curl` 即可完成合成——用于判定「问题在服务端还是客户端」: + +```bash +mkdir -p .temp/voice-samples && cat > .temp/voice-samples/payload.json <<'JSON' +{ + "text": "自进化编码智能体的核心不是写代码,而是让 AI 学会修改自己写代码的方式。", + "ref_path": "/绝对路径/media/pipeline/voices/me-1.wav", + "emo_vector": [0.7, 0, 0, 0, 0, 0, 0.2, 0.1], + "emo_alpha": 0.7, + "duration_factor": 0.97, + "lang": "ZH", + "num_beams": 1 +} +JSON +curl -sS -X POST http://127.0.0.1:8766/synthesize \ + -H 'Content-Type: application/json' -d @.temp/voice-samples/payload.json \ + -D .temp/voice-samples/headers.txt -o .temp/voice-samples/curl.mp3 \ + -w '状态 %{http_code} · 墙钟 %{time_total}s\n' --max-time 900 +grep -i '^x-' .temp/voice-samples/headers.txt # 期望 X-Audio-Format: mp3 与 X-Duration-Sec +afplay .temp/voice-samples/curl.mp3 +``` + +- `emo_vector` 为 8 维(顺序见 §4.1),**取值以 §4.1 表或 `tts.py --list-styles` 输出为准**(勿另抄副本);`neutral` 档传 `null` 或整字段省略即不注入情感; +- 状态码非 200 时响应体是 JSON 错误详情、被 `-o` 写进了 `.mp3`:`cat .temp/voice-samples/curl.mp3` 即可看到 `detail`(如情感有效和超界、参考音频不存在)。 + +### 5.2 全量合成(逐集) + +```bash +cd media/<工程> # 工程内薄包装等价于中心脚本;风格取 5.1 试听定稿的那一档 uv run --no-project --with mutagen scripts/tts.py --engine indextts \ --ref <绝对路径>/media/pipeline/voices/me-1.wav --style passionate - -# 2) 小样试听(先只跑 3 句:临时 narration.json 或 --force 单句验证均可) -# 3) 全量后重渲染(render 脚本定义在 video/package.json,须进入 video/) -cd video && pnpm run render:draft && pnpm run render ``` - 服务启动一次可服务多集;管线客户端不常驻模型; - 每句墙钟与文本长度及束宽相关:MPS fp32 实测 RTF≈40–58(`--num-beams 3`)/ ≈12–14(默认 `--num-beams 1`),即 5 秒的句子约需 1–1.5 分钟;整集(约 180–230 句)约 2.5–3.5 小时,建议 `nohup` 挂后台跑、按句缓存断点续跑(见 §六);长篇管线**不要**用默认 3 束逐句等待; -- 引擎/风格/样本任一变化都会改写时长,合成后**必须重跑草渲**让时间轴重算; - 超长句(>120 token)服务端内部自动分段;极端长句推理可达数分钟。客户端并发为 1(与服务端串行推理对齐,避免排队时间计入超时),HTTP 超时 600s;万一超时——重跑即续传,无需干预。 +### 5.3 合成后重渲染 + +```bash +cd video && pnpm run render:draft && pnpm run render # render 脚本定义在 video/package.json +``` + +引擎/风格/样本任一变化都会改写每句时长,合成后**必须重跑草渲**让 Remotion 时间轴重算。 + ## 六、缓存与幂等 diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py new file mode 100644 index 00000000..d336f964 --- /dev/null +++ b/media/pipeline/scripts/tts_sample.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +"""单句声音小样试听——直调 IndexTTS 服务合成一句话,不需要视频工程。 + +- 动机:全量一集(180–230 句)需 2.5–3.5 小时,而「克隆的音色像不像我」「哪档风格 + 适合本集」只需一句话即可判定。本脚本把 tts.py 的合成路径剥出单句版,用于试听收敛。 +- 一致性:风格预设、口播文本预处理、HTTP 契约全部复用 tts.py(单一事实源),小样与 + 成片走完全相同的合成路径,听感可直接外推。 +- 前置:参考音色样本(prepare_ref.py 产出)+ 已启动的 tts_server.py。 + +用法(仓库根执行): + # 单档试听 + uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-1.wav --style passionate --play + # 五风格 A/B(neutral/passionate/lively/confident/positive 各合成一遍) + uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-1.wav --all-styles --play + +产物:<仓库根>/.temp/voice-samples/{风格}.mp3(已被根 .gitignore 忽略)——内含本人音色, +属生物特征信息,试听后请及时清理。完整手册见 media/pipeline/VOICE-CLONING.md §5.1。 +""" + +from __future__ import annotations + +import argparse +import hashlib +import shutil +import subprocess +import sys +import time +from pathlib import Path + +# tts.py 与本脚本同目录:显式注入 sys.path,令任意 cwd / 调用方式(含 python -m)均可导入 +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from tts import ( # noqa: E402 - 必须在 sys.path 注入之后导入 + MANUAL, + STYLE_PRESETS, + NonRetryableError, + http_json, + http_synthesize, + mp3_duration, + resolve_style, + tts_text, +) + +SERVER_SCRIPT = Path(__file__).resolve().parent / "tts_server.py" +# parents: [0]=scripts [1]=pipeline [2]=media [3]=仓库根(与 prepare_ref.py 的 parents[1] 同惯例) +DEFAULT_OUT_DIR = Path(__file__).resolve().parents[3] / ".temp" / "voice-samples" +DEFAULT_TEXT = "自进化编码智能体的核心不是写代码,而是让 AI 学会修改自己写代码的方式。" +ATTEMPTS = 2 # 非 4xx(如 MPS 数值问题致的 500)再试一次;4xx 立即失败 + + +def build_jobs( + args: argparse.Namespace, +) -> list[tuple[str, list[float] | None, float, float]]: + """→ [(风格名, 情感向量|None, emo_alpha, duration_factor)]。 + + --all-styles 逐档取各预设自带的 alpha/df(这正是 A/B 的意义,故与手动覆盖互斥)。 + """ + if args.all_styles: + return [ + resolve_style( + argparse.Namespace( + emo_vector=None, style=name, emo_alpha=None, duration_factor=None + ) + ) + for name in STYLE_PRESETS + ] + return [resolve_style(args)] + + +def check_server(server: str, need_duration_factor: bool) -> None: + """健康检查——服务未起时给出可直接粘贴的启动命令,避免等到合成阶段才失败。""" + try: + health = http_json("GET", f"{server}/health", None, 10) + if not health.get("ok"): + raise RuntimeError(f"health.ok=false: {health}") + except Exception as e: # noqa: BLE001 - 任何不可达都归一为可操作指引 + sys.exit( + f"IndexTTS 服务不可用({e})。请先启动:\n" + f" cd ~/tools/index-tts && uv run --frozen --with fastapi --with uvicorn \\\n" + f" --with soundfile --with numpy --with lameenc \\\n" + f" python {SERVER_SCRIPT} --model-dir checkpoints --port 8766\n" + f"详见 {MANUAL} §2.3" + ) + print( + f">> 服务就绪:IndexTTS-{health.get('version')} device={health.get('device')} " + f"dtype={health.get('dtype')} encoder={health.get('encoder')}" + ) + if need_duration_factor and not health.get("supports_duration_factor"): + sys.exit( + "当前服务为 IndexTTS-2(无语速控制),而所选风格的 duration_factor≠1.0:\n" + f" 改用 --style neutral,或以 --indextts-version 2.5 重启服务(见 {MANUAL} §七)" + ) + + +def synthesize_one( + args: argparse.Namespace, + name: str, + vec: list[float] | None, + alpha: float, + df: float, + out_dir: Path, +) -> dict: + """合成一档并落盘 → {style, path, duration, wall, rtf}。失败即退出(小样无需容错累积)。""" + out = out_dir / f"{name}.mp3" + last_err: Exception | None = None + for attempt in range(ATTEMPTS): + t0 = time.perf_counter() + try: + audio, fmt = http_synthesize( + args.server, + tts_text(args.text), + str(args.ref), + vec, + alpha, + df, + args.lang, + args.num_beams, + ) + except NonRetryableError as e: + sys.exit(f"[{name}] 请求被拒(4xx,重试无意义):{e}") + except Exception as e: # noqa: BLE001 - 推理服务偶发 500/超时,整体重试 + last_err = e + print(f"[{name}] 第 {attempt + 1}/{ATTEMPTS} 次失败:{e}", file=sys.stderr) + continue + wall = time.perf_counter() - t0 + if fmt != "mp3": + sys.exit( + f"[{name}] 服务端 MP3 编码器不可用(X-Audio-Format={fmt})—— " + f"按 {MANUAL} §七 带 --with lameenc 重启服务" + ) + if not audio: + last_err = RuntimeError("空音频响应") + continue + out.write_bytes(audio) + duration = mp3_duration(out) + rtf = wall / duration if duration else float("nan") + print( # flush:长跑常被 tee/nohup 重定向,缓冲会让进度看起来「卡住」 + f"[{name:<10}] 音频 {duration:5.2f}s · 墙钟 {wall:6.1f}s · RTF {rtf:5.1f} · {out}", + flush=True, + ) + return { + "style": name, + "path": out, + "duration": duration, + "wall": wall, + "rtf": rtf, + } + sys.exit(f"[{name}] 合成失败:{last_err}") + + +def play(results: list[dict]) -> None: + """顺序试听(macOS afplay)。缺 afplay 时只提示,不视为失败。""" + player = shutil.which("afplay") + if not player: + print( + "未找到 afplay(非 macOS?):请用系统播放器打开上述文件试听", + file=sys.stderr, + ) + return + for r in results: + print(f">> 播放 {r['style']}({r['duration']:.2f}s)…") + subprocess.run([player, str(r["path"])], check=False) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="单句声音小样试听(直调 IndexTTS 服务,无需视频工程)" + ) + parser.add_argument( + "--ref", required=True, help="参考音色样本路径(prepare_ref.py 产出)" + ) + parser.add_argument( + "--text", default=None, help=f"试听文本(默认内置一句:{DEFAULT_TEXT})" + ) + parser.add_argument( + "--text-file", default=None, help="从文件读取试听文本(与 --text 互斥)" + ) + parser.add_argument( + "--style", + default="neutral", + choices=list(STYLE_PRESETS), + help="风格预设(默认 neutral)", + ) + parser.add_argument( + "--all-styles", + action="store_true", + help="逐档合成全部风格预设做 A/B(与 --emo-vector/--emo-alpha/--duration-factor 互斥)", + ) + parser.add_argument( + "--emo-vector", + default=None, + help="原始情感向量,如 happy:0.6,calm:0.2(与非默认 --style 互斥)", + ) + parser.add_argument( + "--emo-alpha", default=None, type=float, help="情感强度 0–1(默认随风格)" + ) + parser.add_argument( + "--duration-factor", default=None, type=float, help="语速 0.5–2.0(默认随风格)" + ) + parser.add_argument("--lang", default="ZH", help="语言(默认 ZH)") + parser.add_argument( + "--num-beams", + default=1, + type=int, + choices=[1, 2, 3, 4, 5], + help="GPT 束搜索宽度(默认 1;耗时约按束宽线性放大,见 " + MANUAL + " §4.3b)", + ) + parser.add_argument("--server", default="http://127.0.0.1:8766", help="服务地址") + parser.add_argument( + "--out-dir", + default=None, + help=f"产物目录(默认 {DEFAULT_OUT_DIR},已被 .gitignore 忽略)", + ) + parser.add_argument("--play", action="store_true", help="合成后用 afplay 顺序试听") + parser.add_argument( + "--dry-run", action="store_true", help="只解析并打印参数,不连服务、不合成" + ) + args = parser.parse_args() + args.server = args.server.rstrip("/") + + # ---- 参数互斥与取值校验(尽早失败:单档合成约 2 分钟,不能等到最后才报错)---- + if args.text and args.text_file: + parser.error("--text 与 --text-file 互斥") + if args.all_styles: + overrides = [ + flag + for flag, val in { + "--emo-vector": args.emo_vector, + "--emo-alpha": args.emo_alpha, + "--duration-factor": args.duration_factor, + }.items() + if val is not None + ] + if overrides: + parser.error( + f"--all-styles 会逐档取各预设自带的 alpha/语速,不能与 {' '.join(overrides)} 同用" + ) + if args.style != "neutral" and args.emo_vector: + parser.error("--style 非默认值与 --emo-vector 互斥") + if args.emo_alpha is not None and not 0.0 <= args.emo_alpha <= 1.0: + parser.error("--emo-alpha 必须在 [0, 1]") + if args.duration_factor is not None and not 0.5 <= args.duration_factor <= 2.0: + parser.error("--duration-factor 必须在 [0.5, 2.0]") + + ref = Path(args.ref).expanduser().resolve() + if not ref.is_file(): + parser.error(f"参考样本不存在: {ref}(生成方式见 {MANUAL} §三)") + args.ref = ref # 绝对路径:服务端按自身文件系统解析 ref_path,与客户端 cwd 无关 + + if args.text_file: + text_path = Path(args.text_file).expanduser().resolve() + if not text_path.is_file(): + parser.error(f"文本文件不存在: {text_path}") + args.text = text_path.read_text(encoding="utf-8").strip() + args.text = (args.text or DEFAULT_TEXT).strip() + if not args.text: + parser.error("试听文本为空") + + try: + jobs = build_jobs(args) + except ValueError as e: # parse_emo_vector 的键名/权重错误 + parser.error(str(e)) + for name, vec, alpha, df in jobs: + if ( + vec is not None and sum(vec) * alpha > 0.8 + ): # 与服务端同口径:alpha 缩放后校验有效和 + parser.error( + f"[{name}] 情感向量有效和 {sum(vec) * alpha:.3f}(Σvec×alpha)超过 0.8 上限" + ) + + ref_sha1 = hashlib.sha1(ref.read_bytes()).hexdigest()[ + :12 + ] # 与缓存摘要同前缀,便于与 .sha 对账 + print(f">> 文本(预处理后):{tts_text(args.text)}") + print(f">> 参考样本:{ref}(sha1 {ref_sha1})") + print(f">> 风格 {len(jobs)} 档 · num_beams={args.num_beams} · lang={args.lang}") + for name, vec, alpha, df in jobs: + vec_str = ",".join(f"{x:g}" for x in vec) if vec else "—(不注入情感)" + print(f" {name:<10} vec=[{vec_str}] alpha={alpha:g} df={df:g}") + if args.dry_run: + print(">> --dry-run:仅解析参数,未连接服务") + return + + try: + import mutagen # noqa: F401 - 时长实测依赖,提前失败好过跑完才报错 + except ImportError: + sys.exit( + "缺少 mutagen(实测 MP3 时长用):请以 `uv run --no-project --with mutagen ...` 执行" + ) + + check_server(args.server, need_duration_factor=any(df != 1.0 for *_, df in jobs)) + + out_dir = ( + Path(args.out_dir).expanduser().resolve() if args.out_dir else DEFAULT_OUT_DIR + ) + out_dir.mkdir(parents=True, exist_ok=True) + print(f">> 产物目录:{out_dir}\n") + + results = [ + synthesize_one(args, name, vec, alpha, df, out_dir) + for name, vec, alpha, df in jobs + ] + + total_wall = sum(r["wall"] for r in results) + print(f"\n完成 {len(results)} 档,总墙钟 {total_wall / 60:.1f} 分钟") + if args.play: + play(results) + if ( + len(results) == 1 and results[0]["style"] == "raw" + ): # 手动向量:回显向量而非不存在的 --style raw + chosen = f'--emo-vector "{args.emo_vector}"' + if args.emo_alpha is not None: + chosen += f" --emo-alpha {args.emo_alpha:g}" + if args.duration_factor is not None: + chosen += f" --duration-factor {args.duration_factor:g}" + else: + chosen = f"--style {results[0]['style'] if len(results) == 1 else '<选定风格>'}" + print( + f"\n下一步 · 选定风格后全量合成一集:\n" + f" cd media/<工程> && uv run --no-project --with mutagen scripts/tts.py \\\n" + f" --engine indextts --ref {ref} {chosen}\n" + f"清理 · 小样含本人音色(生物特征信息),试听后请删除:rm -rf {out_dir}" + ) + + +if __name__ == "__main__": + main() From e8875145c8db60b1a33d23320cd3a3df98bd9535 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 11:18:54 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(tts):=20=E6=83=85=E6=84=9F=E4=B8=89?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=EF=BC=88=E5=90=91=E9=87=8F/=E8=AF=AD?= =?UTF-8?q?=E8=B0=83=E8=BF=81=E7=A7=BB/=E8=87=AA=E7=84=B6=E8=AF=AD?= =?UTF-8?q?=E8=A8=80=EF=BC=89+=20=E5=8F=82=E8=80=83=E6=A0=B7=E6=9C=AC?= =?UTF-8?q?=E9=80=89=E6=AE=B5=E5=8B=98=E6=8E=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户反馈五档预设配音「都有点不自然、不够轻快阳光」。定位到两个根因,均已给出工具: **根因一:样本定基线,而成片在用的那段恰是本人最闷的一档。** 克隆会继承参考样本的 韵律,情感向量只能在基线上微调。对同一位说话人的 4 段录音做 12s 滑窗勘探后实测: 成片所用 me-1@180s 的 F0 中位 142.2 Hz / 起伏 31.2 / 音节率 4.42 / 质心 1698 Hz, 综合分仅排 157/275——换成 me-1@0s 或 me-1@28s,纯克隆(零情感注入)即可让音高 +12~16%、语调起伏 +25~40%、明亮度 +5~15%。新增 `scripts/prospect_ref.py` 把这套 筛选固化为工具(F0 中位/四分位距/音节率/谱质心 + 静音与发声占比扣分,多文件同尺度 排序,输出可直接当 prepare_ref.py 的 --start)。 **根因二:向量注入越多越假,而既有预设都偏重。** 读上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 可知 Σw 就是「合成情感挤掉本人 真实情感」的比例:现有 passionate/positive 的有效和 0.70/0.665,意味着只剩 30% 是 本人语调。故新增两条更自然的情感通路: - `--emo-ref <另一段录音>`:音色仍取 --ref,语调/情绪整体迁移自这段录音,零向量注入; - `--emo-text "轻快爽朗、自信阳光"`:服务端 QwenEmotion 转向量(需 --use-qwen-emo), 按 ≤0.8 规则等比缩放后使用,并经 X-Emo-Vector 回显供 --emo-vector 固化复现。 服务端对三来源显式互斥报错(上游遇「向量+情感音频」是静默丢弃音频,静默降级更难排查)。 实现要点:`tts_server.py` 新增 emo_ref_path/emo_text 字段、`--use-qwen-emo` 启动开关、 `/health.supports_emo_text`;`tts.py`/`tts_sample.py` 同步 CLI 与前置校验,摘要新增 `|emoref=` / `|emotext=<原文>` 可选后缀,`tts_sample.py` 另加 `--label` 便于横向 对比不留覆盖。文档:§四 前置「情感三来源」对照表 + 少注入更自然的机制说明、新增 §3.3 「样本决定基线」实测表、§3.2 改用 prospect_ref.py、§2.3/§2.4/§七 补 --use-qwen-emo 与权重缺失兜底、§六 摘要公式补可选后缀。 验证:①存量缓存零失效——对已上线三集 189 句逐句重算摘要,100% 与 sidecar 一致; ②生产路径 E2E——单句 mini 工程跑通 `tts.py --emo-ref`(manifest durationSec 5.666、 sidecar 生成、同参数重跑 0.24s 命中缓存、换情感样本/去掉情感源摘要均改变); ③新增负例 4 条全部给出可操作报错(三来源互斥/与 --all-styles 互斥/情感音频不存在/ 服务未加载 Qwen);④X-Emo-Vector 回显实测(「轻快、爽朗、自信、阳光」→ happy 0.76/surprised 0.016/calm 0.024,恰好被压到 Σ=0.8 上限);⑤本轮共产出 24 个 候选小样(5 样本 × 5 档低注入向量 + 4 档语调迁移 + 3 档自然语言 + 2 档固化后减弱), 客观指标已逐个测量待用户听选。 🤖 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/README.md | 1 + media/pipeline/VOICE-CLONING.md | 75 ++++++--- media/pipeline/scripts/prospect_ref.py | 202 +++++++++++++++++++++++++ media/pipeline/scripts/tts.py | 120 +++++++++++++-- media/pipeline/scripts/tts_sample.py | 95 +++++++++++- media/pipeline/scripts/tts_server.py | 92 +++++++++-- 6 files changed, 538 insertions(+), 47 deletions(-) create mode 100644 media/pipeline/scripts/prospect_ref.py diff --git a/media/pipeline/README.md b/media/pipeline/README.md index 10c0df59..4516e259 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -58,6 +58,7 @@ media/-video/ | [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/qa_frames.py](./scripts/qa_frames.py) | 按句 id 抽帧视觉 QA | `uv run --no-project scripts/qa_frames.py out/draft.mp4 --scene P2` | 中心脚本以 `--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 5fa18116..83a63cbb 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -75,11 +75,12 @@ uv run hf download IndexTeam/IndexTTS-2.5 --local-dir checkpoints cd ~/tools/index-tts uv run --frozen --with fastapi --with uvicorn --with soundfile --with numpy --with lameenc \ python <本仓绝对路径>/media/pipeline/scripts/tts_server.py \ - --model-dir checkpoints --indextts-version 2.5 --host 127.0.0.1 --port 8766 + --model-dir checkpoints --indextts-version 2.5 --host 127.0.0.1 --port 8766 \ + [--use-qwen-emo] # 可选:加载 QwenEmotion(0.6B,约 +1.5 GB 内存),启用 --emo-text 自然语言情感 ``` -- 启动即加载模型(约 30–60 秒),出现 `>> 就绪:IndexTTS-2.5 device=mps ...` 后可服务请求; -- 健康检查:`curl http://127.0.0.1:8766/health` → `{"ok": true, "version": "2.5", "device": "mps", "synthesizing": false, "dtype": "fp32", "encoder": "soundfile", "supports_duration_factor": true}`(MPS 上 dtype 恒为 fp32,属预期); +- 启动即加载模型(约 30–60 秒),出现 `>> 就绪:IndexTTS-2.5 device=mps ... emo_text=on|off` 后可服务请求; +- 健康检查:`curl http://127.0.0.1:8766/health` → `{"ok": true, "version": "2.5", "device": "mps", "synthesizing": false, "dtype": "fp32", "encoder": "soundfile", "supports_duration_factor": true, "supports_emo_text": false}`(MPS 上 dtype 恒为 fp32,属预期;`supports_emo_text` 随 `--use-qwen-emo` 变化); - **仅监听 127.0.0.1、无鉴权,勿暴露公网**;`ref_path` 为服务端本地绝对路径。 @@ -89,6 +90,7 @@ uv run --frozen --with fastapi --with uvicorn --with soundfile --with numpy --wi |---|---| | `uv run --with` 报依赖解析冲突(与 gradio/torch 锁冲突) | 先 `uv pip install fastapi uvicorn soundfile lameenc` 装进 checkout 的 venv,再 `uv run --no-sync python ...` 启动 | | `uv run --frozen` 报锁不同步 | 去掉 `--frozen`(仅当 checkout 的 uv.lock 与 pyproject 状态异常时) | +| `--use-qwen-emo` 启动失败:`no file named model.safetensors ... qwen0.6bemo4-merge/` | 首轮 `hf download` 只落了该子目录的 config/tokenizer,权重(1.19 GB)缺失。补齐:`cd ~/tools/index-tts && uv run hf download IndexTeam/IndexTTS-2.5 --include "qwen0.6bemo4-merge/*" --local-dir checkpoints`(约 20 秒),再带 `--use-qwen-emo` 重启 | | HF 下载超时/中断 | 重跑 `hf download` 即续传;或改用 ModelScope(见 2.2) | | 下载中途「假死」(进程在但字节零增长,连接 CLOSE_WAIT) | 强杀进程重跑即可续传:`pkill -f "hf download"` 后重复 `uv run hf download ...`;可循环重试直至完成 | | 磁盘不足 | checkpoints 可与其它 index-tts 部署共享(启动时 `--model-dir` 指向同一目录) | @@ -100,7 +102,7 @@ uv run --frozen --with fastapi --with uvicorn --with soundfile --with numpy --wi | 项 | 要求 | |---|---| | 时长 | **5–15 秒**(上限 30s) | -| 内容 | 自然说话,与目标成片语速/语调一致(韵律风格会被一并克隆) | +| 内容 | 自然说话,**与目标成片语速/语调一致**——韵律风格会被一并克隆,样本定基线、情感向量只能在基线上微调(实测见 3.3) | | 环境 | 安静房间、固定麦克风距离、无 BGM/混响/系统降噪痕迹 | | 说话人 | 仅本人一人 | | 格式 | WAV 16-bit ≥22.05kHz 优先(mp3/flac 经 `prepare_ref.py` 转换;m4a 需先 `ffmpeg -i in.m4a out.wav`) | @@ -116,28 +118,44 @@ uv run --no-project --with soundfile --with numpy \ 裁剪段须试听确认(`afplay media/pipeline/voices/me-1.wav`):该段人声干净、无背景音乐、语句完整。样本 SHA1 参与缓存摘要(见 §六),替换样本自动失效缓存。 -**选段辅助**——长录音里挑哪一段?按滑窗扫响度与停顿,先筛出候选起点再逐个试听: +**选段辅助**——长录音里挑哪一段?用 [scripts/prospect_ref.py](./scripts/prospect_ref.py) 按滑窗扫客观指标(F0 中位=音高、F0 起伏=语调、音节率=语速、谱质心=明亮度,并对静音过多/发声过少扣分),先筛候选再试听: ```bash -uv run --no-project --with soundfile --with numpy python - <<'PY' -import numpy as np, soundfile as sf -x, sr = sf.read("/Users/<你>/Documents/dify/me-1.mp3", dtype="float32", always_2d=True) -x, W, rows = x.mean(axis=1), 12, [] # W = 目标样本时长(秒) -for s in range(0, int(len(x) / sr) - W + 1): - seg = x[s * sr : (s + W) * sr] - frames = seg[: len(seg) // (sr // 50) * (sr // 50)].reshape(-1, sr // 50) # 20ms 帧 - rms = float(np.sqrt(np.mean(seg**2))) - sil = float(np.mean(np.sqrt(np.mean(frames**2, axis=1)) < 0.1 * rms)) # 静音占比 - rows.append((rms, s, sil)) -for rms, s, sil in sorted(rows, reverse=True)[:8]: - print(f"--start {s:<4d} rms={rms:.4f} 静音占比={sil:.2f}") -PY +uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospect_ref.py \ + ~/Documents/dify/me-1.mp3 ~/Documents/dify/me-2.mp3 --window 12 --top 4 +# 输出可直接当 prepare_ref.py 的 --start 用;多个文件会放在同一把尺子下排序 ``` -响度高只代表「不太小声」,**不代表段落好**(成片所用的 180s 段在 253 个候选窗口中 RMS 仅排 67);真正的判据是人声干净、单说话人、语句完整、语速语调贴近目标成片——只能靠试听定夺。 +分高只代表「不小声、不平、不慢」,**不代表段落好**(成片在用的 180s 段综合分仅排 157/275);真正的判据是人声干净、单说话人、语句完整、语速语调贴近目标成片——只能靠试听定夺。 + +### 3.3 样本决定基线:换段落比调参数更管用(实测) + +若合成结果「不够轻快/不够阳光」,**先怀疑样本,再怀疑向量**。2026-08-19 在同一位说话人的 4 段录音上做 12s 滑窗勘探(指标:F0 中位=音高、F0 四分位距=语调起伏、音节率=语速、谱质心=明亮度),并对每个候选样本做**纯克隆**(`--style neutral`,不注入任何情感)小样: + +| 候选样本(12s) | 样本 F0 中位 | 样本起伏 | 样本音节率 | 样本质心 | → 纯克隆小样 F0 | 小样起伏 | 小样质心 | +|---|---|---|---|---|---|---|---| +| me-1 @180s(**成片在用**) | 142.2 Hz | 31.2 | 4.42 | 1698 Hz | 140.4 Hz | 26.2 | 1120 Hz | +| me-1 @0s | 161.6 Hz | 36.4 | 4.67 | 1728 Hz | 157.5 Hz | 34.1 | 1181 Hz | +| me-1 @28s | 153.8 Hz | 25.5 | 4.75 | **1898 Hz** | **163.3 Hz** | 35.2 | 1286 Hz | +| me-2 @172s | 151.3 Hz | **38.9** | **4.92** | 1840 Hz | 145.1 Hz | 32.8 | 1152 Hz | +| me-3 @48s | 156.9 Hz | 31.8 | 4.83 | 1765 Hz | 156.4 Hz | 36.4 | 1268 Hz | + +结论:**成片在用的那一段恰好是说话人自己最低、最平、最暗的一档**(综合分排名 157/275),换一段同一人的录音即可让克隆音的音高 +12~16%、语调起伏 +25~40%、明亮度 +5~15%——这个幅度靠情感向量很难补回来,且向量越加越假(见 §四)。所以定式是:**`prospect_ref.py` 挑 3–4 段候选 → `prepare_ref.py` 各裁一份 → 各跑一次 `--style neutral` 小样比对 → 选定样本后再谈风格。** + +> 最省力的做法其实是**重录一段 10–15 秒的目标风格样本**:用你想要的那种语气念一段自己视频的开场逐字稿(比平时略快、句尾略上扬、带笑意),克隆会把这份韵律一起继承,之后连情感向量都可以只加一点点。 ## 四、风格与参数 +**情感有三个来源,互斥,只能给一个**(客户端与服务端双向校验;上游对「向量 + 情感音频」是**静默丢弃音频**,本管线改为显式报错): + +| 来源 | 开关 | 机制 | 适用 | +|---|---|---|---| +| **向量注入** | `--style` / `--emo-vector` | 8 维情感基向量加权混合,强度由 `--emo-alpha` 控制 | 要可复现、可微调的确定性风格 | +| **语调迁移** | `--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),而不是继续加权重。 + ### 4.1 风格预设(--style) | 预设 | 定位 | emo_vector(顺序:happy, angry, sad, afraid, disgusted, melancholic, surprised, calm) | alpha | df | @@ -186,6 +204,16 @@ uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ # 4) 五风格 A/B:neutral→passionate→lively→confident→positive 各一遍,顺序试听择优 uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ --ref media/pipeline/voices/me-1.wav --all-styles --play + +# 5) 觉得向量注入「有合成味」:改用语调迁移——音色仍是样本 A,语气搬自样本 B +uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-1.wav --emo-ref media/pipeline/voices/me-bright.wav \ + --label emoref-bright --play # --emo-alpha 0.7 可只迁移七成 + +# 6) 说不清参数、只说得清感觉:用自己的话描述(服务需 --use-qwen-emo) +uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-1.wav --emo-text "轻快、爽朗、自信、阳光" \ + --label qwen-brisk --play # 终端会回显推出的 8 维向量,满意就用 --emo-vector 固化 ``` > **`--start 180 --duration 12` 就是已上线三集成片所用的同源样本**:该段裁剪结果的 `sha1` 前 12 位为 `3ed0d9d60d4b`,与三集音频缓存 sidecar 摘要中的 `ref_sha1` 一致,直接复用即可听到与成片完全一致的音色。想换段落见 §3.2。 @@ -197,8 +225,11 @@ uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ | `--text` / `--text-file` | 试听文本(建议 20–40 字,带数字/术语更易暴露咬字问题) | 内置一句科普文本 | | `--style` / `--all-styles` | 单档 / 全部预设 A/B(`--all-styles` 逐档取预设自带 alpha 与语速,故与下一行三参数互斥) | `neutral` | | `--emo-vector` `--emo-alpha` `--duration-factor` | 手动调参,语义与取值范围同 §四 | 随风格 | +| `--emo-ref <录音>` | 语调迁移:音色仍取 `--ref`,语气来自这段录音(见 §四) | 关 | +| `--emo-text "<描述>"` | 自然语言描述情感(需服务端 `--use-qwen-emo`);推出的向量会打印,可用 `--emo-vector` 固化 | 关 | | `--num-beams` | 束宽;质量敏感的单句可试 `3`(耗时约按束宽线性放大,见 §4.3b) | `1` | | `--dry-run` | 只解析并打印各档向量/alpha/语速,不连服务(秒级核参,改风格后先跑这个) | 关 | +| `--label` | 产物文件名(多档时作前缀 `{label}-{风格}`)——**横向对比多个参考样本或多组自定义向量时必用**,否则同名互相覆盖 | 取风格名 | | `--play` / `--out-dir` | 合成后 `afplay` 顺序试听 / 产物目录 | 关 / `.temp/voice-samples/` | **耗时实测**(2026-08-19 · M3 系列 · MPS fp32 · `num_beams=1` · 上述 34 字文本,机器空闲) @@ -271,8 +302,10 @@ cd video && pnpm run render:draft && pnpm run render # render 脚本定义在 | 引擎 | 摘要公式 | |---|---| | edge(历史不变) | `sha1(voice\|rate\|text)` | -| indextts | `sha1(indextts\|engine_tag\|ref_sha1前12位\|lang\|style\|vec\|alpha\|df\|text)` | +| indextts | `sha1(indextts\|engine_tag\|ref_sha1前12位\|lang\|style\|vec\|alpha\|df\|text[\|beams=N][\|emoref=情感样本sha1前12位][\|emotext=描述原文])` | +- 方括号内为**可选后缀,仅在该项被使用时才拼入**(`--num-beams 1` / 无情感音频 / 无情感描述时省略)——这样新增能力不会失效任何存量缓存(已对已上线三集 189 句逐句核对:摘要 100% 不变); +- 情感样本按**内容 SHA1** 入键,换一段情感录音会自动失效缓存,与 `--ref` 同口径; - sidecar `{id}.sha` 与 `{id}.mp3` 一一对应、单槽位:换引擎/风格/样本/语速 = 全量重合成(一个句 id 只有一个 mp3 槽位,这是 Remotion 契约决定的); - 模型/服务升级后想强制刷新全部音频:`--engine-tag v2.5b`(自定义标记进摘要); - 中断后续跑:直接重跑同命令(已完成句子全部命中缓存跳过)。 @@ -288,6 +321,8 @@ cd video && pnpm run render:draft && pnpm run render # render 脚本定义在 | 服务日志 `QwenEmotion not loaded` | 正常 | 仅向量模式,不加载 Qwen(省内存) | | `X-Audio-Format=wav` | 服务端 MP3 编码器探测失败 | 按 §2.3 带 `--with lameenc` 重启服务 | | `/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 | | 长句合成失败 | 超时(HTTP_TIMEOUT=600s) | 重跑(缓存续传);超长句在逐字稿层面拆句 | | edge 模式失败 | 网络 | 与历史行为一致(重试 4 次后报错) | diff --git a/media/pipeline/scripts/prospect_ref.py b/media/pipeline/scripts/prospect_ref.py new file mode 100644 index 00000000..72b0099c --- /dev/null +++ b/media/pipeline/scripts/prospect_ref.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +"""参考音色样本「选段勘探」——从长录音里筛出更亮、更轻快的候选起点。 + +- 动机:克隆会把参考样本的**韵律风格**一并继承,样本定基线、情感向量只能在基线上微调。 + 若合成结果不够轻快/阳光,换一段自己更亮的录音比继续加情感权重有效得多(实测见 + VOICE-CLONING.md §3.3)。人耳逐段试听 4 分钟录音成本太高,故先用客观指标筛候选。 +- 指标(同一说话人内部相对比较,不作绝对判据): + F0 中位数 —— 音高高低("亮不亮"的主因) + F0 四分位距 —— 语调起伏(越大越有生气,太小则平板) + 音节率 —— 语速("轻快"的主因),能量包络峰计数的粗代理 + 谱质心 —— 明亮度/爽朗感 + RMS / 静音占比 / 发声占比 —— 响度与停顿,用于排除大段留白 +- 输出:按综合分排序的候选起点,可直接喂给 prepare_ref.py 的 --start。 + +用法(仓库根): + uv run --no-project --with soundfile --with numpy \ + media/pipeline/scripts/prospect_ref.py ~/Documents/dify/me-1.mp3 [更多音频…] \ + [--window 12] [--step 2] [--top 4] + +**响度/音高高不等于段落好**:候选仍须逐个 afplay 试听,确认人声干净、单说话人、 +语句完整、语气贴近目标成片(详见 VOICE-CLONING.md §三)。 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np +import soundfile as sf + +FRAME, HOP = 1024, 512 # 32ms 帧 / 16ms 跳(sr=32k) +F0_MIN, F0_MAX = 75.0, 400.0 +VOICED_AC = 0.35 # 自相关归一峰值阈:判定该帧是否为浊音 +MANUAL = "media/pipeline/VOICE-CLONING.md" + + +def framed(x: np.ndarray, frame: int, hop: int) -> np.ndarray: + n = 1 + max(0, (len(x) - frame) // hop) + if not n: + return np.zeros((0, frame), dtype=x.dtype) + return x[np.arange(frame)[None, :] + hop * np.arange(n)[:, None]] + + +def frame_features(x: np.ndarray, sr: int) -> dict: + """帧级 F0 / 谱质心 / RMS(整段算一次,供各窗口聚合)。""" + F = framed(x, FRAME, HOP) + if not len(F): + return {} + win = np.hanning(FRAME).astype(np.float32) + rms = np.sqrt(np.mean(F**2, axis=1)) + ref = float(np.median(rms[rms > 0])) if np.any(rms > 0) else 0.0 + gate = rms > max(1e-4, 0.15 * ref) # 过滤静音帧,避免噪声拉低统计 + lag_lo, lag_hi = int(sr / F0_MAX), int(sr / F0_MIN) + nfft = 1 << (2 * FRAME - 1).bit_length() + freqs = np.fft.rfftfreq(FRAME, 1 / sr) + f0 = np.zeros(len(F), dtype=np.float32) + centroid = np.zeros(len(F), dtype=np.float32) + for s in range(0, len(F), 2048): # 分块:整段一次 FFT 会吃掉几百 MB + blk = F[s : s + 2048] * win + spec = np.fft.rfft(blk, n=nfft) + ac = np.fft.irfft(spec * np.conj(spec), n=nfft)[:, : lag_hi + 1] + ac0 = ac[:, :1].copy() + ac0[ac0 == 0] = 1.0 + seg = (ac / ac0)[:, lag_lo : lag_hi + 1] + best = np.argmax(seg, axis=1) + 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) + den[den == 0] = 1.0 + centroid[s : s + len(blk)] = (mag * freqs).sum(axis=1) / den + return {"f0": f0, "centroid": centroid, "rms": rms, "gate": gate} + + +def syllable_rate(seg: np.ndarray, sr: int) -> float: + """能量包络峰计数 / 秒——音节率的粗代理(不辨声调,仅供相对比较)。""" + hop = sr // 100 # 10ms + e = np.sqrt(np.mean(framed(seg, hop * 2, hop) ** 2, axis=1)) + if len(e) < 3: + return 0.0 + e = np.convolve(e, np.ones(5) / 5, mode="same") + thr = 0.5 * float(np.median(e[e > 0])) if np.any(e > 0) else 0.0 + peaks, last = 0, -10 + for i in range(1, len(e) - 1): + if e[i] > thr and e[i] >= e[i - 1] and e[i] > e[i + 1] and i - last >= 10: + peaks, last = peaks + 1, i + return peaks / (len(seg) / sr) + + +def window_stats(feat: dict, x: np.ndarray, sr: int, start: int, window: int) -> dict: + fs, fe = (start * sr) // HOP, ((start + window) * sr) // HOP + f0, gate = feat["f0"][fs:fe], feat["gate"][fs:fe] + voiced = f0[(f0 > 0) & gate] + seg = x[start * sr : (start + window) * sr] + if len(voiced) < 20 or not len(seg): + return {} + rms = float(np.sqrt(np.mean(seg**2))) + frame_rms = feat["rms"][fs:fe] + return { + "start": start, + "f0_med": float(np.median(voiced)), + "f0_iqr": float(np.percentile(voiced, 75) - np.percentile(voiced, 25)), + "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)), + } + + +def main() -> int: + parser = argparse.ArgumentParser( + description="参考样本选段勘探(更亮/更轻快的候选起点)" + ) + parser.add_argument("sources", nargs="+", help="待勘探音频(mp3/wav/flac…)") + parser.add_argument( + "--window", type=float, default=12.0, help="目标样本时长(秒,默认 12)" + ) + parser.add_argument( + "--step", type=float, default=2.0, help="滑窗步长(秒,默认 2)" + ) + parser.add_argument( + "--top", type=int, default=4, help="每个文件列出的候选数(默认 4)" + ) + args = parser.parse_args() + win, step = int(args.window), max(1, int(args.step)) + + rows: list[dict] = [] + for src in args.sources: + p = Path(src).expanduser() + if not p.is_file(): + print(f"跳过(不存在):{p}", file=sys.stderr) + continue + x, sr = sf.read(str(p), dtype="float32", always_2d=True) + x = x.mean(axis=1) + dur = len(x) / sr + if dur < win: + print(f"跳过(短于 {win}s):{p.name} {dur:.1f}s", file=sys.stderr) + continue + feat = frame_features(x, sr) + for s in range(0, int(dur) - win + 1, step): + st = window_stats(feat, x, sr, s, win) + if st: + rows.append({**st, "file": p.name, "path": str(p)}) + print(f"# {p.name}: {dur:.0f}s sr={sr}", file=sys.stderr) + + if not rows: + print("无有效窗口(音频过短或全为静音)", file=sys.stderr) + return 1 + + keys = ["f0_med", "f0_iqr", "syl", "cen", "rms"] + arr = {k: np.array([r[k] for r in rows]) for k in keys} + z = {k: (arr[k] - arr[k].mean()) / (arr[k].std() or 1.0) for k in keys} + sil = np.array([r["sil"] for r in rows]) + voiced = np.array([r["voiced"] for r in rows]) + # 明亮阳光 = 音高高 + 谱质心高;轻快 = 音节率高;有生气 = 起伏大; + # 扣分项:静音过多(>22%)、发声占比过低(<45%)——这类窗口多是留白或环境声。 + score = ( + 1.0 * z["f0_med"] + + 0.9 * z["cen"] + + 0.9 * z["syl"] + + 0.6 * z["f0_iqr"] + + 0.3 * z["rms"] + - 20.0 * np.maximum(0, sil - 0.22) + - 15.0 * np.maximum(0, 0.45 - voiced) + ) + for r, s in zip(rows, score): + r["score"] = float(s) + + print( + f"\n{'file':<16}{'--start':>9}{'分':>7}{'F0中位':>8}{'F0起伏':>8}" + f"{'音节率':>8}{'质心Hz':>8}{'RMS':>7}{'静音':>7}{'发声':>7}" + ) + picked: dict[str, list[int]] = {} + for r in sorted(rows, key=lambda r: -r["score"]): + starts = picked.setdefault(r["file"], []) + if len(starts) >= args.top: + continue + if any( + abs(r["start"] - o) < win for o in starts + ): # 相邻窗口高度重叠,只留最高分 + continue + starts.append(r["start"]) + print( + 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}" + ) + + 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 试听确认人声干净、单说话人、语句完整。" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 641fc149..1c5ca717 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -15,6 +15,8 @@ indextts:uv run --no-project --with mutagen media/pipeline/scripts/tts.py \ --project media/<工程> --engine indextts --ref <参考样本.wav> \ [--style passionate] [--server http://127.0.0.1:8766] [--force] + 情感三来源(互斥):--style/--emo-vector 向量注入 · --emo-ref <另一段录音> 语调迁移 + (更自然)· --emo-text "轻快爽朗、自信阳光" 自然语言(需服务端 --use-qwen-emo) (工程内薄包装等价于在工程目录下运行 scripts/tts.py) 声音克隆完整手册(部署/风格/排障/许可)见 media/pipeline/VOICE-CLONING.md。 @@ -243,8 +245,17 @@ def http_synthesize( df: float, lang: str, num_beams: int = 1, + emo_ref: str | None = None, + emo_text: str | None = None, + headers_out: dict | None = None, ) -> tuple[bytes, str]: - """POST /synthesize → (mp3 bytes, X-Audio-Format)。4xx 不可重试。""" + """POST /synthesize → (mp3 bytes, X-Audio-Format)。4xx 不可重试。 + + headers_out:可选出参,传入 dict 时回填全部响应头(如 emo_text 模式的 X-Emo-Vector), + 供试听工具回显;管线主路径不需要,故保持返回值签名不变。 + **键统一小写**——urllib 的 HTTPMessage 查找不分大小写,但拷进普通 dict 后会变成 + 大小写敏感,而 Starlette 下发的响应头名是小写的,故此处归一避免调用方取不到值。 + """ payload: dict = { "text": text, "ref_path": ref, @@ -255,6 +266,10 @@ def http_synthesize( } if vec is not None: payload["emo_vector"] = vec + if emo_ref: # 情感参考音频:音色仍取 ref,语调迁移自 emo_ref(服务端与向量互斥) + payload["emo_ref_path"] = emo_ref + if emo_text: # 自然语言情感描述(服务端 QwenEmotion 转向量) + payload["emo_text"] = emo_text req = urllib.request.Request( f"{server}/synthesize", data=json.dumps(payload).encode(), @@ -263,6 +278,8 @@ def http_synthesize( ) try: with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + if headers_out is not None: + headers_out.update({k.lower(): v for k, v in resp.headers.items()}) return resp.read(), resp.headers.get("X-Audio-Format", "unknown") except urllib.error.HTTPError as e: detail = _http_error_detail(e) @@ -283,12 +300,16 @@ def digest_indextts( engine_tag: str, text: str, num_beams: int = 1, + emo_ref_sha1: str | None = None, + emo_text: str | None = None, ) -> str: vec_str = ",".join(repr(x) for x in vec) if vec else "none" - # 束宽改变合成结果,须入键;默认 1 时省略字段——沿用历史摘要格式,存量缓存不失效 + # 束宽/情感来源改变合成结果,须入键;未使用时省略字段——沿用历史摘要格式,存量缓存不失效 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}".encode() + f"indextts|{engine_tag}|{ref_sha1}|{lang}|{style}|{vec_str}|{alpha!r}|{df!r}|{text}{beams_part}{emo_part}".encode() ).hexdigest() @@ -307,11 +328,26 @@ async def synth_indextts( server: str, out_dir: Path, num_beams: int = 1, + emo_ref: str | None = None, + emo_ref_sha1: str | None = None, + emo_text: str | None = None, ) -> dict: sid, text = item["id"], item["text"] mp3 = out_dir / f"{sid}.mp3" meta = out_dir / f"{sid}.sha" - digest = digest_indextts(ref_sha1, style, vec, alpha, df, lang, engine_tag, text, num_beams) + digest = digest_indextts( + ref_sha1, + style, + vec, + alpha, + df, + lang, + engine_tag, + text, + num_beams, + emo_ref_sha1, + emo_text, + ) if ( not force @@ -336,6 +372,8 @@ async def synth_indextts( df, lang, num_beams, + emo_ref, + emo_text, ) if fmt != "mp3": raise NonRetryableError( @@ -401,6 +439,18 @@ async def main() -> None: default=None, help="[indextts] 原始情感向量,如 happy:0.6,calm:0.2(与 --style 非默认值互斥)", ) + idx.add_argument( + "--emo-ref", + default=None, + help="[indextts] 情感参考音频:音色仍取 --ref,语调/情绪迁移自这段录音(比向量注入更自然;" + "与 --style 非默认值/--emo-vector/--emo-text 互斥)", + ) + idx.add_argument( + "--emo-text", + default=None, + help="[indextts] 自然语言情感描述,如「轻快爽朗、自信阳光」(需服务端 --use-qwen-emo;" + "与 --style 非默认值/--emo-vector/--emo-ref 互斥)", + ) idx.add_argument( "--emo-alpha", default=None, @@ -428,7 +478,9 @@ async def main() -> None: help="[indextts] 缓存标记;模型升级后自定义以失效旧缓存", ) args = parser.parse_args() - args.server = args.server.rstrip("/") # 尾斜杠归一:health/synthesize 两处拼 URL 前收口 + args.server = args.server.rstrip( + "/" + ) # 尾斜杠归一:health/synthesize 两处拼 URL 前收口 if args.list_styles: print( @@ -447,6 +499,8 @@ async def main() -> None: for flag, val in { "--ref": args.ref, "--emo-vector": args.emo_vector, + "--emo-ref": args.emo_ref, + "--emo-text": args.emo_text, "--emo-alpha": args.emo_alpha, "--duration-factor": args.duration_factor, "--num-beams": args.num_beams != 1, @@ -480,12 +534,31 @@ async def main() -> None: ) ) else: - if args.style != "neutral" and args.emo_vector: - parser.error("--style 非默认值与 --emo-vector 互斥") - try: - style_name, vec, alpha, df = resolve_style(args) - except ValueError as e: - parser.error(str(e)) + # 情感三来源互斥:显式向量 / 情感参考音频 / 自然语言描述(服务端亦校验,此处提前失败) + emo_sources = [ + f + for f, v in ( + ("--emo-vector", args.emo_vector), + ("--emo-ref", args.emo_ref), + ("--emo-text", args.emo_text), + ) + if v + ] + if len(emo_sources) > 1: + parser.error(f"情感来源互斥,只能给一个:{' '.join(emo_sources)}") + if args.style != "neutral" and emo_sources: + parser.error(f"--style 非默认值与 {emo_sources[0]} 互斥") + if args.emo_ref or args.emo_text: + # 音频/文本驱动情感时不注入向量;alpha 默认 1.0(完全采用该情感来源) + style_name = "emoref" if args.emo_ref else "emotext" + vec = None + alpha = args.emo_alpha if args.emo_alpha is not None else 1.0 + df = args.duration_factor if args.duration_factor is not None else 1.0 + else: + try: + style_name, vec, alpha, df = resolve_style(args) + except ValueError as e: + parser.error(str(e)) if not args.ref: parser.error( "--engine indextts 需要 --ref 参考音色样本(见 " + MANUAL + " §三)" @@ -495,8 +568,12 @@ async def main() -> None: parser.error(f"参考样本不存在: {ref_path}") if args.emo_alpha is not None and not 0.0 <= args.emo_alpha <= 1.0: parser.error("--emo-alpha 必须在 [0, 1]") - if vec is not None and sum(vec) * alpha > 0.8: # infer 内部以 alpha 缩放,校验有效和 - parser.error(f"情感向量有效和 {sum(vec) * alpha:.3f}(Σvec×alpha)超过 0.8 上限") + if ( + vec is not None and sum(vec) * alpha > 0.8 + ): # infer 内部以 alpha 缩放,校验有效和 + parser.error( + f"情感向量有效和 {sum(vec) * alpha:.3f}(Σvec×alpha)超过 0.8 上限" + ) if args.duration_factor is not None and not 0.5 <= args.duration_factor <= 2.0: parser.error("--duration-factor 必须在 [0.5, 2.0]") @@ -521,6 +598,20 @@ async def main() -> None: "当前服务为 IndexTTS-2(无语速控制):去掉 --duration-factor,或风格选 neutral,见 " + MANUAL ) + if args.emo_text and not health.get("supports_emo_text"): + parser.error( + "当前服务未加载 QwenEmotion:重启服务加 --use-qwen-emo,或改用 --emo-vector/--emo-ref,见 " + + MANUAL + ) + + emo_ref_path = emo_ref_sha1 = None + if args.emo_ref: + p = Path(args.emo_ref).expanduser().resolve() + if not p.is_file(): + parser.error(f"情感参考音频不存在: {p}") + emo_ref_path = str(p) + # 情感样本按内容入摘要:换情感录音必须失效缓存(与 ref 同口径) + emo_ref_sha1 = hashlib.sha1(p.read_bytes()).hexdigest()[:12] ref_sha1 = hashlib.sha1(ref_path.read_bytes()).hexdigest()[:12] sem = asyncio.Semaphore(CONCURRENCY_INDEXTTS) @@ -541,6 +632,9 @@ async def main() -> None: args.server, out_dir, num_beams=args.num_beams, + emo_ref=emo_ref_path, + emo_ref_sha1=emo_ref_sha1, + emo_text=args.emo_text, ) for i in items ) diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py index d336f964..5c477335 100644 --- a/media/pipeline/scripts/tts_sample.py +++ b/media/pipeline/scripts/tts_sample.py @@ -57,6 +57,16 @@ def build_jobs( --all-styles 逐档取各预设自带的 alpha/df(这正是 A/B 的意义,故与手动覆盖互斥)。 """ + if args.emo_ref or args.emo_text: + # 音频/文本驱动情感:不注入向量,alpha 默认 1.0(完全采用该情感来源) + return [ + ( + "emoref" if args.emo_ref else "emotext", + None, + args.emo_alpha if args.emo_alpha is not None else 1.0, + args.duration_factor if args.duration_factor is not None else 1.0, + ) + ] if args.all_styles: return [ resolve_style( @@ -69,7 +79,9 @@ def build_jobs( return [resolve_style(args)] -def check_server(server: str, need_duration_factor: bool) -> None: +def check_server( + server: str, need_duration_factor: bool, need_emo_text: bool = False +) -> None: """健康检查——服务未起时给出可直接粘贴的启动命令,避免等到合成阶段才失败。""" try: health = http_json("GET", f"{server}/health", None, 10) @@ -92,6 +104,11 @@ def check_server(server: str, need_duration_factor: bool) -> None: "当前服务为 IndexTTS-2(无语速控制),而所选风格的 duration_factor≠1.0:\n" f" 改用 --style neutral,或以 --indextts-version 2.5 重启服务(见 {MANUAL} §七)" ) + if need_emo_text and not health.get("supports_emo_text"): + sys.exit( + "当前服务未加载 QwenEmotion,无法用 --emo-text:\n" + " 重启服务时加 --use-qwen-emo(约 +1.5 GB 内存),或改用 --emo-vector / --emo-ref" + ) def synthesize_one( @@ -101,12 +118,14 @@ def synthesize_one( alpha: float, df: float, out_dir: Path, + stem: str | None = None, ) -> dict: """合成一档并落盘 → {style, path, duration, wall, rtf}。失败即退出(小样无需容错累积)。""" - out = out_dir / f"{name}.mp3" + out = out_dir / f"{stem or name}.mp3" last_err: Exception | None = None for attempt in range(ATTEMPTS): t0 = time.perf_counter() + headers: dict = {} try: audio, fmt = http_synthesize( args.server, @@ -117,6 +136,9 @@ def synthesize_one( df, args.lang, args.num_beams, + args.emo_ref, + args.emo_text, + headers, ) except NonRetryableError as e: sys.exit(f"[{name}] 请求被拒(4xx,重试无意义):{e}") @@ -136,8 +158,16 @@ def synthesize_one( out.write_bytes(audio) duration = mp3_duration(out) rtf = wall / duration if duration else float("nan") + derived = headers.get( + "x-emo-vector" + ) # headers_out 的键已由 http_synthesize 归一为小写 print( # flush:长跑常被 tee/nohup 重定向,缓冲会让进度看起来「卡住」 - f"[{name:<10}] 音频 {duration:5.2f}s · 墙钟 {wall:6.1f}s · RTF {rtf:5.1f} · {out}", + f"[{name:<10}] 音频 {duration:5.2f}s · 墙钟 {wall:6.1f}s · RTF {rtf:5.1f} · {out}" + + ( + f"\n 情感向量(Qwen 推出,可用 --emo-vector 固化):{derived}" + if derived + else "" + ), flush=True, ) return { @@ -193,6 +223,17 @@ def main() -> None: default=None, help="原始情感向量,如 happy:0.6,calm:0.2(与非默认 --style 互斥)", ) + parser.add_argument( + "--emo-ref", + default=None, + help="情感参考音频:音色仍取 --ref,语调/情绪迁移自这段录音——比向量注入更自然", + ) + parser.add_argument( + "--emo-text", + default=None, + help='自然语言情感描述,如 "轻快爽朗、自信阳光"(需服务端 --use-qwen-emo;' + "推出的向量会回显,可再用 --emo-vector 固化)", + ) parser.add_argument( "--emo-alpha", default=None, type=float, help="情感强度 0–1(默认随风格)" ) @@ -208,6 +249,11 @@ def main() -> None: help="GPT 束搜索宽度(默认 1;耗时约按束宽线性放大,见 " + MANUAL + " §4.3b)", ) parser.add_argument("--server", default="http://127.0.0.1:8766", help="服务地址") + parser.add_argument( + "--label", + default=None, + help="产物文件名(不含扩展名,默认取风格名)——横向对比多个样本/自定义向量时用于避免互相覆盖", + ) parser.add_argument( "--out-dir", default=None, @@ -223,6 +269,26 @@ def main() -> None: # ---- 参数互斥与取值校验(尽早失败:单档合成约 2 分钟,不能等到最后才报错)---- if args.text and args.text_file: parser.error("--text 与 --text-file 互斥") + if args.label and ("/" in args.label or args.label in (".", "..")): + parser.error("--label 只能是文件名片段,不能含路径分隔符") + emo_sources = [ + f + for f, v in ( + ("--emo-vector", args.emo_vector), + ("--emo-ref", args.emo_ref), + ("--emo-text", args.emo_text), + ) + if v + ] + if len(emo_sources) > 1: + parser.error(f"情感来源互斥,只能给一个:{' '.join(emo_sources)}") + if emo_sources and args.all_styles: + parser.error(f"--all-styles 遍历风格预设,不能与 {emo_sources[0]} 同用") + if args.emo_ref: + p = Path(args.emo_ref).expanduser().resolve() + if not p.is_file(): + parser.error(f"情感参考音频不存在: {p}") + args.emo_ref = str(p) # 绝对路径:服务端按自身文件系统解析 if args.all_styles: overrides = [ flag @@ -275,6 +341,10 @@ def main() -> None: ] # 与缓存摘要同前缀,便于与 .sha 对账 print(f">> 文本(预处理后):{tts_text(args.text)}") print(f">> 参考样本:{ref}(sha1 {ref_sha1})") + if args.emo_ref: + print(f">> 情感参考音频:{args.emo_ref}(音色仍取上面的参考样本)") + if args.emo_text: + print(f">> 情感描述:{args.emo_text}(服务端 QwenEmotion 转向量)") print(f">> 风格 {len(jobs)} 档 · num_beams={args.num_beams} · lang={args.lang}") for name, vec, alpha, df in jobs: vec_str = ",".join(f"{x:g}" for x in vec) if vec else "—(不注入情感)" @@ -290,7 +360,11 @@ def main() -> None: "缺少 mutagen(实测 MP3 时长用):请以 `uv run --no-project --with mutagen ...` 执行" ) - check_server(args.server, need_duration_factor=any(df != 1.0 for *_, df in jobs)) + check_server( + args.server, + need_duration_factor=any(df != 1.0 for *_, df in jobs), + need_emo_text=bool(args.emo_text), + ) out_dir = ( Path(args.out_dir).expanduser().resolve() if args.out_dir else DEFAULT_OUT_DIR @@ -298,8 +372,19 @@ def main() -> None: out_dir.mkdir(parents=True, exist_ok=True) print(f">> 产物目录:{out_dir}\n") + # --label 单档时即文件名、多档时作前缀({label}-{风格}),横向对比不同样本时不互相覆盖 results = [ - synthesize_one(args, name, vec, alpha, df, out_dir) + synthesize_one( + args, + name, + vec, + alpha, + df, + out_dir, + stem=None + if not args.label + else (args.label if len(jobs) == 1 else f"{args.label}-{name}"), + ) for name, vec, alpha, df in jobs ] diff --git a/media/pipeline/scripts/tts_server.py b/media/pipeline/scripts/tts_server.py index 2c3d7cf1..65dec0ea 100644 --- a/media/pipeline/scripts/tts_server.py +++ b/media/pipeline/scripts/tts_server.py @@ -7,9 +7,14 @@ 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) + GET /health —— 服务与模型元信息(version/device/dtype/encoder/supports_duration_factor/ + supports_emo_text) POST /synthesize —— JSON 请求合成,返回 MP3 bytes(X-Audio-Format 头) -- 安全:仅监听 127.0.0.1,无鉴权,勿暴露公网;ref_path 为服务端本地绝对路径。 +- 情感三来源(互斥,只能给一个): + emo_vector —— 8 维显式向量(有效和 Σvec×alpha ≤ 0.8) + emo_ref_path —— 情感参考音频:音色仍取 ref_path,语调/情绪迁移自这段录音(无合成味) + emo_text —— 自然语言描述(需 --use-qwen-emo),服务端转向量并在 X-Emo-Vector 头回显 +- 安全:仅监听 127.0.0.1,无鉴权,勿暴露公网;ref_path / emo_ref_path 为服务端本地绝对路径。 完整部署/排障手册见 media/pipeline/VOICE-CLONING.md。 """ @@ -40,9 +45,12 @@ def ensure_indextts_import(index_tts_root: Path) -> None: sys.path.insert(0, str(index_tts_root.resolve())) -def load_model(version: str, model_dir: Path, dtype: str, device: str): +def load_model( + version: str, model_dir: Path, dtype: str, device: str, use_qwen_emo: bool = False +): """按版本构造 IndexTTS2;构造器差异以 webui.py build_tts() 为锚点: - v2.5 仅 use_bf16(MPS 分支内部强制关闭),v2 为 use_fp16。均不加载 QwenEmotion(仅向量模式)。 + v2.5 仅 use_bf16(MPS 分支内部强制关闭),v2 为 use_fp16。 + use_qwen_emo 决定是否加载 QwenEmotion(自然语言情感描述→向量,约 +1.5 GB 内存)。 返回 (tts 对象, 元信息 dict)。 """ @@ -56,7 +64,7 @@ def load_model(version: str, model_dir: Path, dtype: str, device: str): use_bf16=use_bf16, use_cuda_kernel=False, use_deepspeed=False, - use_qwen_emo=False, + use_qwen_emo=use_qwen_emo, device=None if device == "auto" else device, ) return tts, { @@ -75,7 +83,7 @@ def load_model(version: str, model_dir: Path, dtype: str, device: str): use_fp16=use_fp16, use_cuda_kernel=False, use_deepspeed=False, - use_qwen_emo=False, + use_qwen_emo=use_qwen_emo, device=None if device == "auto" else device, ) return tts, { @@ -153,6 +161,12 @@ class SynthesizeRequest(BaseModel): text: str ref_path: str emo_vector: list[float] | None = None + # 情感参考音频:音色取自 ref_path,语调/情绪取自本字段(另一段录音),无合成味的风格迁移。 + # 上游 infer() 在 emo_vector 存在时会「静默丢弃」emo_audio_prompt,故本服务显式拒绝二者同传。 + emo_ref_path: str | None = None + # 自然语言情感描述(如「轻快爽朗、自信阳光」):服务端先用 QwenEmotion 转成 8 维向量, + # 再按 ≤0.8 有效和规则缩放后当作 emo_vector 使用,并在 X-Emo-Vector 响应头回显供固化复用。 + emo_text: str | None = None emo_alpha: float = 1.0 duration_factor: float = 1.0 lang: str = "ZH" @@ -196,6 +210,21 @@ def _beams_ok(cls, v: int) -> int: STATE: dict = {} +def _qwen_vector_sync(tts, emo_text: str, alpha: float) -> list[float]: + """自然语言情感描述 → 8 维向量(QwenEmotion),并按 ≤0.8 有效和规则整体缩放。 + + Qwen 每维 clamp 在 [0, 1.2] 但**不做和归一**,Σ 可能 >1;而上游混合式为 + `emovec = Σ(w·基向量) + (1 - Σw)·参考音频情感`,Σw>1 会让参考音频项变负权重(发音劣化)。 + 故此处等比缩放(保留 Qwen 选定的「方向」,只压「强度」),保底留 ≥0.2 的自然情感残量。 + """ + vec = list(tts.qwen_emo.inference(emo_text).values()) + total = sum(vec) * alpha + if total > 0.8: + scale = 0.8 / total + vec = [round(x * scale, 4) for x in vec] + return vec + + def _infer_sync(tts, ref: Path, req: SynthesizeRequest, tmpdir: Path) -> Path: wav_path = tmpdir / "out.wav" kwargs = dict( @@ -203,6 +232,7 @@ def _infer_sync(tts, ref: Path, req: SynthesizeRequest, tmpdir: Path) -> Path: text=req.text, output_path=str(wav_path), emo_vector=req.emo_vector, + emo_audio_prompt=req.emo_ref_path, emo_alpha=req.emo_alpha, use_random=False, verbose=False, @@ -232,7 +262,9 @@ async def lifespan(app: FastAPI): args = app.state.args ensure_indextts_import(args.index_tts_root) print(">> 加载 IndexTTS 模型(首次运行会自动下载 w2v-bert 等辅助模型)…") - tts, meta = load_model(args.version, args.model_dir, args.dtype, args.device) + tts, meta = load_model( + args.version, args.model_dir, args.dtype, args.device, args.use_qwen_emo + ) encoder = _probe_encoders() STATE.update( tts=tts, @@ -241,10 +273,12 @@ async def lifespan(app: FastAPI): dtype=meta["dtype_flag"], encoder=encoder, supports_duration_factor=meta["supports_duration_factor"], + 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']} encoder={encoder}" + f">> 就绪:IndexTTS-{STATE['version']} device={STATE['device']} dtype={STATE['dtype']} " + f"encoder={encoder} emo_text={'on' if STATE['supports_emo_text'] else 'off'}" ) yield STATE.clear() @@ -265,6 +299,7 @@ async def health(): "dtype": STATE.get("dtype"), "encoder": STATE.get("encoder"), "supports_duration_factor": STATE.get("supports_duration_factor"), + "supports_emo_text": STATE.get("supports_emo_text", False), } @@ -275,6 +310,29 @@ async def synthesize(req: SynthesizeRequest): ref = Path(req.ref_path).expanduser() if not ref.is_file(): raise HTTPException(400, f"参考音频不存在: {ref}") + # 三种情感来源互斥:向量 / 参考音频 / 自然语言描述。上游对「向量+音频」是静默丢弃音频, + # 静默降级比报错更难排查,故此处显式拒绝。 + sources = [ + name + for name, val in ( + ("emo_vector", req.emo_vector), + ("emo_ref_path", req.emo_ref_path), + ("emo_text", req.emo_text), + ) + if val + ] + if len(sources) > 1: + raise HTTPException(400, f"情感来源互斥,只能给一个:{' / '.join(sources)}") + if req.emo_ref_path: + emo_ref = Path(req.emo_ref_path).expanduser() + if not emo_ref.is_file(): + raise HTTPException(400, f"情感参考音频不存在: {emo_ref}") + req.emo_ref_path = str(emo_ref) + if req.emo_text and not STATE.get("supports_emo_text"): + raise HTTPException( + 400, + "emo_text 需要 QwenEmotion:服务启动时加 --use-qwen-emo(约 +1.5 GB 内存)", + ) 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( @@ -287,17 +345,28 @@ async def synthesize(req: SynthesizeRequest): "IndexTTS-2 不支持 duration_factor(v2.5 专属),请改用 v2.5 服务或去掉 --duration-factor", ) + derived: list[float] | None = None async with STATE["infer_lock"]: + if ( + req.emo_text + ): # 先算向量(占 GPU,须在锁内),再走与显式向量完全相同的合成路径 + derived = await asyncio.to_thread( + _qwen_vector_sync, STATE["tts"], req.emo_text, req.emo_alpha + ) + req.emo_vector = derived with tempfile.TemporaryDirectory(prefix="indextts_") as td: wav_path = await asyncio.to_thread( _infer_sync, STATE["tts"], ref, req, Path(td) ) data, sr = await asyncio.to_thread(_read_audio, wav_path) audio, fmt = await asyncio.to_thread(encode_mp3, data, sr) + headers = {"X-Audio-Format": fmt, "X-Duration-Sec": f"{len(data) / sr:.3f}"} + if derived is not None: # 回显 Qwen 推出的向量,便于事后用 --emo-vector 固化复现 + headers["X-Emo-Vector"] = ",".join(f"{x:g}" for x in derived) return Response( audio, media_type="audio/mpeg" if fmt == "mp3" else "audio/wav", - headers={"X-Audio-Format": fmt, "X-Duration-Sec": f"{len(data) / sr:.3f}"}, + headers=headers, ) @@ -323,6 +392,11 @@ def main() -> None: help="auto:v2.5→bf16(MPS 强制 fp32)/ v2→fp16;显式 fp32 两个版本均安全", ) parser.add_argument("--device", choices=["auto", "mps", "cpu"], default="auto") + parser.add_argument( + "--use-qwen-emo", + action="store_true", + help="加载 QwenEmotion(0.6B,约 +1.5 GB 内存),开启后请求可用 emo_text 自然语言描述情感", + ) args = parser.parse_args() args.index_tts_root = Path(args.index_tts_root).resolve() From 520ac67cfb1380dab11e4e5527090bb3281785e9 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 12:00:15 +0800 Subject: [PATCH 3/8] =?UTF-8?q?feat(tts):=20=E6=96=B0=E5=A2=9E=20sunny=20?= =?UTF-8?q?=E6=98=8E=E5=BF=AB=E9=98=B3=E5=85=89=E9=A3=8E=E6=A0=BC=E9=A2=84?= =?UTF-8?q?=E8=AE=BE=E5=B9=B6=E5=8F=96=E4=BB=A3=20passionate=20=E6=88=90?= =?UTF-8?q?=E4=B8=BA=E7=A7=91=E6=99=AE=E6=8E=A8=E8=8D=90=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户在 29 个候选小样中选定「Qwen 选方向 + 人工压强度」那一档,本次将其固化为 `STYLE_PRESETS["sunny"]`(happy 0.95 / surprised 0.02 / calm 0.03,alpha 0.35, df 0.95),并把配套样本固化为 `voices/me-bright.wav`(me-1.mp3 --start 0.36 --duration 12,sha1 54b699cce97f)——`--style sunny --ref me-bright.wav` 与用户 听中的那次调用参数逐项一致(已用 --dry-run 核对)。 为何是 0.35 而非 Qwen 原始强度:Qwen 对「轻快、爽朗、自信、阳光」推出的向量会顶到 Σ=0.8 上限,实测把克隆音高推到 199–223 Hz,而该说话人自然区间仅 142–163 Hz,听感 「像另一个人在用力」。保留方向、把强度压到 0.35(留 65% 给本人真实语调)后即为 sunny。 这条「Qwen 选方向 → 人工压强度 → 固化预设」定式已写进手册 §4.1/§4.4。 文档同步:§4.1 预设表新增 sunny 行并补「有效注入」列(一眼看出各档挤掉多少真实语调) + 来历说明;§4.4 调参建议重写为四步定式(先定样本 → 再定方向 → 最后压强度 → 微调语速) 并给出 0.3–0.45 自然平衡带的实测依据,推荐位由 passionate 改为 sunny;§5.1/§5.2 示例 全部改用 me-bright.wav + sunny,步骤 1 改为推荐档裁剪命令;voices/README.md 补选段勘探 与小样试听两步、点明「样本决定基线」;CHANGELOG 与知识索引同步本轮能力。 🤖 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 --- CHANGELOG.md | 1 + docs/.agents/knowledge-map.md | 2 +- media/pipeline/VOICE-CLONING.md | 47 ++++++++++++++++++++------------- media/pipeline/scripts/tts.py | 12 +++++++++ media/pipeline/voices/README.md | 20 +++++++++++--- 5 files changed, 59 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 71eb5ea3..e306a477 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added +- **声音克隆试听闭环:单句小样 + 情感三来源 + 样本选段勘探 + `sunny` 明快阳光预设**:此前管线只有工程级入口 `tts.py`(强依赖 `narration.json`),选样本/选风格却要等 2.5–3.5 小时的整集合成——新增 [`scripts/tts_sample.py`](media/pipeline/scripts/tts_sample.py) 直调服务合成单句(`--style` / `--all-styles` 五档 A/B / `--dry-run` 秒级核参 / `--label` 横向对比 / `--play` 顺序试听),复用 `tts.py` 的风格预设与文本预处理故与成片同路径,单档暖机后约 20 秒。针对「五档预设都不够自然」的实测复盘,定位两个根因并各给工具:①**样本定基线**——新增 [`scripts/prospect_ref.py`](media/pipeline/scripts/prospect_ref.py) 按 F0 中位/四分位距/音节率/谱质心筛「更亮更轻快」的候选起点(成片原用样本综合分仅 157/275,换段即让克隆音高 +12~16%、起伏 +25~40%);②**注入越多越假**——由上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 推出「Σw 就是合成情感挤掉真实语调的比例」,故新增两条自然通路:`--emo-ref`(音色取 A、语调迁移自 B,零向量注入)与 `--emo-text`(QwenEmotion 把「轻快爽朗、自信阳光」转向量,按 ≤0.8 等比缩放并经 `X-Emo-Vector` 回显供固化),服务端新增 `--use-qwen-emo` 与 `/health.supports_emo_text`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 29 个候选小样试听定档 **`sunny`(明快阳光:happy 主载方向 + 有效注入 0.35 + df 0.95,配 `voices/me-bright.wav`)** 取代 `passionate` 成为科普长视频推荐位。[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) 同步扩写:§三 增 3.3「样本决定基线」实测表、§四 增「情感三来源」对照与强度调参带(0.3–0.45 为自然平衡带)、§五 拆 5.1 小样试听/5.2 全量/5.3 重渲染并附纯 `curl` 直调、§六 摘要公式补 `|emoref=` / `|emotext=` 可选后缀(对已上线三集 189 句逐句核对摘要 100% 不变,存量缓存零失效)。 - **自进化系列三集科普视频内容升级与本人音色克隆配音**:三集([《AI 如何自己变强?》](media/self-improving-agents-video/README.md) v3 / [《上线之后,AI 才开始上学》](media/experience-era-agents-video/README.md) v2 / [《会写代码的 AI,开始给自己写代码》](media/self-evolving-coding-agents-video/README.md) v3)统一换用本人音色克隆配音(IndexTTS-2.5 `passionate` 激情风格,me-1.wav 样本经 RMS 预筛裁剪);源论文第二遍重读校准(三集全部断言零事实漂移)+ 官方工程站点信源补充(ep1 312 条收录统计/九篇敲门砖→P5 活地图+卡片墙镜;ep2 经验编译器闭环/Gen3 适应面语义/SIP-Bench 四指标/111 篇×9 章→6-B2 闭环复盘+6-F 清单卡镜;ep3 无站点,SICA 三选择信号+Table 2 斜线规律两句锚定);每集新增 `research/upgrade-2026-08.md` 审计文档(校准审计表+句级 delta+双重校验 delta RISKY/REWRITE=0+G2 验证记录);动画增强(计数器滚动/环形图合拢/双色分拣重排/青紫双速差流光/斜线光带扫掠/检查点卡+四仪表/数值轨迹雷达/流光巡游,全部 skill-06 四红线合规)。 - **科普视频管线新增「激情」配音风格预设与官方工程站点信源补充规范**:`media/pipeline/scripts/tts.py` 的 IndexTTS 风格预设表新增 `passionate`(充满激情与轻快:happy 0.70 主载高唤醒正价 + surprised 0.20 跳跃感 + calm 0.10 锚定咬字,有效和 1.00×0.7=0.70 ≤ 0.8 上限,语速 0.97 护密集技术句清晰度),`--list-styles` 与 [VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §四 同步收录并给出科普长视频推荐位;[skills/01-paper-extraction.md](media/pipeline/skills/01-paper-extraction.md) 扩展「官方工程站点信源补充」纪律——只收事实性内容、逐字引用+URL+访问日期、落 `paper-notes.md` 末尾独立「信源补充」大节并与论文正文物理隔离、严禁下载/嵌入站点图片(概念转文字规格供 Remotion 代码动画重建)、站点信源单集专属不跨集。 - **IndexTTS 推理束搜索宽度旋钮(`--num-beams`,长跑提速主开关)**:服务端 `tts_server.py` 请求模型与推理透传 num_beams(1–5),客户端 `tts.py` 新增 `--num-beams`(默认 1);根因为 MPS fp32 实测每句墙钟 RTF 40–58(上游库内部 num_beams=3 使 GPT 段耗时按束宽线性放大),降为 1 束后 RTF≈12–15(采样生成下听感差异可忽略);[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §4.3b 沉淀实测数据并修正原「每句 5–30 秒」的失实估计(整集实际数小时,需 nohup 后台+断点续跑)。 diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 2188129a..6be36de5 100644 --- a/docs/.agents/knowledge-map.md +++ b/docs/.agents/knowledge-map.md @@ -71,5 +71,5 @@ - [经验时代的自驱迭代进化智能体调研](../research/self-evolution/140-experience-era-self-improvement.md) — 精读 88 页综述提炼 Harness 经验基础设施框架,对照 negentropy Routine 闭环诊断出两处根本断点(Judge 无历史锚点 ±20 振荡 / `decay_override` 死配置致经验记忆 7-8 天全灭 + 反馈链断),落地双支柱改进:证据锚定纵向评估(trajectory + progress_evidence + 量化振荡 opt-in)与经验记忆闭环补强(衰减修复 E / 检索反馈闭环 B / 写入去重准入 A / 失败教训结构化与注入 C-D) - [Skill 进化闭环 × 自我改进评测](../research/self-evolution/141-skills-evolution-and-si-measurement.md) — 综述 §3(Skills 三阶段 Evolution 缺口)/ §7(Meta-Evolving 三体制)/ §8(SI 六目标 + SIP-Bench + 反事实归因)映射到 negentropy:PR [#1038](https://github.com/ThreeFish-AI/negentropy/pull/1038) 落地 eval 四表 + held-out 双相门(decide_skill_shadow/canary)+ 反事实 Skill Influence Pattern + TargetHandler 抽象 + SkillTemplateHandler 闭环(GEPA 变异 prompt_template + active_version 发布),补 140 号未覆盖的 Skills/Meta/SI 度量框架 - [arXiv §5 科普视频制作包](../../video-package/README.md) — 基于 [141 号调研同源综述](../research/self-evolution/141-skills-evolution-and-si-measurement.md)(arXiv:2607.13104 §5 基座模型自我改进)的完整视频制作包:13:42 逐字稿(N0–N6 段落 ID 主键体系,4.7 字/秒自洽)+ 46 镜头分镜表(md/csv 双格式,822s 与逐字稿/动画三表对齐)+ 单文件 Canvas 动画 demo(1920×1080、8 场景 3B1B 风格、←/→/空格/R/1-8/H 快捷键、file:// 零依赖直开)+ 71 条事实核查表(引文 100% grep 验证命中论文原文、RISKY/REWRITE 双零、ANALOGY 类比显式登记) -- [科普视频制作 Pipeline(公共基建)](../../media/pipeline/README.md) — 全仓可复用的论文→视频九阶段流水线(精读提取含官方工程站点信源补充/策划/逐字稿 SSOT/双重校验/分镜/TTS/Remotion/抽帧 QA/终渲):中心脚本三件套(`--project` 参数化)+ 每 Stage 代理提示词规格(skills/01–06:01–05 内容层 + 06 生产层 Remotion 实现)+ 新集脚手架清单与复用边界(Python 脚本集中 SSOT、Remotion 原语复制适配);作品:[《AI 如何自己变强?》](../../media/self-improving-agents-video/README.md)(Schmidhuber 综述 · 蓝/橙契约 · 6 幕)、[《上线之后,AI 才开始上学》](../../media/experience-era-agents-video/README.md)([140 号调研](../research/self-evolution/140-experience-era-self-improvement.md)同源清华×Frontis 综述 · 金/青/紫契约 · 7 幕 13.6 分钟)与 [《会写代码的 AI,开始给自己写代码》](../../media/self-evolving-coding-agents-video/README.md)(arXiv:2608.03392 NJUST×NJU 综述 · 终端绿/洋红契约 · 7 幕 14.7 分钟,五对象×时机证据×可信进化,论文笔记由 8 并行代理逐章精读产出);配音支持用自己的声音克隆(激情/轻快/自信/正能量风格,见 [VOICE-CLONING.md](../../media/pipeline/VOICE-CLONING.md);2026-08 三集统一换用本人音色 passionate 风格 + 内容升级审计文档 [upgrade-2026-08.md](../../media/self-improving-agents-video/research/upgrade-2026-08.md) 各集一份,站点信源补充规范沉淀于 skills/01) +- [科普视频制作 Pipeline(公共基建)](../../media/pipeline/README.md) — 全仓可复用的论文→视频九阶段流水线(精读提取含官方工程站点信源补充/策划/逐字稿 SSOT/双重校验/分镜/TTS/Remotion/抽帧 QA/终渲):中心脚本三件套(`--project` 参数化)+ 每 Stage 代理提示词规格(skills/01–06:01–05 内容层 + 06 生产层 Remotion 实现)+ 新集脚手架清单与复用边界(Python 脚本集中 SSOT、Remotion 原语复制适配);作品:[《AI 如何自己变强?》](../../media/self-improving-agents-video/README.md)(Schmidhuber 综述 · 蓝/橙契约 · 6 幕)、[《上线之后,AI 才开始上学》](../../media/experience-era-agents-video/README.md)([140 号调研](../research/self-evolution/140-experience-era-self-improvement.md)同源清华×Frontis 综述 · 金/青/紫契约 · 7 幕 13.6 分钟)与 [《会写代码的 AI,开始给自己写代码》](../../media/self-evolving-coding-agents-video/README.md)(arXiv:2608.03392 NJUST×NJU 综述 · 终端绿/洋红契约 · 7 幕 14.7 分钟,五对象×时机证据×可信进化,论文笔记由 8 并行代理逐章精读产出);配音支持用自己的声音克隆(明快阳光 sunny 推荐位 + 激情/轻快/自信/正能量,另有语调迁移 `--emo-ref` 与自然语言 `--emo-text` 两条情感通路;单句小样试听 `tts_sample.py`、样本选段勘探 `prospect_ref.py`,见 [VOICE-CLONING.md](../../media/pipeline/VOICE-CLONING.md);2026-08 三集统一换用本人音色 passionate 风格 + 内容升级审计文档 [upgrade-2026-08.md](../../media/self-improving-agents-video/research/upgrade-2026-08.md) 各集一份,站点信源补充规范沉淀于 skills/01) - [自进化 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/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index 83a63cbb..8682dbd0 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -158,13 +158,16 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec ### 4.1 风格预设(--style) -| 预设 | 定位 | emo_vector(顺序:happy, angry, sad, afraid, disgusted, melancholic, surprised, calm) | alpha | df | -|---|---|---|---|---| -| neutral | 中性(默认) | 不注入情感,纯克隆参考音色 | — | 1.0 | -| passionate 激情 | 充满激情与轻快 | happy=.70, surprised=.20, calm=.10 | 0.7 | 0.97 | -| lively 轻快 | 明快跳跃 | happy=.55, surprised=.15, calm=.15 | 0.6 | 0.95 | -| confident 自信 | 沉稳有力 | calm=.65, happy=.25 | 0.7 | 1.05 | -| positive 正能量 | 昂扬向上 | happy=.75, calm=.20 | 0.7 | 1.0 | +| 预设 | 定位 | emo_vector(顺序:happy, angry, sad, afraid, disgusted, melancholic, surprised, calm) | alpha | 有效注入 | df | +|---|---|---|---|---|---| +| **sunny 明快阳光** | **科普长视频推荐位**(2026-08-19 试听定档) | happy=.95, surprised=.02, calm=.03 | **0.35** | **0.35** | 0.95 | +| neutral | 中性(默认) | 不注入情感,纯克隆参考音色 | — | 0 | 1.0 | +| passionate 激情 | 充满激情与轻快 | happy=.70, surprised=.20, calm=.10 | 0.7 | 0.70 | 0.97 | +| lively 轻快 | 明快跳跃 | happy=.55, surprised=.15, calm=.15 | 0.6 | 0.51 | 0.95 | +| confident 自信 | 沉稳有力 | calm=.65, happy=.25 | 0.7 | 0.63 | 1.05 | +| positive 正能量 | 昂扬向上 | happy=.75, calm=.20 | 0.7 | 0.665 | 1.0 | + +> **`sunny` 的来历**(也是一份调参范例):方向由 QwenEmotion 对「轻快、爽朗、自信、阳光」推出——happy 近乎独载;但 Qwen 的原始强度会顶到 Σ=0.8 上限,实测把克隆音高推到 **199–223 Hz**,而该说话人自然区间只有 142–163 Hz,听感"像另一个人在用力"。**保留方向、把强度压到 0.35**(留 65% 给本人真实语调)+ `df 0.95` 后即为 `sunny`。**该档在 `voices/me-bright.wav` 上定档**(`prepare_ref.py ~/Documents/dify/me-1.mp3 --start 0.36 --duration 12`),换回更闷的样本会失去明快感(见 §3.3)。定式可复用:**Qwen 选方向 → 人工压强度 → 固化成预设**。 ### 4.2 自定义向量(--emo-vector) @@ -180,7 +183,14 @@ GPT 声码段的束搜索宽度,默认 **1**(上游库内部默认 3)。 ### 4.4 调参建议 -风格向量是 8 维情感空间中的方向+强度,首次使用建议:固定一句文本,`--style` 各档合成一次试听对比(一条命令跑完五档,见 §5.1 的 `--all-styles`);同风格微调用 `--emo-alpha 0.5`(更含蓄)或 `--duration-factor 0.92`(更紧凑)。**先跑小样确认,再全量合成**。科普长视频推荐 `passionate`(充满激情与轻快:高唤醒正价 happy 主载 + surprised 跳跃感 + 少量 calm 锚定咬字);数字/术语密集的段落若嫌糊,可 `--duration-factor 1.0` 重跑该集。 +风格向量是 8 维情感空间中的**方向 + 强度**,两者要分开调: + +1. **先定样本**(§3.3)——样本决定基线明亮度与语速,这一步的收益最大且零副作用; +2. **再定方向**:固定一句文本,`--style` 各档跑一遍对比(`--all-styles` 一条命令跑完,见 §5.1);说不清就用 `--emo-text` 让 Qwen 选方向,再把回显向量固化; +3. **最后压强度**:`--emo-alpha` 才是"像不像真人"的开关。**注入 ≥0.6 普遍开始"用力/像另一个人",0.3–0.45 是自然与风格的平衡带**(实测:同一方向 0.35 → 音高 169 Hz,0.60 → 188 Hz,0.80 → 199–223 Hz,而说话人自然区间 142–163 Hz); +4. 语速用 `--duration-factor` 微调:0.92–0.95 更明快,1.0+ 更稳;术语密集的段落嫌糊就回到 1.0。 + +**科普长视频推荐 `sunny`(明快阳光)**——本人音色 + happy 主载方向 + 强度 0.35 + 语速 0.95,配 `voices/me-bright.wav`。历史上曾推荐 `passionate`,但其有效注入 0.70 在本人样本上偏"用力",已改为 `sunny`。**任何情况下都先跑小样确认,再全量合成。** ## 五、小样试听与逐集合成 @@ -189,31 +199,32 @@ GPT 声码段的束搜索宽度,默认 **1**(上游库内部默认 3)。 全量一集要跑 2.5–3.5 小时,而「克隆出的音色像不像我」「哪档风格适合本集」用**一句话**就能判定——所以**定稿风格前必须先听小样**。[scripts/tts_sample.py](./scripts/tts_sample.py) 直调 IndexTTS 服务合成单句,无需 `narration.json`、无需视频工程;它复用 `tts.py` 的风格预设与口播文本预处理(单一事实源),故小样与成片走**完全相同**的合成路径,听感可直接外推。 ```bash -# 1) 生成参考样本(已有可跳过;本人长录音截取一段干净人声) +# 1) 生成参考样本(已有可跳过)。推荐档:me-1.mp3 的 [0.36s, 12.36s) 这一段更亮更快 uv run --no-project --with soundfile --with numpy \ - media/pipeline/scripts/prepare_ref.py ~/Documents/dify/me-1.mp3 --start 180 --duration 12 -# → media/pipeline/voices/me-1.wav(12.0s · 32 kHz · 单声道 · 16-bit · sha1 3ed0d9d60d4b) + media/pipeline/scripts/prepare_ref.py ~/Documents/dify/me-1.mp3 --start 0.36 --duration 12 \ + --out media/pipeline/voices/me-bright.wav # sha1 54b699cce97f · sunny 档即在此样本上定档 +# 换段落先用 prospect_ref.py 筛候选(§3.2),成片曾用的更闷一档是 --start 180(§3.3) # 2) 确认服务在线(未启动见 §2.3) curl -s http://127.0.0.1:8766/health # 3) 单档试听:合成后立即播放 uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ - --ref media/pipeline/voices/me-1.wav --style passionate --play + --ref media/pipeline/voices/me-bright.wav --style sunny --play -# 4) 五风格 A/B:neutral→passionate→lively→confident→positive 各一遍,顺序试听择优 +# 4) 全风格 A/B:sunny→neutral→passionate→lively→confident→positive 各一遍,顺序试听择优 uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ - --ref media/pipeline/voices/me-1.wav --all-styles --play + --ref media/pipeline/voices/me-bright.wav --all-styles --play -# 5) 觉得向量注入「有合成味」:改用语调迁移——音色仍是样本 A,语气搬自样本 B +# 5) 觉得向量注入「有合成味」:改用语调迁移——音色仍是本样本,语气搬自另一段录音 uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ --ref media/pipeline/voices/me-1.wav --emo-ref media/pipeline/voices/me-bright.wav \ --label emoref-bright --play # --emo-alpha 0.7 可只迁移七成 # 6) 说不清参数、只说得清感觉:用自己的话描述(服务需 --use-qwen-emo) uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ - --ref media/pipeline/voices/me-1.wav --emo-text "轻快、爽朗、自信、阳光" \ - --label qwen-brisk --play # 终端会回显推出的 8 维向量,满意就用 --emo-vector 固化 + --ref media/pipeline/voices/me-bright.wav --emo-text "轻快、爽朗、自信、阳光" \ + --label qwen-brisk --play # 回显 8 维向量;强度务必自己再压(§4.4 第 3 步) ``` > **`--start 180 --duration 12` 就是已上线三集成片所用的同源样本**:该段裁剪结果的 `sha1` 前 12 位为 `3ed0d9d60d4b`,与三集音频缓存 sidecar 摘要中的 `ref_sha1` 一致,直接复用即可听到与成片完全一致的音色。想换段落见 §3.2。 @@ -281,7 +292,7 @@ afplay .temp/voice-samples/curl.mp3 ```bash cd media/<工程> # 工程内薄包装等价于中心脚本;风格取 5.1 试听定稿的那一档 uv run --no-project --with mutagen scripts/tts.py --engine indextts \ - --ref <绝对路径>/media/pipeline/voices/me-1.wav --style passionate + --ref <绝对路径>/media/pipeline/voices/me-bright.wav --style sunny ``` - 服务启动一次可服务多集;管线客户端不常驻模型; diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 1c5ca717..277632f2 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -86,6 +86,18 @@ "alpha": 0.7, "df": 1.0, }, + "sunny": { + "label": "明快阳光", + # 2026-08-19 试听定档(科普长视频推荐位)。方向由 QwenEmotion 对「轻快、爽朗、 + # 自信、阳光」推出(happy 近乎独载),强度则**人工压到 0.35**——Qwen 原始输出会顶到 + # Σ=0.8 上限,实测把克隆音高推到 199–223 Hz(说话人自然区间仅 142–163 Hz)而显假; + # 0.35 留 65% 给本人真实语调,配 df 0.95 取「明快但不飘」。 + # 配套样本很关键:本档在 voices/me-bright.wav(me-1.mp3 --start 0.36 --duration 12) + # 上定档,换回更闷的样本会失去明快感——见 VOICE-CLONING.md §3.3。 + "vec": [0.95, 0, 0, 0, 0, 0, 0.02, 0.03], + "alpha": 0.35, + "df": 0.95, + }, } diff --git a/media/pipeline/voices/README.md b/media/pipeline/voices/README.md index e5771822..94c36c0c 100644 --- a/media/pipeline/voices/README.md +++ b/media/pipeline/voices/README.md @@ -15,15 +15,27 @@ ## 使用方式 ```bash -# 长录音先裁剪(截取 10s–25s 的一段干净人声): +# 0) 长录音里挑哪一段?先按客观指标筛候选(F0/起伏/音节率/谱质心): +uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospect_ref.py \ + ~/Documents/dify/me-1.mp3 --window 12 + +# 1) 裁剪并规范化(当前推荐档:me-1.mp3 的 [0.36s, 12.36s),sunny 风格即在此样本上定档): uv run --no-project --with soundfile media/pipeline/scripts/prepare_ref.py \ - ~/Documents/dify/me-1.mp3 --start 10 --duration 15 + ~/Documents/dify/me-1.mp3 --start 0.36 --duration 12 \ + --out media/pipeline/voices/me-bright.wav + +# 2) 先用单句小样试听择优(不需要视频工程): +uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ + --ref media/pipeline/voices/me-bright.wav --all-styles --play -# 合成时通过 --ref 指定: +# 3) 定稿后全量合成: uv run --no-project --with mutagen media/pipeline/scripts/tts.py \ - --project media/<工程> --engine indextts --ref media/pipeline/voices/me-1.wav --style lively + --project media/<工程> --engine indextts \ + --ref media/pipeline/voices/me-bright.wav --style sunny ``` +**样本决定基线**:克隆会连韵律一起继承,样本比参数更关键——同一位说话人换一段录音,克隆音的音高可差 12~16%、语调起伏差 25~40%(实测见 [VOICE-CLONING.md](../VOICE-CLONING.md) §3.3)。 + ## 隐私提醒 个人声音属于生物特征信息。**本目录下的音频文件已被根 `.gitignore` 忽略,不会提交入库**;请勿通过其它途径(聊天工具/公开仓库)传播克隆源音频。克隆他人声音需获得本人书面同意,见 [VOICE-CLONING.md](../VOICE-CLONING.md) §八 许可。 From d25c3afab42aec8a0808d4a003c38796df3c03df Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 12:24:05 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(tts):=20=E6=96=B0=E5=A2=9E=20sunny-ste?= =?UTF-8?q?ady=20=E6=98=8E=E5=BF=AB=E7=A8=B3=E5=81=A5=E9=A2=84=E8=AE=BE?= =?UTF-8?q?=EF=BC=8C=E6=9D=9F=E5=AE=BD=E6=8F=90=E5=8D=87=E4=B8=BA=E9=A3=8E?= =?UTF-8?q?=E6=A0=BC=E7=9A=84=E4=B8=80=E9=83=A8=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户选定第四轮的 79 档(= sunny 同参数 + 束宽 3)。束宽此前只是命令行开关、不在 STYLE_PRESETS 里,`--style` 选了也不会生效,故本次把它变成预设的一等公民: - `STYLE_PRESETS` 支持可选键 `beams`(缺省 1);新增 `sunny-steady`(明快稳健)= sunny 的向量/alpha/语速 + `beams: 3`; - `resolve_style()` 返回值扩展为 5 元组并统一"命令行显式优先、否则取预设"的解析口径; `--num-beams` 的 argparse 默认值由 `1` 改为 `None`——否则无法区分「没给」与「给了 1」, 预设束宽会被永久压掉;两个客户端(tts.py / tts_sample.py)同步按 job 传束宽; - `--list-styles` 增列「有效注入」与「束宽」,一眼看出每档挤掉多少真实语调、跑多慢。 为何值得单独成档:同文本同样本实测,束宽 1→3 把语调起伏从 48.4 收到 40–44(更稳、 更「令人信服」),而亮度基本不掉(谱质心 1245 → 1214–1223)——是目前唯一不牺牲明快度 就能让语气更可信的旋钮。代价是 GPT 段耗时按束宽放大:单句墙钟 20–35 秒 → 56–131 秒, 整集 2.5–3.5 小时 → 8–10 小时。故定位为「日常/批量用 sunny,成片定稿用 sunny-steady」, 手册 §4.1/§4.3b/§4.4/§5.1/§5.2 均已按此口径改写并标注排期差异。 验证:①`--dry-run` 四例——sunny-steady 解析出 beams=3、sunny 仍为 1、显式 `--num-beams 1` 能压过预设的 3、`--all-styles` 七档各带自己的束宽;②实机跑通 `--style sunny-steady` (墙钟 131.1s / RTF 25.4,客观指标与用户选中的 79 档一致:F0 175.0、起伏 40.3、质心 1214); ③摘要回归——已上线三集 189 句仍 100% 与 sidecar 一致(束宽默认值改动零影响),且 sunny 与 sunny-steady 摘要不同(换档会全量重合成,符合预期);④edge 引擎误传 `--num-beams` 仍正确提示忽略。 🤖 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 --- CHANGELOG.md | 2 +- media/pipeline/VOICE-CLONING.md | 31 +++++++++------ media/pipeline/scripts/tts.py | 56 +++++++++++++++++++++------- media/pipeline/scripts/tts_sample.py | 45 +++++++++++++--------- 4 files changed, 90 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e306a477..a95c2b41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Added -- **声音克隆试听闭环:单句小样 + 情感三来源 + 样本选段勘探 + `sunny` 明快阳光预设**:此前管线只有工程级入口 `tts.py`(强依赖 `narration.json`),选样本/选风格却要等 2.5–3.5 小时的整集合成——新增 [`scripts/tts_sample.py`](media/pipeline/scripts/tts_sample.py) 直调服务合成单句(`--style` / `--all-styles` 五档 A/B / `--dry-run` 秒级核参 / `--label` 横向对比 / `--play` 顺序试听),复用 `tts.py` 的风格预设与文本预处理故与成片同路径,单档暖机后约 20 秒。针对「五档预设都不够自然」的实测复盘,定位两个根因并各给工具:①**样本定基线**——新增 [`scripts/prospect_ref.py`](media/pipeline/scripts/prospect_ref.py) 按 F0 中位/四分位距/音节率/谱质心筛「更亮更轻快」的候选起点(成片原用样本综合分仅 157/275,换段即让克隆音高 +12~16%、起伏 +25~40%);②**注入越多越假**——由上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 推出「Σw 就是合成情感挤掉真实语调的比例」,故新增两条自然通路:`--emo-ref`(音色取 A、语调迁移自 B,零向量注入)与 `--emo-text`(QwenEmotion 把「轻快爽朗、自信阳光」转向量,按 ≤0.8 等比缩放并经 `X-Emo-Vector` 回显供固化),服务端新增 `--use-qwen-emo` 与 `/health.supports_emo_text`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 29 个候选小样试听定档 **`sunny`(明快阳光:happy 主载方向 + 有效注入 0.35 + df 0.95,配 `voices/me-bright.wav`)** 取代 `passionate` 成为科普长视频推荐位。[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) 同步扩写:§三 增 3.3「样本决定基线」实测表、§四 增「情感三来源」对照与强度调参带(0.3–0.45 为自然平衡带)、§五 拆 5.1 小样试听/5.2 全量/5.3 重渲染并附纯 `curl` 直调、§六 摘要公式补 `|emoref=` / `|emotext=` 可选后缀(对已上线三集 189 句逐句核对摘要 100% 不变,存量缓存零失效)。 +- **声音克隆试听闭环:单句小样 + 情感三来源 + 样本选段勘探 + `sunny` 明快阳光预设**:此前管线只有工程级入口 `tts.py`(强依赖 `narration.json`),选样本/选风格却要等 2.5–3.5 小时的整集合成——新增 [`scripts/tts_sample.py`](media/pipeline/scripts/tts_sample.py) 直调服务合成单句(`--style` / `--all-styles` 五档 A/B / `--dry-run` 秒级核参 / `--label` 横向对比 / `--play` 顺序试听),复用 `tts.py` 的风格预设与文本预处理故与成片同路径,单档暖机后约 20 秒。针对「五档预设都不够自然」的实测复盘,定位两个根因并各给工具:①**样本定基线**——新增 [`scripts/prospect_ref.py`](media/pipeline/scripts/prospect_ref.py) 按 F0 中位/四分位距/音节率/谱质心筛「更亮更轻快」的候选起点(成片原用样本综合分仅 157/275,换段即让克隆音高 +12~16%、起伏 +25~40%);②**注入越多越假**——由上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 推出「Σw 就是合成情感挤掉真实语调的比例」,故新增两条自然通路:`--emo-ref`(音色取 A、语调迁移自 B,零向量注入)与 `--emo-text`(QwenEmotion 把「轻快爽朗、自信阳光」转向量,按 ≤0.8 等比缩放并经 `X-Emo-Vector` 回显供固化),服务端新增 `--use-qwen-emo` 与 `/health.supports_emo_text`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 41 个候选小样试听定档 **`sunny`(明快阳光:happy 主载方向 + 有效注入 0.35 + df 0.95,配 `voices/me-bright.wav`)** 取代 `passionate` 成为科普长视频推荐位;并新增其定稿档 **`sunny-steady`(明快稳健)**——同方向同强度同语速、只把束宽提到 3,实测语调起伏 48.4 → 40–44 而亮度不掉(谱质心 1245 → 1214–1223),是唯一"不牺牲明快度就让语气更可信"的旋钮,代价是单句墙钟 20–35 秒 → 56–131 秒(整集 8–10 小时)。为此把**束宽提升为预设的一部分**(`STYLE_PRESETS` 可选键 `beams`,缺省 1;`--num-beams` 显式给值优先,其 argparse 默认值改为 `None` 以区分"没给"与"给了 1"),`--list-styles` 增列"有效注入/束宽"。[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) 同步扩写:§三 增 3.3「样本决定基线」实测表、§四 增「情感三来源」对照与强度调参带(0.3–0.45 为自然平衡带)、§五 拆 5.1 小样试听/5.2 全量/5.3 重渲染并附纯 `curl` 直调、§六 摘要公式补 `|emoref=` / `|emotext=` 可选后缀(对已上线三集 189 句逐句核对摘要 100% 不变,存量缓存零失效)。 - **自进化系列三集科普视频内容升级与本人音色克隆配音**:三集([《AI 如何自己变强?》](media/self-improving-agents-video/README.md) v3 / [《上线之后,AI 才开始上学》](media/experience-era-agents-video/README.md) v2 / [《会写代码的 AI,开始给自己写代码》](media/self-evolving-coding-agents-video/README.md) v3)统一换用本人音色克隆配音(IndexTTS-2.5 `passionate` 激情风格,me-1.wav 样本经 RMS 预筛裁剪);源论文第二遍重读校准(三集全部断言零事实漂移)+ 官方工程站点信源补充(ep1 312 条收录统计/九篇敲门砖→P5 活地图+卡片墙镜;ep2 经验编译器闭环/Gen3 适应面语义/SIP-Bench 四指标/111 篇×9 章→6-B2 闭环复盘+6-F 清单卡镜;ep3 无站点,SICA 三选择信号+Table 2 斜线规律两句锚定);每集新增 `research/upgrade-2026-08.md` 审计文档(校准审计表+句级 delta+双重校验 delta RISKY/REWRITE=0+G2 验证记录);动画增强(计数器滚动/环形图合拢/双色分拣重排/青紫双速差流光/斜线光带扫掠/检查点卡+四仪表/数值轨迹雷达/流光巡游,全部 skill-06 四红线合规)。 - **科普视频管线新增「激情」配音风格预设与官方工程站点信源补充规范**:`media/pipeline/scripts/tts.py` 的 IndexTTS 风格预设表新增 `passionate`(充满激情与轻快:happy 0.70 主载高唤醒正价 + surprised 0.20 跳跃感 + calm 0.10 锚定咬字,有效和 1.00×0.7=0.70 ≤ 0.8 上限,语速 0.97 护密集技术句清晰度),`--list-styles` 与 [VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §四 同步收录并给出科普长视频推荐位;[skills/01-paper-extraction.md](media/pipeline/skills/01-paper-extraction.md) 扩展「官方工程站点信源补充」纪律——只收事实性内容、逐字引用+URL+访问日期、落 `paper-notes.md` 末尾独立「信源补充」大节并与论文正文物理隔离、严禁下载/嵌入站点图片(概念转文字规格供 Remotion 代码动画重建)、站点信源单集专属不跨集。 - **IndexTTS 推理束搜索宽度旋钮(`--num-beams`,长跑提速主开关)**:服务端 `tts_server.py` 请求模型与推理透传 num_beams(1–5),客户端 `tts.py` 新增 `--num-beams`(默认 1);根因为 MPS fp32 实测每句墙钟 RTF 40–58(上游库内部 num_beams=3 使 GPT 段耗时按束宽线性放大),降为 1 束后 RTF≈12–15(采样生成下听感差异可忽略);[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §4.3b 沉淀实测数据并修正原「每句 5–30 秒」的失实估计(整集实际数小时,需 nohup 后台+断点续跑)。 diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index 8682dbd0..2b72402c 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -158,14 +158,19 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec ### 4.1 风格预设(--style) -| 预设 | 定位 | emo_vector(顺序:happy, angry, sad, afraid, disgusted, melancholic, surprised, calm) | alpha | 有效注入 | df | -|---|---|---|---|---|---| -| **sunny 明快阳光** | **科普长视频推荐位**(2026-08-19 试听定档) | happy=.95, surprised=.02, calm=.03 | **0.35** | **0.35** | 0.95 | -| neutral | 中性(默认) | 不注入情感,纯克隆参考音色 | — | 0 | 1.0 | -| passionate 激情 | 充满激情与轻快 | happy=.70, surprised=.20, calm=.10 | 0.7 | 0.70 | 0.97 | -| lively 轻快 | 明快跳跃 | happy=.55, surprised=.15, calm=.15 | 0.6 | 0.51 | 0.95 | -| confident 自信 | 沉稳有力 | calm=.65, happy=.25 | 0.7 | 0.63 | 1.05 | -| positive 正能量 | 昂扬向上 | happy=.75, calm=.20 | 0.7 | 0.665 | 1.0 | +| 预设 | 定位 | emo_vector(顺序:happy, angry, sad, afraid, disgusted, melancholic, surprised, calm) | alpha | 有效注入 | df | 束宽 | +|---|---|---|---|---|---|---| +| **sunny 明快阳光** | **日常/批量推荐位**(2026-08-19 试听定档) | happy=.95, surprised=.02, calm=.03 | **0.35** | **0.35** | 0.95 | 1 | +| **sunny-steady 明快稳健** | **成片定稿推荐位**:同上但韵律更收敛,代价是慢 2–5 倍 | 同 sunny | 0.35 | 0.35 | 0.95 | **3** | +| neutral | 中性(默认) | 不注入情感,纯克隆参考音色 | — | 0 | 1.0 | 1 | +| passionate 激情 | 充满激情与轻快 | happy=.70, surprised=.20, calm=.10 | 0.7 | 0.70 | 0.97 | 1 | +| lively 轻快 | 明快跳跃 | happy=.55, surprised=.15, calm=.15 | 0.6 | 0.51 | 0.95 | 1 | +| confident 自信 | 沉稳有力 | calm=.65, happy=.25 | 0.7 | 0.63 | 1.05 | 1 | +| positive 正能量 | 昂扬向上 | happy=.75, calm=.20 | 0.7 | 0.665 | 1.0 | 1 | + +预设可自带**束宽**(`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 束口径乘上去。 > **`sunny` 的来历**(也是一份调参范例):方向由 QwenEmotion 对「轻快、爽朗、自信、阳光」推出——happy 近乎独载;但 Qwen 的原始强度会顶到 Σ=0.8 上限,实测把克隆音高推到 **199–223 Hz**,而该说话人自然区间只有 142–163 Hz,听感"像另一个人在用力"。**保留方向、把强度压到 0.35**(留 65% 给本人真实语调)+ `df 0.95` 后即为 `sunny`。**该档在 `voices/me-bright.wav` 上定档**(`prepare_ref.py ~/Documents/dify/me-1.mp3 --start 0.36 --duration 12`),换回更闷的样本会失去明快感(见 §3.3)。定式可复用:**Qwen 选方向 → 人工压强度 → 固化成预设**。 @@ -179,7 +184,7 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec ### 4.3b 束搜索宽度(--num-beams,速度主旋钮) -GPT 声码段的束搜索宽度,默认 **1**(上游库内部默认 3)。采样生成(`do_sample=True`)下 1 与 3 的听感差异可忽略,但 GPT 段耗时约按束宽线性放大。**MPS fp32 实测**(2026-08-18,M3 系列):每句墙钟 = GPT 束搜索 + 扩散声码 + BigVGAN,RTF(耗时/音频时长)约 40–58(beams=3);beams=1 下三集全量连续跑(596 句 / 40.2 分钟纯语音 / 8.5 小时墙钟)折算整集 RTF≈12–14;整集(约 180–230 句)约 2.5–3.5 小时,按句缓存可断点续跑。质量敏感的单句可 `--num-beams 3` 单独重合成。 +GPT 声码段的束搜索宽度,**缺省随风格**(多数预设 1、`sunny-steady` 为 3;上游库内部默认 3)。采样生成(`do_sample=True`)下 1 与 3 的听感差异可忽略,但 GPT 段耗时约按束宽线性放大。**MPS fp32 实测**(2026-08-18,M3 系列):每句墙钟 = GPT 束搜索 + 扩散声码 + BigVGAN,RTF(耗时/音频时长)约 40–58(beams=3);beams=1 下三集全量连续跑(596 句 / 40.2 分钟纯语音 / 8.5 小时墙钟)折算整集 RTF≈12–14;整集(约 180–230 句)约 2.5–3.5 小时,按句缓存可断点续跑。质量敏感的单句可 `--num-beams 3` 单独重合成,整集定稿可直接用 `--style sunny-steady`(束宽 3 已固化进预设)。**束宽也是韵律稳定度旋钮**,不只是速度旋钮:3 束把语调起伏收窄约 10%(48.4 → 43.5),听感更"稳/可信",见 §4.1。 ### 4.4 调参建议 @@ -190,7 +195,9 @@ GPT 声码段的束搜索宽度,默认 **1**(上游库内部默认 3)。 3. **最后压强度**:`--emo-alpha` 才是"像不像真人"的开关。**注入 ≥0.6 普遍开始"用力/像另一个人",0.3–0.45 是自然与风格的平衡带**(实测:同一方向 0.35 → 音高 169 Hz,0.60 → 188 Hz,0.80 → 199–223 Hz,而说话人自然区间 142–163 Hz); 4. 语速用 `--duration-factor` 微调:0.92–0.95 更明快,1.0+ 更稳;术语密集的段落嫌糊就回到 1.0。 -**科普长视频推荐 `sunny`(明快阳光)**——本人音色 + happy 主载方向 + 强度 0.35 + 语速 0.95,配 `voices/me-bright.wav`。历史上曾推荐 `passionate`,但其有效注入 0.70 在本人样本上偏"用力",已改为 `sunny`。**任何情况下都先跑小样确认,再全量合成。** +5. **最后定束宽**:想让语气更"稳/可信"就上 3 束(`--style sunny-steady`),代价是整集墙钟 ×2–5;赶工或改稿频繁期用 1 束的 `sunny`。 + +**推荐位**:日常/批量用 **`sunny`(明快阳光)**,成片定稿用 **`sunny-steady`(明快稳健)**——两档参数完全相同、只差束宽,故可"先用 sunny 快速迭代文稿,定稿再用 sunny-steady 重跑一遍"(换档会改摘要 → 全量重合成,须留出时间)。两档都配 `voices/me-bright.wav`。历史上曾推荐 `passionate`,但其有效注入 0.70 在本人样本上偏"用力",已改为 sunny 系。**任何情况下都先跑小样确认,再全量合成。** ## 五、小样试听与逐集合成 @@ -238,7 +245,7 @@ uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ | `--emo-vector` `--emo-alpha` `--duration-factor` | 手动调参,语义与取值范围同 §四 | 随风格 | | `--emo-ref <录音>` | 语调迁移:音色仍取 `--ref`,语气来自这段录音(见 §四) | 关 | | `--emo-text "<描述>"` | 自然语言描述情感(需服务端 `--use-qwen-emo`);推出的向量会打印,可用 `--emo-vector` 固化 | 关 | -| `--num-beams` | 束宽;质量敏感的单句可试 `3`(耗时约按束宽线性放大,见 §4.3b) | `1` | +| `--num-beams` | 束宽;越大韵律越稳、耗时约按束宽线性放大(见 §4.3b)。显式给值会压过预设 | 随风格(`sunny-steady` 为 3,其余 1) | | `--dry-run` | 只解析并打印各档向量/alpha/语速,不连服务(秒级核参,改风格后先跑这个) | 关 | | `--label` | 产物文件名(多档时作前缀 `{label}-{风格}`)——**横向对比多个参考样本或多组自定义向量时必用**,否则同名互相覆盖 | 取风格名 | | `--play` / `--out-dir` | 合成后 `afplay` 顺序试听 / 产物目录 | 关 / `.temp/voice-samples/` | @@ -296,7 +303,7 @@ uv run --no-project --with mutagen scripts/tts.py --engine indextts \ ``` - 服务启动一次可服务多集;管线客户端不常驻模型; -- 每句墙钟与文本长度及束宽相关:MPS fp32 实测 RTF≈40–58(`--num-beams 3`)/ ≈12–14(默认 `--num-beams 1`),即 5 秒的句子约需 1–1.5 分钟;整集(约 180–230 句)约 2.5–3.5 小时,建议 `nohup` 挂后台跑、按句缓存断点续跑(见 §六);长篇管线**不要**用默认 3 束逐句等待; +- 每句墙钟与文本长度及束宽相关:MPS fp32 实测 RTF≈40–58(3 束,即 `sunny-steady`)/ ≈12–14(1 束,如 `sunny`)。**1 束整集(约 180–230 句)约 2.5–3.5 小时;3 束按口径要 8–10 小时**——请据此选档并 `nohup` 挂后台跑,按句缓存可断点续跑(见 §六); - 超长句(>120 token)服务端内部自动分段;极端长句推理可达数分钟。客户端并发为 1(与服务端串行推理对齐,避免排队时间计入超时),HTTP 超时 600s;万一超时——重跑即续传,无需干预。 ### 5.3 合成后重渲染 diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 277632f2..99e89d88 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -6,7 +6,8 @@ - 引擎: - edge(默认):edge-tts 预置音色,免密钥,行为与历史版本完全一致; - indextts:声音克隆(IndexTTS-2.5 本地服务),需先启动 tts_server.py, - 通过 --ref 提供参考音色样本、--style 选择风格(激情/轻快/自信/正能量等)。 + 通过 --ref 提供参考音色样本、--style 选择风格(sunny 明快阳光为推荐位, + sunny-steady 为其定稿档=同参数 + 束宽 3;另有激情/轻快/自信/正能量)。 - 幂等:参数与文本未变则跳过(SHA1 摘要 sidecar 缓存)。 用法: @@ -57,7 +58,9 @@ "calm", ] -# 风格预设:激情/轻快/自信/正能量 —— 数值为初值,可实测试听后微调。 +# 风格预设 —— 数值为初值,可实测试听后微调。 +# 可选键 "beams":预设自带的束搜索宽度(缺省 1)。束宽会改变韵律稳定度,属风格的一部分, +# 故允许写进预设;命令行 --num-beams 显式给值时优先。注意束宽 3 使整集墙钟约 ×3。 STYLE_PRESETS: dict[str, dict] = { "neutral": {"label": "中性", "vec": None, "alpha": 1.0, "df": 1.0}, "passionate": { @@ -98,6 +101,18 @@ "alpha": 0.35, "df": 0.95, }, + "sunny-steady": { + "label": "明快稳健", + # = sunny 同方向同强度同语速,只把束宽提到 3:GPT 段搜索更宽 → 韵律更收敛。 + # 实测(同文本同样本):语调起伏 48.4 → 43.5、音节率 4.10 → 4.55,亮度基本不掉 + # (质心 1245 → 1223)——是唯一「不牺牲明快度就让语气更稳」的旋钮。 + # 代价:GPT 段耗时约按束宽线性放大,**整集墙钟约 ×3**(见 VOICE-CLONING.md §4.3b), + # 适合成片定稿或质量敏感段落;长篇批量赶工仍用 sunny。 + "vec": [0.95, 0, 0, 0, 0, 0, 0.02, 0.03], + "alpha": 0.35, + "df": 0.95, + "beams": 3, + }, } @@ -141,17 +156,24 @@ def parse_emo_vector(spec: str) -> list[float]: def resolve_style( args: argparse.Namespace, -) -> tuple[str, list[float] | None, float, float]: - """返回 (风格名, 情感向量|None, emo_alpha, duration_factor)。""" +) -> tuple[str, list[float] | None, float, float, int]: + """返回 (风格名, 情感向量|None, emo_alpha, duration_factor, num_beams)。 + + 三个可覆盖参数(alpha / df / beams)一律「命令行显式给值优先,否则取预设」—— + 故 --num-beams 的 argparse 默认值必须是 None 而非 1,否则无法区分「没给」与「给了 1」。 + """ + beams = args.num_beams if args.num_beams is not None else 1 if args.emo_vector: vec = parse_emo_vector(args.emo_vector) alpha = args.emo_alpha if args.emo_alpha is not None else 0.6 df = args.duration_factor if args.duration_factor is not None else 1.0 - return "raw", vec, alpha, df + return "raw", vec, alpha, df, beams preset = STYLE_PRESETS[args.style] alpha = args.emo_alpha if args.emo_alpha is not None else preset["alpha"] df = args.duration_factor if args.duration_factor is not None else preset["df"] - return args.style, preset["vec"], alpha, df + if args.num_beams is None: # 预设可自带束宽(如 sunny-steady=3),缺省为 1 + beams = preset.get("beams", 1) + return args.style, preset["vec"], alpha, df, beams # ---------------- 引擎一:edge-tts(历史路径,保持字节级一致) ---------------- @@ -478,11 +500,11 @@ async def main() -> None: idx.add_argument("--lang", default="ZH", help="[indextts] 语言(默认 ZH)") idx.add_argument( "--num-beams", - default=1, + default=None, type=int, choices=[1, 2, 3, 4, 5], - help="[indextts] GPT 束搜索宽度(默认 1;采样生成下与上游默认 3 听感差异可忽略," - "但 GPT 段耗时约按束宽线性放大——长篇管线跑 1,质量敏感单句可试 3)", + help="[indextts] GPT 束搜索宽度(缺省随风格,多数预设为 1、sunny-steady 为 3;" + "束宽越大韵律越稳但 GPT 段耗时约按束宽线性放大——长篇批量跑 1,定稿可试 3)", ) idx.add_argument( "--engine-tag", @@ -496,13 +518,18 @@ async def main() -> None: if args.list_styles: print( - "风格 说明 情感向量(happy,angry,sad,afraid,disgusted,melancholic,surprised,calm) alpha 语速" + "风格 说明 情感向量(happy,angry,sad,afraid,disgusted,melancholic,surprised,calm)" + " alpha 有效注入 语速 束宽" ) for name, p in STYLE_PRESETS.items(): vec = ( ",".join(f"{x:g}" for x in p["vec"]) if p["vec"] else "—(不注入情感)" ) - print(f"{name:<10} {p['label']:<6} {vec:<62} {p['alpha']:<5} {p['df']}") + eff = (sum(p["vec"]) * p["alpha"]) if p["vec"] else 0.0 + print( + f"{name:<14} {p['label']:<6} {vec:<62} {p['alpha']:<5} " + f"{eff:<8.3g} {p['df']:<4} {p.get('beams', 1)}" + ) return if args.engine == "edge": @@ -515,7 +542,7 @@ async def main() -> None: "--emo-text": args.emo_text, "--emo-alpha": args.emo_alpha, "--duration-factor": args.duration_factor, - "--num-beams": args.num_beams != 1, + "--num-beams": args.num_beams is not None, "--server": args.server != "http://127.0.0.1:8766", "--style": args.style != "neutral", "--lang": args.lang != "ZH", @@ -566,9 +593,10 @@ async def main() -> None: vec = None alpha = args.emo_alpha if args.emo_alpha is not None else 1.0 df = args.duration_factor if args.duration_factor is not None else 1.0 + beams = args.num_beams if args.num_beams is not None else 1 else: try: - style_name, vec, alpha, df = resolve_style(args) + style_name, vec, alpha, df, beams = resolve_style(args) except ValueError as e: parser.error(str(e)) if not args.ref: @@ -643,7 +671,7 @@ async def main() -> None: args.engine_tag, args.server, out_dir, - num_beams=args.num_beams, + num_beams=beams, # 已含「命令行优先、否则取预设」的解析结果 emo_ref=emo_ref_path, emo_ref_sha1=emo_ref_sha1, emo_text=args.emo_text, diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py index 5c477335..8a2025ae 100644 --- a/media/pipeline/scripts/tts_sample.py +++ b/media/pipeline/scripts/tts_sample.py @@ -8,12 +8,12 @@ - 前置:参考音色样本(prepare_ref.py 产出)+ 已启动的 tts_server.py。 用法(仓库根执行): - # 单档试听 + # 单档试听(科普推荐档) uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ - --ref media/pipeline/voices/me-1.wav --style passionate --play - # 五风格 A/B(neutral/passionate/lively/confident/positive 各合成一遍) + --ref media/pipeline/voices/me-bright.wav --style sunny --play + # 全风格 A/B(STYLE_PRESETS 逐档各合成一遍,含各自的 alpha/语速/束宽) uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ - --ref media/pipeline/voices/me-1.wav --all-styles --play + --ref media/pipeline/voices/me-bright.wav --all-styles --play 产物:<仓库根>/.temp/voice-samples/{风格}.mp3(已被根 .gitignore 忽略)——内含本人音色, 属生物特征信息,试听后请及时清理。完整手册见 media/pipeline/VOICE-CLONING.md §5.1。 @@ -52,10 +52,10 @@ def build_jobs( args: argparse.Namespace, -) -> list[tuple[str, list[float] | None, float, float]]: - """→ [(风格名, 情感向量|None, emo_alpha, duration_factor)]。 +) -> list[tuple[str, list[float] | None, float, float, int]]: + """→ [(风格名, 情感向量|None, emo_alpha, duration_factor, num_beams)]。 - --all-styles 逐档取各预设自带的 alpha/df(这正是 A/B 的意义,故与手动覆盖互斥)。 + --all-styles 逐档取各预设自带的 alpha/df/beams(这正是 A/B 的意义,故与手动覆盖互斥)。 """ if args.emo_ref or args.emo_text: # 音频/文本驱动情感:不注入向量,alpha 默认 1.0(完全采用该情感来源) @@ -65,13 +65,18 @@ def build_jobs( None, args.emo_alpha if args.emo_alpha is not None else 1.0, args.duration_factor if args.duration_factor is not None else 1.0, + args.num_beams if args.num_beams is not None else 1, ) ] if args.all_styles: return [ resolve_style( argparse.Namespace( - emo_vector=None, style=name, emo_alpha=None, duration_factor=None + emo_vector=None, + style=name, + emo_alpha=None, + duration_factor=None, + num_beams=None, # 让每档取自己的束宽(sunny-steady=3,其余 1) ) ) for name in STYLE_PRESETS @@ -117,6 +122,7 @@ def synthesize_one( vec: list[float] | None, alpha: float, df: float, + beams: int, out_dir: Path, stem: str | None = None, ) -> dict: @@ -135,7 +141,7 @@ def synthesize_one( alpha, df, args.lang, - args.num_beams, + beams, args.emo_ref, args.emo_text, headers, @@ -243,10 +249,11 @@ def main() -> None: parser.add_argument("--lang", default="ZH", help="语言(默认 ZH)") parser.add_argument( "--num-beams", - default=1, + default=None, type=int, choices=[1, 2, 3, 4, 5], - help="GPT 束搜索宽度(默认 1;耗时约按束宽线性放大,见 " + MANUAL + " §4.3b)", + help="GPT 束搜索宽度(缺省随风格:多数预设 1、sunny-steady 3);越大韵律越稳但耗时" + "约按束宽线性放大,见 " + MANUAL + " §4.3b", ) parser.add_argument("--server", default="http://127.0.0.1:8766", help="服务地址") parser.add_argument( @@ -328,7 +335,7 @@ def main() -> None: jobs = build_jobs(args) except ValueError as e: # parse_emo_vector 的键名/权重错误 parser.error(str(e)) - for name, vec, alpha, df in jobs: + for name, vec, alpha, df, _beams in jobs: if ( vec is not None and sum(vec) * alpha > 0.8 ): # 与服务端同口径:alpha 缩放后校验有效和 @@ -345,10 +352,13 @@ def main() -> None: print(f">> 情感参考音频:{args.emo_ref}(音色仍取上面的参考样本)") if args.emo_text: print(f">> 情感描述:{args.emo_text}(服务端 QwenEmotion 转向量)") - print(f">> 风格 {len(jobs)} 档 · num_beams={args.num_beams} · lang={args.lang}") - for name, vec, alpha, df in jobs: + print(f">> 风格 {len(jobs)} 档 · lang={args.lang}") + for name, vec, alpha, df, beams in jobs: vec_str = ",".join(f"{x:g}" for x in vec) if vec else "—(不注入情感)" - print(f" {name:<10} vec=[{vec_str}] alpha={alpha:g} df={df:g}") + slow = "(束宽 3,约慢 3 倍)" if beams >= 3 else "" + print( + f" {name:<14} vec=[{vec_str}] alpha={alpha:g} df={df:g} beams={beams}{slow}" + ) if args.dry_run: print(">> --dry-run:仅解析参数,未连接服务") return @@ -362,7 +372,7 @@ def main() -> None: check_server( args.server, - need_duration_factor=any(df != 1.0 for *_, df in jobs), + need_duration_factor=any(df != 1.0 for _n, _v, _a, df, _b in jobs), need_emo_text=bool(args.emo_text), ) @@ -380,12 +390,13 @@ def main() -> None: vec, alpha, df, + beams, out_dir, stem=None if not args.label else (args.label if len(jobs) == 1 else f"{args.label}-{name}"), ) - for name, vec, alpha, df in jobs + for name, vec, alpha, df, beams in jobs ] total_wall = sum(r["wall"] for r in results) From 2557c445ce0c13ef05b3fd3eeeedb60b36693db9 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 14:07:26 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(tts):=20=E6=B7=B7=E5=90=88=E6=A1=A3=20?= =?UTF-8?q?--steady=20+=20=E6=8E=92=E6=9C=9F=E9=A2=84=E6=BC=94=20--plan?= =?UTF-8?q?=EF=BC=8C=E6=95=B4=E9=9B=86=E5=8F=AA=E8=AE=A9=E5=85=B3=E9=94=AE?= =?UTF-8?q?=E5=8F=A5=E5=8D=87=E6=9D=9F=E5=AE=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3 束(sunny-steady)韵律更稳但整集要 9.9 小时,而真正决定第一印象的只是冷开场与各幕 金句。新增两个开关把「稳」买在关键处: - `--steady 'P0,p3-25b,p5-*'`:命中句改用高束宽(`--steady-beams`,默认 3),其余仍按 风格束宽。选择器规则可预测无歧义——以 `*` 结尾为前缀通配、含 `-` 为句 id、其余为幕名, 大小写不敏感;**任一项匹配不到句子立即报错**(拼错幕名/句 id 后静默按低束宽跑完 3 小时 是最难发现的失败模式)。缓存 sidecar 按句独立(摘要含 |beams=N),故同集混用两种束宽 安全,也支持先全集跑 sunny、事后只补跑 --steady 命中的那几句。 - `--plan`:纯本地算摘要并与 sidecar 比对,打印各束宽的待合成/已缓存句数与估算墙钟后退出, 不连服务。长跑前必经一步——改几行稿子后往往只有那几句 miss,不必按整集排期。 估时口径修正:初版 --plan 用「单句空闲机器」RTF(1 束 6.5)会低估近一半,改为**长跑折算 口径**并提为模块常量 RTF_1BEAM=13(三集 596 句 8.5 h / 40.2 min 语音实测)、 RTF_MULTIBEAM=45(同句 1↔3 束 A/B 实测约 3.2 倍,与早前 3 束直测 RTF 40–58 吻合)、 AVG_SEC_PER_LINE=4.2。校准后 189 句一集估 2.9 h,与三集真实用时 2.5–3.5 h 一致。 同时修正三处已失实的注释/文档口径:束宽 3 的整集倍数由「×3」改为「×3.4」;§4.3b 补入 本轮实测「短句最贵、数字句更贵」(4–6 s 短句 3 束 RTF 19.6–31.5 / 1 束 6.0–7.4,而 13–15 s 长句 3 束仅 8.9–13.8;数字密集句 5.87 s 音频烧 185 s,因数字被归一展开、token 暴涨) ——逐字稿为字幕可读性都拆到 ≤43 字,正好落在最贵区间。手册新增 §5.2.1 混合档(选择器语法 表 + 四方案代价对照表),§5.2 把 --plan 写成长跑前必经步骤。 验证:①选择器 7 例——幕名/句 id/前缀通配/大小写混用均正确命中,句 id 与幕名拼错各自报错, `--steady-beams` 不高于基础束宽时报错;②E2E 双句工程实跑,逐句 sidecar 反查束宽(P0→3、 P1→1)全部命中期望,重跑 0.19 s 全缓存,--plan 缓存计数正确;③摘要回归——三集 596 句 逐句重算 100% 与 sidecar 一致(束宽传参链路改动零影响)。 🤖 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 --- CHANGELOG.md | 2 +- media/pipeline/README.md | 4 +- media/pipeline/VOICE-CLONING.md | 43 +++++++- media/pipeline/scripts/tts.py | 175 +++++++++++++++++++++++++++++--- 4 files changed, 205 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a95c2b41..7a7d8829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Added -- **声音克隆试听闭环:单句小样 + 情感三来源 + 样本选段勘探 + `sunny` 明快阳光预设**:此前管线只有工程级入口 `tts.py`(强依赖 `narration.json`),选样本/选风格却要等 2.5–3.5 小时的整集合成——新增 [`scripts/tts_sample.py`](media/pipeline/scripts/tts_sample.py) 直调服务合成单句(`--style` / `--all-styles` 五档 A/B / `--dry-run` 秒级核参 / `--label` 横向对比 / `--play` 顺序试听),复用 `tts.py` 的风格预设与文本预处理故与成片同路径,单档暖机后约 20 秒。针对「五档预设都不够自然」的实测复盘,定位两个根因并各给工具:①**样本定基线**——新增 [`scripts/prospect_ref.py`](media/pipeline/scripts/prospect_ref.py) 按 F0 中位/四分位距/音节率/谱质心筛「更亮更轻快」的候选起点(成片原用样本综合分仅 157/275,换段即让克隆音高 +12~16%、起伏 +25~40%);②**注入越多越假**——由上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 推出「Σw 就是合成情感挤掉真实语调的比例」,故新增两条自然通路:`--emo-ref`(音色取 A、语调迁移自 B,零向量注入)与 `--emo-text`(QwenEmotion 把「轻快爽朗、自信阳光」转向量,按 ≤0.8 等比缩放并经 `X-Emo-Vector` 回显供固化),服务端新增 `--use-qwen-emo` 与 `/health.supports_emo_text`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 41 个候选小样试听定档 **`sunny`(明快阳光:happy 主载方向 + 有效注入 0.35 + df 0.95,配 `voices/me-bright.wav`)** 取代 `passionate` 成为科普长视频推荐位;并新增其定稿档 **`sunny-steady`(明快稳健)**——同方向同强度同语速、只把束宽提到 3,实测语调起伏 48.4 → 40–44 而亮度不掉(谱质心 1245 → 1214–1223),是唯一"不牺牲明快度就让语气更可信"的旋钮,代价是单句墙钟 20–35 秒 → 56–131 秒(整集 8–10 小时)。为此把**束宽提升为预设的一部分**(`STYLE_PRESETS` 可选键 `beams`,缺省 1;`--num-beams` 显式给值优先,其 argparse 默认值改为 `None` 以区分"没给"与"给了 1"),`--list-styles` 增列"有效注入/束宽"。[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) 同步扩写:§三 增 3.3「样本决定基线」实测表、§四 增「情感三来源」对照与强度调参带(0.3–0.45 为自然平衡带)、§五 拆 5.1 小样试听/5.2 全量/5.3 重渲染并附纯 `curl` 直调、§六 摘要公式补 `|emoref=` / `|emotext=` 可选后缀(对已上线三集 189 句逐句核对摘要 100% 不变,存量缓存零失效)。 +- **声音克隆试听闭环:单句小样 + 情感三来源 + 样本选段勘探 + `sunny` 明快阳光预设**:此前管线只有工程级入口 `tts.py`(强依赖 `narration.json`),选样本/选风格却要等 2.5–3.5 小时的整集合成——新增 [`scripts/tts_sample.py`](media/pipeline/scripts/tts_sample.py) 直调服务合成单句(`--style` / `--all-styles` 五档 A/B / `--dry-run` 秒级核参 / `--label` 横向对比 / `--play` 顺序试听),复用 `tts.py` 的风格预设与文本预处理故与成片同路径,单档暖机后约 20 秒。针对「五档预设都不够自然」的实测复盘,定位两个根因并各给工具:①**样本定基线**——新增 [`scripts/prospect_ref.py`](media/pipeline/scripts/prospect_ref.py) 按 F0 中位/四分位距/音节率/谱质心筛「更亮更轻快」的候选起点(成片原用样本综合分仅 157/275,换段即让克隆音高 +12~16%、起伏 +25~40%);②**注入越多越假**——由上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 推出「Σw 就是合成情感挤掉真实语调的比例」,故新增两条自然通路:`--emo-ref`(音色取 A、语调迁移自 B,零向量注入)与 `--emo-text`(QwenEmotion 把「轻快爽朗、自信阳光」转向量,按 ≤0.8 等比缩放并经 `X-Emo-Vector` 回显供固化),服务端新增 `--use-qwen-emo` 与 `/health.supports_emo_text`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 41 个候选小样试听定档 **`sunny`(明快阳光:happy 主载方向 + 有效注入 0.35 + df 0.95,配 `voices/me-bright.wav`)** 取代 `passionate` 成为科普长视频推荐位;并新增其定稿档 **`sunny-steady`(明快稳健)**——同方向同强度同语速、只把束宽提到 3,实测语调起伏 48.4 → 40–44 而亮度不掉(谱质心 1245 → 1214–1223),是唯一"不牺牲明快度就让语气更可信"的旋钮,代价是单句墙钟 20–35 秒 → 56–131 秒(整集 8–10 小时)。并配套 **`--steady` 混合档**(整集跑低束宽、仅冷开场/金句按 `--steady 'P0,p3-25b,p5-*'` 升到 3 束;选择器支持幕名/句 id/前缀通配,任一项匹配不到句子即报错以防拼错后静默降级)与 **`--plan` 排期预演**(纯本地算摘要,输出各束宽待合成/已缓存句数与长跑折算估时)——189 句一集实测:纯 sunny 2.9 h、升 5 句 3.1 h(+7%)、升 20 句 3.6 h(+24%)、整集升档 9.9 h(+241%),即每升 1 句约 +2.2 分钟。为此把**束宽提升为预设的一部分**(`STYLE_PRESETS` 可选键 `beams`,缺省 1;`--num-beams` 显式给值优先,其 argparse 默认值改为 `None` 以区分"没给"与"给了 1"),`--list-styles` 增列"有效注入/束宽"。[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) 同步扩写:§三 增 3.3「样本决定基线」实测表、§四 增「情感三来源」对照与强度调参带(0.3–0.45 为自然平衡带)、§五 拆 5.1 小样试听/5.2 全量/5.3 重渲染并附纯 `curl` 直调、§六 摘要公式补 `|emoref=` / `|emotext=` 可选后缀(对已上线三集 189 句逐句核对摘要 100% 不变,存量缓存零失效)。 - **自进化系列三集科普视频内容升级与本人音色克隆配音**:三集([《AI 如何自己变强?》](media/self-improving-agents-video/README.md) v3 / [《上线之后,AI 才开始上学》](media/experience-era-agents-video/README.md) v2 / [《会写代码的 AI,开始给自己写代码》](media/self-evolving-coding-agents-video/README.md) v3)统一换用本人音色克隆配音(IndexTTS-2.5 `passionate` 激情风格,me-1.wav 样本经 RMS 预筛裁剪);源论文第二遍重读校准(三集全部断言零事实漂移)+ 官方工程站点信源补充(ep1 312 条收录统计/九篇敲门砖→P5 活地图+卡片墙镜;ep2 经验编译器闭环/Gen3 适应面语义/SIP-Bench 四指标/111 篇×9 章→6-B2 闭环复盘+6-F 清单卡镜;ep3 无站点,SICA 三选择信号+Table 2 斜线规律两句锚定);每集新增 `research/upgrade-2026-08.md` 审计文档(校准审计表+句级 delta+双重校验 delta RISKY/REWRITE=0+G2 验证记录);动画增强(计数器滚动/环形图合拢/双色分拣重排/青紫双速差流光/斜线光带扫掠/检查点卡+四仪表/数值轨迹雷达/流光巡游,全部 skill-06 四红线合规)。 - **科普视频管线新增「激情」配音风格预设与官方工程站点信源补充规范**:`media/pipeline/scripts/tts.py` 的 IndexTTS 风格预设表新增 `passionate`(充满激情与轻快:happy 0.70 主载高唤醒正价 + surprised 0.20 跳跃感 + calm 0.10 锚定咬字,有效和 1.00×0.7=0.70 ≤ 0.8 上限,语速 0.97 护密集技术句清晰度),`--list-styles` 与 [VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §四 同步收录并给出科普长视频推荐位;[skills/01-paper-extraction.md](media/pipeline/skills/01-paper-extraction.md) 扩展「官方工程站点信源补充」纪律——只收事实性内容、逐字引用+URL+访问日期、落 `paper-notes.md` 末尾独立「信源补充」大节并与论文正文物理隔离、严禁下载/嵌入站点图片(概念转文字规格供 Remotion 代码动画重建)、站点信源单集专属不跨集。 - **IndexTTS 推理束搜索宽度旋钮(`--num-beams`,长跑提速主开关)**:服务端 `tts_server.py` 请求模型与推理透传 num_beams(1–5),客户端 `tts.py` 新增 `--num-beams`(默认 1);根因为 MPS fp32 实测每句墙钟 RTF 40–58(上游库内部 num_beams=3 使 GPT 段耗时按束宽线性放大),降为 1 束后 RTF≈12–15(采样生成下听感差异可忽略);[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §4.3b 沉淀实测数据并修正原「每句 5–30 秒」的失实估计(整集实际数小时,需 nohup 后台+断点续跑)。 diff --git a/media/pipeline/README.md b/media/pipeline/README.md index 4516e259..d5676f3a 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -54,9 +54,9 @@ media/-video/ | 脚本 | 用途 | 工程内等价调用 | | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | [scripts/build_narration.py](./scripts/build_narration.py) | narration.md → narration.json + 时长估算 | `uv run --no-project scripts/build_narration.py` | -| [scripts/tts.py](./scripts/tts.py) | 逐句配音合成 + 时长 manifest(幂等,双引擎:edge 预置音色 / indextts 声音克隆,风格含 passionate 激情 / lively 轻快等) | `uv run --no-project --with edge-tts --with mutagen scripts/tts.py`(克隆模式免 edge-tts,见 [VOICE-CLONING.md](./VOICE-CLONING.md)) | +| [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/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/qa_frames.py](./scripts/qa_frames.py) | 按句 id 抽帧视觉 QA | `uv run --no-project scripts/qa_frames.py out/draft.mp4 --scene P2` | diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index 2b72402c..fde4c414 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -184,7 +184,9 @@ uv run --no-project --with soundfile --with numpy media/pipeline/scripts/prospec ### 4.3b 束搜索宽度(--num-beams,速度主旋钮) -GPT 声码段的束搜索宽度,**缺省随风格**(多数预设 1、`sunny-steady` 为 3;上游库内部默认 3)。采样生成(`do_sample=True`)下 1 与 3 的听感差异可忽略,但 GPT 段耗时约按束宽线性放大。**MPS fp32 实测**(2026-08-18,M3 系列):每句墙钟 = GPT 束搜索 + 扩散声码 + BigVGAN,RTF(耗时/音频时长)约 40–58(beams=3);beams=1 下三集全量连续跑(596 句 / 40.2 分钟纯语音 / 8.5 小时墙钟)折算整集 RTF≈12–14;整集(约 180–230 句)约 2.5–3.5 小时,按句缓存可断点续跑。质量敏感的单句可 `--num-beams 3` 单独重合成,整集定稿可直接用 `--style sunny-steady`(束宽 3 已固化进预设)。**束宽也是韵律稳定度旋钮**,不只是速度旋钮:3 束把语调起伏收窄约 10%(48.4 → 43.5),听感更"稳/可信",见 §4.1。 +GPT 声码段的束搜索宽度,**缺省随风格**(多数预设 1、`sunny-steady` 为 3;上游库内部默认 3)。采样生成(`do_sample=True`)下 1 与 3 的听感差异可忽略,但 GPT 段耗时约按束宽线性放大。**MPS fp32 实测**(2026-08-18,M3 系列):每句墙钟 = 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。固定开销(条件提取、25 步扩散、BigVGAN)被长音频摊薄了。数字密集句最贵(`2026 年 6 月…88 页` 一句 5.87 秒音频烧了 185 秒,RTF 31.5),因为数字会被文本归一展开成口语形式、token 数暴涨。**逐字稿为字幕可读性把句子都拆到 ≤43 字,正好落在最贵区间**,排期请按短句口径留余量。 ### 4.4 调参建议 @@ -298,12 +300,49 @@ afplay .temp/voice-samples/curl.mp3 ```bash cd media/<工程> # 工程内薄包装等价于中心脚本;风格取 5.1 试听定稿的那一档 + +# 0) 先看计划(纯本地计算,不连服务、不合成):各束宽多少句、缓存命中多少、大致要跑多久 +uv run --no-project --with mutagen scripts/tts.py --engine indextts \ + --ref <绝对路径>/media/pipeline/voices/me-bright.wav --style sunny --plan + +# 1) 全量合成 uv run --no-project --with mutagen scripts/tts.py --engine indextts \ --ref <绝对路径>/media/pipeline/voices/me-bright.wav --style sunny ``` +**`--plan` 是长跑前的必经一步**:它逐句算摘要并与 sidecar 比对,告诉你「真正要合成几句」——改了几行稿子后重跑,往往只有那几句是 miss,不必按整集排期。 + +#### 5.2.1 混合档:整集 1 束 + 关键句 3 束(`--steady`) + +3 束(`sunny-steady`)韵律更稳但整集要 9.9 小时,而真正决定第一印象的只是冷开场与各幕金句。`--steady` 让这些句子单独升档,其余仍按风格的束宽跑: + +```bash +uv run --no-project --with mutagen scripts/tts.py --engine indextts \ + --ref <绝对路径>/media/pipeline/voices/me-bright.wav --style sunny \ + --steady 'P0,p3-25b,p5-01' [--steady-beams 3] --plan # 先 --plan 核对命中句数,再去掉 --plan 实跑 +``` + +选择器语法(逗号分隔、大小写不敏感、**任一项匹配不到句子直接报错**,避免拼错后静默按低束宽跑完): + +| 写法 | 含义 | +|---|---| +| `P0` | 整幕(匹配 `narration.json` 的 `scene`) | +| `p3-25b` | 单句(含 `-` 即视为句 id) | +| `p5-*` | 前缀通配(该幕内 `p5-` 开头的全部句子) | + +**代价按句线性**(189 句一集、长跑折算口径,`--plan` 输出): + +| 方案 | 3 束句数 | 估算墙钟 | 相对纯 sunny | +|---|---|---|---| +| `--style sunny` | 0 | **2.9 h** | — | +| `--style sunny --steady 'p0-01,p0-02,p0-03,p3-25b,p5-01'` | 5 | 3.1 h | +7% | +| `--style sunny --steady 'P0,p3-25b,p5-01'` | 20 | 3.6 h | +24% | +| `--style sunny-steady`(整集升档) | 189 | **9.9 h** | +241% | + +即**每升 1 句约 +2.2 分钟(+1.3%)**——升十几句买到关键处的稳定度是划算的,整集升档则不划算。缓存 sidecar 按句独立(摘要含 `|beams=N`),故混用两种束宽完全安全,也可以先全集跑 `sunny`、事后再补 `--steady 'P0'` 只重合成那几句。 + - 服务启动一次可服务多集;管线客户端不常驻模型; -- 每句墙钟与文本长度及束宽相关:MPS fp32 实测 RTF≈40–58(3 束,即 `sunny-steady`)/ ≈12–14(1 束,如 `sunny`)。**1 束整集(约 180–230 句)约 2.5–3.5 小时;3 束按口径要 8–10 小时**——请据此选档并 `nohup` 挂后台跑,按句缓存可断点续跑(见 §六); +- 每句墙钟与文本长度及束宽相关:长跑折算 RTF≈45(3 束)/ ≈13(1 束)。**1 束整集(约 180–230 句)约 2.5–3.5 小时;3 束整集约 9–10 小时**(`--plan` 会按这两个口径给出估算)——请据此选档并 `nohup` 挂后台跑,按句缓存可断点续跑(见 §六); - 超长句(>120 token)服务端内部自动分段;极端长句推理可达数分钟。客户端并发为 1(与服务端串行推理对齐,避免排队时间计入超时),HTTP 超时 600s;万一超时——重跑即续传,无需干预。 ### 5.3 合成后重渲染 diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 99e89d88..998f3c1f 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -45,6 +45,13 @@ HTTP_TIMEOUT = 600 # MPS fp32 长句可达数分钟;须覆盖队列等待 MANUAL = "media/pipeline/VOICE-CLONING.md" +# --plan 排期估算用的实测常数(MPS fp32,长跑折算口径:含降频、机器争用与逐句开销)。 +# RTF_1BEAM 来自三集 596 句连续跑 8.5 小时 / 40.2 分钟纯语音;RTF_MULTIBEAM 由同句 +# 1↔3 束 A/B 实测的约 3.2 倍推得(与早期 3 束直测的 RTF 40–58 区间一致)。 +RTF_1BEAM = 13.0 +RTF_MULTIBEAM = 45.0 +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 不自动归一,双端校验)。 EMO_KEYS = [ @@ -60,7 +67,8 @@ # 风格预设 —— 数值为初值,可实测试听后微调。 # 可选键 "beams":预设自带的束搜索宽度(缺省 1)。束宽会改变韵律稳定度,属风格的一部分, -# 故允许写进预设;命令行 --num-beams 显式给值时优先。注意束宽 3 使整集墙钟约 ×3。 +# 故允许写进预设;命令行 --num-beams 显式给值时优先。注意束宽 3 使整集墙钟约 ×3.4 +#(长跑折算 RTF 13→45),若只想让关键句更稳,用 --steady 混合档而非整集升档。 STYLE_PRESETS: dict[str, dict] = { "neutral": {"label": "中性", "vec": None, "alpha": 1.0, "df": 1.0}, "passionate": { @@ -106,8 +114,8 @@ # = sunny 同方向同强度同语速,只把束宽提到 3:GPT 段搜索更宽 → 韵律更收敛。 # 实测(同文本同样本):语调起伏 48.4 → 43.5、音节率 4.10 → 4.55,亮度基本不掉 # (质心 1245 → 1223)——是唯一「不牺牲明快度就让语气更稳」的旋钮。 - # 代价:GPT 段耗时约按束宽线性放大,**整集墙钟约 ×3**(见 VOICE-CLONING.md §4.3b), - # 适合成片定稿或质量敏感段落;长篇批量赶工仍用 sunny。 + # 代价:GPT 段耗时约按束宽线性放大,**整集墙钟约 ×3.4**(189 句估算 2.9→9.9 小时, + # 见 VOICE-CLONING.md §4.3b)。想只让关键句变稳请用 `--steady` 混合档,别整集升档。 "vec": [0.95, 0, 0, 0, 0, 0, 0.02, 0.03], "alpha": 0.35, "df": 0.95, @@ -176,6 +184,48 @@ def resolve_style( return args.style, preset["vec"], alpha, df, beams +# ---------------- 混合档:整集低束宽 + 指定句高束宽 ---------------- +# +# 动机(实测):3 束把语调起伏收窄约 10–20%、听感更「稳/可信」,但短句 RTF 从 6–7 涨到 +# 20–31(数字密集句可达 31.5),整集从 2.5–3.5 小时涨到 8–15 小时。而真正决定第一印象的 +# 只是冷开场与各幕金句——把这几句单独升到 3 束即可。代价按句线性:189 句一集里每升 1 句 +# 约 +2.2 分钟(+1.3%),升 5 句 2.9→3.1 小时、升 20 句 →3.6 小时,而整集升档要 9.9 小时。 +# 缓存 sidecar 按句独立(摘要含 |beams=N),故同一集内混用两种束宽完全安全、可分批补跑。 + + +def parse_steady_selector(spec: str) -> tuple[set[str], set[str], list[str]]: + """`P0,p3-25b,p5-*` → (精确句 id, 幕名, 前缀)。 + + 判定规则(可预测、无歧义):以 `*` 结尾→前缀通配;含 `-`→精确句 id;其余→幕名。 + 全部大小写不敏感。 + """ + ids: set[str] = set() + scenes: set[str] = set() + prefixes: list[str] = [] + for raw in spec.split(","): + tok = raw.strip().lower() + if not tok: + continue + if tok.endswith("*"): + prefixes.append(tok[:-1]) + elif "-" in tok: + ids.add(tok) + else: + scenes.add(tok) + if not (ids or scenes or prefixes): + raise ValueError("--steady 不能为空") + return ids, scenes, prefixes + + +def steady_match( + item: dict, ids: set[str], scenes: set[str], prefixes: list[str] +) -> bool: + sid = str(item["id"]).lower() + if sid in ids or str(item.get("scene", "")).lower() in scenes: + return True + return any(sid.startswith(p) for p in prefixes) + + # ---------------- 引擎一:edge-tts(历史路径,保持字节级一致) ---------------- @@ -506,6 +556,26 @@ async def main() -> None: help="[indextts] GPT 束搜索宽度(缺省随风格,多数预设为 1、sunny-steady 为 3;" "束宽越大韵律越稳但 GPT 段耗时约按束宽线性放大——长篇批量跑 1,定稿可试 3)", ) + idx.add_argument( + "--steady", + default=None, + metavar="P0,p3-25b,p5-*", + help="[indextts] 混合档:仅这些句子改用高束宽(默认 3),其余仍按风格的束宽。" + "支持幕名(P0)/精确句 id(p3-25b)/前缀通配(p5-*),逗号分隔、大小写不敏感。" + "用于把冷开场与金句升到更稳的档而不拖长整集耗时", + ) + idx.add_argument( + "--steady-beams", + default=3, + type=int, + choices=[2, 3, 4, 5], + help="[indextts] --steady 命中句所用束宽(默认 3)", + ) + idx.add_argument( + "--plan", + action="store_true", + help="[indextts] 只打印合成计划(各束宽句数、缓存命中/待合成、耗时估算)并退出,不连服务", + ) idx.add_argument( "--engine-tag", default="indextts", @@ -617,6 +687,86 @@ async def main() -> None: if args.duration_factor is not None and not 0.5 <= args.duration_factor <= 2.0: parser.error("--duration-factor 必须在 [0.5, 2.0]") + emo_ref_path = emo_ref_sha1 = None + if args.emo_ref: + p = Path(args.emo_ref).expanduser().resolve() + if not p.is_file(): + parser.error(f"情感参考音频不存在: {p}") + emo_ref_path = str(p) + # 情感样本按内容入摘要:换情感录音必须失效缓存(与 ref 同口径) + emo_ref_sha1 = hashlib.sha1(p.read_bytes()).hexdigest()[:12] + ref_sha1 = hashlib.sha1(ref_path.read_bytes()).hexdigest()[:12] + + # 混合档:解析选择器并逐句定束宽(此处即失败,避免典型的「id 拼错→静默全按低束宽跑完」) + beams_of: dict[str, int] = dict.fromkeys((i["id"] for i in items), beams) + if args.steady: + if args.steady_beams <= beams: + parser.error( + f"--steady-beams {args.steady_beams} 不高于基础束宽 {beams},混合档无意义" + ) + try: + sids, scenes, prefixes = parse_steady_selector(args.steady) + except ValueError as e: + parser.error(str(e)) + for tok in sorted(sids | scenes) + [p + "*" for p in prefixes]: + one_id, one_scene, one_pre = parse_steady_selector(tok) + if not any(steady_match(i, one_id, one_scene, one_pre) for i in items): + parser.error( + f"--steady 的 {tok!r} 未命中任何句子(幕名/句 id 拼错?)" + ) + for i in items: + if steady_match(i, sids, scenes, prefixes): + beams_of[i["id"]] = args.steady_beams + + if args.plan: # 计划模式:纯本地计算,不连服务 + print( + f">> 计划:{root.name} · 风格 {style_name} · alpha {alpha:g} · 语速 {df:g}" + ) + todo = {b: 0 for b in sorted(set(beams_of.values()))} + cached = dict(todo) + for i in items: + b = beams_of[i["id"]] + d = digest_indextts( + ref_sha1, + style_name, + vec, + alpha, + df, + args.lang, + args.engine_tag, + i["text"], + b, + emo_ref_sha1, + args.emo_text, + ) + meta, mp3 = out_dir / f"{i['id']}.sha", out_dir / f"{i['id']}.mp3" + hit = ( + not args.force + and mp3.exists() + and mp3.stat().st_size > 0 + and meta.exists() + and meta.read_text() == d + ) + (cached if hit else todo)[b] += 1 + # 估时用**整集长跑折算口径**(含降频、机器争用与逐句开销),不是单句空闲口径: + # 1 束 RTF≈13(三集 596 句实测 8.5 h 折算)、≥2 束≈45(短句 A/B 实测约 3.2 倍); + # 每句音频按 4.2s(三集均值)。单句空闲时可快到 RTF 6–7,故本估算偏保守。 + est = sum( + n * AVG_SEC_PER_LINE * (RTF_1BEAM if b == 1 else RTF_MULTIBEAM) + for b, n in todo.items() + ) + for b in sorted(todo): + print( + f" 束宽 {b}:待合成 {todo[b]:>3} 句 · 已缓存 {cached[b]:>3} 句" + + ("" if b == 1 else "(高束宽档)") + ) + print( + f">> 待合成合计 {sum(todo.values())} 句,估算墙钟约 {est / 3600:.1f} 小时" + f"(长跑折算口径 RTF 1 束≈{RTF_1BEAM:g} / 高束宽≈{RTF_MULTIBEAM:g}," + f"机器负载会显著影响,仅作排期参考)" + ) + return + try: health = await asyncio.to_thread( http_json, "GET", f"{args.server}/health", None, 10 @@ -644,16 +794,6 @@ async def main() -> None: + MANUAL ) - emo_ref_path = emo_ref_sha1 = None - if args.emo_ref: - p = Path(args.emo_ref).expanduser().resolve() - if not p.is_file(): - parser.error(f"情感参考音频不存在: {p}") - emo_ref_path = str(p) - # 情感样本按内容入摘要:换情感录音必须失效缓存(与 ref 同口径) - emo_ref_sha1 = hashlib.sha1(p.read_bytes()).hexdigest()[:12] - - ref_sha1 = hashlib.sha1(ref_path.read_bytes()).hexdigest()[:12] sem = asyncio.Semaphore(CONCURRENCY_INDEXTTS) results = await asyncio.gather( *( @@ -671,7 +811,8 @@ async def main() -> None: args.engine_tag, args.server, out_dir, - num_beams=beams, # 已含「命令行优先、否则取预设」的解析结果 + # 逐句束宽:基础值来自「命令行优先、否则取预设」,--steady 命中句再提高 + num_beams=beams_of[i["id"]], emo_ref=emo_ref_path, emo_ref_sha1=emo_ref_sha1, emo_text=args.emo_text, @@ -686,6 +827,12 @@ async def main() -> None: ) total = sum(r["durationSec"] for r in results) print(f"合成 {len(results)} 句,纯语音总时长 {total / 60:.2f} 分钟") + if args.engine == "indextts" and args.steady: # 混合档:回执两档各多少句,便于对账 + hi = sum(1 for i in items if beams_of[i["id"]] != beams) + print( + f"混合档:{len(items) - hi} 句按束宽 {beams}({style_name})+ " + f"{hi} 句按束宽 {args.steady_beams}(--steady {args.steady})" + ) print(f"manifest: {manifest_path}") From 0ca5f737a190c10c2e245e1091bae63aa87a9306 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 15:16:12 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(tts):=20=E4=BF=AE=E5=A4=8D=E5=B0=8F?= =?UTF-8?q?=E6=A0=B7/=E7=AE=A1=E7=BA=BF=E5=9B=9B=E5=A4=84=E9=9D=99?= =?UTF-8?q?=E9=BB=98=E9=99=8D=E7=BA=A7=E2=80=94=E2=80=94edge=20=E5=88=86?= =?UTF-8?q?=E6=94=AF=20--plan=20=E7=A1=AC=E5=A4=B1=E8=B4=A5=E3=80=81?= =?UTF-8?q?=E6=83=85=E6=84=9F=E6=9D=A5=E6=BA=90=E4=BA=92=E6=96=A5=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 评审发现四处「参数被静默丢弃」,均会让用户以为跑了 A 实际跑了 B: 1. `tts.py` edge 分支未处理 `--plan`/`--steady`/`--steady-beams`。`--engine` 缺省是 edge,漏写 `--engine indextts` 时 `--plan` 会跳过计划模式直接走 `synth_edge` 全量合成;两引擎摘要必然不同且 `{id}.mp3` 是单槽位,会把整集 已合成的克隆音频逐句改写成 edge 预置音色。`--plan` 语义是「只看不跑」,故 改为直接 `parser.error`;`--steady`/`--steady-beams` 按既有约定进「已忽略」提示表。 2. `tts_sample.py` 的 `--style` 互斥只查 `--emo-vector`,漏了同批新增的 `--emo-ref`/`--emo-text`:`--style sunny --emo-text "..."` 会静默丢弃 sunny (连带丢掉预设的 alpha 0.35 与 df 0.95),而 `tts.py` 对同组合是硬报错。 试听工具的用途正是判定「哪档风格合适」,静默换档直接误导选档结论。 3. `--all-styles` 的手动覆盖拦截表漏了 `--num-beams`,`build_jobs()` 又把它硬写为 None,`--all-styles --num-beams 1` 被静默忽略。统一压成 1 会让 sunny 与 sunny-steady 产出完全相同的音频(两档只差束宽),A/B 失去意义,故与 alpha/语速同口径拒绝。 4. 「下一步全量合成」回显命令在 `--emo-ref`/`--emo-text` 模式下打印 `--style emoref`,而 `tts.py --style` 的 choices 无此取值,照抄必然 argparse 报错、真正生效的开关也丢失。改为回显各自开关,并把显式给的 alpha/语速/束宽一并带上,避免全量合成时悄悄退回预设值。 验证:edge+--plan 硬报错、edge+--steady 进提示表、indextts+--plan/--steady 功能不变 (189 句中 19 句升档);三组 `--style X + 情感来源` 均报错而合法单用仍通过; `--all-styles --num-beams` 报错、单用仍 7 档末档 3 束、非 all-styles 时显式束宽仍 压过预设;`--emo-ref` 实跑小样回显的命令原样喂回 `tts.py` 可正常解析执行。 已上线三集 596 句摘要逐句比对零变化(存量缓存零失效),ruff check 通过。 🤖 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 | 9 +++++++ media/pipeline/scripts/tts_sample.py | 40 +++++++++++++++++++--------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 998f3c1f..1b65a6c5 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -603,6 +603,13 @@ async def main() -> None: return if args.engine == "edge": + # --plan 语义是「只看不跑」,而 edge 无束宽/无估时口径。若沿用「提示后照跑」的处理, + # 漏写 --engine indextts 时会静默全量合成,把整集克隆音频改写成 edge 预置音色 + #(两引擎摘要必然不同,且 {id}.mp3 单槽位),故此处硬失败而非忽略。 + if args.plan: + parser.error( + "--plan 仅对 --engine indextts 生效(是否漏写 --engine indextts?)" + ) ignored = [ flag for flag, val in { @@ -613,6 +620,8 @@ async def main() -> None: "--emo-alpha": args.emo_alpha, "--duration-factor": args.duration_factor, "--num-beams": args.num_beams is not None, + "--steady": args.steady, + "--steady-beams": args.steady_beams != 3, "--server": args.server != "http://127.0.0.1:8766", "--style": args.style != "neutral", "--lang": args.lang != "ZH", diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py index 8a2025ae..a384c38d 100644 --- a/media/pipeline/scripts/tts_sample.py +++ b/media/pipeline/scripts/tts_sample.py @@ -222,7 +222,8 @@ def main() -> None: parser.add_argument( "--all-styles", action="store_true", - help="逐档合成全部风格预设做 A/B(与 --emo-vector/--emo-alpha/--duration-factor 互斥)", + help="逐档合成全部风格预设做 A/B(各档取自带的 alpha/语速/束宽," + "故与 --emo-vector/--emo-alpha/--duration-factor/--num-beams 互斥)", ) parser.add_argument( "--emo-vector", @@ -252,8 +253,10 @@ def main() -> None: default=None, type=int, choices=[1, 2, 3, 4, 5], - help="GPT 束搜索宽度(缺省随风格:多数预设 1、sunny-steady 3);越大韵律越稳但耗时" - "约按束宽线性放大,见 " + MANUAL + " §4.3b", + help="GPT 束搜索宽度(缺省随风格:多数预设 1、sunny-steady 3;显式给值压过预设," + "但与 --all-styles 互斥);越大韵律越稳但耗时约按束宽线性放大,见 " + + MANUAL + + " §4.3b", ) parser.add_argument("--server", default="http://127.0.0.1:8766", help="服务地址") parser.add_argument( @@ -297,21 +300,25 @@ def main() -> None: parser.error(f"情感参考音频不存在: {p}") args.emo_ref = str(p) # 绝对路径:服务端按自身文件系统解析 if args.all_styles: + # 束宽同为「预设自带、A/B 要如实呈现」的一项:统一压成 1 会让 sunny 与 sunny-steady + # 产出完全相同的音频(两档只差束宽),A/B 失去意义,故与 alpha/语速同口径拒绝 overrides = [ flag for flag, val in { "--emo-vector": args.emo_vector, "--emo-alpha": args.emo_alpha, "--duration-factor": args.duration_factor, + "--num-beams": args.num_beams, }.items() if val is not None ] if overrides: parser.error( - f"--all-styles 会逐档取各预设自带的 alpha/语速,不能与 {' '.join(overrides)} 同用" + f"--all-styles 会逐档取各预设自带的 alpha/语速/束宽,不能与 {' '.join(overrides)} 同用" ) - if args.style != "neutral" and args.emo_vector: - parser.error("--style 非默认值与 --emo-vector 互斥") + # 与 tts.py 同口径:风格本身即一条情感通路,故与 --emo-vector/--emo-ref/--emo-text 互斥 + if args.style != "neutral" and emo_sources: + parser.error(f"--style 非默认值与 {emo_sources[0]} 互斥") if args.emo_alpha is not None and not 0.0 <= args.emo_alpha <= 1.0: parser.error("--emo-alpha 必须在 [0, 1]") if args.duration_factor is not None and not 0.5 <= args.duration_factor <= 2.0: @@ -403,16 +410,23 @@ def main() -> None: print(f"\n完成 {len(results)} 档,总墙钟 {total_wall / 60:.1f} 分钟") if args.play: play(results) - if ( - len(results) == 1 and results[0]["style"] == "raw" - ): # 手动向量:回显向量而非不存在的 --style raw + # raw / emoref / emotext 都是本脚本内部的档名,并非 tts.py `--style` 的合法取值 + #(其 choices=list(STYLE_PRESETS)),故须回显各自的开关,否则这条命令照抄即 argparse 报错 + if len(results) == 1 and results[0]["style"] == "raw": chosen = f'--emo-vector "{args.emo_vector}"' - if args.emo_alpha is not None: - chosen += f" --emo-alpha {args.emo_alpha:g}" - if args.duration_factor is not None: - chosen += f" --duration-factor {args.duration_factor:g}" + elif args.emo_ref: + chosen = f"--emo-ref {args.emo_ref}" + elif args.emo_text: + chosen = f'--emo-text "{args.emo_text}"' else: chosen = f"--style {results[0]['style'] if len(results) == 1 else '<选定风格>'}" + for flag, val in ( # 显式给的覆盖值一并带上,否则全量合成会悄悄退回预设值 + ("--emo-alpha", args.emo_alpha), + ("--duration-factor", args.duration_factor), + ("--num-beams", args.num_beams), + ): + if val is not None: + chosen += f" {flag} {val:g}" print( f"\n下一步 · 选定风格后全量合成一集:\n" f" cd media/<工程> && uv run --no-project --with mutagen scripts/tts.py \\\n" From a2577de8d8347f934d49c6bab385aba4c6d838e1 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 15:16:31 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(voice-cloning):=20=E6=A0=A1=E5=87=86?= =?UTF-8?q?=20--all-styles=20=E6=A1=A3=E6=95=B0=E4=B8=8E=E8=80=97=E6=97=B6?= =?UTF-8?q?=E5=8F=A3=E5=BE=84=EF=BC=8C=E5=90=8C=E6=AD=A5=E4=BA=92=E6=96=A5?= =?UTF-8?q?=E7=BA=A6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--all-styles` 遍历 `STYLE_PRESETS` 全表,加入 sunny/sunny-steady 后实际是 7 档, 而 §5.1 仍写旧的 6 档且顺序不符;「耗时实测」表的「五风格 A/B 全跑 2.2 分钟」 同步失真——该表标注 num_beams=1,而 sunny-steady 自带束宽 3。 - §5.1 步骤 4 改为按 `STYLE_PRESETS` 顺序列出 7 档(neutral→…→sunny-steady); - 耗时表以本次实测替换失真行:全 7 档 A/B 合计音频 46.2s / 墙钟 6.3 分钟;条件 标注下沉到行内(前两行机器空闲、A/B 行机器有其它负载),不再由表头统一声称空闲; - 并入「口径提醒」:该次 A/B 单档墙钟散布 37.7–86.6 秒(最慢者为该服务会话内首次 用该样本的那档),其中 3 束的 sunny-steady 只用 38.6 秒、并未比 1 束档更慢——单句 墙钟受机器负载支配,不足以据单次样本推断束宽代价,整集排期一律以 §4.3b 长跑折算 口径为准(避免与 §4.1 的 56–131 秒区间互相拆台); - 开关表同步 fix 提交的约束:`--all-styles` 与 `--emo-vector`/`--emo-alpha`/ `--duration-factor`/`--num-beams` 互斥(否则 sunny 与 sunny-steady 会产出完全 相同的音频),`--dry-run` 打印项补「束宽」; - CHANGELOG 的「`--all-styles` 五档 A/B」改为「全预设 A/B」,不再随预设增减失真。 🤖 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 --- CHANGELOG.md | 2 +- media/pipeline/VOICE-CLONING.md | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a7d8829..1bbe37ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ ### Added -- **声音克隆试听闭环:单句小样 + 情感三来源 + 样本选段勘探 + `sunny` 明快阳光预设**:此前管线只有工程级入口 `tts.py`(强依赖 `narration.json`),选样本/选风格却要等 2.5–3.5 小时的整集合成——新增 [`scripts/tts_sample.py`](media/pipeline/scripts/tts_sample.py) 直调服务合成单句(`--style` / `--all-styles` 五档 A/B / `--dry-run` 秒级核参 / `--label` 横向对比 / `--play` 顺序试听),复用 `tts.py` 的风格预设与文本预处理故与成片同路径,单档暖机后约 20 秒。针对「五档预设都不够自然」的实测复盘,定位两个根因并各给工具:①**样本定基线**——新增 [`scripts/prospect_ref.py`](media/pipeline/scripts/prospect_ref.py) 按 F0 中位/四分位距/音节率/谱质心筛「更亮更轻快」的候选起点(成片原用样本综合分仅 157/275,换段即让克隆音高 +12~16%、起伏 +25~40%);②**注入越多越假**——由上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 推出「Σw 就是合成情感挤掉真实语调的比例」,故新增两条自然通路:`--emo-ref`(音色取 A、语调迁移自 B,零向量注入)与 `--emo-text`(QwenEmotion 把「轻快爽朗、自信阳光」转向量,按 ≤0.8 等比缩放并经 `X-Emo-Vector` 回显供固化),服务端新增 `--use-qwen-emo` 与 `/health.supports_emo_text`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 41 个候选小样试听定档 **`sunny`(明快阳光:happy 主载方向 + 有效注入 0.35 + df 0.95,配 `voices/me-bright.wav`)** 取代 `passionate` 成为科普长视频推荐位;并新增其定稿档 **`sunny-steady`(明快稳健)**——同方向同强度同语速、只把束宽提到 3,实测语调起伏 48.4 → 40–44 而亮度不掉(谱质心 1245 → 1214–1223),是唯一"不牺牲明快度就让语气更可信"的旋钮,代价是单句墙钟 20–35 秒 → 56–131 秒(整集 8–10 小时)。并配套 **`--steady` 混合档**(整集跑低束宽、仅冷开场/金句按 `--steady 'P0,p3-25b,p5-*'` 升到 3 束;选择器支持幕名/句 id/前缀通配,任一项匹配不到句子即报错以防拼错后静默降级)与 **`--plan` 排期预演**(纯本地算摘要,输出各束宽待合成/已缓存句数与长跑折算估时)——189 句一集实测:纯 sunny 2.9 h、升 5 句 3.1 h(+7%)、升 20 句 3.6 h(+24%)、整集升档 9.9 h(+241%),即每升 1 句约 +2.2 分钟。为此把**束宽提升为预设的一部分**(`STYLE_PRESETS` 可选键 `beams`,缺省 1;`--num-beams` 显式给值优先,其 argparse 默认值改为 `None` 以区分"没给"与"给了 1"),`--list-styles` 增列"有效注入/束宽"。[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) 同步扩写:§三 增 3.3「样本决定基线」实测表、§四 增「情感三来源」对照与强度调参带(0.3–0.45 为自然平衡带)、§五 拆 5.1 小样试听/5.2 全量/5.3 重渲染并附纯 `curl` 直调、§六 摘要公式补 `|emoref=` / `|emotext=` 可选后缀(对已上线三集 189 句逐句核对摘要 100% 不变,存量缓存零失效)。 +- **声音克隆试听闭环:单句小样 + 情感三来源 + 样本选段勘探 + `sunny` 明快阳光预设**:此前管线只有工程级入口 `tts.py`(强依赖 `narration.json`),选样本/选风格却要等 2.5–3.5 小时的整集合成——新增 [`scripts/tts_sample.py`](media/pipeline/scripts/tts_sample.py) 直调服务合成单句(`--style` / `--all-styles` 全预设 A/B / `--dry-run` 秒级核参 / `--label` 横向对比 / `--play` 顺序试听),复用 `tts.py` 的风格预设与文本预处理故与成片同路径,单档暖机后约 20 秒。针对「五档预设都不够自然」的实测复盘,定位两个根因并各给工具:①**样本定基线**——新增 [`scripts/prospect_ref.py`](media/pipeline/scripts/prospect_ref.py) 按 F0 中位/四分位距/音节率/谱质心筛「更亮更轻快」的候选起点(成片原用样本综合分仅 157/275,换段即让克隆音高 +12~16%、起伏 +25~40%);②**注入越多越假**——由上游混合式 `emovec = Σ(wᵢ·基向量ᵢ) + (1 − Σwᵢ)·参考音频情感` 推出「Σw 就是合成情感挤掉真实语调的比例」,故新增两条自然通路:`--emo-ref`(音色取 A、语调迁移自 B,零向量注入)与 `--emo-text`(QwenEmotion 把「轻快爽朗、自信阳光」转向量,按 ≤0.8 等比缩放并经 `X-Emo-Vector` 回显供固化),服务端新增 `--use-qwen-emo` 与 `/health.supports_emo_text`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 41 个候选小样试听定档 **`sunny`(明快阳光:happy 主载方向 + 有效注入 0.35 + df 0.95,配 `voices/me-bright.wav`)** 取代 `passionate` 成为科普长视频推荐位;并新增其定稿档 **`sunny-steady`(明快稳健)**——同方向同强度同语速、只把束宽提到 3,实测语调起伏 48.4 → 40–44 而亮度不掉(谱质心 1245 → 1214–1223),是唯一"不牺牲明快度就让语气更可信"的旋钮,代价是单句墙钟 20–35 秒 → 56–131 秒(整集 8–10 小时)。并配套 **`--steady` 混合档**(整集跑低束宽、仅冷开场/金句按 `--steady 'P0,p3-25b,p5-*'` 升到 3 束;选择器支持幕名/句 id/前缀通配,任一项匹配不到句子即报错以防拼错后静默降级)与 **`--plan` 排期预演**(纯本地算摘要,输出各束宽待合成/已缓存句数与长跑折算估时)——189 句一集实测:纯 sunny 2.9 h、升 5 句 3.1 h(+7%)、升 20 句 3.6 h(+24%)、整集升档 9.9 h(+241%),即每升 1 句约 +2.2 分钟。为此把**束宽提升为预设的一部分**(`STYLE_PRESETS` 可选键 `beams`,缺省 1;`--num-beams` 显式给值优先,其 argparse 默认值改为 `None` 以区分"没给"与"给了 1"),`--list-styles` 增列"有效注入/束宽"。[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) 同步扩写:§三 增 3.3「样本决定基线」实测表、§四 增「情感三来源」对照与强度调参带(0.3–0.45 为自然平衡带)、§五 拆 5.1 小样试听/5.2 全量/5.3 重渲染并附纯 `curl` 直调、§六 摘要公式补 `|emoref=` / `|emotext=` 可选后缀(对已上线三集 189 句逐句核对摘要 100% 不变,存量缓存零失效)。 - **自进化系列三集科普视频内容升级与本人音色克隆配音**:三集([《AI 如何自己变强?》](media/self-improving-agents-video/README.md) v3 / [《上线之后,AI 才开始上学》](media/experience-era-agents-video/README.md) v2 / [《会写代码的 AI,开始给自己写代码》](media/self-evolving-coding-agents-video/README.md) v3)统一换用本人音色克隆配音(IndexTTS-2.5 `passionate` 激情风格,me-1.wav 样本经 RMS 预筛裁剪);源论文第二遍重读校准(三集全部断言零事实漂移)+ 官方工程站点信源补充(ep1 312 条收录统计/九篇敲门砖→P5 活地图+卡片墙镜;ep2 经验编译器闭环/Gen3 适应面语义/SIP-Bench 四指标/111 篇×9 章→6-B2 闭环复盘+6-F 清单卡镜;ep3 无站点,SICA 三选择信号+Table 2 斜线规律两句锚定);每集新增 `research/upgrade-2026-08.md` 审计文档(校准审计表+句级 delta+双重校验 delta RISKY/REWRITE=0+G2 验证记录);动画增强(计数器滚动/环形图合拢/双色分拣重排/青紫双速差流光/斜线光带扫掠/检查点卡+四仪表/数值轨迹雷达/流光巡游,全部 skill-06 四红线合规)。 - **科普视频管线新增「激情」配音风格预设与官方工程站点信源补充规范**:`media/pipeline/scripts/tts.py` 的 IndexTTS 风格预设表新增 `passionate`(充满激情与轻快:happy 0.70 主载高唤醒正价 + surprised 0.20 跳跃感 + calm 0.10 锚定咬字,有效和 1.00×0.7=0.70 ≤ 0.8 上限,语速 0.97 护密集技术句清晰度),`--list-styles` 与 [VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §四 同步收录并给出科普长视频推荐位;[skills/01-paper-extraction.md](media/pipeline/skills/01-paper-extraction.md) 扩展「官方工程站点信源补充」纪律——只收事实性内容、逐字引用+URL+访问日期、落 `paper-notes.md` 末尾独立「信源补充」大节并与论文正文物理隔离、严禁下载/嵌入站点图片(概念转文字规格供 Remotion 代码动画重建)、站点信源单集专属不跨集。 - **IndexTTS 推理束搜索宽度旋钮(`--num-beams`,长跑提速主开关)**:服务端 `tts_server.py` 请求模型与推理透传 num_beams(1–5),客户端 `tts.py` 新增 `--num-beams`(默认 1);根因为 MPS fp32 实测每句墙钟 RTF 40–58(上游库内部 num_beams=3 使 GPT 段耗时按束宽线性放大),降为 1 束后 RTF≈12–15(采样生成下听感差异可忽略);[VOICE-CLONING.md](media/pipeline/VOICE-CLONING.md) §4.3b 沉淀实测数据并修正原「每句 5–30 秒」的失实估计(整集实际数小时,需 nohup 后台+断点续跑)。 diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index fde4c414..4d92dfb1 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -221,7 +221,8 @@ curl -s http://127.0.0.1:8766/health uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ --ref media/pipeline/voices/me-bright.wav --style sunny --play -# 4) 全风格 A/B:sunny→neutral→passionate→lively→confident→positive 各一遍,顺序试听择优 +# 4) 全风格 A/B:7 档预设按 STYLE_PRESETS 顺序各一遍,顺序试听择优 +# neutral→passionate→lively→confident→positive→sunny→sunny-steady(末档自带 3 束,耗时见下表) uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ --ref media/pipeline/voices/me-bright.wav --all-styles --play @@ -243,24 +244,24 @@ uv run --no-project --with mutagen media/pipeline/scripts/tts_sample.py \ | 开关 | 作用 | 默认 | |---|---|---| | `--text` / `--text-file` | 试听文本(建议 20–40 字,带数字/术语更易暴露咬字问题) | 内置一句科普文本 | -| `--style` / `--all-styles` | 单档 / 全部预设 A/B(`--all-styles` 逐档取预设自带 alpha 与语速,故与下一行三参数互斥) | `neutral` | +| `--style` / `--all-styles` | 单档 / 全部 7 档预设 A/B(`--all-styles` 逐档取预设自带的 alpha/语速/束宽,故与 `--emo-vector` `--emo-alpha` `--duration-factor` `--num-beams` 互斥) | `neutral` | | `--emo-vector` `--emo-alpha` `--duration-factor` | 手动调参,语义与取值范围同 §四 | 随风格 | | `--emo-ref <录音>` | 语调迁移:音色仍取 `--ref`,语气来自这段录音(见 §四) | 关 | | `--emo-text "<描述>"` | 自然语言描述情感(需服务端 `--use-qwen-emo`);推出的向量会打印,可用 `--emo-vector` 固化 | 关 | -| `--num-beams` | 束宽;越大韵律越稳、耗时约按束宽线性放大(见 §4.3b)。显式给值会压过预设 | 随风格(`sunny-steady` 为 3,其余 1) | -| `--dry-run` | 只解析并打印各档向量/alpha/语速,不连服务(秒级核参,改风格后先跑这个) | 关 | +| `--num-beams` | 束宽;越大韵律越稳、耗时约按束宽线性放大(见 §4.3b)。显式给值会压过预设,但与 `--all-styles` 互斥(否则 sunny 与 sunny-steady 会产出完全相同的音频) | 随风格(`sunny-steady` 为 3,其余 1) | +| `--dry-run` | 只解析并打印各档向量/alpha/语速/束宽,不连服务(秒级核参,改风格后先跑这个) | 关 | | `--label` | 产物文件名(多档时作前缀 `{label}-{风格}`)——**横向对比多个参考样本或多组自定义向量时必用**,否则同名互相覆盖 | 取风格名 | | `--play` / `--out-dir` | 合成后 `afplay` 顺序试听 / 产物目录 | 关 / `.temp/voice-samples/` | -**耗时实测**(2026-08-19 · M3 系列 · MPS fp32 · `num_beams=1` · 上述 34 字文本,机器空闲) +**耗时实测**(2026-08-19 · M3 系列 · MPS fp32 · 上述 34 字文本) | 环节 | 音频时长 | 墙钟 | RTF | |---|---|---|---| -| 单档 · 首档(含服务暖机) | 6.86s | 47.0s | 6.8 | -| 单档 · 暖机后 | 6.0–6.9s | 20–22s | 3.2–3.4 | -| 五风格 A/B 全跑 | 合计 32.3s | **2.2 分钟** | — | +| 单档 · 首档(含服务暖机,1 束,机器空闲) | 6.86s | 47.0s | 6.8 | +| 单档 · 暖机后(1 束,机器空闲) | 6.0–6.9s | 20–22s | 3.2–3.4 | +| 全 7 档 A/B(`--all-styles`,含 sunny-steady 的 3 束档,机器有其它负载) | 合计 46.2s | **6.3 分钟** | — | -> **口径提醒**:小样 RTF(≈3.3)与 §4.3b 整集折算 RTF(≈12–14)测的不是同一件事——前者是暖机后、机器空闲、单句;后者含数小时长跑的降频、机器争用与逐句开销。**小样耗时不可线性外推到整集**,整集排期仍按 §4.3b。 +> **口径提醒**:小样 RTF(暖机后、机器空闲、单句,≈3.3)与 §4.3b 整集折算 RTF(≈12–14)测的不是同一件事——后者含数小时长跑的降频、机器争用与逐句开销。上表 A/B 行即反例:机器有其它负载时单档墙钟散布在 37.7–86.6 秒(最慢的是该服务会话内首次用该样本的那档),其中 3 束的 sunny-steady 只用 38.6 秒、并未比 1 束档更慢。**小样耗时既不可线性外推到整集,也不足以据单次样本推断束宽代价**——束宽的系统性代价与整集排期一律以 §4.3b 的长跑折算口径为准。 **注意事项** From 93384a5d245c84781a46c58f15dfb0110e371ecc Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Wed, 19 Aug 2026 15:59:59 +0800 Subject: [PATCH 8/8] =?UTF-8?q?fix(tts):=20=E5=B0=8F=E6=A0=B7=20--text-fil?= =?UTF-8?q?e=20=E8=AF=BB=E5=88=B0=E7=A9=BA=E5=86=85=E5=AE=B9=E6=97=B6?= =?UTF-8?q?=E7=A1=AC=E5=A4=B1=E8=B4=A5=EF=BC=8C=E5=A0=B5=E4=BD=8F=E6=9C=80?= =?UTF-8?q?=E5=90=8E=E4=B8=80=E5=A4=84=E9=9D=99=E9=BB=98=E9=99=8D=E7=BA=A7?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--text-file` 指向空文件或纯空白文件时,`args.text` 被 strip 成空串, 随后 `(args.text or DEFAULT_TEXT)` 把它当作「没给文本」而静默替换为内置 默认句;紧随其后的 `if not args.text: parser.error("试听文本为空")` 对该 路径已成死代码。用户以为在听自己指定的文本、实则听到的是内置那句,并据此 选定风格与样本——与本分支其余各处(edge 分支 --plan 硬失败、--steady 未 命中即报错、--all-styles 与覆盖参数互斥)一致对抗静默降级的取向相悖。 改为读取后立即校验:空内容直接 parser.error 并回显文件路径。fallback 语义 收窄为「两个文本开关都没给」,`--text` 与 `--text-file` 行为就此对称。 实测七例:空文件/纯空白文件报错、正常文件生效、不给文本仍回退内置默认、 `--text ' '` 与文件不存在两条既有错误路径不变、`--all-styles` 无回归。 🤖 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_sample.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py index a384c38d..ae1d2198 100644 --- a/media/pipeline/scripts/tts_sample.py +++ b/media/pipeline/scripts/tts_sample.py @@ -334,6 +334,10 @@ def main() -> None: if not text_path.is_file(): parser.error(f"文本文件不存在: {text_path}") args.text = text_path.read_text(encoding="utf-8").strip() + # 显式给了 --text-file 却读到空内容:必须硬失败。否则下一行的 `or DEFAULT_TEXT` + # 会把它当"没给文本"而静默替换成内置默认句,用户以为在听自己的文本、实则听到别的。 + if not args.text: + parser.error(f"文本文件内容为空: {text_path}") args.text = (args.text or DEFAULT_TEXT).strip() if not args.text: parser.error("试听文本为空")