文章

SP-03 · 用 ChatTTS 构建本地对话式语音合成工具

#059 · 2026-04-18 · Python

引言:直击痛点

很多人第一次接触 ChatTTS,会直接照着 README 写 chat.load_models()chat.infer()。结果一运行就卡住:Python 版本不合适、PyTorch 没装、模型权重没下载、显存不够、WebUI 启动后不知道每个参数在控制什么。

这篇不做 README 翻译,而是把 ChatTTS 当成一个真实开源项目来工程化拆解。我们先用标准库写一个 MockChatTTS,默认就能生成 WAV 文件和实时进度;再提供 real 模式接入真实 ChatTTS。这样新手先看懂“文本 -> 模型 -> 波形 -> WAV -> WebUI”的流水线,再去处理重依赖环境。

授权与安全提醒:ChatTTS 项目采用 CC BY-NC 4.0 非商业授权,本文仅用于学习、研究与本地实验。不要用于冒充真人、欺骗性内容、商业合成或任何违反法律与伦理的场景。

知识图谱导航:建议先回顾《38 · 音频信号处理》里的采样率和波形概念,《47 · 本地 AI Agent 调度》里的本地模型加载思路,以及《54 · 性能探针》里的依赖与性能边界意识。


步步为营:核心逻辑拆解

Step 1:先把重依赖拆成可理解的工程模块

痛点解析:ChatTTS 不是一个普通小脚本。torch 是模型运行底座,transformers 和 tokenizer 负责文本 token,vocos 像“声带”,把模型输出还原成波形,omegaconf 负责读取模型配置。直接让新手装一堆依赖,很容易还没理解流程就被环境劝退。

实战代码

requirements = [
    ("torch", "模型张量计算底座,像发动机"),
    ("transformers", "文本 token 与模型组件,像语言翻译器"),
    ("vocos", "声码器,把声学特征变成波形,像喉咙"),
    ("omegaconf", "读取模型配置,像施工图纸"),
    ("gradio", "WebUI 封装,像操作面板"),
]

print("ChatTTS 依赖拆解")
for name, role in requirements:
    print(f"- {name:<12} -> {role}")


# ChatTTS 依赖拆解
# - torch        -> 模型张量计算底座,像发动机
# - transformers -> 文本 token 与模型组件,像语言翻译器
# - vocos        -> 声码器,把声学特征变成波形,像喉咙

极客点评:先拆依赖,是为了让读者知道自己在装什么。TTS 管线不是“文字变声音”的魔法,而是“文本处理器 + 生成模型 + 声码器 + 音频保存器”接力完成。


Step 2:用 VoiceParams 收拢音色、随机性和韵律参数

痛点解析:真实 ChatTTS 的参数分散在 spk_embtemperaturetop_Ptop_Kparams_refine_text 里。新手如果到处传字典,很快就会乱。先把参数收拢成一个 dataclass,代码就像填表一样清楚。

实战代码

from dataclasses import dataclass

@dataclass
class VoiceParams:
    speaker_seed: int = 2
    text_seed: int = 42
    temperature: float = 0.3
    top_p: float = 0.7
    top_k: int = 20
    refine_text: bool = True

    def infer_code_params(self) -> dict[str, float | int | str]:
        return {
            "temperature": self.temperature,
            "top_P": self.top_p,
            "top_K": self.top_k,
            "prompt": "[speed_5]",
        }

    def refine_text_params(self) -> dict[str, str]:
        return {"prompt": "[oral_2][laugh_0][break_6]"}

params = VoiceParams(speaker_seed=8, temperature=0.35)
print("推理参数:", params.infer_code_params())
print("文本润色参数:", params.refine_text_params())

# 💻 终端预期输出:
# 推理参数: {'temperature': 0.35, 'top_P': 0.7, 'top_K': 20, 'prompt': '[speed_5]'}
# 文本润色参数: {'prompt': '[oral_2][laugh_0][break_6]'}

极客点评speaker_seed 像“挑演员”,temperature 像“表演自由度”,top_P/top_K 像“台词候选范围”。把这些参数收进 VoiceParams,后面无论接 Mock 还是接真模型,接口都稳定。


Step 3:用 MockChatTTS 跑通模型加载和说话人采样

痛点解析:真实模型加载可能需要下载权重、占用显存,还可能因为 Python 版本不匹配失败。教学第一步不应该卡在这里。Mock 模型不追求音质,只模拟“加载模型 -> 采样说话人 -> 准备推理”的流程。

实战代码

import random

class MockChatTTS:
    def __init__(self) -> None:
        self.loaded = False

    def load_models(self, source: str = "mock", local_path: str | None = None) -> None:
        self.loaded = True
        print(f"[mock] 模型加载完成 source={source} local_path={local_path or 'N/A'}")

    def sample_random_speaker(self, seed: int) -> str:
        rng = random.Random(seed)
        return f"mock-speaker-{rng.randint(1000, 9999)}"

chat = MockChatTTS()
chat.load_models()
print("说话人:", chat.sample_random_speaker(2))

# 💻 终端预期输出:
# [mock] 模型加载完成 source=mock local_path=N/A
# 说话人: mock-speaker-1926

极客点评:Mock 就像飞行模拟器。它不是让你真的飞上天,而是让你先熟悉仪表盘、油门和方向舵。等流程熟了,再换真实 ChatTTS 模型。


Step 4:不用 numpy,也能生成一个真实可播放的 WAV 文件

痛点解析:TTS 最终产物不是字符串,而是音频波形。为了让默认代码 100% 可运行,我们用标准库 wave 生成一个模拟语音 WAV。它不是真人音色,但能让读者立刻看到文件输出。

实战代码

import math
import struct
import tempfile
import wave
from pathlib import Path

SAMPLE_RATE = 24_000

def save_wav(samples: list[float], path: Path, sample_rate: int = SAMPLE_RATE) -> Path:
    with wave.open(str(path), "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sample_rate)
        for value in samples:
            safe = max(-1.0, min(1.0, value))
            wf.writeframes(struct.pack("<h", int(safe * 32767)))
    return path

samples = [0.25 * math.sin(2 * math.pi * 220 * i / SAMPLE_RATE) for i in range(SAMPLE_RATE)]
out = Path(tempfile.gettempdir()) / "chattts_step4_demo.wav"
save_wav(samples, out)
print("WAV 已生成:", out)
print("文件大小:", out.stat().st_size, "bytes")

# 💻 终端预期输出:
# WAV 已生成: /tmp/chattts_step4_demo.wav
# 文件大小: 48044 bytes

极客点评:采样率 24000 就像每秒拍 24000 张“声音照片”。wave 文件只是把这些照片按固定格式装订成册,播放器就能读懂。


Step 5:给长任务加实时进度反馈

痛点解析:模型加载和音频推理都可能耗时。如果终端一片安静,新手会以为程序卡死。进度条不一定代表真实 token 流,但至少能把“程序还活着”这件事讲清楚。

实战代码

import time

def progress(label: str, steps: int = 8, delay: float = 0.02) -> None:
    print(label)
    for i in range(1, steps + 1):
        bar = "█" * i + "░" * (steps - i)
        print(f"  [{bar}] {i}/{steps}")
        time.sleep(delay)

progress("正在模拟 ChatTTS 推理")
print("完成:音频已准备写入 WAV")

# 💻 终端预期输出:
# 正在模拟 ChatTTS 推理
#   [█░░░░░░░] 1/8
#   ...
#   [████████] 8/8
# 完成:音频已准备写入 WAV

极客点评:进度条像餐厅后厨的取餐屏。哪怕菜还没端出来,用户也知道订单正在处理,而不是被系统遗忘了。


Step 6:真实模式必须延迟导入,并给出友好修复建议

痛点解析:ChatTTS 的真实模式依赖 torchtorchaudioChatTTS 等重包。你自己的电脑可能已经装好,也可能一运行就遇到 ModuleNotFoundError。脚本不能在启动时就炸掉,而应该先让 mock 模式可用;只有用户明确选择 real 模式时,才检查真实依赖并给出清晰提示。

实战代码

def check_real_dependencies() -> None:
    try:
        import torch
        import torchaudio
        import ChatTTS
    except Exception as exc:
        print("真实模式缺少依赖。建议使用 Python 3.10/3.11 创建独立环境:")
        print("  pip install -r /Users/zhaosj/Documents/GitHub/Python/ChatTTS/requirements.txt")
        print(f"原始错误: {type(exc).__name__}: {exc}")
        return
    print("真实 ChatTTS 依赖已就绪")

check_real_dependencies()

# 💻 终端预期输出:
# 如果网页运行器或本机环境没有装好依赖,会看到类似:
# 真实模式缺少依赖。建议使用 Python 3.10/3.11 创建独立环境:
#   pip install -r /Users/zhaosj/Documents/GitHub/Python/ChatTTS/requirements.txt
# 原始错误: ModuleNotFoundError: No module named 'torch'
#
# 如果你已经装好依赖,会看到:
# 真实 ChatTTS 依赖已就绪

极客点评:延迟导入像机场安检分流。普通游客走 Mock 通道马上体验;真正要上飞机的人再进入依赖检查通道,缺证件就给出明确补办路线。


极客实战:完整工程源码

下面这份脚本默认 mock 模式可直接运行,会生成一个真实 WAV 文件;real 模式用于接入本地 ChatTTS;webui-plan 用来解释 WebUI 组件如何映射模型参数。

#!/usr/bin/env python3
"""
sp-03-chattts-local-tts.py - ChatTTS 本地语音合成工程化演示

用法:
  python3 sp-03-chattts-local-tts.py --mode mock
  python3 sp-03-chattts-local-tts.py --mode real --local-path /path/to/ChatTTS-model
  python3 sp-03-chattts-local-tts.py --mode webui-plan

默认 mock 模式只依赖 Python 标准库,会生成一个可播放的 WAV 文件,方便先理解工程流程。
real 模式才会延迟导入 torch / torchaudio / ChatTTS。
"""

import argparse
import math
import random
import struct
import tempfile
import time
import wave
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

SAMPLE_RATE = 24_000
DEFAULT_TEXT = "ChatTTS 可以把对话文本合成为更自然的语音,但工程上要先处理环境、参数和安全边界。"


@dataclass
class VoiceParams:
    """语音生成参数:把模型推理参数收拢成一个可传递对象。"""

    speaker_seed: int = 2
    text_seed: int = 42
    temperature: float = 0.3
    top_p: float = 0.7
    top_k: int = 20
    refine_text: bool = True

    def infer_code_params(self) -> dict[str, float | int | str]:
        return {
            "temperature": self.temperature,
            "top_P": self.top_p,
            "top_K": self.top_k,
            "prompt": "[speed_5]",
        }

    def refine_text_params(self) -> dict[str, str]:
        return {"prompt": "[oral_2][laugh_0][break_6]"}


class MockChatTTS:
    """标准库模拟器:不下载模型,也能展示 TTS 工程管线。"""

    def __init__(self) -> None:
        self.loaded = False

    def load_models(self, source: str = "mock", local_path: str | None = None) -> None:
        self.loaded = True
        print(f"[mock] 模型加载完成 source={source} local_path={local_path or 'N/A'}")

    def sample_random_speaker(self, seed: int) -> str:
        rng = random.Random(seed)
        return f"mock-speaker-{rng.randint(1000, 9999)}"

    def infer(self, text: str, params: VoiceParams) -> list[float]:
        if not self.loaded:
            raise RuntimeError("请先调用 load_models()")
        rng = random.Random(params.speaker_seed + params.text_seed)
        duration = min(4.0, max(1.0, len(text) / 18.0))
        total = int(SAMPLE_RATE * duration)
        base_freq = 180 + rng.randint(0, 120)
        waveform: list[float] = []
        for i in range(total):
            t = i / SAMPLE_RATE
            envelope = min(1.0, i / 2400) * min(1.0, (total - i) / 2400)
            value = 0.25 * envelope * math.sin(2 * math.pi * base_freq * t)
            value += 0.08 * envelope * math.sin(2 * math.pi * (base_freq * 2.01) * t)
            waveform.append(value)
        return waveform


def save_wav(samples: Iterable[float], path: Path, sample_rate: int = SAMPLE_RATE) -> Path:
    """把 -1.0~1.0 的浮点采样保存成 16-bit PCM WAV。"""
    values = [max(-1.0, min(1.0, x)) for x in samples]
    with wave.open(str(path), "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sample_rate)
        for value in values:
            wf.writeframes(struct.pack("<h", int(value * 32767)))
    return path


def progress(label: str, steps: int = 12, delay: float = 0.02) -> None:
    """终端实时反馈:让用户看到任务正在推进。"""
    print(label)
    for i in range(1, steps + 1):
        bar = "█" * i + "░" * (steps - i)
        print(f"  [{bar}] {i}/{steps}")
        time.sleep(delay)


def run_mock(text: str, out: Path, params: VoiceParams) -> Path:
    """默认演示路径:不依赖模型权重,生成一个真实 WAV 文件。"""
    chat = MockChatTTS()
    progress("Step 1/4 加载 Mock 模型")
    chat.load_models()
    speaker = chat.sample_random_speaker(params.speaker_seed)
    print(f"Step 2/4 采样说话人: {speaker}")
    print(f"Step 3/4 推理文本: {text[:40]}")
    samples = chat.infer(text, params)
    progress("Step 4/4 写入 WAV 文件", steps=8)
    save_wav(samples, out)
    print(f"完成: {out} ({out.stat().st_size} bytes, {SAMPLE_RATE} Hz)")
    return out


def run_real(text: str, out: Path, params: VoiceParams, local_path: str | None) -> Path:
    """真实 ChatTTS 路径:延迟导入依赖,失败时给出可执行修复建议。"""
    try:
        import torch
        import torchaudio
        import ChatTTS
    except Exception as exc:
        raise SystemExit(
            "真实模式缺少依赖。建议使用 Python 3.10/3.11 创建独立环境后执行:\n"
            "  pip install -r /Users/zhaosj/Documents/GitHub/Python/ChatTTS/requirements.txt\n"
            f"原始错误: {type(exc).__name__}: {exc}"
        ) from exc

    chat = ChatTTS.Chat()
    if local_path:
        chat.load_models(source="local", local_path=local_path, compile=False)
    else:
        chat.load_models(compile=False)

    torch.manual_seed(params.speaker_seed)
    speaker = chat.sample_random_speaker()
    infer_params = params.infer_code_params()
    infer_params["spk_emb"] = speaker
    refine_params = params.refine_text_params()

    refined_text = [text]
    if params.refine_text:
        refined_text = chat.infer(
            refined_text,
            skip_refine_text=False,
            refine_text_only=True,
            params_refine_text=refine_params,
            params_infer_code=infer_params.copy(),
        )

    wavs = chat.infer(
        refined_text,
        skip_refine_text=True,
        params_refine_text=refine_params,
        params_infer_code=infer_params,
    )
    torchaudio.save(str(out), torch.from_numpy(wavs[0]), SAMPLE_RATE)
    print(f"完成: {out} ({out.stat().st_size} bytes, {SAMPLE_RATE} Hz)")
    return out


def print_webui_plan() -> None:
    """把 webui.py 的界面结构翻译成工程清单。"""
    rows = [
        ("Textbox", "输入文本", "给模型的原始台词"),
        ("Slider", "temperature/top_P/top_K", "控制采样随机性"),
        ("Number", "audio_seed/text_seed", "复现说话人和文本润色"),
        ("Checkbox", "refine_text", "是否先润色文本"),
        ("Audio", "输出 WAV", "把 numpy 波形交给浏览器播放"),
    ]
    print("ChatTTS WebUI 组件规划")
    for widget, field, role in rows:
        print(f"- {widget:<8} | {field:<24} | {role}")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="ChatTTS 本地语音合成工程化演示")
    parser.add_argument("--mode", choices=["mock", "real", "webui-plan"], default="mock")
    parser.add_argument("--text", default=DEFAULT_TEXT)
    parser.add_argument("--out", default="")
    parser.add_argument("--local-path", default=None, help="真实 ChatTTS 模型本地路径")
    parser.add_argument("--speaker-seed", type=int, default=2)
    parser.add_argument("--text-seed", type=int, default=42)
    parser.add_argument("--temperature", type=float, default=0.3)
    parser.add_argument("--top-p", type=float, default=0.7)
    parser.add_argument("--top-k", type=int, default=20)
    parser.add_argument("--no-refine", action="store_true")
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    params = VoiceParams(
        speaker_seed=args.speaker_seed,
        text_seed=args.text_seed,
        temperature=args.temperature,
        top_p=args.top_p,
        top_k=args.top_k,
        refine_text=not args.no_refine,
    )
    out = Path(args.out) if args.out else Path(tempfile.gettempdir()) / "chattts_mock_output.wav"

    if args.mode == "webui-plan":
        print_webui_plan()
    elif args.mode == "real":
        run_real(args.text, out, params, args.local_path)
    else:
        run_mock(args.text, out, params)


if __name__ == "__main__":
    main()

运行示例

$ python3 sp-03-chattts-local-tts.py --mode mock
Step 1/4 加载 Mock 模型
  [█░░░░░░░░░░░] 1/12
  [██░░░░░░░░░░] 2/12
  ...
[mock] 模型加载完成 source=mock local_path=N/A
Step 2/4 采样说话人: mock-speaker-1926
Step 3/4 推理文本: ChatTTS 可以把对话文本合成为更自然的语音
Step 4/4 写入 WAV 文件
  [█░░░░░░░] 1/8
  ...
完成: /tmp/chattts_mock_output.wav (约 100KB, 24000 Hz)

$ python3 sp-03-chattts-local-tts.py --mode webui-plan
ChatTTS WebUI 组件规划
- Textbox  | 输入文本                 | 给模型的原始台词
- Slider   | temperature/top_P/top_K  | 控制采样随机性
- Number   | audio_seed/text_seed     | 复现说话人和文本润色

架构师结语

ChatTTS 真正值得学习的,不只是“能不能合成声音”,而是它背后的工程链路:环境隔离、模型加载、参数收口、可复现随机种子、韵律控制、音频落盘和 WebUI 封装。先用 Mock 跑通管线,再接真实模型,这才是复杂 AI 项目最稳的学习方式。