diff --git a/CHANGELOG.md b/CHANGELOG.md index 71eb5ea3..1bbe37ec 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`、对三来源显式互斥报错(上游对「向量+情感音频」是静默丢弃音频)。经 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/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/README.md b/media/pipeline/README.md index 78c939d2..d5676f3a 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -54,9 +54,11 @@ 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/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 3a2d9d99..4d92dfb1 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)零改动。 @@ -73,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` 为服务端本地绝对路径。 @@ -87,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` 指向同一目录) | @@ -98,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`) | @@ -106,25 +110,69 @@ 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 参与缓存摘要(见 §六),替换样本自动失效缓存。 + +**选段辅助**——长录音里挑哪一段?用 [scripts/prospect_ref.py](./scripts/prospect_ref.py) 按滑窗扫客观指标(F0 中位=音高、F0 起伏=语调、音节率=语速、谱质心=明亮度,并对静音过多/发声过少扣分),先筛候选再试听: + +```bash +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 段综合分仅排 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 | -|---|---|---|---|---| -| 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 | 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 选方向 → 人工压强度 → 固化成预设**。 ### 4.2 自定义向量(--emo-vector) @@ -136,41 +184,186 @@ uv run --no-project --with soundfile --with numpy \ ### 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 小时,按句缓存可断点续跑。**束宽也是韵律稳定度旋钮**,不只是速度旋钮: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 调参建议 -风格向量是 8 维情感空间中的方向+强度,首次使用建议:固定一句文本,`--style` 各档合成一次试听对比;同风格微调用 `--emo-alpha 0.5`(更含蓄)或 `--duration-factor 0.92`(更紧凑)。**先跑 3 句小样确认,再全量合成**。科普长视频推荐 `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。 + +5. **最后定束宽**:想让语气更"稳/可信"就上 3 束(`--style sunny-steady`),代价是整集墙钟 ×2–5;赶工或改稿频繁期用 1 束的 `sunny`。 + +**推荐位**:日常/批量用 **`sunny`(明快阳光)**,成片定稿用 **`sunny-steady`(明快稳健)**——两档参数完全相同、只差束宽,故可"先用 sunny 快速迭代文稿,定稿再用 sunny-steady 重跑一遍"(换档会改摘要 → 全量重合成,须留出时间)。两档都配 `voices/me-bright.wav`。历史上曾推荐 `passionate`,但其有效注入 0.70 在本人样本上偏"用力",已改为 sunny 系。**任何情况下都先跑小样确认,再全量合成。** + +## 五、小样试听与逐集合成 -## 五、逐集使用 +### 5.1 小样试听(单句直调服务,不需要工程) + +全量一集要跑 2.5–3.5 小时,而「克隆出的音色像不像我」「哪档风格适合本集」用**一句话**就能判定——所以**定稿风格前必须先听小样**。[scripts/tts_sample.py](./scripts/tts_sample.py) 直调 IndexTTS 服务合成单句,无需 `narration.json`、无需视频工程;它复用 `tts.py` 的风格预设与口播文本预处理(单一事实源),故小样与成片走**完全相同**的合成路径,听感可直接外推。 ```bash -# 0) 确认服务在线 +# 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 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 -# 1) 全量合成(工程内薄包装等价) -cd media/<工程> +# 3) 单档试听:合成后立即播放 +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: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 + +# 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-bright.wav --emo-text "轻快、爽朗、自信、阳光" \ + --label qwen-brisk --play # 回显 8 维向量;强度务必自己再压(§4.4 第 3 步) +``` + +> **`--start 180 --duration 12` 就是已上线三集成片所用的同源样本**:该段裁剪结果的 `sha1` 前 12 位为 `3ed0d9d60d4b`,与三集音频缓存 sidecar 摘要中的 `ref_sha1` 一致,直接复用即可听到与成片完全一致的音色。想换段落见 §3.2。 + +**常用开关** + +| 开关 | 作用 | 默认 | +|---|---|---| +| `--text` / `--text-file` | 试听文本(建议 20–40 字,带数字/术语更易暴露咬字问题) | 内置一句科普文本 | +| `--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)。显式给值会压过预设,但与 `--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 · 上述 34 字文本) + +| 环节 | 音频时长 | 墙钟 | RTF | +|---|---|---|---| +| 单档 · 首档(含服务暖机,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)测的不是同一件事——后者含数小时长跑的降频、机器争用与逐句开销。上表 A/B 行即反例:机器有其它负载时单档墙钟散布在 37.7–86.6 秒(最慢的是该服务会话内首次用该样本的那档),其中 3 束的 sunny-steady 只用 38.6 秒、并未比 1 束档更慢。**小样耗时既不可线性外推到整集,也不足以据单次样本推断束宽代价**——束宽的系统性代价与整集排期一律以 §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 试听定稿的那一档 + +# 0) 先看计划(纯本地计算,不连服务、不合成):各束宽多少句、缓存命中多少、大致要跑多久 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 --plan -# 2) 小样试听(先只跑 3 句:临时 narration.json 或 --force 单句验证均可) -# 3) 全量后重渲染(render 脚本定义在 video/package.json,须进入 video/) -cd video && pnpm run render:draft && pnpm run render +# 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(`--num-beams 3`)/ ≈12–14(默认 `--num-beams 1`),即 5 秒的句子约需 1–1.5 分钟;整集(约 180–230 句)约 2.5–3.5 小时,建议 `nohup` 挂后台跑、按句缓存断点续跑(见 §六);长篇管线**不要**用默认 3 束逐句等待; -- 引擎/风格/样本任一变化都会改写时长,合成后**必须重跑草渲**让时间轴重算; +- 每句墙钟与文本长度及束宽相关:长跑折算 RTF≈45(3 束)/ ≈13(1 束)。**1 束整集(约 180–230 句)约 2.5–3.5 小时;3 束整集约 9–10 小时**(`--plan` 会按这两个口径给出估算)——请据此选档并 `nohup` 挂后台跑,按句缓存可断点续跑(见 §六); - 超长句(>120 token)服务端内部自动分段;极端长句推理可达数分钟。客户端并发为 1(与服务端串行推理对齐,避免排队时间计入超时),HTTP 超时 600s;万一超时——重跑即续传,无需干预。 +### 5.3 合成后重渲染 + +```bash +cd video && pnpm run render:draft && pnpm run render # render 脚本定义在 video/package.json +``` + +引擎/风格/样本任一变化都会改写每句时长,合成后**必须重跑草渲**让 Remotion 时间轴重算。 + ## 六、缓存与幂等 | 引擎 | 摘要公式 | |---|---| | 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`(自定义标记进摘要); - 中断后续跑:直接重跑同命令(已完成句子全部命中缓存跳过)。 @@ -186,6 +379,8 @@ cd video && pnpm run render:draft && pnpm run 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..1b65a6c5 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 缓存)。 用法: @@ -15,6 +16,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。 @@ -42,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 = [ @@ -55,7 +65,10 @@ "calm", ] -# 风格预设:激情/轻快/自信/正能量 —— 数值为初值,可实测试听后微调。 +# 风格预设 —— 数值为初值,可实测试听后微调。 +# 可选键 "beams":预设自带的束搜索宽度(缺省 1)。束宽会改变韵律稳定度,属风格的一部分, +# 故允许写进预设;命令行 --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": { @@ -84,6 +97,30 @@ "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, + }, + "sunny-steady": { + "label": "明快稳健", + # = sunny 同方向同强度同语速,只把束宽提到 3:GPT 段搜索更宽 → 韵律更收敛。 + # 实测(同文本同样本):语调起伏 48.4 → 43.5、音节率 4.10 → 4.55,亮度基本不掉 + # (质心 1245 → 1223)——是唯一「不牺牲明快度就让语气更稳」的旋钮。 + # 代价:GPT 段耗时约按束宽线性放大,**整集墙钟约 ×3.4**(189 句估算 2.9→9.9 小时, + # 见 VOICE-CLONING.md §4.3b)。想只让关键句变稳请用 `--steady` 混合档,别整集升档。 + "vec": [0.95, 0, 0, 0, 0, 0, 0.02, 0.03], + "alpha": 0.35, + "df": 0.95, + "beams": 3, + }, } @@ -127,17 +164,66 @@ 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 + + +# ---------------- 混合档:整集低束宽 + 指定句高束宽 ---------------- +# +# 动机(实测):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(历史路径,保持字节级一致) ---------------- @@ -243,8 +329,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 +350,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 +362,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 +384,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 +412,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 +456,8 @@ async def synth_indextts( df, lang, num_beams, + emo_ref, + emo_text, ) if fmt != "mp3": raise NonRetryableError( @@ -401,6 +523,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, @@ -416,11 +550,31 @@ 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( + "--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", @@ -428,28 +582,46 @@ 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( - "风格 说明 情感向量(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": + # --plan 语义是「只看不跑」,而 edge 无束宽/无估时口径。若沿用「提示后照跑」的处理, + # 漏写 --engine indextts 时会静默全量合成,把整集克隆音频改写成 edge 预置音色 + #(两引擎摘要必然不同,且 {id}.mp3 单槽位),故此处硬失败而非忽略。 + if args.plan: + parser.error( + "--plan 仅对 --engine indextts 生效(是否漏写 --engine indextts?)" + ) ignored = [ flag 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, + "--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", @@ -480,12 +652,32 @@ 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 + beams = args.num_beams if args.num_beams is not None else 1 + else: + try: + style_name, vec, alpha, df, beams = resolve_style(args) + except ValueError as e: + parser.error(str(e)) if not args.ref: parser.error( "--engine indextts 需要 --ref 参考音色样本(见 " + MANUAL + " §三)" @@ -495,11 +687,95 @@ 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]") + 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 @@ -521,8 +797,12 @@ 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 + ) - ref_sha1 = hashlib.sha1(ref_path.read_bytes()).hexdigest()[:12] sem = asyncio.Semaphore(CONCURRENCY_INDEXTTS) results = await asyncio.gather( *( @@ -540,7 +820,11 @@ async def main() -> None: args.engine_tag, args.server, out_dir, - num_beams=args.num_beams, + # 逐句束宽:基础值来自「命令行优先、否则取预设」,--steady 命中句再提高 + num_beams=beams_of[i["id"]], + emo_ref=emo_ref_path, + emo_ref_sha1=emo_ref_sha1, + emo_text=args.emo_text, ) for i in items ) @@ -552,6 +836,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}") diff --git a/media/pipeline/scripts/tts_sample.py b/media/pipeline/scripts/tts_sample.py new file mode 100644 index 00000000..ae1d2198 --- /dev/null +++ b/media/pipeline/scripts/tts_sample.py @@ -0,0 +1,443 @@ +#!/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-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-bright.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, int]]: + """→ [(风格名, 情感向量|None, emo_alpha, duration_factor, num_beams)]。 + + --all-styles 逐档取各预设自带的 alpha/df/beams(这正是 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, + 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, + num_beams=None, # 让每档取自己的束宽(sunny-steady=3,其余 1) + ) + ) + for name in STYLE_PRESETS + ] + return [resolve_style(args)] + + +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) + 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} §七)" + ) + 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( + args: argparse.Namespace, + name: str, + vec: list[float] | None, + alpha: float, + df: float, + beams: int, + out_dir: Path, + stem: str | None = None, +) -> dict: + """合成一档并落盘 → {style, path, duration, wall, rtf}。失败即退出(小样无需容错累积)。""" + 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, + tts_text(args.text), + str(args.ref), + vec, + alpha, + df, + args.lang, + beams, + args.emo_ref, + args.emo_text, + headers, + ) + 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") + 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"\n 情感向量(Qwen 推出,可用 --emo-vector 固化):{derived}" + if derived + else "" + ), + 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(各档取自带的 alpha/语速/束宽," + "故与 --emo-vector/--emo-alpha/--duration-factor/--num-beams 互斥)", + ) + parser.add_argument( + "--emo-vector", + 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(默认随风格)" + ) + 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=None, + type=int, + choices=[1, 2, 3, 4, 5], + 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( + "--label", + default=None, + 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.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: + # 束宽同为「预设自带、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)} 同用" + ) + # 与 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: + 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() + # 显式给了 --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("试听文本为空") + + try: + jobs = build_jobs(args) + except ValueError as e: # parse_emo_vector 的键名/权重错误 + parser.error(str(e)) + for name, vec, alpha, df, _beams 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})") + if args.emo_ref: + print(f">> 情感参考音频:{args.emo_ref}(音色仍取上面的参考样本)") + if args.emo_text: + print(f">> 情感描述:{args.emo_text}(服务端 QwenEmotion 转向量)") + 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 "—(不注入情感)" + 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 + + 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 _n, _v, _a, df, _b 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 + ) + out_dir.mkdir(parents=True, exist_ok=True) + print(f">> 产物目录:{out_dir}\n") + + # --label 单档时即文件名、多档时作前缀({label}-{风格}),横向对比不同样本时不互相覆盖 + results = [ + synthesize_one( + args, + name, + 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, beams 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) + # 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}"' + 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" + f" --engine indextts --ref {ref} {chosen}\n" + f"清理 · 小样含本人音色(生物特征信息),试听后请删除:rm -rf {out_dir}" + ) + + +if __name__ == "__main__": + main() 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() 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) §八 许可。