本文档说明当前 speech_tts_onnx_opt.py 的完整实现,包括整体架构、模型导入流程、TTS 推理流程、缓存机制、接口行为以及关键函数职责。
当前 ONNX 版本服务主文件是:
它是一个基于 FastAPI 的 TTS 服务,依赖:
onnxruntime:执行 ONNX 模型推理kokoro-onnx:封装 Kokoro ONNX 的 tokenizer、voice style 和模型适配numpy:构造推理输入、拼接输出音频soundfile:把 float 音频写成 WAVaiofiles:异步读写缓存元数据当前服务可以分成 6 层:
API 层
负责对外暴露 HTTP 接口:/tts、/generate、/clear-cache、/cache-info。
请求控制层
负责并发限制、请求打断、按客户端跟踪当前流式请求。
文本切分层
负责把整段文本拆成多个句子或片段,降低单次推理长度。
模型与音色加载层
负责加载 ONNX 模型、初始化 kokoro_onnx.Kokoro 引擎、准备 voice 文件。
推理层
负责文本转音素、音素转 token、构造 ONNX 输入、调用 session.run() 得到音频。
缓存层
包括内存缓存、磁盘缓存和同 key 合并计算,避免重复句子反复推理。
可以概括成下面这条链路:
HTTP 请求 -> 参数校验 -> 文本切分 -> 查缓存 -> ONNX 推理 -> WAV 编码 -> HTTP 返回
对于 /generate:
HTTP 请求 -> 参数校验 -> 文本切分 -> 逐句查缓存/推理 -> Base64 编码 -> NDJSON 流式返回
文件开头定义了运行时配置,核心参数如下:
TTS_ONNX_MODEL_NAME:默认模型名,默认 model_uint8.onnxTTS_ONNX_MODEL_DIR:模型目录,默认 /home/tts-server/onnxTTS_ONNX_CONFIG_PATH:配置文件路径TTS_ONNX_VOICES_DIR:voice .bin 文件目录TTS_ONNX_VOICES_V1_PATH:备用 voice 打包文件CACHE_DIR:磁盘缓存目录MAX_CONCURRENT_REQUESTS:流式接口最大并发数TTS_SAMPLE_RATE:采样率,默认 24000ORT_INTRA_OP_THREADS:ORT 单次推理内部线程数ORT_INTER_OP_THREADS:ORT 算子间线程数ORT_ENABLE_CPU_MEM_ARENA:是否开启 ORT CPU 内存池ORT_ENABLE_MEM_PATTERN:是否开启 ORT 内存模式这些参数在模块导入时读取,因此进程启动前设置环境变量即可生效。
模块初始化时创建了几个关键全局对象:
model_lock:保护模型加载,避免并发重复初始化request_semaphore:限制 /generate 并发量current_requests:记录当前客户端请求状态,支持打断memory_cache:内存音频缓存executor:线程池,主要用于文件 I/Oinflight_tasks:同一个缓存 key 的“进行中任务”复用表model_session:全局 ONNX Runtime Session_KOKORO_ONNX_ENGINE:全局 Kokoro ONNX 引擎应用使用了 lifespan:
lifespan() 在服务启动时调用 load_model()对应函数:
lifespan(app)load_model(force_reload=False, name=None)这一部分是当前实现的核心。
函数:
resolve_model_path(name: str) -> str逻辑:
MODEL_DIR / namename 是绝对路径且文件存在,也允许直接使用FileNotFoundError默认情况下,会去找:
/home/tts-server/onnx/model_uint8.onnx函数:
resolve_voices_path() -> str逻辑分两种:
如果 VOICES_DIR 存在并且里面有多个 *.bin
np.fromfile(..., dtype=np.float32) 读取原始数据510 * 1 * 256510 x 1 x 256_voices.generated.npz.npz 路径如果 VOICES_DIR 不可用
VOICES_V1_PATH这样做的目的,是把多个独立 voice .bin 文件打成一个 npz,方便 kokoro_onnx.Kokoro 统一读取。
函数:
_download_voice_file(voice_path: Path) -> Path它会从 Hugging Face 下载 voice 文件到本地,但当前主调用链并没有自动调用这个函数。
也就是说,当前代码里下载能力存在,但主流程实际上主要依赖本地已有 voice 文件。
函数:
load_onnx_session(name: str)逻辑:
onnxruntime as ortresolve_model_path(name) 拿到模型路径ort.SessionOptions()intra_op_num_threadsinter_op_num_threadsenable_cpu_mem_arenaenable_mem_patternort.InferenceSession(model_path, sess_options=sess_options, providers=["CPUExecutionProvider"])这里已经明确指定只使用 CPU provider。
函数:
load_kokoro_engine(name: str)逻辑:
from kokoro_onnx import KokoroKokoro(
model_path=model_path,
voices_path=resolve_voices_path(),
vocab_config=CONFIG_PATH if Path(CONFIG_PATH).exists() else None,
)
这个对象负责:
函数:
load_model(force_reload=False, name=None)逻辑:
model_lock 加锁force_reload=Truemodel_session is Nonetarget != model_namemodel_session = load_onnx_session(target)_KOKORO_ONNX_ENGINE = load_kokoro_engine(target)model_name = target这意味着当前实现会同时维护两套模型相关对象:
onnxruntime.InferenceSessionkokoro_onnx.Kokoro原因是:
session 真正执行推理engine 负责 tokenizer、voice style、文本到模型输入的辅助逻辑函数:
get_kokoro_engine(name=None)如果引擎为空,或者请求的模型名和当前不一致,就触发 load_model()。
TTS 核心在 synthesize_audio()。
函数:
synthesize_audio(text, voice, speed, model_name=None) -> np.ndarray它的完整处理流程如下。
先判断:
text.strip() 是否为空为空直接报 HTTPException(400)。
函数内部先拿两个对象:
engine = get_kokoro_engine(name=model_name)session = load_model(name=model_name)其中:
engine 用于文本前处理session 用于真正推理逻辑:
if voice not in set(engine.get_voices()):
raise HTTPException(...)
也就是说 voice 必须存在于当前 Kokoro 引擎已加载的 voice 列表中。
逻辑:
phonemes = engine.tokenizer.phonemize(text, "en-us")
这里把输入文本转成音素串。当前实现写死了 "en-us",因此这条 ONNX 逻辑本质上按英文音素流程在走。
逻辑:
batched_phonemes = engine._split_phonemes(phonemes)
这是对长文本的第二次切分。
前面的 iter_text_parts() 是句子级切分,这里是音素级切分,目的是避免单次 token 太长。
逻辑:
voice_style = engine.get_voice_style(voice)
voice style 是当前 voice 对应的风格张量集合,后续会根据 token 长度取其中一个切片。
对每一个 phoneme_batch:
tokens = np.array(engine.tokenizer.tokenize(phoneme_batch), dtype=np.int64)
跳过空 token
构造 feeds:
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
语速控制参数
逻辑:
outputs = session.run(None, feeds)
这里就是实际调用 onnxruntime 执行模型。
返回结果后:
audio_segments.append(to_mono_numpy(outputs[0]))
第一路输出被当成音频,随后通过 to_mono_numpy() 归一成一维 float32 单声道数组。
如果有多个 batch,就做:
audio = np.concatenate(audio_segments, axis=0)
最终返回一个完整的一维 numpy.ndarray 音频数组。
推理完成后还会检查:
NaN / Inf不合法就抛 500 错误。
当前实现有两级切分。
函数:
split_sentences(text)iter_text_parts(text, split_pattern)逻辑:
iter_text_parts() 先按调用方传入的 split_pattern 切成 blocksplit_sentences()split_sentences() 再按标点和换行拆句作用:
函数:
engine._split_phonemes(phonemes)这是模型输入长度控制,属于更底层的切分。
函数:
to_mono_numpy(audio)作用:
float32函数:
encode_wav_bytes(audio, sr) -> bytes作用:
numpy 音频写入 BytesIOWAV PCM_16函数:
synthesize_wav_bytes(text, voice, speed, split_pattern, model_name=None) -> io.BytesIO逻辑:
synthesize_audio()注意:
/tts 这条路径只直接使用内存缓存/generate 那样主动走“内存缓存 + 磁盘缓存 + inflight 合并”完整链路当前实现有 3 层缓存/去重能力。
类:
MemoryAudioCache这是一个带 TTL 和容量控制的内存缓存。
特点:
OrderedDict 维护近似 LRU缓存值结构大致为:
{
"sentence": "...",
"sample_rate": 24000,
"audio_bytes": b"..."
}
关键方法:
get(key)set(key, value)clear()info()函数:
sentence_cache_path(key)meta_cache_path(key)save_sentence_to_disk(...)load_sentence_from_disk(key)clean_disk_cache()存储方式:
CACHE_DIR/<key>.wavCACHE_DIR/<key>.json元数据里保存:
sentencesample_rate清理策略:
.wav 文件修改时间排序DISK_CACHE_SIZE 后删除最旧文件.json函数:
get_or_create_sentence_cache_item(sentence, voice, speed, model)这里用 inflight_tasks 做了一个很实用的优化:
_compute_sentence_item(...)这样能避免热点句子被并发重复算多次。
函数:
sentence_cache_key(sentence, voice, speed, model)生成方式:
raw = f"{sentence}|{voice}|{speed:.4f}|{model}"
md5(raw.encode("utf-8"))
说明:
/tts POST函数:
tts_post(req: TTSRequest)输入模型:
textvoicespeedsplit_patternmodel_name行为:
synthesize_wav_bytes(...)audio/wav适合:
/tts GET函数:
tts_get(...)行为和 POST 基本一致,只是参数来自 query string。
/generate POST函数:
generate_audio_stream(data: Dict = Body(...))这是更复杂的一条链路。
逻辑:
request_semaphore,限制整体并发textvoicespeedmodel_namesplit_patternclient_idclient_id 已经有活跃请求:
interrupt=True0.05sstream()get_or_create_sentence_cache_item(...)application/x-ndjson每一行数据结构大致是:
{
"index": 0,
"sentence": "...",
"sample_rate": 24000,
"audio": "base64..."
}
适合:
/clear-cache函数:
clear_cache()行为:
CACHE_DIR 目录下所有缓存文件/cache-info函数:
get_cache_info()返回:
FastAPI lifespan -> load_model() -> load_onnx_session() + load_kokoro_engine()
/tts 链路tts_post/tts_get -> synthesize_wav_bytes() -> iter_text_parts() -> memory_cache.get() or synthesize_audio() -> session.run() -> sf.write() -> StreamingResponse
/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
executor = ThreadPoolExecutor(max_workers=8) 主要用于文件 I/O
但真正推理主要是通过 asyncio.to_thread() 和同步函数组合完成。
/tts 路径没有走完整的磁盘缓存和 inflight 合并逻辑
它更偏向直接同步生成完整 WAV。
phonemize(text, "en-us") 是写死的
这说明当前 ONNX TTS 主流程是按英文音素化逻辑设计的。
_download_voice_file() 当前没有接入主路径
voice 自动下载并不是当前服务的主行为。
同时保留 model_session 和 _KOKORO_ONNX_ENGINE 是必要设计
因为 kokoro_onnx 并没有完全替代手动 session.run() 这条调用链。
如果你后续要改这个服务,最值得优先看的是下面这些函数:
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()
流式接口主控制逻辑。
当前这套 ONNX TTS 的实现,本质上是:
kokoro_onnx 负责文本前处理、voice style 和 tokenizeronnxruntime.InferenceSession.run() 真正执行 ONNX 推理FastAPI 暴露同步整段返回和异步逐句流式返回两套接口如果后续你要重构成 Go,最需要完整复刻的不是 HTTP 层,而是下面这几块: