Files
ai-companion/app/voice/tts.py
T
ylhan 35e107c290 助手本体:会话引擎、LLM 路由、记忆、身份、语音适配
从 Obsidian 笔记库剥离为独立仓库。规划文档与 P0 报告留在笔记库,
代码(助手本体、TTS 服务、P0 基准脚本、ESP32 固件)在此。

架构:服务器是助手,本地是客户端
- 助手本体跑在 cadadmin-server,N5095 只做 USB 外设桥与网络桥接
- 起因:实测 N5095 无 AVX2,跑不动可用的语音模型

当前技术栈
- LLM:DeepSeek deepseek-flash(云端,必须关思考)
- TTS:Qwen3-TTS 1.7B(服务器 3090,独立 HTTP 服务)
- STT:Zipformer 中英双语 int8(CPU,中文识别率未测)

实测:端到端 16/16 通过,首音延迟 1545ms
(对比:本地 LLM + vits-zh-ll 是 179ms,但音色不满意——音质与延迟
在此硬件上此消彼长)

设计要点
- MemoryStore 接口按"可能远程"设计(全 async、可超时、可失败)
- user_id / session_id / track_id 三者分开,人脸只作候选不自动切身份
- 记忆查询强制带 owner_user_id,无按 id 单独取的口子
- 自动抽取的记忆是"候选",默认不参与检索
- 异步写记忆绑定创建时的身份,不读"当前用户"
- 引擎与传输解耦,只用文件音频即可端到端测试

模型与音频产物不进版本管理(1.1GB),由 download_models.sh 恢复
2026-09-15 11:05:09 +08:00

158 lines
5.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""本地 TTS(sherpa-onnx VITS 中文)。
选型依据(P0-语音基准报告.md §六之二):
vits-zh-ll 服务器 RTF 0.09 / N5095 RTF 1.86 ← 用户试听选定,音质最好
piper-huayan-med 服务器 0.36 / N5095 0.32 (音质次之)
kokoro 1.96 以上,排除
**两个必须写进代码的实测结论:**
1. **VITS 是非自回归的,没有"边合成边播"。**
实测回调只在合成结束后触发一次,所以首音延迟 == 整句合成耗时。
提前出声的唯一手段是**按语义短句逐句合成**——把长回复切成短句,
第一句合成完就能播。
2. **`max_num_sentences=1` 会静默截断多句输入。**
传含多个句号的文本只会合成第一句,**不报错**。
第一版测"长段"时只出 0.94 秒音频,差点当成正常结果。
所以这里必须先分句,逐句送。
另外按方案 §5 要求,输出是 16kHz 单声道 float32;调用方负责重采样。
"""
from __future__ import annotations
import logging
import re
from pathlib import Path
from typing import Iterator
import numpy as np
import sherpa_onnx
from ..config import VoiceConfig
log = logging.getLogger(__name__)
#: 句末标点。中英文都要,方案 §5 要求覆盖中英混读。
_SENT_END = "。!?;!?;"
#: 从句标点,长句再切一刀,避免单句合成太久
_CLAUSE = ",,、::"
#: 单句最大字符数。超过就按从句标点再切——
#: 句子越长,首音延迟越大(首音 == 整句合成耗时)。
MAX_CHARS_PER_SEGMENT = 30
class TTSError(RuntimeError):
pass
def split_sentences(text: str, *, max_chars: int = MAX_CHARS_PER_SEGMENT) -> list[str]:
"""按语义切分,返回可逐句合成的片段。
先按句末标点切,过长的再按从句标点切,仍过长就硬切。
标点保留在片段末尾(TTS 需要它来判断语调)。
这个函数存在的原因是 `max_num_sentences=1` 会静默丢内容——
不自己切,就会"念到一半没了"。
"""
text = (text or "").strip()
if not text:
return []
parts = re.split(rf"(?<=[{_SENT_END}])", text)
out: list[str] = []
for p in parts:
p = p.strip()
if not p:
continue
if len(p) <= max_chars:
out.append(p)
continue
# 过长:按从句标点再切
subs = re.split(rf"(?<=[{_CLAUSE}])", p)
buf = ""
for s in subs:
if not s:
continue
if len(buf) + len(s) <= max_chars:
buf += s
else:
if buf:
out.append(buf)
# 单从句仍然过长,硬切
while len(s) > max_chars:
out.append(s[:max_chars])
s = s[max_chars:]
buf = s
if buf:
out.append(buf)
return out
class LocalTTS:
def __init__(self, cfg: VoiceConfig):
self.cfg = cfg
d = Path(cfg.model_dir) / "tts"
root = Path(cfg.model_dir)
files = {
"model": d / "model.onnx",
"lexicon": d / "lexicon.txt",
"tokens": d / "tokens.txt",
}
missing = [k for k, p in files.items() if not p.is_file()]
if missing:
raise TTSError(
f"TTS 模型文件缺失:{missing}(目录 {d})。"
f"先跑 p0/stt_tts_bench/download_models.sh")
rule_fsts = ",".join(
str(d / f) for f in ("date.fst", "number.fst", "phone.fst", "new_heteronym.fst")
if (d / f).is_file())
self._tts = sherpa_onnx.OfflineTts(sherpa_onnx.OfflineTtsConfig(
model=sherpa_onnx.OfflineTtsModelConfig(
vits=sherpa_onnx.OfflineTtsVitsModelConfig(
model=str(files["model"]),
lexicon=str(files["lexicon"]),
tokens=str(files["tokens"]),
dict_dir=str(d / "dict"),
),
num_threads=cfg.tts_threads,
provider="cpu",
),
rule_fsts=rule_fsts,
# 逐句生成:这是唯一能提前出声的手段(见模块文档)
max_num_sentences=1,
))
self.sample_rate = self._tts.sample_rate
log.info("TTS 就绪:%d 线程,输出 %d Hz", cfg.tts_threads, self.sample_rate)
def synthesize(self, text: str) -> np.ndarray:
"""合成一段文本(内部已确保是单句)。"""
if not text.strip():
return np.zeros(0, dtype=np.float32)
audio = self._tts.generate(text, sid=0, speed=1.0)
return np.asarray(audio.samples, dtype=np.float32)
def synthesize_sentences(self, text: str) -> Iterator[tuple[str, np.ndarray]]:
"""按句合成,逐句 yield (句子, 音频)。
调用方拿到第一句就能开播,不必等整段——
这正是 VITS 非自回归下唯一的低延迟路径。
"""
for seg in split_sentences(text):
yield seg, self.synthesize(seg)
def synthesize_all(self, text: str) -> np.ndarray:
"""合成整段并拼接。用于离线/测试,不适合实时播放。"""
chunks = [a for _, a in self.synthesize_sentences(text) if len(a)]
if not chunks:
return np.zeros(0, dtype=np.float32)
return np.concatenate(chunks)
__all__ = ["LocalTTS", "TTSError", "split_sentences", "MAX_CHARS_PER_SEGMENT"]