# 架构说明 面向要改代码的人。功能说明见 [README.md](README.md)。 ## 1. 分层 ``` ┌──────────────────────────────────────────────┐ │ UI 层 app/ui.py(Gradio Blocks) │ ├──────────────────────────────────────────────┤ │ 编排层 app/batch.py 文档级分段/拼接 │ │ app/markdown.py 解析与清洗 │ │ app/fetcher.py URL 抓取 │ │ app/text.py 长文本切分 │ ├──────────────────────────────────────────────┤ │ 调度层 app/registry.py 按需加载/空闲卸载 │ ├──────────────────────────────────────────────┤ │ 引擎层 engines/base.py TTSEngine 抽象 │ │ edge / kokoro / cosyvoice │ ├──────────────────────────────────────────────┤ │ 存储层 voices/store.py(SQLite) │ │ voices/refs/*.wav(克隆参考音频) │ └──────────────────────────────────────────────┘ ``` ## 2. 引擎契约 所有引擎实现 `app/engines/base.py` 的 `TTSEngine`: | 成员 | 约定 | |---|---| | `name` | 唯一标识,也是注册表 key | | `label` | 界面显示名 | | `supports_pitch` / `supports_mix` | UI 据此决定控件显隐 | | `load()` | 建立连接或校验模型,**幂等**;不含重活 | | `unload()` | 释放全部资源,可重复调用 | | `list_voices()` | 返回 `VoiceInfo` 列表 | | `synthesize(SynthParams, out_path)` | 写盘并返回 `Path`;内部自行处理分段 | 关键点: - `load()` 只做校验或不做事,真正建模型放在首次 `synthesize()`。 这样只打开页面不会占用内存,也避免 Kokoro 在无请求时白白加载 327MB。 - `synthesize()` 必须自己保证 `out_path.parent` 存在。 - 引擎不感知"混合"这件事,混合在 `kokoro_engine._resolve_voice()` 里把表达式解析成 style tensor,外部只看到字符串。 ### 音色表达式(voice spec) 统一用字符串传递,避免引擎之间互相依赖: | 形态 | 引擎 | 含义 | |---|---|---| | `zh-CN-XiaoxiaoNeural` | edge | ShortName | | `zf_xiaoxiao` | kokoro | 单个内置音色 | | `zf_xiaoxiao:0.60,zm_yunxi:0.40` | kokoro | 加权混合,冒号后为权重 | | `clone:我-日常` | cosyvoice | 指向 `voices/refs/我-日常.wav` | 推断逻辑在 `store.engine_of_spec()`,供音色库「自动」模式使用。 ## 3. 调度:registry `EngineRegistry` 三件事: 1. **惰性实例化**:第一次 `get(name)` 才构造引擎对象。 2. **ensure_loaded**:每次 `get` 都调用,幂等。 3. **空闲卸载**:`main._start_reaper()` 起后台线程,每 60s 扫一次, 超过 `IDLE_TTL`(300s)未使用且已加载的引擎调用 `unload()`。 edge 引擎例外——它没有常驻模型,跳过卸载。 ## 4. 文档合成:流式写盘 `batch.synthesize_document()` 的核心约束是"内存与文档长度无关": ``` for chunk in chunk_blocks(blocks, max_chars): tmp = 引擎.synthesize(chunk, tmp_path) # 单段落临时文件 data, sr = sf.read(tmp) # 读进来就重采样成 24k 单声道 sink.write(data) # 立刻写进输出文件 sink.write(静音填充) tmp.unlink() # 立即删,磁盘也只占一段 ``` 因此: - 峰值内存 ≈ 一段音频,不是整篇。 - 输出恒定 24kHz / 单声道 / PCM_16,不同引擎混排也不会有采样率断层。 - 单段异常被 catch 并记录,不中断整篇;`written == 0` 才整体报错。 ## 5. 克隆链路 ``` 录音/上传 → prepare_reference() 16k 单声道 + 峰值归一化 + 时长校验 → voices/refs/.wav → synth: split_text(max 120) → inference_zero_shot(chunk, "", ref_wav) ``` - 参考音频存放与音色记录分离:wav 在文件系统,`clone:` 记录在 SQLite。 删除音色时两处一起清(`ui.do_delete_ref`)。 - 参考音频的特征提取由 CosyVoice 内部按 ref 路径缓存;本项目不重复实现。 - 文本按 120 字切分(比 Kokoro 的 400 小),因为克隆路径下长句更容易跑偏韵律。 ## 6. 新增一个引擎要改哪里 1. 在 `app/engines/` 新建文件,继承 `TTSEngine`,实现五个成员。 2. `app/engines/__init__.py` 导出。 3. `app/main.py` 里 `registry.register(XxxEngine)`。 4. `app/ui.py` 的引擎 Radio 加一项;`voice_choices()` 加分枝。 5. `store.engine_of_spec()` 加前缀判断(音色库复用需要)。 6. 写 `scripts/smoke_xxx.py` 冒烟测试。 ## 7. 已知取舍 | 取舍 | 原因 | |---|---| | edge 用线程池包异步 | Gradio 内部有 event loop,`asyncio.run` 会冲突 | | Kokoro 自己算加权混合 | 上游 `load_voice("a,b")` 只做等权平均 | | 重采样用 `np.interp` | 避免引入 scipy 依赖;24k 目标下听感损失可忽略 | | 参考音频限 20s | 更长不提升克隆质量,只增加提取耗时 | | 空闲卸载而非常驻 | 8GB 机器上常驻三个引擎必 OOM |