# ONNX TTS 当前实现说明 本文档说明当前 `speech_tts_onnx_opt.py` 的完整实现,包括整体架构、模型导入流程、TTS 推理流程、缓存机制、接口行为以及关键函数职责。 ## 1. 文件定位 当前 ONNX 版本服务主文件是: - [speech_tts_onnx_opt.py](/home/tts-server/speech_tts_onnx_opt.py) 它是一个基于 `FastAPI` 的 TTS 服务,依赖: - `onnxruntime`:执行 ONNX 模型推理 - `kokoro-onnx`:封装 Kokoro ONNX 的 tokenizer、voice style 和模型适配 - `numpy`:构造推理输入、拼接输出音频 - `soundfile`:把 float 音频写成 WAV - `aiofiles`:异步读写缓存元数据 ## 2. 整体架构 当前服务可以分成 6 层: 1. API 层 负责对外暴露 HTTP 接口:`/tts`、`/generate`、`/clear-cache`、`/cache-info`。 2. 请求控制层 负责并发限制、请求打断、按客户端跟踪当前流式请求。 3. 文本切分层 负责把整段文本拆成多个句子或片段,降低单次推理长度。 4. 模型与音色加载层 负责加载 ONNX 模型、初始化 `kokoro_onnx.Kokoro` 引擎、准备 voice 文件。 5. 推理层 负责文本转音素、音素转 token、构造 ONNX 输入、调用 `session.run()` 得到音频。 6. 缓存层 包括内存缓存、磁盘缓存和同 key 合并计算,避免重复句子反复推理。 可以概括成下面这条链路: `HTTP 请求 -> 参数校验 -> 文本切分 -> 查缓存 -> ONNX 推理 -> WAV 编码 -> HTTP 返回` 对于 `/generate`: `HTTP 请求 -> 参数校验 -> 文本切分 -> 逐句查缓存/推理 -> Base64 编码 -> NDJSON 流式返回` ## 3. 启动和初始化 ### 3.1 环境变量 文件开头定义了运行时配置,核心参数如下: - `TTS_ONNX_MODEL_NAME`:默认模型名,默认 `model_uint8.onnx` - `TTS_ONNX_MODEL_DIR`:模型目录,默认 `/home/tts-server/onnx` - `TTS_ONNX_CONFIG_PATH`:配置文件路径 - `TTS_ONNX_VOICES_DIR`:voice `.bin` 文件目录 - `TTS_ONNX_VOICES_V1_PATH`:备用 voice 打包文件 - `CACHE_DIR`:磁盘缓存目录 - `MAX_CONCURRENT_REQUESTS`:流式接口最大并发数 - `TTS_SAMPLE_RATE`:采样率,默认 `24000` - `ORT_INTRA_OP_THREADS`:ORT 单次推理内部线程数 - `ORT_INTER_OP_THREADS`:ORT 算子间线程数 - `ORT_ENABLE_CPU_MEM_ARENA`:是否开启 ORT CPU 内存池 - `ORT_ENABLE_MEM_PATTERN`:是否开启 ORT 内存模式 这些参数在模块导入时读取,因此进程启动前设置环境变量即可生效。 ### 3.2 全局对象 模块初始化时创建了几个关键全局对象: - `model_lock`:保护模型加载,避免并发重复初始化 - `request_semaphore`:限制 `/generate` 并发量 - `current_requests`:记录当前客户端请求状态,支持打断 - `memory_cache`:内存音频缓存 - `executor`:线程池,主要用于文件 I/O - `inflight_tasks`:同一个缓存 key 的“进行中任务”复用表 - `model_session`:全局 ONNX Runtime Session - `_KOKORO_ONNX_ENGINE`:全局 Kokoro ONNX 引擎 ### 3.3 FastAPI 生命周期 应用使用了 `lifespan`: - `lifespan()` 在服务启动时调用 `load_model()` - 也就是说模型会在服务启动阶段预加载,而不是等第一个请求才懒加载 对应函数: - `lifespan(app)` - `load_model(force_reload=False, name=None)` ## 4. 模型和音色是如何导入的 这一部分是当前实现的核心。 ### 4.1 模型文件定位 函数: - `resolve_model_path(name: str) -> str` 逻辑: 1. 优先拼接 `MODEL_DIR / name` 2. 如果存在,直接返回 3. 如果 `name` 是绝对路径且文件存在,也允许直接使用 4. 否则抛出 `FileNotFoundError` 默认情况下,会去找: - `/home/tts-server/onnx/model_uint8.onnx` ### 4.2 音色文件定位和打包 函数: - `resolve_voices_path() -> str` 逻辑分两种: 1. 如果 `VOICES_DIR` 存在并且里面有多个 `*.bin` - 遍历每个 voice 文件 - 用 `np.fromfile(..., dtype=np.float32)` 读取原始数据 - 校验长度必须是 `510 * 1 * 256` - reshape 成 `510 x 1 x 256` - 最终保存成 `_voices.generated.npz` - 返回这个 `.npz` 路径 2. 如果 `VOICES_DIR` 不可用 - 尝试回退到 `VOICES_V1_PATH` 这样做的目的,是把多个独立 voice `.bin` 文件打成一个 `npz`,方便 `kokoro_onnx.Kokoro` 统一读取。 ### 4.3 预留的 voice 下载函数 函数: - `_download_voice_file(voice_path: Path) -> Path` 它会从 Hugging Face 下载 voice 文件到本地,但当前主调用链并没有自动调用这个函数。 也就是说,当前代码里下载能力存在,但主流程实际上主要依赖本地已有 voice 文件。 ### 4.4 ONNX Runtime Session 的创建 函数: - `load_onnx_session(name: str)` 逻辑: 1. 导入 `onnxruntime as ort` 2. 调 `resolve_model_path(name)` 拿到模型路径 3. 创建 `ort.SessionOptions()` 4. 设置: - `intra_op_num_threads` - `inter_op_num_threads` - `enable_cpu_mem_arena` - `enable_mem_pattern` 5. 创建: - `ort.InferenceSession(model_path, sess_options=sess_options, providers=["CPUExecutionProvider"])` 这里已经明确指定只使用 CPU provider。 ### 4.5 Kokoro 引擎的创建 函数: - `load_kokoro_engine(name: str)` 逻辑: 1. 导入 `from kokoro_onnx import Kokoro` 2. 解析模型路径 3. 调用: ```python Kokoro( model_path=model_path, voices_path=resolve_voices_path(), vocab_config=CONFIG_PATH if Path(CONFIG_PATH).exists() else None, ) ``` 这个对象负责: - tokenizer - phonemize - voice style 获取 - 音素批切分 ### 4.6 全局模型加载入口 函数: - `load_model(force_reload=False, name=None)` 逻辑: 1. 用 `model_lock` 加锁 2. 判断是否需要重载: - `force_reload=True` - `model_session is None` - 传入的 `target != model_name` 3. 如果需要重载: - `model_session = load_onnx_session(target)` - `_KOKORO_ONNX_ENGINE = load_kokoro_engine(target)` - `model_name = target` 这意味着当前实现会同时维护两套模型相关对象: - 原生 `onnxruntime.InferenceSession` - `kokoro_onnx.Kokoro` 原因是: - `session` 真正执行推理 - `engine` 负责 tokenizer、voice style、文本到模型输入的辅助逻辑 ### 4.7 获取当前引擎 函数: - `get_kokoro_engine(name=None)` 如果引擎为空,或者请求的模型名和当前不一致,就触发 `load_model()`。 ## 5. TTS 是如何实现的 TTS 核心在 `synthesize_audio()`。 函数: - `synthesize_audio(text, voice, speed, model_name=None) -> np.ndarray` 它的完整处理流程如下。 ### 5.1 文本合法性检查 先判断: - `text.strip()` 是否为空 为空直接报 `HTTPException(400)`。 ### 5.2 获取模型对象 函数内部先拿两个对象: - `engine = get_kokoro_engine(name=model_name)` - `session = load_model(name=model_name)` 其中: - `engine` 用于文本前处理 - `session` 用于真正推理 ### 5.3 校验 voice 逻辑: ```python if voice not in set(engine.get_voices()): raise HTTPException(...) ``` 也就是说 voice 必须存在于当前 Kokoro 引擎已加载的 voice 列表中。 ### 5.4 文本转音素 逻辑: ```python phonemes = engine.tokenizer.phonemize(text, "en-us") ``` 这里把输入文本转成音素串。当前实现写死了 `"en-us"`,因此这条 ONNX 逻辑本质上按英文音素流程在走。 ### 5.5 按模型可接受长度切分音素 逻辑: ```python batched_phonemes = engine._split_phonemes(phonemes) ``` 这是对长文本的第二次切分。 前面的 `iter_text_parts()` 是句子级切分,这里是音素级切分,目的是避免单次 token 太长。 ### 5.6 取 voice style 逻辑: ```python voice_style = engine.get_voice_style(voice) ``` voice style 是当前 voice 对应的风格张量集合,后续会根据 token 长度取其中一个切片。 ### 5.7 逐批构造 ONNX 输入 对每一个 `phoneme_batch`: 1. 音素转 token: ```python tokens = np.array(engine.tokenizer.tokenize(phoneme_batch), dtype=np.int64) ``` 2. 跳过空 token 3. 构造 `feeds`: ```python feeds = { "input_ids": np.asarray([[0, *tokens.tolist(), 0]], dtype=np.int64), "style": np.asarray(voice_style[len(tokens)], dtype=np.float32), "speed": np.asarray([speed], dtype=np.float32), } ``` 三个输入含义如下: - `input_ids` 模型文本输入 token,前后补 `0` - `style` 根据 token 长度索引 voice style,说明 voice style 不是固定一份,而是按长度取对应条目 - `speed` 语速控制参数 ### 5.8 执行 ONNX 推理 逻辑: ```python outputs = session.run(None, feeds) ``` 这里就是实际调用 `onnxruntime` 执行模型。 返回结果后: ```python audio_segments.append(to_mono_numpy(outputs[0])) ``` 第一路输出被当成音频,随后通过 `to_mono_numpy()` 归一成一维 `float32` 单声道数组。 ### 5.9 拼接所有音频片段 如果有多个 batch,就做: ```python audio = np.concatenate(audio_segments, axis=0) ``` 最终返回一个完整的一维 `numpy.ndarray` 音频数组。 ### 5.10 输出校验 推理完成后还会检查: - 是否有输出 - 输出长度是否为 0 - 是否包含 `NaN` / `Inf` 不合法就抛 500 错误。 ## 6. 文本切分机制 当前实现有两级切分。 ### 6.1 句子级切分 函数: - `split_sentences(text)` - `iter_text_parts(text, split_pattern)` 逻辑: 1. `iter_text_parts()` 先按调用方传入的 `split_pattern` 切成 block 2. 再对每个 block 调 `split_sentences()` 3. `split_sentences()` 再按标点和换行拆句 4. 如果句子太短,例如长度小于 3,会尽量并回上一句 作用: - 降低单次推理长度 - 方便缓存 - 方便流式逐句返回 ### 6.2 音素级切分 函数: - `engine._split_phonemes(phonemes)` 这是模型输入长度控制,属于更底层的切分。 ## 7. 音频编码和格式转换 ### 7.1 单声道归一 函数: - `to_mono_numpy(audio)` 作用: - 把各种形状的音频输出转成一维 `float32` - 如果是二维多声道,按规则压成单声道 ### 7.2 编码成 WAV 字节 函数: - `encode_wav_bytes(audio, sr) -> bytes` 作用: - 把 `numpy` 音频写入 `BytesIO` - 格式固定为 `WAV PCM_16` ### 7.3 直接返回整段 WAV 函数: - `synthesize_wav_bytes(text, voice, speed, split_pattern, model_name=None) -> io.BytesIO` 逻辑: 1. 按句切分文本 2. 对每一段: - 先查内存缓存 - 有缓存则直接读取 WAV 并解码成 float32 - 没缓存则调用 `synthesize_audio()` 3. 拼接所有段 4. 最终写成一个完整 WAV 返回 注意: - `/tts` 这条路径只直接使用内存缓存 - 它不会像 `/generate` 那样主动走“内存缓存 + 磁盘缓存 + inflight 合并”完整链路 ## 8. 缓存机制 当前实现有 3 层缓存/去重能力。 ### 8.1 内存缓存 类: - `MemoryAudioCache` 这是一个带 TTL 和容量控制的内存缓存。 特点: - 基于 `OrderedDict` 维护近似 LRU - 支持过期清理 - 限制最大条目数 - 限制总字节数 缓存值结构大致为: ```python { "sentence": "...", "sample_rate": 24000, "audio_bytes": b"..." } ``` 关键方法: - `get(key)` - `set(key, value)` - `clear()` - `info()` ### 8.2 磁盘缓存 函数: - `sentence_cache_path(key)` - `meta_cache_path(key)` - `save_sentence_to_disk(...)` - `load_sentence_from_disk(key)` - `clean_disk_cache()` 存储方式: - 音频保存为 `CACHE_DIR/.wav` - 元数据保存为 `CACHE_DIR/.json` 元数据里保存: - `sentence` - `sample_rate` 清理策略: - 按 `.wav` 文件修改时间排序 - 超过 `DISK_CACHE_SIZE` 后删除最旧文件 - 同时删除对应 `.json` ### 8.3 进行中任务合并 函数: - `get_or_create_sentence_cache_item(sentence, voice, speed, model)` 这里用 `inflight_tasks` 做了一个很实用的优化: - 如果两个请求同时要同一句文本、同一个 voice、同一个 speed、同一个 model - 第一个请求会创建一个异步任务 `_compute_sentence_item(...)` - 第二个请求不会重复推理,而是直接 await 同一个 future 这样能避免热点句子被并发重复算多次。 ### 8.4 缓存 key 函数: - `sentence_cache_key(sentence, voice, speed, model)` 生成方式: ```python raw = f"{sentence}|{voice}|{speed:.4f}|{model}" md5(raw.encode("utf-8")) ``` 说明: - 句子内容、音色、语速、模型名任一变化,都会生成新 key ## 9. HTTP 接口说明 ### 9.1 `/tts` POST 函数: - `tts_post(req: TTSRequest)` 输入模型: - `text` - `voice` - `speed` - `split_pattern` - `model_name` 行为: 1. 调 `synthesize_wav_bytes(...)` 2. 返回 `audio/wav` 适合: - 直接拿完整 WAV 文件 ### 9.2 `/tts` GET 函数: - `tts_get(...)` 行为和 POST 基本一致,只是参数来自 query string。 ### 9.3 `/generate` POST 函数: - `generate_audio_stream(data: Dict = Body(...))` 这是更复杂的一条链路。 逻辑: 1. 进入 `request_semaphore`,限制整体并发 2. 读取: - `text` - `voice` - `speed` - `model_name` - `split_pattern` - `client_id` 3. 按句拆分文本 4. 如果同一个 `client_id` 已经有活跃请求: - 把旧请求标记为 `interrupt=True` - 稍等 `0.05s` 5. 新建当前请求状态 6. 定义异步生成器 `stream()` 7. 对每一段文本: - 检查是否被打断 - 调 `get_or_create_sentence_cache_item(...)` - 转成 Base64 - 以一行 JSON 输出 8. 返回 `application/x-ndjson` 每一行数据结构大致是: ```json { "index": 0, "sentence": "...", "sample_rate": 24000, "audio": "base64..." } ``` 适合: - 前端逐句播放 - 长文本流式处理 - 边生成边下发 ### 9.4 `/clear-cache` 函数: - `clear_cache()` 行为: - 清空内存缓存 - 删除 `CACHE_DIR` 目录下所有缓存文件 ### 9.5 `/cache-info` 函数: - `get_cache_info()` 返回: - 内存缓存条数 - 内存缓存总字节数 - 磁盘缓存文件数 ## 10. 关键函数关系图 ### 10.1 启动链路 `FastAPI lifespan -> load_model() -> load_onnx_session() + load_kokoro_engine()` ### 10.2 `/tts` 链路 `tts_post/tts_get -> synthesize_wav_bytes() -> iter_text_parts() -> memory_cache.get() or synthesize_audio() -> session.run() -> sf.write() -> StreamingResponse` ### 10.3 `/generate` 链路 `generate_audio_stream() -> iter_text_parts() -> get_or_create_sentence_cache_item() -> memory_cache/disk_cache/inflight -> _compute_sentence_item() -> synthesize_audio() -> encode_wav_bytes() -> NDJSON stream` ## 11. 当前实现的设计特点 ### 11.1 优点 - 模型启动时预加载,避免首请求冷启动 - ONNX 推理和 tokenizer/voice style 职责分离 - 有内存缓存、磁盘缓存、并发去重 - 支持整段返回和逐句流式返回两种模式 - 文本切分策略比较务实,适合长文本 ### 11.2 当前代码中值得注意的点 1. `executor = ThreadPoolExecutor(max_workers=8)` 主要用于文件 I/O 但真正推理主要是通过 `asyncio.to_thread()` 和同步函数组合完成。 2. `/tts` 路径没有走完整的磁盘缓存和 inflight 合并逻辑 它更偏向直接同步生成完整 WAV。 3. `phonemize(text, "en-us")` 是写死的 这说明当前 ONNX TTS 主流程是按英文音素化逻辑设计的。 4. `_download_voice_file()` 当前没有接入主路径 voice 自动下载并不是当前服务的主行为。 5. 同时保留 `model_session` 和 `_KOKORO_ONNX_ENGINE` 是必要设计 因为 `kokoro_onnx` 并没有完全替代手动 `session.run()` 这条调用链。 ## 12. 维护时最常关注的函数 如果你后续要改这个服务,最值得优先看的是下面这些函数: - `load_model()` 模型重载入口。 - `load_onnx_session()` ORT 线程、provider、session 配置都在这里。 - `load_kokoro_engine()` Kokoro ONNX 引擎初始化在这里。 - `resolve_voices_path()` voice 文件处理逻辑在这里。 - `synthesize_audio()` 文本到音频数组的核心推理函数。 - `synthesize_wav_bytes()` `/tts` 直接返回整段音频的关键函数。 - `get_or_create_sentence_cache_item()` `/generate` 的缓存和并发合并核心。 - `generate_audio_stream()` 流式接口主控制逻辑。 ## 13. 一句话总结 当前这套 ONNX TTS 的实现,本质上是: - 用 `kokoro_onnx` 负责文本前处理、voice style 和 tokenizer - 用 `onnxruntime.InferenceSession.run()` 真正执行 ONNX 推理 - 用 `FastAPI` 暴露同步整段返回和异步逐句流式返回两套接口 - 用内存缓存、磁盘缓存和 inflight 去重减少重复计算 如果后续你要重构成 Go,最需要完整复刻的不是 HTTP 层,而是下面这几块: - 文本切分策略 - voice 文件加载和 style 索引逻辑 - phonemize/tokenize 流程 - ONNX 输入构造方式 - 缓存与流式输出机制