From ff9f7659fac462fc32341b1e12943c42786ef8e0 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 17 Aug 2026 16:43:35 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(tts):=20=E7=A7=91=E6=99=AE=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E7=AE=A1=E7=BA=BF=E6=94=AF=E6=8C=81=20IndexTTS-2.5=20?= =?UTF-8?q?=E5=A3=B0=E9=9F=B3=E5=85=8B=E9=9A=86=E5=8F=8C=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E9=85=8D=E9=9F=B3;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tts.py 双引擎改造:edge(默认,digest/CLI/manifest 字节级不变)+ indextts(HTTP 客户端逐句克隆合成); - 新增 tts_server.py:运行于 index-tts checkout 环境的 FastAPI 推理服务(lifespan 模型常驻、串行锁、v2/v2.5 双版本、NaN 后置检测、soundfile→lameenc MP3 编码链); - 新增 prepare_ref.py:长录音裁剪/归一化为 5–15s 干净 16-bit 单声道参考样本; - 风格预设(中性/轻快/自信/正能量)映射 8 维情感向量 + emo_alpha + duration_factor,双端校验(非负有限值 + 有效和 ≤0.8); - 缓存摘要覆盖 engine_tag/ref_sha1/style/vec/alpha/df/lang,4xx 不可重试、5xx 指数退避重试; 🤖 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/prepare_ref.py | 101 ++++++ media/pipeline/scripts/qa_frames.py | 11 +- media/pipeline/scripts/tts.py | 474 ++++++++++++++++++++++++-- media/pipeline/scripts/tts_server.py | 336 ++++++++++++++++++ 4 files changed, 899 insertions(+), 23 deletions(-) create mode 100644 media/pipeline/scripts/prepare_ref.py create mode 100644 media/pipeline/scripts/tts_server.py diff --git a/media/pipeline/scripts/prepare_ref.py b/media/pipeline/scripts/prepare_ref.py new file mode 100644 index 00000000..6487b8d2 --- /dev/null +++ b/media/pipeline/scripts/prepare_ref.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""参考音色样本预处理——裁剪并规范化为 IndexTTS 克隆用 WAV。 + +- 输入:任意 mp3/wav/m4a/flac 录音(如手机录音、长片段素材) +- 输出:media/pipeline/voices/<名字>.wav —— 16-bit PCM 单声道,保留原始采样率(IndexTTS 内部重采样) +- 动机:克隆参考音频建议 5–15 秒干净人声;过长样本(如 4 分钟录音)会拖慢每句合成的 + 条件提取,且质量并不更好。 + +用法:uv run --no-project --with soundfile media/pipeline/scripts/prepare_ref.py \ + <源音频> [--start 10] [--duration 15] [--out media/pipeline/voices/me.wav] +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import numpy as np +import soundfile as sf + +VOICES_DIR = Path(__file__).resolve().parents[1] / "voices" + + +def main() -> int: + parser = argparse.ArgumentParser( + description="裁剪/规范化参考音色样本 → 16-bit 单声道 WAV" + ) + parser.add_argument( + "source", help="源音频文件(mp3/wav/m4a/flac 等 soundfile 可读格式)" + ) + parser.add_argument( + "--start", type=float, default=0.0, help="裁剪起点(秒,默认 0)" + ) + parser.add_argument( + "--duration", + type=float, + default=15.0, + help="保留时长(秒,默认 15,建议 5–15)", + ) + parser.add_argument( + "--out", + default=None, + help="输出路径(默认 media/pipeline/voices/<源文件名>.wav)", + ) + args = parser.parse_args() + + src = Path(args.source).expanduser().resolve() + if not src.is_file(): + print(f"源文件不存在: {src}", file=sys.stderr) + return 1 + if args.start < 0: + print("--start 不能为负数", file=sys.stderr) + return 1 + if not 3.0 <= args.duration <= 30.0: + print( + "--duration 建议 5–15 秒(允许 3–30),克隆效果与速度的平衡点", + file=sys.stderr, + ) + return 1 + + data, sr = sf.read(str(src), dtype="float32", always_2d=True) + total = len(data) / sr + if args.start >= total: + print(f"--start {args.start}s 超出源文件时长 {total:.1f}s", file=sys.stderr) + return 1 + + begin = int(args.start * sr) + end = min(len(data), begin + int(args.duration * sr)) + clip = data[begin:end] + if not len(clip): + print( + f"裁剪区间为空(--start {args.start}s 过大或源文件过短)", file=sys.stderr + ) + return 1 + if clip.shape[1] > 1: # 立体声 → 单声道 + clip = clip.mean(axis=1, keepdims=True) + peak = float(np.max(np.abs(clip))) if len(clip) else 0.0 + if peak > 0: # 峰值归一到 -3dB,避免过小音量削弱克隆相似度 + clip = clip * min(0.7 / peak, 4.0) + + out = ( + Path(args.out).expanduser().resolve() + if args.out + else VOICES_DIR / f"{src.stem}.wav" + ) + out.parent.mkdir(parents=True, exist_ok=True) + sf.write(str(out), clip, sr, subtype="PCM_16") + + print(f"已生成参考样本: {out}") + print(f"时长 {len(clip) / sr:.1f}s · {sr} Hz · 单声道 · 16-bit") + if total > args.duration + 5: + print( + f"提示:源文件共 {total:.0f}s,仅截取 [{args.start:.0f}s, {args.start + len(clip) / sr:.0f}s)," + f"请试听确认该段人声干净、无背景音乐" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/media/pipeline/scripts/qa_frames.py b/media/pipeline/scripts/qa_frames.py index 80cc0d94..918ae30b 100644 --- a/media/pipeline/scripts/qa_frames.py +++ b/media/pipeline/scripts/qa_frames.py @@ -40,9 +40,16 @@ def timeline(manifest: Path) -> dict[str, tuple[float, float]]: def main() -> None: parser = argparse.ArgumentParser(description="按句 id 抽帧视觉 QA") - parser.add_argument("--project", default=".", help="视频工程根目录(含 video/ 与 out/)") + parser.add_argument( + "--project", default=".", help="视频工程根目录(含 video/ 与 out/)" + ) parser.add_argument("--scene", help="按幕抽样(如 P1),与位置参数 ids 二选一") - parser.add_argument("--offset", type=float, default=0.0, help="时间轴整体偏移(草渲与终渲时间基准不一致时用)") + parser.add_argument( + "--offset", + type=float, + default=0.0, + help="时间轴整体偏移(草渲与终渲时间基准不一致时用)", + ) parser.add_argument("video", help="渲染产物 mp4 路径") parser.add_argument("ids", nargs="*", help="句 id 列表(与 --scene 二选一)") args = parser.parse_args() diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index d3208361..3ceb4959 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -1,13 +1,23 @@ #!/usr/bin/env python3 -"""逐句合成配音并产出时长 manifest——公共管线版本。 +"""逐句合成配音并产出时长 manifest——公共管线版本(双引擎)。 - 输入:<工程>/script/narration.json(单一事实源派生) - 输出:<工程>/video/public/audio/{id}.mp3 + <工程>/video/public/audio/manifest.json -- 引擎:edge-tts(免密钥);每句一个文件,幂等(文本未变则跳过)。 +- 引擎: + - edge(默认):edge-tts 预置音色,免密钥,行为与历史版本完全一致; + - indextts:声音克隆(IndexTTS-2.5 本地服务),需先启动 tts_server.py, + 通过 --ref 提供参考音色样本、--style 选择风格(轻快/自信/正能量等)。 +- 幂等:参数与文本未变则跳过(SHA1 摘要 sidecar 缓存)。 -用法:uv run --no-project --with edge-tts --with mutagen media/pipeline/scripts/tts.py \ - --project media/<工程> [--voice zh-CN-YunxiNeural] [--rate +4%] [--force] - 工程内薄包装等价于:uv run --no-project --with edge-tts --with mutagen scripts/tts.py +用法: + edge: uv run --no-project --with edge-tts --with mutagen media/pipeline/scripts/tts.py \ + --project media/<工程> [--voice zh-CN-YunxiNeural] [--rate +4%] [--force] + indextts:uv run --no-project --with mutagen media/pipeline/scripts/tts.py \ + --project media/<工程> --engine indextts --ref <参考样本.wav> \ + [--style lively] [--server http://127.0.0.1:8766] [--force] + (工程内薄包装等价于在工程目录下运行 scripts/tts.py) + +声音克隆完整手册(部署/风格/排障/许可)见 media/pipeline/VOICE-CLONING.md。 """ from __future__ import annotations @@ -16,15 +26,56 @@ import asyncio import hashlib import json +import sys +import urllib.error +import urllib.request from pathlib import Path -import edge_tts -from mutagen.mp3 import MP3 +# edge_tts / mutagen 均惰性导入:克隆模式(indextts)无需 edge_tts;`--list-styles` 等本地操作零依赖。 DEFAULT_VOICE = "zh-CN-YunxiNeural" DEFAULT_RATE = "+4%" -CONCURRENCY = 6 +CONCURRENCY_EDGE = 6 +CONCURRENCY_INDEXTTS = 2 RETRIES = 4 +HTTP_TIMEOUT = 300 +MANUAL = "media/pipeline/VOICE-CLONING.md" + +# IndexTTS 8 维情感向量顺序(indextts/infer_v2_5.py 固定):happy, angry, sad, afraid, +# disgusted, melancholic, surprised, calm。分量和须 ≤0.8(直调 infer 不自动归一,双端校验)。 +EMO_KEYS = [ + "happy", + "angry", + "sad", + "afraid", + "disgusted", + "melancholic", + "surprised", + "calm", +] + +# 风格预设:轻快/自信/正能量 —— 数值为初值,可实测试听后微调。 +STYLE_PRESETS: dict[str, dict] = { + "neutral": {"label": "中性", "vec": None, "alpha": 1.0, "df": 1.0}, + "lively": { + "label": "轻快", + "vec": [0.55, 0, 0, 0, 0, 0, 0.15, 0.15], + "alpha": 0.6, + "df": 0.95, + }, + "confident": { + "label": "自信", + "vec": [0.25, 0, 0, 0, 0, 0, 0, 0.65], + "alpha": 0.7, + "df": 1.05, + }, + "positive": { + "label": "正能量", + "vec": [0.75, 0, 0, 0, 0, 0, 0, 0.2], + "alpha": 0.7, + "df": 1.0, + }, +} def tts_text(text: str) -> str: @@ -32,7 +83,62 @@ def tts_text(text: str) -> str: return text.replace("——", ",").replace("……", "。") -async def synth_one( +def mp3_duration(path: Path) -> float: + """mutagen 实测 MP3 时长(两引擎共用)。""" + from mutagen.mp3 import MP3 + + return MP3(str(path)).info.length + + +# ---------------- 风格解析 ---------------- + + +def parse_emo_vector(spec: str) -> list[float]: + """`happy:0.6,calm:0.2` → 8 维向量;未知键/负值/空集报错。""" + vec = [0.0] * 8 + seen = 0 + for part in spec.split(","): + key, _, val = part.partition(":") + key, val = key.strip().lower(), val.strip() + if key not in EMO_KEYS: + raise ValueError(f"未知情感键 {key!r}(可用:{','.join(EMO_KEYS)})") + if not val: + raise ValueError(f"情感权重缺失:{key}(格式如 happy:0.6)") + weight = float(val) + if not (weight >= 0) or weight == float( + "inf" + ): # 拦 NaN/Inf(比较恒 False 漏网) + raise ValueError(f"情感权重必须为非负有限数值:{key}") + vec[EMO_KEYS.index(key)] = weight + seen += 1 + if seen == 0: + raise ValueError("--emo-vector 不能为空") + return vec + + +def resolve_style( + args: argparse.Namespace, +) -> tuple[str, list[float] | None, float, float]: + """返回 (风格名, 情感向量|None, emo_alpha, duration_factor)。""" + 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 + 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 + + +# ---------------- 引擎一:edge-tts(历史路径,保持字节级一致) ---------------- + + +def digest_edge(voice: str, rate: str, text: str) -> str: + return hashlib.sha1(f"{voice}|{rate}|{text}".encode()).hexdigest() + + +async def synth_edge( sem: asyncio.Semaphore, item: dict, force: bool, @@ -40,12 +146,20 @@ async def synth_one( rate: str, out_dir: Path, ) -> dict: + import edge_tts # 惰性导入:仅 edge 引擎需要 + sid, text = item["id"], item["text"] mp3 = out_dir / f"{sid}.mp3" meta = out_dir / f"{sid}.sha" - digest = hashlib.sha1(f"{voice}|{rate}|{text}".encode()).hexdigest() + digest = digest_edge(voice, rate, text) - if not force and mp3.exists() and mp3.stat().st_size > 0 and meta.exists() and meta.read_text() == digest: + if ( + not force + and mp3.exists() + and mp3.stat().st_size > 0 + and meta.exists() + and meta.read_text() == digest + ): pass else: async with sem: @@ -64,28 +178,346 @@ async def synth_one( else: raise RuntimeError(f"{sid} 合成失败: {last_err}") - duration = MP3(str(mp3)).info.length + duration = mp3_duration(mp3) return {**item, "durationSec": round(duration, 3)} +# ---------------- 引擎二:IndexTTS 声音克隆(本地 HTTP 服务) ---------------- + + +class NonRetryableError(Exception): + """4xx 类错误:重试无意义,直接失败并携带服务端错误详情。""" + + +def _http_error_detail(e: urllib.error.HTTPError) -> str: + try: + parsed = json.loads(e.read()) + if isinstance(parsed, dict): + return str(parsed.get("detail", parsed)) + if isinstance(parsed, list) and parsed and isinstance(parsed[0], dict): + return str(parsed[0].get("msg", parsed[0])) # FastAPI 422 校验数组 + return str(parsed) + except Exception: # noqa: BLE001 - 详情解析失败退化为字符串 + return str(e) + + +def http_json( + method: str, url: str, payload: dict | None = None, timeout: int = HTTP_TIMEOUT +) -> dict: + """同步 urllib 调用(调用方需置于 asyncio.to_thread);4xx → NonRetryableError。""" + data = json.dumps(payload).encode() if payload is not None else None + req = urllib.request.Request( + url, data=data, method=method, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + body = resp.read() + ctype = resp.headers.get("Content-Type", "") + except urllib.error.HTTPError as e: + detail = _http_error_detail(e) + if 400 <= e.code < 500: + raise NonRetryableError(f"HTTP {e.code}: {detail}") from e + raise RuntimeError(f"HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"连接失败: {e.reason}") from e + if "application/json" not in ctype: + raise NonRetryableError(f"响应 Content-Type 异常: {ctype}") + return json.loads(body) + + +def http_synthesize( + server: str, + text: str, + ref: str, + vec: list[float] | None, + alpha: float, + df: float, + lang: str, +) -> tuple[bytes, str]: + """POST /synthesize → (mp3 bytes, X-Audio-Format)。4xx 不可重试。""" + payload: dict = { + "text": text, + "ref_path": ref, + "emo_alpha": alpha, + "duration_factor": df, + "lang": lang, + } + if vec is not None: + payload["emo_vector"] = vec + req = urllib.request.Request( + f"{server.rstrip('/')}/synthesize", + data=json.dumps(payload).encode(), + method="POST", + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + return resp.read(), resp.headers.get("X-Audio-Format", "unknown") + except urllib.error.HTTPError as e: + detail = _http_error_detail(e) + if 400 <= e.code < 500: + raise NonRetryableError(f"HTTP {e.code}: {detail}") from e + raise RuntimeError(f"HTTP {e.code}: {detail}") from e + except urllib.error.URLError as e: + raise RuntimeError(f"连接失败: {e.reason}") from e + + +def digest_indextts( + ref_sha1: str, + style: str, + vec: list[float] | None, + alpha: float, + df: float, + lang: str, + engine_tag: str, + text: str, +) -> str: + vec_str = ",".join(repr(x) for x in vec) if vec else "none" + return hashlib.sha1( + f"indextts|{engine_tag}|{ref_sha1}|{lang}|{style}|{vec_str}|{alpha!r}|{df!r}|{text}".encode() + ).hexdigest() + + +async def synth_indextts( + sem: asyncio.Semaphore, + item: dict, + force: bool, + ref: str, + ref_sha1: str, + style: str, + vec: list[float] | None, + alpha: float, + df: float, + lang: str, + engine_tag: str, + server: str, + out_dir: Path, +) -> 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) + + if ( + not force + and mp3.exists() + and mp3.stat().st_size > 0 + and meta.exists() + and meta.read_text() == digest + ): + pass + else: + async with sem: + last_err: Exception | None = None + for attempt in range(RETRIES): + try: + audio, fmt = await asyncio.to_thread( + http_synthesize, + server, + tts_text(text), + ref, + vec, + alpha, + df, + lang, + ) + if fmt != "mp3": + raise NonRetryableError( + f"服务端编码器不可用(X-Audio-Format={fmt})—— 按 {MANUAL} §七 检查 soundfile/lameenc" + ) + if not audio: + raise RuntimeError("空音频响应") + mp3.write_bytes(audio) + if mp3.stat().st_size == 0: + raise RuntimeError("空音频文件") + meta.write_text(digest) + break + except NonRetryableError: + raise + except Exception as e: # noqa: BLE001 - 推理服务需要整体重试 + last_err = e + await asyncio.sleep(1.5 * (attempt + 1)) + else: + raise RuntimeError(f"{sid} 合成失败: {last_err}") + + duration = mp3_duration(mp3) + return {**item, "durationSec": round(duration, 3)} + + +# ---------------- 主流程 ---------------- + + async def main() -> None: - parser = argparse.ArgumentParser(description="逐句 edge-tts 合成 + 时长 manifest") - parser.add_argument("--project", default=".", help="视频工程根目录(含 script/ 与 video/)") - parser.add_argument("--voice", default=DEFAULT_VOICE, help="edge-tts 语音(默认 zh-CN-YunxiNeural)") - parser.add_argument("--rate", default=DEFAULT_RATE, help="语速(默认 +4%%)") + parser = argparse.ArgumentParser( + description="逐句 TTS 合成 + 时长 manifest(双引擎)" + ) + parser.add_argument( + "--engine", + choices=["edge", "indextts"], + default="edge", + help="edge=预置音色(默认);indextts=声音克隆(需本地服务)", + ) + parser.add_argument( + "--project", default=".", help="视频工程根目录(含 script/ 与 video/)" + ) + parser.add_argument( + "--voice", default=DEFAULT_VOICE, help="[edge] 语音(默认 zh-CN-YunxiNeural)" + ) + parser.add_argument("--rate", default=DEFAULT_RATE, help="[edge] 语速(默认 +4%%)") parser.add_argument("--force", action="store_true", help="忽略缓存强制重合成") + parser.add_argument("--list-styles", action="store_true", help="列出风格预设并退出") + + idx = parser.add_argument_group("indextts 声音克隆") + idx.add_argument( + "--ref", default=None, help="[indextts] 参考音色样本路径(建议 5–15s 干净人声)" + ) + idx.add_argument( + "--server", default="http://127.0.0.1:8766", help="[indextts] 服务地址" + ) + idx.add_argument( + "--style", + default="neutral", + choices=list(STYLE_PRESETS), + help="[indextts] 风格预设(默认 neutral)", + ) + idx.add_argument( + "--emo-vector", + default=None, + help="[indextts] 原始情感向量,如 happy:0.6,calm:0.2(与 --style 非默认值互斥)", + ) + idx.add_argument( + "--emo-alpha", + default=None, + type=float, + help="[indextts] 情感强度 0–1(默认随风格)", + ) + idx.add_argument( + "--duration-factor", + default=None, + type=float, + help="[indextts] 语速 0.5–2.0(默认随风格)", + ) + idx.add_argument("--lang", default="ZH", help="[indextts] 语言(默认 ZH)") + idx.add_argument( + "--engine-tag", + default="indextts", + help="[indextts] 缓存标记;模型升级后自定义以失效旧缓存", + ) args = parser.parse_args() + if args.list_styles: + print( + "风格 说明 情感向量(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']}") + return + + if args.engine == "edge": + ignored = [ + flag + for flag, val in { + "--ref": args.ref, + "--emo-vector": args.emo_vector, + "--emo-alpha": args.emo_alpha, + "--duration-factor": args.duration_factor, + "--server": args.server != "http://127.0.0.1:8766", + "--style": args.style != "neutral", + "--lang": args.lang != "ZH", + "--engine-tag": args.engine_tag != "indextts", + }.items() + if val + ] + if ignored: + print( + f"提示:以下参数仅对 --engine indextts 生效,已忽略: {' '.join(ignored)}", + file=sys.stderr, + ) + root = Path(args.project).resolve() src = root / "script" / "narration.json" + if not src.is_file(): + sys.exit(f"narration.json 不存在: {src} —— 先运行 build_narration.py 生成") out_dir = root / "video" / "public" / "audio" - items = json.loads(src.read_text(encoding="utf-8")) out_dir.mkdir(parents=True, exist_ok=True) - sem = asyncio.Semaphore(CONCURRENCY) - results = await asyncio.gather( - *(synth_one(sem, i, args.force, args.voice, args.rate, out_dir) for i in items) - ) + + if args.engine == "edge": + sem = asyncio.Semaphore(CONCURRENCY_EDGE) + results = await asyncio.gather( + *( + synth_edge(sem, i, args.force, args.voice, args.rate, out_dir) + for i in items + ) + ) + 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)) + if not args.ref: + parser.error( + "--engine indextts 需要 --ref 参考音色样本(见 " + MANUAL + " §三)" + ) + ref_path = Path(args.ref).expanduser().resolve() + if not ref_path.is_file(): + 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 args.duration_factor is not None and not 0.5 <= args.duration_factor <= 2.0: + parser.error("--duration-factor 必须在 [0.5, 2.0]") + + try: + health = await asyncio.to_thread( + http_json, "GET", f"{args.server}/health", None, 10 + ) + if not health.get("ok"): + raise RuntimeError(f"health.ok=false: {health}") + except Exception as e: # noqa: BLE001 - 服务未启动给出可操作指引 + print( + f"IndexTTS 服务不可用({e})。请先启动:\n" + f" cd ~/tools/index-tts && uv run --frozen --with fastapi --with uvicorn --with soundfile \\\n" + f" --with numpy --with lameenc python <仓库路径>/media/pipeline/scripts/tts_server.py \\\n" + f" --model-dir checkpoints --port 8766\n" + f"详见 {MANUAL} §二", + file=sys.stderr, + ) + sys.exit(1) + if df != 1.0 and not health.get("supports_duration_factor"): + parser.error( + "当前服务为 IndexTTS-2(无语速控制):去掉 --duration-factor,或风格选 neutral,见 " + + MANUAL + ) + + ref_sha1 = hashlib.sha1(ref_path.read_bytes()).hexdigest()[:12] + sem = asyncio.Semaphore(CONCURRENCY_INDEXTTS) + results = await asyncio.gather( + *( + synth_indextts( + sem, + i, + args.force, + str(ref_path), + ref_sha1, + style_name, + vec, + alpha, + df, + args.lang, + args.engine_tag, + args.server, + out_dir, + ) + for i in items + ) + ) manifest_path = out_dir / "manifest.json" manifest_path.write_text( diff --git a/media/pipeline/scripts/tts_server.py b/media/pipeline/scripts/tts_server.py new file mode 100644 index 00000000..64a680ae --- /dev/null +++ b/media/pipeline/scripts/tts_server.py @@ -0,0 +1,336 @@ +#!/usr/bin/env python3 +"""IndexTTS 声音克隆推理服务——运行于 index-tts 工程环境内的本地 HTTP 服务。 + +- 位置约定:本脚本属于公共管线(SSOT),但必须在 index-tts checkout(如 ~/tools/index-tts) + 的 uv 环境内运行(torch/indextts 等重依赖不进入本仓); +- 启动(在 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 --port 8766 +- 端点: + GET /health —— 服务与模型元信息(version/device/dtype/encoder/supports_duration_factor) + POST /synthesize —— JSON 请求合成,返回 MP3 bytes(X-Audio-Format 头) +- 安全:仅监听 127.0.0.1,无鉴权,勿暴露公网;ref_path 为服务端本地绝对路径。 + +完整部署/排障手册见 media/pipeline/VOICE-CLONING.md。 +""" + +from __future__ import annotations + +import argparse +import asyncio +import io +import math +import sys +import tempfile +from contextlib import asynccontextmanager +from pathlib import Path + +import numpy as np +import soundfile as sf +import uvicorn +from fastapi import FastAPI, HTTPException, Response +from pydantic import BaseModel, field_validator + + +def ensure_indextts_import(index_tts_root: Path) -> None: + """优先依赖 venv 已安装的 indextts;仅源码未安装时把 checkout 根目录塞进 sys.path 兜底。""" + try: + import indextts # noqa: F401 + except ImportError: + sys.path.insert(0, str(index_tts_root.resolve())) + + +def load_model(version: str, model_dir: Path, dtype: str, device: str): + """按版本构造 IndexTTS2;构造器差异以 webui.py build_tts() 为锚点: + v2.5 仅 use_bf16(MPS 分支内部强制关闭),v2 为 use_fp16。均不加载 QwenEmotion(仅向量模式)。 + + 返回 (tts 对象, 元信息 dict)。 + """ + if version == "2.5": + from indextts.infer_v2_5 import IndexTTS2 + + use_bf16 = dtype in ("auto", "bf16") # MPS 分支内部会强制 False → 实际 fp32 + tts = IndexTTS2( + cfg_path=str(model_dir / "config.yaml"), + model_dir=str(model_dir), + use_bf16=use_bf16, + use_cuda_kernel=False, + use_deepspeed=False, + use_qwen_emo=False, + device=None if device == "auto" else device, + ) + return tts, { + "version": "2.5", + "supports_duration_factor": True, + # 从对象实际状态派生:MPS 分支构造器内部强制 use_bf16=False(实际 fp32) + "dtype_flag": "bf16" if getattr(tts, "use_bf16", use_bf16) else "fp32", + } + + from indextts.infer_v2 import IndexTTS2 + + use_fp16 = dtype in ("auto", "fp16") + tts = IndexTTS2( + cfg_path=str(model_dir / "config.yaml"), + model_dir=str(model_dir), + use_fp16=use_fp16, + use_cuda_kernel=False, + use_deepspeed=False, + use_qwen_emo=False, + device=None if device == "auto" else device, + ) + return tts, { + "version": "2", + "supports_duration_factor": False, + "dtype_flag": "fp16" if getattr(tts, "use_fp16", use_fp16) else "fp32", + } + + +# ---------------- 编码层:WAV(float32) → MP3 bytes ---------------- + +_ENCODER: str | None = None + + +def _probe_encoders() -> str: + """启动时一次性探测可用 MP3 编码器:soundfile(libsndfile≥1.1 自带 LAME)→ lameenc。""" + global _ENCODER + sr = 22050 + tone = (np.sin(2 * np.pi * 440 * np.arange(sr) / sr) * 0.5).astype(np.float32) + try: + buf = io.BytesIO() + sf.write(buf, tone, sr, format="MP3", subtype="MPEG_LAYER_III") + if buf.tell() > 0: + _ENCODER = "soundfile" + return _ENCODER + except Exception: # noqa: BLE001 - 探测失败换下一档 + pass + try: + import lameenc + + enc = lameenc.Encoder() + enc.set_bit_rate(128) + enc.set_in_sample_rate(sr) + enc.set_channels(1) + enc.set_quality(2) + out = enc.encode(tone.tobytes()) + enc.flush() + if len(out) > 0: + _ENCODER = "lameenc" + return _ENCODER + except Exception: # noqa: BLE001 - 双双失败,保留 None(返回 WAV) + pass + _ENCODER = None + return "none" + + +def encode_mp3(data: np.ndarray, sr: int) -> tuple[bytes, str]: + """输入 (N,) float32 → (音频 bytes, 实际格式)。无可用 MP3 编码器时回退 WAV。""" + if _ENCODER == "soundfile": + buf = io.BytesIO() + sf.write(buf, data, sr, format="MP3", subtype="MPEG_LAYER_III") + return buf.getvalue(), "mp3" + if _ENCODER == "lameenc": + import lameenc + + enc = lameenc.Encoder() + enc.set_bit_rate(128) + enc.set_in_sample_rate(sr) + enc.set_channels(1) + enc.set_quality(2) + pcm = (np.clip(data, -1.0, 1.0) * 32767.0).astype(np.int16).tobytes() + out = enc.encode(pcm) + enc.flush() + return bytes(out), "mp3" + # 双编码器均不可用:返回 WAV,由客户端按 X-Audio-Format 报错指引 + buf = io.BytesIO() + sf.write(buf, data, sr, format="WAV", subtype="PCM_16") + return buf.getvalue(), "wav" + + +# ---------------- FastAPI 应用 ---------------- + +EMO_LABELS = "happy,angry,sad,afraid,disgusted,melancholic,surprised,calm" + + +class SynthesizeRequest(BaseModel): + text: str + ref_path: str + emo_vector: list[float] | None = None + emo_alpha: float = 1.0 + duration_factor: float = 1.0 + lang: str = "ZH" + + @field_validator("emo_vector") + @classmethod + def _vec_ok(cls, v: list[float] | None) -> list[float] | None: + if v is None: + return v + if len(v) != 8: + raise ValueError(f"emo_vector 必须为 8 维({EMO_LABELS})") + if not all( + math.isfinite(x) and x >= 0 for x in v + ): # isfinite 拦 NaN/Inf(比较恒 False 漏网) + raise ValueError("emo_vector 各分量必须为非负有限数值") + return v # 有效和(×emo_alpha)校验在 handler 内跨字段联合进行 + + @field_validator("emo_alpha") + @classmethod + def _alpha_ok(cls, v: float) -> float: + if not 0.0 <= v <= 1.0: # NaN 会因比较恒 False 被拦截 + raise ValueError("emo_alpha 必须在 [0, 1]") + return v + + @field_validator("duration_factor") + @classmethod + def _df_ok(cls, v: float) -> float: + if not 0.5 <= v <= 2.0: # NaN 会因比较恒 False 被拦截 + raise ValueError("duration_factor 必须在 [0.5, 2.0]") + return v + + +STATE: dict = {} + + +def _infer_sync(tts, ref: Path, req: SynthesizeRequest, tmpdir: Path) -> Path: + wav_path = tmpdir / "out.wav" + kwargs = dict( + spk_audio_prompt=str(ref), + text=req.text, + output_path=str(wav_path), + emo_vector=req.emo_vector, + emo_alpha=req.emo_alpha, + use_random=False, + verbose=False, + ) + if STATE["supports_duration_factor"]: + kwargs["duration_factor"] = req.duration_factor + kwargs["lang"] = req.lang + tts.infer(**kwargs) + return wav_path + + +def _read_audio(path: Path) -> tuple[np.ndarray, int]: + data, sr = sf.read(str(path), dtype="float32") + if not np.isfinite(data).all(): + raise HTTPException( + 500, + "生成音频含 NaN/Inf(MPS 数值问题):请重试;仍失败则服务加 --device cpu 重启", + ) + return data, sr + + +@asynccontextmanager +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) + encoder = _probe_encoders() + STATE.update( + tts=tts, + version=meta["version"], + device=str(getattr(tts, "device", "unknown")), + dtype=meta["dtype_flag"], + encoder=encoder, + supports_duration_factor=meta["supports_duration_factor"], + infer_lock=asyncio.Lock(), + ) + print( + f">> 就绪:IndexTTS-{STATE['version']} device={STATE['device']} dtype={STATE['dtype']} encoder={encoder}" + ) + yield + STATE.clear() + + +app = FastAPI(title="IndexTTS Pipeline Server", version="1.0", lifespan=lifespan) + + +@app.get("/health") +async def health(): + return { + "ok": True, + "version": STATE.get("version"), + "device": STATE.get("device"), + "synthesizing": STATE["infer_lock"].locked() + if STATE.get("infer_lock") + else False, + "dtype": STATE.get("dtype"), + "encoder": STATE.get("encoder"), + "supports_duration_factor": STATE.get("supports_duration_factor"), + } + + +@app.post("/synthesize") +async def synthesize(req: SynthesizeRequest): + if not STATE: + raise HTTPException(503, "模型尚未加载完成,请稍候") + ref = Path(req.ref_path).expanduser() + if not ref.is_file(): + raise HTTPException(400, f"参考音频不存在: {ref}") + 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( + 400, + f"情感向量有效和 {effective_sum:.3f}(Σvec×alpha)超过 0.8 上限,请降低权重或 --emo-alpha", + ) + if req.duration_factor != 1.0 and not STATE["supports_duration_factor"]: + raise HTTPException( + 400, + "IndexTTS-2 不支持 duration_factor(v2.5 专属),请改用 v2.5 服务或去掉 --duration-factor", + ) + + async with STATE["infer_lock"]: + 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) + 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}"}, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="IndexTTS 声音克隆推理服务") + parser.add_argument( + "--model-dir", default="checkpoints", help="模型目录(绝对或相对当前目录)" + ) + parser.add_argument( + "--index-tts-root", + default=str(Path.cwd()), + help="index-tts checkout 根目录(sys.path 兜底用)", + ) + parser.add_argument( + "--indextts-version", choices=["2", "2.5"], default="2.5", dest="version" + ) + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8766) + parser.add_argument( + "--dtype", + choices=["auto", "bf16", "fp16", "fp32"], + default="auto", + help="auto:v2.5→bf16(MPS 强制 fp32)/ v2→fp16;显式 fp32 两个版本均安全", + ) + parser.add_argument("--device", choices=["auto", "mps", "cpu"], default="auto") + args = parser.parse_args() + + args.index_tts_root = Path(args.index_tts_root).resolve() + args.model_dir = Path(args.model_dir).resolve() + if not args.model_dir.is_dir(): + sys.exit( + f"模型目录不存在: {args.model_dir} —— 先按 VOICE-CLONING.md §二 下载 checkpoints" + ) + if not (args.model_dir / "config.yaml").is_file(): + sys.exit( + f"模型目录缺少 config.yaml: {args.model_dir} —— checkpoints 下载不完整" + ) + + app.state.args = args + print( + f">> IndexTTS 服务器启动: {args.host}:{args.port} version={args.version} model_dir={args.model_dir}" + ) + uvicorn.run(app, host=args.host, port=args.port, log_level="info") + + +if __name__ == "__main__": + main() From 32febc13c5673db17c8ebe59a4d87c49f7fcc711 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 17 Aug 2026 16:43:47 +0800 Subject: [PATCH 2/6] =?UTF-8?q?docs(pipeline):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=A3=B0=E9=9F=B3=E5=85=8B=E9=9A=86=E6=93=8D=E4=BD=9C=E6=89=8B?= =?UTF-8?q?=E5=86=8C=20VOICE-CLONING.md=20=E5=B9=B6=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E7=B4=A2=E5=BC=95;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VOICE-CLONING.md 九节操作手册:部署(磁盘预算/断点续传/下载假死处置)/参考样本录制要求/风格预设与调参/逐集使用/缓存幂等语义/MPS 排障/bilibili 许可边界/备选方案对比 + IEEE 引用; - voices/README.md:样本目录约定与隐私提醒;根 .gitignore 目录级忽略个人声纹(仅白名单 README); - media/pipeline/README.md:脚本表补 tts_server/prepare_ref、§五 引擎可选说明、§八 许可补充; - knowledge-map:管线条目追加声音克隆手册链接; 🤖 Generated with [Claude Code](https://github.com/claude), [CodeX](https://openai.com), [Gemini](https://github.com/apps/gemini-code-assist) Co-Authored-By: Aurelius Huang --- .gitignore | 5 + docs/.agents/knowledge-map.md | 2 +- media/pipeline/README.md | 8 +- media/pipeline/VOICE-CLONING.md | 218 ++++++++++++++++++++++++++++++++ media/pipeline/voices/README.md | 29 +++++ 5 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 media/pipeline/VOICE-CLONING.md create mode 100644 media/pipeline/voices/README.md diff --git a/.gitignore b/.gitignore index cecd3639..9d4fafff 100644 --- a/.gitignore +++ b/.gitignore @@ -309,3 +309,8 @@ media/experience-era-agents-video/out/ media/experience-era-agents-video/**/*.mp4 media/experience-era-agents-video/**/*.mp3 media/experience-era-agents-video/**/*.wav + +# 声音克隆参考样本(media/pipeline/voices/):个人声音属生物特征信息,绝不入库。 +# 目录级忽略 + README 白名单,防漏网音频格式(ogg/opus/wma 等)。 +media/pipeline/voices/* +!media/pipeline/voices/README.md diff --git a/docs/.agents/knowledge-map.md b/docs/.agents/knowledge-map.md index 7e0cb424..c8d81f5d 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–05)+ 新集脚手架清单与复用边界(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 分钟,论文笔记由 9 并行代理逐章精读产出) +- [科普视频制作 Pipeline(公共基建)](../../media/pipeline/README.md) — 全仓可复用的论文→视频九阶段流水线(精读提取/策划/逐字稿 SSOT/双重校验/分镜/TTS/Remotion/抽帧 QA/终渲):中心脚本三件套(`--project` 参数化)+ 每 Stage 代理提示词规格(skills/01–05)+ 新集脚手架清单与复用边界(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 分钟,论文笔记由 9 并行代理逐章精读产出);配音支持用自己的声音克隆(轻快/自信/正能量风格,见 [VOICE-CLONING.md](../../media/pipeline/VOICE-CLONING.md)) - [自进化 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 17cbdb15..c866cb2f 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -54,7 +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) | 逐句 edge-tts 合成 + 时长 manifest(幂等) | `uv run --no-project --with edge-tts --with mutagen scripts/tts.py` | +| [scripts/tts.py](./scripts/tts.py) | 逐句配音合成 + 时长 manifest(幂等,双引擎:edge 预置音色 / indextts 声音克隆) | `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/prepare_ref.py](./scripts/prepare_ref.py) | 参考音色样本裁剪/规范化(长录音 → 5–15s 干净 WAV) | `uv run --no-project --with soundfile --with numpy 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` | 中心脚本以 `--project <工程根>` 参数化;工程内 `scripts/*.py` 为薄包装(透传参数、保持原 CLI)。改造/迭代只改 `media/pipeline/scripts/`,验证门 = 受影响工程的 `narration.json` / `manifest.json` 字节级不变。 @@ -67,7 +69,7 @@ media/-video/ ## 五、音画同步机制(零手工对轨) -每句一段 MP3;`tts.py` 产出 `video/public/audio/manifest.json`(含每句实测时长);Remotion `calculateMetadata` 读取 manifest 计算全片时间轴(默认句间 0.32s、幕间 +0.9s、片头 0.6s、片尾 2s)。**改稿后只需重跑:build → tts → render**。 +每句一段 MP3;`tts.py` 产出 `video/public/audio/manifest.json`(含每句实测时长);Remotion `calculateMetadata` 读取 manifest 计算全片时间轴(默认句间 0.32s、幕间 +0.9s、片头 0.6s、片尾 2s)。**改稿后只需重跑:build → tts → render**。引擎可选 edge 预置音色(默认)或用自己的声音克隆([VOICE-CLONING.md](./VOICE-CLONING.md)),两种引擎的 manifest 契约完全一致。 ⚠️ 若工程自定义了 `timing.ts` 常量,须同步 `qa_frames.py` 顶部的镜像常量,否则抽帧时间错位。 @@ -98,4 +100,4 @@ media/-video/ ## 八、许可注意 -Remotion 对超过 3 人的公司需商业授权(个人/小团队免费);edge-tts 为微软在线语音,发布前确认平台对合成语音的标注要求;不使用任何未经授权的第三方图片/音频素材。 +Remotion 对超过 3 人的公司需商业授权(个人/小团队免费);edge-tts 为微软在线语音,发布前确认平台对合成语音的标注要求;**IndexTTS-2.5 按 bilibili 模型使用许可发布,个人/研究可用,商用需联系 indexspeech@bilibili.com**(详见 [VOICE-CLONING.md §八](./VOICE-CLONING.md));不使用任何未经授权的第三方图片/音频素材。 diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md new file mode 100644 index 00000000..8915c8e1 --- /dev/null +++ b/media/pipeline/VOICE-CLONING.md @@ -0,0 +1,218 @@ +# 科普视频配音 · 声音克隆操作手册(IndexTTS-2.5) + +> **文档定位**:本文是公共视频管线声音克隆能力(用自己的声音配音 + 轻快/自信/正能量等风格控制)的**单一参考**。 +> 管线总纲见 [README.md](./README.md);参考音色样本目录约定见 [voices/README.md](./voices/README.md)。 + +## 目录 + +1. [总览与架构](#一总览与架构) +2. [一次性部署(index-tts + 模型)](#二一次性部署index-tts--模型) +3. [参考音色样本](#三参考音色样本) +4. [风格与参数](#四风格与参数) +5. [逐集使用](#五逐集使用) +6. [缓存与幂等](#六缓存与幂等) +7. [故障排查](#七故障排查) +8. [许可与合规](#八许可与合规) +9. [备选方案与参考文献](#九备选方案与参考文献) + +## 一、总览与架构 + +**能力**:用一段 5–15 秒的本人录音作为参考音色,零样本(zero-shot)克隆出本人音色,逐句合成整集配音;并通过情感向量注入轻快、自信、正能量等风格。 + +**架构**(管线脚本轻依赖 与 重型推理环境 完全解耦): + +```mermaid +flowchart LR + subgraph 管线侧["本仓 media/pipeline(轻依赖)"] + A["tts.py
--engine indextts"] -->|"HTTP 127.0.0.1:8766
逐句 POST /synthesize"| B + end + subgraph 推理侧["~/tools/index-tts(重依赖:torch/indextts)"] + B["tts_server.py
FastAPI + IndexTTS-2.5"] --> C["模型常驻内存
MPS 串行推理"] + C --> D["22.05kHz WAV"] + D --> E["MP3 编码
soundfile / lameenc"] + end + B -->|"MP3 bytes
X-Audio-Format 头"| A + A --> F["{id}.mp3 + manifest.json
Remotion 时间轴自动重算"] +``` + +**契约不变**:无论哪个引擎,输出仍是 `<工程>/video/public/audio/{id}.mp3` 与 `manifest.json`(`durationSec` 为实测时长),下游(Remotion 场景、字幕、抽帧 QA)零改动。 + +## 二、一次性部署(index-tts + 模型) + +### 2.1 磁盘预算 + +| 项 | 占用 | +|---|---| +| index-tts 仓库 + uv 虚拟环境(含 torch) | ~4.5 GB | +| IndexTTS-2.5 checkpoints(含首跑自动下载的辅助模型) | ~6.5 GB | +| **合计** | **~11 GB**(本机部署前请确认剩余磁盘 ≥ 15 GB) + + +### 2.2 步骤 + +```bash +# 1) clone 仓库(仓库外,避免污染本仓) +mkdir -p ~/tools && cd ~/tools +git clone https://github.com/index-tts/index-tts.git +cd index-tts + +# 2) 创建环境(uv 自动安装 Python 3.11 并锁定依赖;--all-extras 会装 webui 依赖,此处省磁盘不装) +uv sync + +# 3) 下载模型(~6 GB,支持断点续传:中断后重跑同一命令即继续) +uv run hf download IndexTeam/IndexTTS-2.5 --local-dir checkpoints +# 网络不通时换 ModelScope 镜像: +# git clone https://www.modelscope.cn/models/IndexTeam/IndexTTS-2.5.git checkpoints +``` + +### 2.3 启动推理服务 + +在 index-tts 根目录: + +```bash +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 +``` + +- 启动即加载模型(约 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,属预期); +- **仅监听 127.0.0.1、无鉴权,勿暴露公网**;`ref_path` 为服务端本地绝对路径。 + + +### 2.4 部署问题与兜底 + +| 症状 | 处理 | +|---|---| +| `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 状态异常时) | +| HF 下载超时/中断 | 重跑 `hf download` 即续传;或改用 ModelScope(见 2.2) | +| 下载中途「假死」(进程在但字节零增长,连接 CLOSE_WAIT) | 强杀进程重跑即可续传:`pkill -f "hf download"` 后重复 `uv run hf download ...`;可循环重试直至完成 | +| 磁盘不足 | checkpoints 可与其它 index-tts 部署共享(启动时 `--model-dir` 指向同一目录) | + +## 三、参考音色样本 + +### 3.1 要求 + +| 项 | 要求 | +|---|---| +| 时长 | **5–15 秒**(上限 30s) | +| 内容 | 自然说话,与目标成片语速/语调一致(韵律风格会被一并克隆) | +| 环境 | 安静房间、固定麦克风距离、无 BGM/混响/系统降噪痕迹 | +| 说话人 | 仅本人一人 | +| 格式 | WAV 16-bit ≥22.05kHz 优先(mp3/m4a 经 `prepare_ref.py` 转换) | + +### 3.2 长录音裁剪(prepare_ref.py) + +```bash +# 从长录音截取 [8s, 22s) 共 14s,归一化峰值、转 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 +``` + +裁剪段须试听确认:该段人声干净、无背景音乐、语句完整。样本 SHA1 参与缓存摘要(见 §六),替换样本自动失效缓存。 + +## 四、风格与参数 + +### 4.1 风格预设(--style) + +| 预设 | 定位 | emo_vector(顺序:happy, angry, sad, afraid, disgusted, melancholic, surprised, calm) | alpha | df | +|---|---|---|---|---| +| neutral | 中性(默认) | 不注入情感,纯克隆参考音色 | — | 1.0 | +| 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 | + +### 4.2 自定义向量(--emo-vector) + +`--emo-vector "happy:0.6,calm:0.2"` 语法覆盖预设;与 `--style` 非默认值互斥。**各分量非负且总和 ≤0.8**(客户端与服务端双重校验;管线直调 `infer` 不做自动归一,超界会拒绝请求)。alpha ≤0.8 推荐(官方建议)。 + +### 4.3 语速(--duration-factor) + +0.5–2.0(>1 变慢、<1 变快)。仅 v2.5 支持;`--emo-vector` 模式默认 1.0(手动传 df 需服务为 v2.5)。 + +### 4.4 调参建议 + +风格向量是 8 维情感空间中的方向+强度,首次使用建议:固定一句文本,`--style` 四档各合成一次试听对比;同风格微调用 `--emo-alpha 0.5`(更含蓄)或 `--duration-factor 0.92`(更紧凑)。**先跑 3 句小样确认,再全量合成**。 + +## 五、逐集使用 + +```bash +# 0) 确认服务在线 +curl -s http://127.0.0.1:8766/health + +# 1) 全量合成(工程内薄包装等价) +cd media/<工程> +uv run --no-project --with mutagen scripts/tts.py --engine indextts \ + --ref <绝对路径>/media/pipeline/voices/me-1.wav --style lively + +# 2) 小样试听(先只跑 3 句:临时 narration.json 或 --force 单句验证均可) +# 3) 全量后重渲染 +pnpm run render:draft && pnpm run render +``` + +- 服务启动一次可服务多集;管线客户端不常驻模型; +- 每句 5–30 秒(MPS fp32,与文本长度相关),整集(约 100 句)预计 20–60 分钟,按句缓存可断点续跑(见 §六); +- 引擎/风格/样本任一变化都会改写时长,合成后**必须重跑草渲**让时间轴重算; +- 超长句(>120 token)服务端内部自动分段,但极端长句连续排队可能触发客户端 300s 超时——重跑即续传,无需干预。 + + +## 六、缓存与幂等 + +| 引擎 | 摘要公式 | +|---|---| +| edge(历史不变) | `sha1(voice\|rate\|text)` | +| indextts | `sha1(indextts\|engine_tag\|ref_sha1前12位\|lang\|style\|vec\|alpha\|df\|text)` | + +- sidecar `{id}.sha` 与 `{id}.mp3` 一一对应、单槽位:换引擎/风格/样本/语速 = 全量重合成(一个句 id 只有一个 mp3 槽位,这是 Remotion 契约决定的); +- 模型/服务升级后想强制刷新全部音频:`--engine-tag v2.5b`(自定义标记进摘要); +- 中断后续跑:直接重跑同命令(已完成句子全部命中缓存跳过)。 + + +## 七、故障排查 + +| 症状 | 原因 | 处理 | +|---|---|---| +| 合成请求全部失败,报「服务不可用」 | 服务未启动/端口错 | `curl 127.0.0.1:8766/health`;按 §2.3 启动;`lsof -ti:8766` 查占用 | +| 生成音频含 NaN(HTTP 500,detail 提示) | MPS 数值问题 | 客户端自动重试常可清;持续则服务加 `--device cpu` 重启(速度大幅下降,仅救急) | +| 合成极慢 / 内存飙高 | fp32 + 长句 | 服务串行推理已是缓解;进一步可 `--device cpu` 换稳定;句长已由 max_text_tokens_per_segment=120 内部切分 | +| 服务日志 `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` | +| 生成音色「不像我」 | 样本质量问题 | 按 §三 重录/重裁:换更干净段落、保证单说话人、5–15s | +| 长句合成失败 | 超时(HTTP_TIMEOUT=300s) | 重跑(缓存续传);超长句在逐字稿层面拆句 | +| edge 模式失败 | 网络 | 与历史行为一致(重试 4 次后报错) | + +## 八、许可与合规 + +- **模型许可**:IndexTTS-2.5 按 [bilibili 模型使用许可协议](https://github.com/index-tts/index-tts/blob/main/LICENSE)(bilibili Model Use License)发布——**个人/研究用途可用;商用需联系 indexspeech@bilibili.com**。制作对外发布的视频前请自行评估许可范围。 +- **声音权利**:克隆他人声音必须获得本人书面同意;本仓 `media/pipeline/voices/` 下样本已被根 `.gitignore` 忽略,绝不入库。 +- **edge-tts 义务**:edge-tts 为微软服务免费接口,成品需遵守微软服务条款;当前默认引擎仍为 edge,行为与历史完全一致。 + +## 九、备选方案与参考文献 + +### 9.1 方案对比(为何选 IndexTTS-2.5) + +| 方案 | 克隆 | 风格控制 | Mac 部署 | 备注 | +|---|---|---|---|---| +| edge-tts | ❌ 仅预置 | 仅 rate | 无需部署 | 本管线默认引擎(零成本回退) | +| **IndexTTS-2.5** | ✅ 单样本零样本 | ✅ 向量+强度+语速 | ✅ MPS fp32 | **主方案**;中英日西阿 | +| IndexTTS-2 | ✅ | ✅ 向量(无语速) | ✅ fp16 成熟 | 服务端一键回退档(`--indextts-version 2`) | +| mlx-indextts(社区 MLX 移植) | ✅ | 仅 2.0 | ✅ 最省内存 | 不支持 2.5,需自行转换权重 | +| GPT-SoVITS | ✅ 微调最佳 | 依赖参考音频 | 推理可/训练差 | 需训练工作流,过重 | +| CosyVoice 2 | ✅ 3–10s | instruct 指令 | ✅ | 克隆相似度略逊 | +| 云端(Azure Custom Voice 等) | ✅ | ✅ | 无需 | 收费/审核/隐私,不采纳 | + +### 9.2 参考文献(IEEE) + +[1] B. Si et al., "IndexTTS: An Industrial-Level Controllable and Efficient Zero-Shot Text-To-Speech System," *arXiv preprint arXiv:2502.05512*, 2025. + +[2] B. Si et al., "IndexTTS-2: Breakthrough Emotionally Expressive and Duration-Controlled Auto-Regressive Zero-Shot Text-To-Speech," *arXiv preprint arXiv:2506.21619*, 2025. + +[3] B. Si et al., "IndexTTS-2.5 Technical Report," *arXiv preprint arXiv:2601.03888*, 2026. + +[4] Skywork 博客:[Index-TTS 2 on Mac](https://skywork.ai/blog/index-tts-2-on-mac-how-i-got-emotion-aware-lip-sync-ready-tts-running-without-cuda/) —— Mac MPS 部署实测(NaN clamp 等补丁)。 + +[5] macOS 部署参考:[张洪Heo:Mac 部署 IndexTTS2](https://blog.zhheo.com/p/gulzh21p.html)。 diff --git a/media/pipeline/voices/README.md b/media/pipeline/voices/README.md new file mode 100644 index 00000000..68e21f90 --- /dev/null +++ b/media/pipeline/voices/README.md @@ -0,0 +1,29 @@ +# voices/ · 参考音色样本目录 + +本目录存放**声音克隆**用的参考音色样本(个人录音),供 `scripts/tts.py --engine indextts --ref ...` 使用。 + +## 录制要求 + +| 项 | 要求 | 原因 | +|---|---|---| +| 时长 | **5–15 秒**(最长不超过 30s) | 过长不提升克隆质量,反而拖慢每句合成的条件提取 | +| 内容 | 自然说话,与目标成片语速/语调一致 | 克隆音色+韵律风格均取自样本 | +| 环境 | 安静房间、单一麦克风距离、无背景音乐/混响/降噪痕迹 | 噪声会被一起克隆进音色 | +| 说话人 | **仅本人一人** | 多人声混杂会污染音色 | +| 格式 | WAV 16-bit(≥22.05 kHz)优先;mp3/m4a 可先经 `prepare_ref.py` 转换 | IndexTTS 内部会重采样,但干净源更稳 | + +## 使用方式 + +```bash +# 长录音先裁剪(截取 10s–25s 的一段干净人声): +uv run --no-project --with soundfile media/pipeline/scripts/prepare_ref.py \ + ~/Documents/dify/me-1.mp3 --start 10 --duration 15 + +# 合成时通过 --ref 指定: +uv run --no-project --with mutagen media/pipeline/scripts/tts.py \ + --project media/<工程> --engine indextts --ref media/pipeline/voices/me-1.wav --style lively +``` + +## 隐私提醒 + +个人声音属于生物特征信息。**本目录下的音频文件已被根 `.gitignore` 忽略,不会提交入库**;请勿通过其它途径(聊天工具/公开仓库)传播克隆源音频。克隆他人声音需获得本人书面同意,见 [VOICE-CLONING.md](../VOICE-CLONING.md) §八 许可。 From b70afc34a550c7aea1872cbda2e9ab7230511d2b Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 17 Aug 2026 18:21:27 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(tts):=20indextts=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E5=B9=B6=E5=8F=91=E9=99=8D=E4=B8=BA=201=20=E5=B9=B6?= =?UTF-8?q?=E6=8F=90=E5=8D=87=E8=B6=85=E6=97=B6=EF=BC=8C=E6=B6=88=E9=99=A4?= =?UTF-8?q?=E6=8E=92=E9=98=9F=E8=B6=85=E6=97=B6=E6=A0=B9=E5=9B=A0;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CONCURRENCY_INDEXTTS 2→1:服务端为串行推理锁,>1 的并发请求在锁后排队,排队时长计入客户端 HTTP 超时,长句场景连续超时(实测 p0-08/p0-12/p0-12b 三次命中); - HTTP_TIMEOUT 300→600:覆盖 MPS fp32 长句(33 字句实测可达 5 分钟); - 手册 §五 同步并发语义说明; 🤖 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/VOICE-CLONING.md | 2 +- media/pipeline/scripts/tts.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index 8915c8e1..c06db9c4 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -156,7 +156,7 @@ pnpm run render:draft && pnpm run render - 服务启动一次可服务多集;管线客户端不常驻模型; - 每句 5–30 秒(MPS fp32,与文本长度相关),整集(约 100 句)预计 20–60 分钟,按句缓存可断点续跑(见 §六); - 引擎/风格/样本任一变化都会改写时长,合成后**必须重跑草渲**让时间轴重算; -- 超长句(>120 token)服务端内部自动分段,但极端长句连续排队可能触发客户端 300s 超时——重跑即续传,无需干预。 +- 超长句(>120 token)服务端内部自动分段,但极端长句连续排队可能触发客户端 300s 超时——重跑即续传,无需干预。客户端并发为 1(与服务端串行推理对齐,避免排队时间计入超时),HTTP 超时 600s。 ## 六、缓存与幂等 diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 3ceb4959..204df7e2 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -36,9 +36,9 @@ DEFAULT_VOICE = "zh-CN-YunxiNeural" DEFAULT_RATE = "+4%" CONCURRENCY_EDGE = 6 -CONCURRENCY_INDEXTTS = 2 +CONCURRENCY_INDEXTTS = 1 # 服务端串行锁推理;>1 会在锁后排队,排队时长计入客户端超时 RETRIES = 4 -HTTP_TIMEOUT = 300 +HTTP_TIMEOUT = 600 # MPS fp32 长句可达数分钟;须覆盖队列等待 MANUAL = "media/pipeline/VOICE-CLONING.md" # IndexTTS 8 维情感向量顺序(indextts/infer_v2_5.py 固定):happy, angry, sad, afraid, From 0da14131d450b540eae6b38082f78f5624d28682 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 17 Aug 2026 21:41:48 +0800 Subject: [PATCH 4/6] =?UTF-8?q?docs(pipeline):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E5=A3=B0=E9=9F=B3=E5=85=8B=E9=9A=86=E6=89=8B=E5=86=8C=E4=B8=8E?= =?UTF-8?q?=20prepare=5Fref=20=E4=B8=89=E5=A4=84=E5=A4=B1=E5=AE=9E?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=EF=BC=88=E8=AF=84=E5=AE=A1=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=EF=BC=89;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 渲染命令补 cd video &&(render 脚本定义在 video/package.json,照抄原命令会报 Missing script) - 故障排查表 HTTP_TIMEOUT 300s→600s 并同步 §五 超时描述(对齐 commit b70afc34 与 tts.py:41) - 移除 m4a 可转换声明:libsndfile 1.2.2 无 MP4 容器支持,改为注明先经 ffmpeg 转 wav 🤖 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/VOICE-CLONING.md | 10 +++++----- media/pipeline/scripts/prepare_ref.py | 5 +++-- media/pipeline/voices/README.md | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/media/pipeline/VOICE-CLONING.md b/media/pipeline/VOICE-CLONING.md index c06db9c4..1d5d6edd 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -101,7 +101,7 @@ uv run --frozen --with fastapi --with uvicorn --with soundfile --with numpy --wi | 内容 | 自然说话,与目标成片语速/语调一致(韵律风格会被一并克隆) | | 环境 | 安静房间、固定麦克风距离、无 BGM/混响/系统降噪痕迹 | | 说话人 | 仅本人一人 | -| 格式 | WAV 16-bit ≥22.05kHz 优先(mp3/m4a 经 `prepare_ref.py` 转换) | +| 格式 | WAV 16-bit ≥22.05kHz 优先(mp3/flac 经 `prepare_ref.py` 转换;m4a 需先 `ffmpeg -i in.m4a out.wav`) | ### 3.2 长录音裁剪(prepare_ref.py) @@ -149,14 +149,14 @@ uv run --no-project --with mutagen scripts/tts.py --engine indextts \ --ref <绝对路径>/media/pipeline/voices/me-1.wav --style lively # 2) 小样试听(先只跑 3 句:临时 narration.json 或 --force 单句验证均可) -# 3) 全量后重渲染 -pnpm run render:draft && pnpm run render +# 3) 全量后重渲染(render 脚本定义在 video/package.json,须进入 video/) +cd video && pnpm run render:draft && pnpm run render ``` - 服务启动一次可服务多集;管线客户端不常驻模型; - 每句 5–30 秒(MPS fp32,与文本长度相关),整集(约 100 句)预计 20–60 分钟,按句缓存可断点续跑(见 §六); - 引擎/风格/样本任一变化都会改写时长,合成后**必须重跑草渲**让时间轴重算; -- 超长句(>120 token)服务端内部自动分段,但极端长句连续排队可能触发客户端 300s 超时——重跑即续传,无需干预。客户端并发为 1(与服务端串行推理对齐,避免排队时间计入超时),HTTP 超时 600s。 +- 超长句(>120 token)服务端内部自动分段;极端长句推理可达数分钟。客户端并发为 1(与服务端串行推理对齐,避免排队时间计入超时),HTTP 超时 600s;万一超时——重跑即续传,无需干预。 ## 六、缓存与幂等 @@ -182,7 +182,7 @@ pnpm run render:draft && pnpm run render | `X-Audio-Format=wav` | 服务端 MP3 编码器探测失败 | 按 §2.3 带 `--with lameenc` 重启服务 | | `/health` 报 `supports_duration_factor=false` | 服务为 IndexTTS-2 | 语速控制需 v2.5:重启服务 `--indextts-version 2.5` | | 生成音色「不像我」 | 样本质量问题 | 按 §三 重录/重裁:换更干净段落、保证单说话人、5–15s | -| 长句合成失败 | 超时(HTTP_TIMEOUT=300s) | 重跑(缓存续传);超长句在逐字稿层面拆句 | +| 长句合成失败 | 超时(HTTP_TIMEOUT=600s) | 重跑(缓存续传);超长句在逐字稿层面拆句 | | edge 模式失败 | 网络 | 与历史行为一致(重试 4 次后报错) | ## 八、许可与合规 diff --git a/media/pipeline/scripts/prepare_ref.py b/media/pipeline/scripts/prepare_ref.py index 6487b8d2..6b3b6169 100644 --- a/media/pipeline/scripts/prepare_ref.py +++ b/media/pipeline/scripts/prepare_ref.py @@ -1,7 +1,8 @@ #!/usr/bin/env python3 """参考音色样本预处理——裁剪并规范化为 IndexTTS 克隆用 WAV。 -- 输入:任意 mp3/wav/m4a/flac 录音(如手机录音、长片段素材) +- 输入:任意 mp3/wav/flac 录音(如手机录音、长片段素材;m4a 不受 libsndfile 支持, + 需先 `ffmpeg -i in.m4a out.wav`) - 输出:media/pipeline/voices/<名字>.wav —— 16-bit PCM 单声道,保留原始采样率(IndexTTS 内部重采样) - 动机:克隆参考音频建议 5–15 秒干净人声;过长样本(如 4 分钟录音)会拖慢每句合成的 条件提取,且质量并不更好。 @@ -27,7 +28,7 @@ def main() -> int: description="裁剪/规范化参考音色样本 → 16-bit 单声道 WAV" ) parser.add_argument( - "source", help="源音频文件(mp3/wav/m4a/flac 等 soundfile 可读格式)" + "source", help="源音频文件(mp3/wav/flac 等 soundfile 可读格式;m4a 需先 ffmpeg 转 wav)" ) parser.add_argument( "--start", type=float, default=0.0, help="裁剪起点(秒,默认 0)" diff --git a/media/pipeline/voices/README.md b/media/pipeline/voices/README.md index 68e21f90..e5771822 100644 --- a/media/pipeline/voices/README.md +++ b/media/pipeline/voices/README.md @@ -10,7 +10,7 @@ | 内容 | 自然说话,与目标成片语速/语调一致 | 克隆音色+韵律风格均取自样本 | | 环境 | 安静房间、单一麦克风距离、无背景音乐/混响/降噪痕迹 | 噪声会被一起克隆进音色 | | 说话人 | **仅本人一人** | 多人声混杂会污染音色 | -| 格式 | WAV 16-bit(≥22.05 kHz)优先;mp3/m4a 可先经 `prepare_ref.py` 转换 | IndexTTS 内部会重采样,但干净源更稳 | +| 格式 | WAV 16-bit(≥22.05 kHz)优先;mp3/flac 可先经 `prepare_ref.py` 转换;m4a 需先 `ffmpeg -i in.m4a out.wav` | IndexTTS 内部会重采样,但干净源更稳 | ## 使用方式 From 86b165c476c53da84cafa512c4a29288aa36dfd8 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 17 Aug 2026 22:40:13 +0800 Subject: [PATCH 5/6] =?UTF-8?q?docs(pipeline):=20=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E8=AF=84=E5=AE=A1=E6=8C=87=E5=87=BA=E7=9A=84=E4=B8=89=E5=A4=84?= =?UTF-8?q?=E5=A4=B1=E5=AE=9E=E6=8F=8F=E8=BF=B0=E4=B8=8E=E9=9A=90=E6=80=A7?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=E5=86=99=E6=B3=95;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VOICE-CLONING.md §4.2 与 tts.py 顶部注释:情感向量约束实为有效和(Σ分量×emo-alpha)≤0.8, 而非分量总和 ≤0.8(positive 预设总和 0.95 即为反例),补 happy:1.0@alpha=0.6 放行示例; - tts.py parse_emo_vector:NaN/Inf 拦截改为显式 math.isfinite(与服务端写法对齐, 拒绝/放行语义经 10 用例回归验证不变); - README.md 公共脚本表:prepare_ref.py 无工程薄包装,改为仓库根全路径调用指引。 🤖 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 | 2 +- media/pipeline/VOICE-CLONING.md | 2 +- media/pipeline/scripts/tts.py | 7 +++---- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/media/pipeline/README.md b/media/pipeline/README.md index c866cb2f..cb65faba 100644 --- a/media/pipeline/README.md +++ b/media/pipeline/README.md @@ -56,7 +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 声音克隆) | `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/prepare_ref.py](./scripts/prepare_ref.py) | 参考音色样本裁剪/规范化(长录音 → 5–15s 干净 WAV) | `uv run --no-project --with soundfile --with numpy scripts/prepare_ref.py <源音频>` | +| [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` | 中心脚本以 `--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 1d5d6edd..ac62d232 100644 --- a/media/pipeline/VOICE-CLONING.md +++ b/media/pipeline/VOICE-CLONING.md @@ -127,7 +127,7 @@ uv run --no-project --with soundfile --with numpy \ ### 4.2 自定义向量(--emo-vector) -`--emo-vector "happy:0.6,calm:0.2"` 语法覆盖预设;与 `--style` 非默认值互斥。**各分量非负且总和 ≤0.8**(客户端与服务端双重校验;管线直调 `infer` 不做自动归一,超界会拒绝请求)。alpha ≤0.8 推荐(官方建议)。 +`--emo-vector "happy:0.6,calm:0.2"` 语法覆盖预设;与 `--style` 非默认值互斥。**各分量非负,且有效和(Σ分量×emo-alpha)≤ 0.8**(客户端与服务端双重校验;管线直调 `infer` 不做自动归一,超界会拒绝请求;如 `happy:1.0` 在 alpha=0.6 下有效和 0.6,可放行)。alpha ≤0.8 推荐(官方建议)。 ### 4.3 语速(--duration-factor) diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 204df7e2..62b399d9 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -26,6 +26,7 @@ import asyncio import hashlib import json +import math import sys import urllib.error import urllib.request @@ -42,7 +43,7 @@ MANUAL = "media/pipeline/VOICE-CLONING.md" # IndexTTS 8 维情感向量顺序(indextts/infer_v2_5.py 固定):happy, angry, sad, afraid, -# disgusted, melancholic, surprised, calm。分量和须 ≤0.8(直调 infer 不自动归一,双端校验)。 +# disgusted, melancholic, surprised, calm。有效和(Σ分量×emo_alpha)须 ≤0.8(直调 infer 不自动归一,双端校验)。 EMO_KEYS = [ "happy", "angry", @@ -105,9 +106,7 @@ def parse_emo_vector(spec: str) -> list[float]: if not val: raise ValueError(f"情感权重缺失:{key}(格式如 happy:0.6)") weight = float(val) - if not (weight >= 0) or weight == float( - "inf" - ): # 拦 NaN/Inf(比较恒 False 漏网) + if not math.isfinite(weight) or weight < 0: # isfinite 显式拦 NaN/Inf raise ValueError(f"情感权重必须为非负有限数值:{key}") vec[EMO_KEYS.index(key)] = weight seen += 1 From 040fa6231fd6001c391628958c6ef57bdf87d026 Mon Sep 17 00:00:00 2001 From: ThreeFish Date: Mon, 17 Aug 2026 23:09:32 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(tts):=20=E8=AF=84=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2=E2=80=94=E2=80=94server=20=E5=B0=BE=E6=96=9C=E6=9D=A0?= =?UTF-8?q?=E5=BD=92=E4=B8=80=E6=94=B6=E5=8F=A3=E4=B8=8E=E6=83=85=E6=84=9F?= =?UTF-8?q?=E9=94=AE=E9=87=8D=E5=A4=8D=E6=98=BE=E5=BC=8F=E6=8A=A5=E9=94=99?= =?UTF-8?q?;?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - --server 尾部斜杠原仅 synthesize 拼接处 rstrip、health 检查拼出 //health 致 404 误报「服务不可用」;改为解析参数后统一归一化一处收口; - parse_emo_vector 原对重复情感键静默 last-wins 丢弃首个权重;改为显式报「情感键重复」,与未知键/负值/NaN 同级校验。 🤖 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 | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/media/pipeline/scripts/tts.py b/media/pipeline/scripts/tts.py index 62b399d9..efec89c9 100644 --- a/media/pipeline/scripts/tts.py +++ b/media/pipeline/scripts/tts.py @@ -97,20 +97,22 @@ def mp3_duration(path: Path) -> float: def parse_emo_vector(spec: str) -> list[float]: """`happy:0.6,calm:0.2` → 8 维向量;未知键/负值/空集报错。""" vec = [0.0] * 8 - seen = 0 + seen: set[str] = set() for part in spec.split(","): key, _, val = part.partition(":") key, val = key.strip().lower(), val.strip() if key not in EMO_KEYS: raise ValueError(f"未知情感键 {key!r}(可用:{','.join(EMO_KEYS)})") + if key in seen: + raise ValueError(f"情感键重复:{key}") if not val: raise ValueError(f"情感权重缺失:{key}(格式如 happy:0.6)") weight = float(val) if not math.isfinite(weight) or weight < 0: # isfinite 显式拦 NaN/Inf raise ValueError(f"情感权重必须为非负有限数值:{key}") vec[EMO_KEYS.index(key)] = weight - seen += 1 - if seen == 0: + seen.add(key) + if not seen: raise ValueError("--emo-vector 不能为空") return vec @@ -244,7 +246,7 @@ def http_synthesize( if vec is not None: payload["emo_vector"] = vec req = urllib.request.Request( - f"{server.rstrip('/')}/synthesize", + f"{server}/synthesize", data=json.dumps(payload).encode(), method="POST", headers={"Content-Type": "application/json"}, @@ -403,6 +405,7 @@ async def main() -> None: help="[indextts] 缓存标记;模型升级后自定义以失效旧缓存", ) args = parser.parse_args() + args.server = args.server.rstrip("/") # 尾斜杠归一:health/synthesize 两处拼 URL 前收口 if args.list_styles: print(