整体架构与工作原理
全景图
text
用户消息
│
▼
┌──────────────┐
│ AstrBot │ AI 生成回复
└──────┬───────┘
▼
┌──────────────────────┐
│ AstrBot 插件(客户端)│ on_decorating_result:发送消息前拦截
│ ① 过滤()()[]【】 │ 支持嵌套括号
│ ② 判断是否值得合成 │ 太短 / 太长 / 全是括号 → 直接发文字
└──────┬───────────────┘
│ POST /api/tts/file (带 X-API-Key)
▼
┌────────────────────────────────────────┐
│ 服务端(另一台机器也行) │
│ │
│ FastAPI ──► 队列 / 并发限制 / 超时 │
│ │ │
│ ▼ │
│ 长文本分段 chunk.max_chars │
│ │ │
│ ▼ │
│ Edge TTS ──► RVC ──► wav │
│ │ ▲ │
│ │ 你的 .pth 模型 │
│ │ │
│ └─ 失败自动重试 → 仍失败则本地离线语音 │
│ │
│ 显存不足 → 自动降级 CPU 重试 │
└──────────────┬─────────────────────────┘
│ 音频字节流
▼
┌──────────────────────┐
│ 插件把 Plain 文本段 │
│ 替换为 Record 语音段 │
└──────┬───────────────┘
▼
AstrBot 发送语音消息
任意环节失败 ────────────► 发送原始文字(内容一字不改)服务端内部
服务端是一个 FastAPI 应用,核心代码在 tts-server/:
| 文件 | 职责 |
|---|---|
main.py | HTTP 路由、鉴权、错误码映射、启动入口 |
engine.py | 真正干活的地方:分段、调用 TTS+RVC、重试、显存降级、语音源选择 |
config.py | 配置加载 / 校验 / 环境变量覆盖 |
storage.py | 音频落盘、按文本缓存、过期清理 |
webui.py | 网页控制台后端:状态接口 + 登录 |
static/index.html | 网页控制台前端(单文件、零外链) |
一次合成请求的处理顺序:
- 校验文本 —— 空文本、超长文本直接拒绝(不会浪费 GPU)
- 查缓存 —— 相同文本在保留期内直接复用已有音频,秒回
- 进队列 ——
max_concurrent限制同时推理的数量,max_queue_size限制排队长度 - 分段 —— 超过
chunk.max_chars的文本按句号/逗号切成多段 - 逐段推理 —— 每段:Edge TTS 合成 → RVC 换音色
- 拼接 —— 用 ffmpeg 把多段音频无损拼成一段
- 落盘 + 记录 —— 写入
output/,到点自动删除
三个"不会失败"的设计
① 在线语音瞬时故障 → 自动重试
Edge TTS 是微软的公开朗读接口,不是承诺可用的 API,会限流、会抽风,典型报错:
No audio was received. Please verify that your parameters are correct.Cannot connect to host speech.platform.bing.comConnection timeout to host wss://speech.platform.bing.com/...
这些都被归类为可重试错误,服务端会自动重试(默认 3 次,带退避+随机抖动)。
② 重试仍然失败 → 切本地离线语音
tts.source: "auto"(默认)时,在线语音彻底不可用会自动改用本地语音:
| 系统 | 本地语音 | 需要安装 |
|---|---|---|
| Windows | 系统自带 SAPI5 | 不用,系统自带(如 Microsoft Huihui Desktop) |
| Linux / macOS | espeak-ng | sudo apt install -y espeak-ng |
本地语音音色比较机械,但经过 RVC 转换之后可用 —— 至少断网也能出声。
③ 显存不够 → 自动降级 CPU
两种情况都会自动降级,请求不会失败:
- 启动时:显存低于
rvc.min_vram_mb→ 直接选 CPU - 运行时:真的 OOM 了 → 自动切 CPU 重跑这一句
状态会记录在 /api/health 的 device_fallback 字段和网页控制台上。
分段为什么能省显存
RVC 推理的显存占用与单次输入的音频时长近似线性相关,与文本总长度无关。 实测约 15 MB / 字(中文)。
text
不分段:
10 字 → 815 MB
100 字 → 2289 MB
192 字 → 3493 MB ← 2GB 显存直接爆
按 80 字分段:
10 字 → 815 MB
100 字 → 1941 MB ← 被切成 2 段,峰值只看单段
192 字 → 1941 MB ← 被切成 3 段,峰值不变所以 2 GB 显存的小卡也能处理任意长度的文本,代价是分段处可能有极轻微的停顿。
想减少分段就把 chunk.max_chars 调大(需要更多显存)。
客户端内部
AstrBot 插件用的是官方 on_decorating_result 钩子 —— 在消息发出前拿到消息链, 所以可以把它改掉。
text
on_decorating_result
│
├─ 不是大模型回复? ──► 放行
├─ 已经处理过? ──► 放行(防重复)
├─ 平台不支持语音? ──► 放行(走 skip_platforms)
├─ 开关关掉了? ──► 放行(/voice off)
│
▼
过滤括号内容
│
├─ 过滤后为空 ──► 发原文
├─ 超过 max_text_length ─► 发原文
│
▼
POST /api/tts/file (信号量限流 + 超时)
│
├─ 失败 / 超时 / 401 ──► 发原文
│
▼
把 Plain 文本段替换成 Record 语音段括号过滤用的是字符扫描 + 深度计数(不是简单正则),所以能正确处理嵌套:
| 输入 | 实际送去合成的文本 |
|---|---|
你好呀!(开心地笑) | 你好呀! |
你好!(开心地说:[笑]) | 你好! |
【系统提示】你好,很高兴见到你。 | 你好,很高兴见到你。 |
(开心)[笑]【挥手】 | 空 → 不合成,直接发原文 |
规则细节见 AstrBot 插件文档。