测试
Mizuoto 的测试怎样组织:核心依靠 dummy 引擎在几秒内跑通整条链路,每个引擎和训练器都在 CI 里用小型随机模型运行(也在模拟设备上流式加载运行),真实模型有契约套件,对照工具把原生引擎与参照实现逐采样比较,行为一致性分层判断,Web UI 用 vitest,CI 覆盖三个平台和四个 Python 版本。
Mizuoto 的测试分三层:
- 核心的测试不需要 GPU,也不需要模型:dummy 引擎在测试自己的进程里,端到端地支撑起规划、任务、运行时服务、流式、取消和错误。
- 引擎的测试在 CPU 上用权重随机的小模型运行每个引擎和训练器,包括在模拟设备上部分加载、流式加载的运行,同样在 CI 里跑。
- 真实模型:契约套件让已安装的模型走完整条链路,对照工具把原生引擎与参照实现逐采样比较。开发者改动引擎时手动运行它们。
python scripts/dev.py check 运行 CI 跑的全部:lint、typecheck-py、test-py、test-web、typegen-check。
布局
tests/
conftest.py make_services、services 和 wav_file 夹具
test_architecture.py 分层规则、全局状态规则、注意力入口、轻量导入、单一发行包
core/ 每个服务一个文件:jobs、engines、models、tts、voices、presets、training、projects、
remote、settings、runtime_service、compute、protocol(帧、任务、任务 worker 事件)、
audio/text/process、db/errors/events,以及 CPU 调优和 compute.fast 的开关
api/ test_api.py(通过 TestClient 测整个 API)、test_embed.py(MizuotoServer)
cli/ test_cli.py(命令树 EXPECTED_TREE 与各条命令)
runtime/ 运行时的各部分:上下文、ops、加载(test_runtime.py),设备(test_device.py),
显存单元、部分加载与管理器(test_memory.py),注意力调度与后端(test_accel*.py),
mizuoto.nn 与参考实现逐比特一致(test_nn_exact.py),DSP 夹具(test_dsp.py),
compute.compile(test_compile.py),随机数生成器跳转(test_rng_jump.py)
text/ mizuoto.text 的分词器,用一个很小的词表
engines_native/ 每个引擎和训练器在小型随机模型上运行(test_<id>_tiny.py、test_<id>_train_tiny.py);
streaming.py(模拟设备上的流式加载检查)、training_memory.py(训练显存工具的检查)
engines/ test_contract.py:针对真实模型的契约套件(默认排除)
fixtures/dsp/ 由 scripts/parity/dsp_fixtures.py 生成的 DSP 夹具
packages/native/<pkg>/tests/
需要接受许可的引擎自己的测试,与其许可证覆盖的代码放在一起:小模型测试
(test_fishspeech_tiny.py、test_fishspeech_train_tiny.py、test_indextts_tiny.py)和
test_<id>_package.py(它的版本与 Mizuoto 范围、清单对照、引擎规则)pyproject.toml 定义了四个标记,默认全部排除(addopts = "-m 'not live and not engine and not weights and not e2e'"):
| 标记 | 含义 |
|---|---|
live | 需要本仓库之外的东西 |
engine | 针对已安装模型的引擎契约套件(dev.py test-engines) |
weights | 需要已下载的模型权重 |
e2e | 浏览器端到端测试 |
所以 python -m pytest(或 python scripts/dev.py test-py)运行的是核心的测试和小模型测试。需要 torch 或某个可选包的测试,在没有它时会跳过(pytest.importorskip)。
dummy 引擎
mizuoto/engines/dummy/ 是一个没有模型的完整引擎:引擎(engine.py)和训练器(train.py),清单是 mizuoto/core/engines/manifests/dummy.toml。它只导入 numpy,不需要 torch,并像所有引擎一样在测试的进程里运行。
- 它把每个字符变成一小段音调,音高取决于音色(预设、描述、参考音频的 SHA-256 或权重的哈希),所以不同音色听起来不同;随机数从
ctx.rng抽取,所以同一个种子得到同样的音频。 - 它声明了全部能力:预设说话人、克隆、设计、风格与情感、流式输出与流式文本输入、音色转换、语音编辑、批处理、权重、
transcribe操作(所以 ASR 路径也能测)、训练。 - 引擎选项可以让它故意出错:
delay_ms、chunk_ms和load_delay_ms让它变慢;fail = error | oom | crash | exception和fail_at_segment让它在指定位置失败、内存不足、报告崩溃(worker_failed)或抛出未处理的异常。
正是靠它,在下一次检查时停下的取消、内存不足变成 compute_unavailable、超时、卸载、空闲回收和流式音频块,都能在没有 GPU 的 CI 机器上几秒内测完。dummy 引擎也是用户试用 Mizuoto 的方式,smoke_wheels.py 也用它。
夹具
tests/conftest.py:
@pytest.fixture
def make_services(tmp_path: Path):
from mizuoto.core.context import build_services
built = []
def make(start_background: bool = False, **kwargs):
env = kwargs.pop("env", {})
s = build_services(data_dir=tmp_path / "data", environ=dict(env), start_background=start_background, **kwargs)
built.append(s)
return s
yield make
for s in built:
s.close()- 每个测试一个临时数据目录、一个空的环境(
environ={}:开发者自己的MIZUOTO_*变量不会混进来)。 services是make_services()的简写;wav_file是一段 2 秒、22.05 kHz 的立体声 WAV。- 需要调度器和空闲回收的测试传
start_background=True。API 测试就是这样:create_app(make_services(start_background=True), bound_host="127.0.0.1", serve_ui=False),然后用TestClient。 - 下载测试把
services.models.http_transport换成httpx.MockTransport。 - 远程测试(
tests/core/test_remote.py)在本机启动一个AgentServer,由主机通过 TCP 驱动它的 dummy 引擎。
小型随机模型
tests/engines_native/ 为每个引擎搭一个与引擎结构相同、只有几个小层、权重随机的模型,按引擎期望的样子写进临时目录(配置、字节级词表、safetensors),然后驱动真实的代码:用 Runtime(DeviceService(), ModelManager(...)) 调 Engine.load,配一个 RequestContext,测音色提示、解码、批处理、可重复的种子和取消。test_<id>_train_tiny.py 用几段音频跑完引擎训练器的每个阶段。它们不需要下载,所以 CI 每次推送都用 CPU 版 torch 运行。需要接受许可的引擎的测试放在各自的包里,即 packages/native/<pkg>/tests/;pyproject.toml 的 testpaths 列出了这些目录,所以 python -m pytest 会连同其余测试一起运行它们,没装对应的包时跳过。它们用到 tests/engines_native 的辅助模块(streaming.py、accel_checks.py),所以要在仓库根目录运行;在包目录里运行也可以,根目录 pyproject.toml 的 pythonpath 会把根目录加进 sys.path。
这里还守着几条跨引擎的规则:
- 注意力。 每个调用点传入的
mask_info都与它的稠密掩码对照检查。MIZUOTO_TEST_ACCEL=count python -m pytest tests/engines_native packages/native让每次注意力调用都经过一个策略:它统计调用点,并按调用点自己的参照方式计算;如果某个调用点从没以这种方式被调用到(例如有代码绕过compute.attention直接调用 SDPA),测试结束时就会失败。每个 conftest 列出本目录的调用点(SITES_TESTED);检查本身在tests/engines_native/accel_checks.py。 - 训练显存工具。
training_memory.py的check_checkpointing把训练器的阶段在全新的工作目录里跑两遍,一遍原样,一遍每个块都做梯度检查点,比较每个指标和每个写出的权重文件:必须完全相同。保持结果的其他工具(如把优化器状态放在内存里)用同样的方式检查。
模拟设备上的流式加载
显存管理改变的是权重放在哪里,不是计算什么,所以部分加载和流式加载的结果必须与整体加载完全相同。tests/engines_native/streaming.py 让 CPU 扮演一个给定大小的设备(DeviceService(simulate=…, strict=True)):
check_streamed在三种设备大小下(每个单元都流式加载、一半、全部常驻)运行小型引擎,与普通 CPU 上的结果逐采样比较。- 严格模式下,不在设备上的组件以 meta 张量存放,被流式加载的单元的权重只在该单元运行时存在。所以一个引擎在某个阶段用了没有为这个阶段放置(
ctx.use)的组件,或者在单元的forward之外读了它的权重,就会在这里失败。 check_oom_retry让阶段里的某一层第一次调用时内存不足:阶段以更多权重流式加载的方式重新运行,结果必须与未受干扰时完全相同。
显存管理本身见显存管理。
真实引擎的契约套件
tests/engines/test_contract.py 对每个可用引擎的每个已安装目录模型,走完整的链路(规划 → 任务 → 运行时 → 引擎):
python scripts/dev.py test-engines # 所有已安装的模型
python scripts/dev.py test-engines qwen3tts f5tts -- -x -k cancel
MIZUOTO_TEST_DEVICE=cpu python scripts/dev.py test-engines cosyvoicetest-engines 设置 MIZUOTO_CONTRACT=1 和 MIZUOTO_TEST_ENGINES(指定的引擎),以 -m engine -v 运行 tests/engines。套件使用 MIZUOTO_TEST_DATA_DIR(默认是平常的数据目录)里的模型,但在临时数据目录里运行,不往真实目录写任何东西,设备是 MIZUOTO_TEST_DEVICE(默认 auto)。MIZUOTO_TEST_SETTINGS 以 JSON 追加设置:{"compute": {"vram_limit_mb": 4096}} 让每个模型部分加载,在大卡上模拟一块 4 GB 的 GPU。
| 测试 | 检查什么 |
|---|---|
test_load_and_describe | 加载后的 describe 与清单一致:采样率、预设说话人 |
test_every_voice_mode_speaks_the_text | 声明的每种音色模式,中英文都能发声(RMS > 0.01)、采样率正确、时长与文本相称;装了 ASR 时词错误率 < 0.35 |
test_seed_repeats_and_segments | 同一个种子逐采样复现;放不进一段的两句话切成两段(或由引擎自己处理长文本) |
test_streaming_sends_the_audio_as_it_is_made | 能流式输出的模型:接收端得到其变体的粒度;按 token 流式输出时,一段会分成多块到达;存储的输出就是这些块拼起来的结果 |
test_streamed_audio_starts_early | 逐 token 流式输出的模型在一个三句话请求的前四分之三时间内送出首段音频(GPT-SoVITS v2 使用固定长度的分块:默认在停顿处切分) |
test_unloading_frees_the_device | 在 CUDA 上,请求之后卸载模型,已分配的显存比加载前最多多 64 MB(实际为 8 MB 的 cuBLAS 工作区) |
test_another_engine_in_between_changes_nothing | 同一个带种子的请求,在进程中运行了另一个引擎的模型前后,音频相同 |
test_cancel_stops_the_engine | 在生成途中取消,作业在 GPU 上 2 秒内(CPU 上 30 秒内)变为 cancelled,模型仍然加载,下一个请求正常朗读 |
test_no_residue | 进程级状态(工作目录、环境变量、sys.path、随机数生成器、torch 标志、日志处理器、警告过滤器)的摘要在多次请求前后相同,通过 mizuoto.snapshot 操作获取 |
要克隆的参考音频来自 MIZUOTO_TEST_REFERENCE(加上 MIZUOTO_TEST_REFERENCE_TEXT),没有时由已安装的预设或设计模型说一段。需要接受许可的引擎只有在设了 MIZUOTO_TEST_ACCEPT_LICENCES=1(并装了它们的包)时才参加,许可只在临时目录里接受。
在 CPU 上,每个引擎的全部目录模型都通过契约套件。在 RTX 3090 上(2026-10-19),每个已安装的模型(含需要接受许可的引擎)都通过了上面四项新测试:0.9–6 秒的请求在 0.1–2.3 秒后送出首段音频,取消在 0.05–0.6 秒内完成,卸载后设备上最多留下 8 MB。整套跑完需要几个小时(模型越大越慢),所以改动某个引擎时只跑它。
与参照实现对照
scripts/parity/ 把原生引擎与它们的参照实现逐采样比较(它的 README 有完整说明)。参照是 Mizuoto 0.1 的引擎 worker(git 标签 v1-engine-workers,各引擎在自己原来的环境里运行上游代码);0.1 没有的 VoxCPM,参照是上游自己的包,经 stack.py --impl upstream 套进同一条链路。代码在仓库里;数据放在仓库之外的工作区 MIZUOTO_PARITY_DIR(默认 ~/code_workspace/experiment/kodama-refactor),其中有:
reference/:位于标签v1-engine-workers的 git 工作树,即原生引擎与之比对的 0.1 代码;reference-host/和native-env/:分别驱动参照实现和原生实现的 Python 环境(原生一侧用参照环境相同的 torch 2.8.0,差异因此只来自代码而不是内核);CosyVoice 另有一套 torch 2.8 上的参照环境和数据目录;corpus/(参考音频及其转写)、fixtures/(每次运行写出的音频和行为)、reports/(比较报告)。
W=~/code_workspace/experiment/kodama-refactor
CUDA_VISIBLE_DEVICES= $W/reference-host/bin/python scripts/parity/stack.py run scripts/parity/cases/qwen3tts.jsonl --impl reference
CUDA_VISIBLE_DEVICES= $W/native-env/bin/python scripts/parity/stack.py run scripts/parity/cases/qwen3tts.jsonl --impl native
$W/native-env/bin/python scripts/parity/compare.py qwen3tts --a reference --b native --report $W/reports/qwen3tts-T3.md| 脚本 | 作用 |
|---|---|
stack.py run <cases.jsonl> --impl reference|native|upstream | 让每个用例(一个 SpeakRequest,或一个 convert / edit 请求)走某一实现的完整链路,把音频和行为存进 fixtures/<engine>/<case>/<name>。--name 设置本次运行的文件名(如 reference-repeat、reference-t28),--only 选用例,--setting k=v 对整次运行应用一项设置,--repeat 在同一条链路里把每个用例再跑几遍,--device(默认 cpu) |
compare.py <engine…> [--a reference] [--b native] | 逐采样比较两次运行(--tolerance;--report 写出 Markdown 报告);不运行模型,直接从夹具打印当前的对照表 |
train.py run <engine> --impl reference|native --model <id> --method <m> --hp k=v --name <n> | 用某一实现在工作区的数据集上训练,保留实验、指标、预览和导出结果 |
train.py compare <engine> <name> | 比较两种实现的训练结果;导出的音色或模型可以交给另一种实现加载 |
perf.py、train_perf.py | 按阶段计时同样的用例和微调,并与上一轮的音频、指标和权重对照(见性能工作) |
memory.py | 每个用例的显存与内存峰值、加载和卸载时间(见显存管理) |
voxcpm_modules.py、voxcpm_upstream.py | VoxCPM 的组件级对照,以及把上游包作为引擎套进链路的适配器 |
check_rng.py --device cuda:0 | 用种子 s 初始化的全新 torch.Generator,对比 torch.manual_seed(s) 之后的默认随机流 |
check_tokenizer.py <model> | 在语料上比较 mizuoto.text 的 BPE 与 transformers 的分词器(在装有 transformers 的环境里运行) |
dsp_fixtures.py | 用真正的 torchaudio、librosa、Whisper 和 soxr 写出 tests/fixtures/dsp/*.npz |
先自洽,再对照。 参照实现先与自己的重复运行比较(--name reference-repeat):自己都不能复现的用例无法用来评判原生实现。cases/<engine>.jsonl 每行一个用例,字段见 README。
结果:在 CPU 上以 fp32 运行时,每个引擎都与参照完全相同(VoxCPM 在 bf16 下也是),只有 CosyVoice 在两边各自生成音色提示时例外:CAM++ 的说话人嵌入与 onnxruntime 相差约 1e-6,HiFT 的正弦源把这个差异沿句子积累放大;给定同一个音色提示时,CosyVoice 也逐采样相同。每个训练器在 CPU 上与上游带同样种子的运行给出逐字节相同的损失、学习率和权重。
行为一致性的层级与容差
“相同”分层判断。低层级逐比特检查,高层级判断整条链路:
| 层级 | 比较什么 | 在哪里 | 通过条件 |
|---|---|---|---|
| T0 运算 | DSP、采样器、分词器、规范化器、字音转换 | CPU | 完全相同(torch.equal、相同的 id、相同的字符串) |
| T1 模块 | 每个模块用真实权重处理截取的输入 | CPU fp32 | 最大绝对误差 ≤ 1e-5 × max(1, ‖参照‖∞),余弦相似度 ≥ 0.99999 |
| GPU bf16/fp16 | 余弦相似度 ≥ 0.999 | ||
| T2 阶段 | 教师强制的 logits;给定截取的输入时的离散输出(语音分词器的码、VQ 码、注入噪声后的 ODE 轨迹) | CPU fp32 | argmax 100% 一致;离散输出一致率 ≥ 99.9%,每个翻转都要检查并解释(如 FSQ 取整边界上的平局);轨迹在 T1 之内 |
| T3 端到端(带种子) | 完整请求走完 Mizuoto 的链路:原生与参照,同一种子、同一 torch 版本 | CPU fp32 | token 完全相同;音频完全相同或最大绝对误差 ≤ 1e-4;例外逐个引擎列出并说明原因 |
| T4 统计 | 每个引擎在其生产精度和设备上的语料运行,原生与参照;种子不对齐(GPU 内核不同) | GPU(bf16/fp16)、CPU | 每种音色模式和模型至少 20 段文本 × 3 个音色 × 每个音色 3 个种子:平均 CER/WER(Whisper large-v3-turbo)≤ 参照 + 0.5 个百分点,且没有用例比参照最差的再差 5 个百分点以上;说话人相似度(CAM++ 与参考片段的余弦)均值 ≥ 参照 − 0.01;时长比在 ±5% 之内;UTMOS 均值 ≥ 参照 − 0.05;响度和 F0 中位数(按音色)在参照的四分位距之内。失败的片段(错误率高于 50 %)单独比较(Fisher 精确检验),不计入其他指标;超出阈值只有在配对差异显著时(Wilcoxon,p < 0.05)才算不通过 |
- 为什么 T3 只在 CPU 上以 fp32 判断。 任何一点 logits 差异都会翻转一次多项式抽样(或一个束),之后的一切随之分叉。在 GPU 上降精度运行时,连上游自己都不能与自己逐比特一致(例如批量和单条的贪心解码),所以 GPU 上的一致性由 T1 的模块级误差加 T4 的统计指标判断。
- 音频之外的行为也是对照的一部分,在两种实现上都经过契约套件检查:实际生效的参数和警告、音色模式的判定(
fit)、流式的粒度与块数、取消的行为、对无效输入的错误码(参考音频太长、不支持的语言、缺少转写)、输出采样率、分段的拼接。 - 加速与提速遵守同样的层级:会改变比特的加速(例如编译)至少保持在该引擎的 T1/T3 容差之内,并记录实测误差;默认路径上的提速必须逐比特不变。
- T0 的 DSP 夹具只在生成它们的那类机器上完全相同:带 AVX2、不带 AVX-512 的 x86-64 Linux,且 torch 版本相同。在别处,内核的舍入不同(numpy 的 AVX-512
exp和log、Windows CRT 的 libm、Arm 上 torch 的 FFT 和 soxr 的 SIMD、另一个 torch 版本),所以tests/runtime/test_dsp.py在那里检查输出在这种舍入之内一致:float32 最大绝对误差 ≤ 1e-4 × max(1, ‖ref‖∞),float64 ≤ 1e-12 × max(1, ‖ref‖∞)。CI 的 macOS、Windows 和 AVX-512 机器上实测最大为 4e-5。
T4 结果
scripts/parity/quality.py 运行这一层(命令见 scripts/parity/README.md)。语料(scripts/parity/corpus/t4.json)共 17 组,每组是一个模型和一种音色模式:三个音色(克隆用语料中的两段英文片段和一段中文片段;三个预置说话人;三个设计音色)朗读 20 段文本(10 段英文、10 段中文,不含数字),种子为 1–3,每组每侧 180 个片段。原生实现用本仓库运行;参照实现对 0.1 已有的引擎用 0.1,对 VoxCPM 用上游的包,各自在自己的一块 RTX 3090 上以生产精度运行。评判工具:通过 Mizuoto 的 ASR 运行的 Whisper large-v3-turbo(英文按词、中文按字计算,繁体字用 OpenCC 的表转为简体)、CAM++(CosyVoice 的)、UTMOS22 strong(SpeechMOS)、BS.1770 响度和 YIN F0。
第一次完整运行后做了三处改进,上面的层级规则已经包含它们:
- 失败计数,而不是平均。 失控的片段(采样模型停不下来,或一直生成静音)错误率没有上限:Qwen3-TTS 的一个 965 % 的片段把均值从 1.3 % 拉到 6.7 %。错误率高于 50 % 的片段在每侧单独计数,用 Fisher 精确检验比较;其他指标只用没有失败的片段。
- 超出阈值还需要差异显著。 在 180 个采样片段上,最差片段和 0.05 的 UTMOS 余量都会随机波动:只有配对检验(按音色和文本,种子取平均)给出 p < 0.05 时,该组才不通过。
- 用定向重跑判断个别离群值。 语料中发现的两个失控片段(Qwen3-TTS 的英文说话人读中文,以及 IndexTTS 1.5 读到一半变成静音)都在两侧用该音色和语言、10 个种子重跑:参照实现失败得一样多(各 100 个中 3 和 3;2 和 3)。两者都是上游自身的行为。
测量于 2026-10-18(参照 → 原生;失败片段数为参照 / 原生):
| 组 | 错误率 % | 失败 | 说话人相似度 | 时长比 | UTMOS | 结论 |
|---|---|---|---|---|---|---|
| Qwen3-TTS 1.7B Base,克隆 | 0.80 → 0.90 | 0 / 0 | 0.784 → 0.785 | 1.005 | 4.15 → 4.15 | 通过 |
| Qwen3-TTS 1.7B CustomVoice,预置说话人 | 1.33 → 1.33 | 0 / 1 | 0.794 → 0.797 | 0.998 | 4.00 → 4.02 | 通过(最差片段在噪声范围内) |
| Qwen3-TTS 1.7B VoiceDesign | 1.44 → 1.16 | 1 / 1 | 0.698 → 0.696 | 1.002 | 3.50 → 3.51 | 通过 |
| CosyVoice 2,克隆 | 1.30 → 1.42 | 0 / 0 | 0.827 → 0.828 | 1.002 | 3.95 → 3.96 | 通过 |
| CosyVoice 3,克隆 | 1.18 → 1.20 | 0 / 0 | 0.847 → 0.847 | 1.000 | 3.88 → 3.88 | 通过 |
| CosyVoice 1 SFT,预置说话人 | 3.44 → 3.45 | 1 / 1 | 0.886 → 0.886 | 1.000 | 3.82 → 3.82 | 通过 |
| F5-TTS v1,克隆 | 1.45 → 1.45 | 0 / 0 | 0.812 → 0.812 | 1.000 | 3.37 → 3.37 | 通过 |
| GPT-SoVITS v2Pro,克隆 | 1.75 → 1.70 | 0 / 0 | 0.781 → 0.782 | 1.003 | 3.69 → 3.69 | 通过 |
| GPT-SoVITS v4,克隆 | 1.51 → 1.52 | 0 / 0 | 0.809 → 0.808 | 1.003 | 3.19 → 3.20 | 通过 |
| IndexTTS 1.5,克隆 | 0.57 → 0.55 | 0 / 1 | 0.754 → 0.753 | 0.992 | 3.69 → 3.66 | 通过 |
| IndexTTS 2,克隆 | 0.96 → 0.99 | 0 / 0 | 0.829 → 0.829 | 1.000 | 3.35 → 3.35 | 通过 |
| OmniVoice,克隆 | 1.00 → 1.01 | 0 / 0 | 0.845 → 0.846 | 1.001 | 3.76 → 3.73 | 通过 |
| OmniVoice,设计音色 | 0.53 → 0.76 | 0 / 0 | 0.659 → 0.659 | 0.999 | 4.06 → 4.07 | 通过 |
| VoxCPM 2,克隆(对照上游) | 0.76 → 0.91 | 0 / 0 | 0.803 → 0.803 | 0.995 | 3.60 → 3.61 | 通过 |
| VoxCPM 2,设计音色(对照上游) | 0.84 → 0.76 | 0 / 0 | 0.687 → 0.687 | 1.000 | 3.31 → 3.31 | 通过 |
| VoxCPM 1.5,克隆(对照上游) | 0.74 → 0.85 | 0 / 0 | 0.802 → 0.801 | 1.003 | 3.57 → 3.56 | 通过 |
每组的每个音色,响度和 F0 都在参照的四分位距之内。容差之内唯一的系统性差异是 IndexTTS 1.5 的 UTMOS,低 0.03(p 0.025,主要来自第二个英文音色);同样种子下,它的 GPU 输出本来就与 0.1 不同(2.3 dB,见性能中的 G0);原因后来已经查明并修正(见 IndexTTS)。Fish Speech S2 Pro 在 GPU 上没有参照(0.1 的克隆在 GPU 上失败):仅原生的结果为错误率 1.23 %,没有失败片段,相似度 0.723,UTMOS 4.07。改变音频的可选项用同样的方法评判:在每个可选项改变的组上,用该可选项(--setting,请求选项则用 --option)运行原生实现,与默认的原生运行对照。全部通过(默认 → 可选项):
| 可选项 | 组 | 错误率 % | 失败 | 说话人相似度 | UTMOS | 结论 |
|---|---|---|---|---|---|---|
compute.attention=fast,flash-attn 2.8.3 | VoxCPM 2 / 1.5 | 0.91 → 0.94 / 0.85 → 0.82 | 0 / 0 | 0.803 → 0.801 / 0.801 → 0.802 | 3.61 → 3.58(p 0.038)/ 3.56 → 3.58 | 通过 |
| GPT-SoVITS v4 | 1.52 → 1.54 | 0 / 0 | 0.808 → 0.808 | 3.20 → 3.19 | 通过(每个请求快 7 %) | |
| F5-TTS | 音频相同 | 通过 | ||||
fused_projections | Qwen3-TTS Base / CustomVoice | 0.90 → 0.77 / 1.33 → 1.09 | 0 / 0,1 / 0 | 0.785 → 0.785 / 0.796 → 0.793 | 4.15 → 4.15 / 4.02 → 4.02 | 通过 |
packed_guidance | OmniVoice 克隆 / 设计音色 | 1.01 → 1.02 / 0.76 → 0.66 | 0 / 0 | 0.846 → 0.844 / 0.660 → 0.660 | 3.73 → 3.73 / 4.07 → 4.08 | 通过 |
attention_span | VoxCPM 2 / 1.5 | 0.91 → 0.68(p 0.028)/ 0.85 → 0.79 | 0 / 0 | 0.803 → 0.803 / 0.801 → 0.802 | 3.61 → 3.61 / 3.56 → 3.60 | 通过 |
compute.compile | CosyVoice 2 / F5-TTS / IndexTTS 2 | 1.42 → 1.35 / 1.45 → 1.39 / 0.99 → 1.02 | 0 / 0 | 不变 | 3.96 → 3.96 / 3.37 → 3.41(p 0.002)/ 3.35 → 3.35 | 通过 |
compute.fast(全部四个开关) | F5-TTS / GPT-SoVITS v4 / IndexTTS 1.5 / VoxCPM 2 | 1.45 → 1.52 / 1.52 → 1.88 / 0.55 → 0.68 / 0.91 → 0.88 | 0 / 0(IndexTTS 1 / 1) | 相差不超过 0.004 | 相差不超过 0.03 | 通过(GPT-SoVITS 的最差片段在噪声范围内) |
未安装 flash-attn 时,compute.attention=fast 处处都是 SDPA,所以音频相同。安装后,F5-TTS 没有可交给 FlashAttention 的带填充的半精度调用。GPT-SoVITS v4 的 DiT 对引导时大小为 2 的批量只给出一行键长度,flash-attn 因此拒绝(seqlens_k must have shape (batch_size)),直到后端把长度扩展到查询的批大小;现在这 704 次调用都由它计算。
第一次可选项运行暴露了评测工具的一个错误:请求和对照用例把引擎选项写在 options 下,而请求模型没有这个字段,pydantic 会直接忽略它,所以 packed_guidance 和六个 T3 用例(GPT-SoVITS 的 cut5、批量和串行用例,VoxCPM 的续写和重试用例)实际上都用默认值运行。现在它们改用 engine_options;在 CPU 上重跑后,这六个用例在应用了各自选项的情况下与 0.1 和上游逐采样相同。
Web UI 测试
mizuoto/webui/src/**/*.test.ts 用 vitest 运行;python scripts/dev.py test-web 先运行 bun run test,再运行 bun run typecheck(vue-tsc)。除了组件和 store 的单元测试,还有几个守护整个界面的规则测试:imports.test.ts、tokens.test.ts、layout.test.ts、ui/layers.test.ts 和 i18n/i18n.test.ts(两种语言的键相同);见 Web UI。
python scripts/dev.py typegen-check 重新生成 schema.d.ts 并与提交的版本比较;API 改了而类型没有重新生成时失败。
文档站(site/)有自己的 vitest 测试;src/lib/i18n-content.test.ts 要求每个中文 <slug>.mdx 都有对应的 <slug>.en.mdx,且两个侧栏的页面顺序相同。
CI
.github/workflows/ci.yml 在每次推送到分支和每个拉取请求时运行,使用 torch 的 CPU 构建(https://download.pytorch.org/whl/cpu)。Mizuoto 和两个需要接受许可的包各自发布,所以各自检查,一次改动只运行它所改内容的检查。依赖用 uv 安装(astral-sh/setup-uv,UV_SYSTEM_PYTHON=1:装进 setup-python 提供的 Python,不建虚拟环境;缓存关闭,因为解包后的 torch wheel 会挤占 10 GB 的缓存上限)。
第一个作业 scope 读取改动的文件(推送的来自 compare API,拉取请求的来自它的文件列表),由 python scripts/ci.py scope 映射为检查:
| 改动 | 检查 |
|---|---|
packages/native/<id>/** | 该包的 gated 和 oldest-mizuoto 作业,以及针对它的 torch |
mizuoto/webui/** | web |
site/** | site(文档站的测试和构建) |
mizuoto/、tests/、scripts/ 下的其他内容、pyproject.toml,或新路径 | host、web(它的 API 类型来自代码)、torch,以及两个包的作业:它们跑在本次提交的 Mizuoto 运行时上 |
.github/workflows/**、scripts/ci.py | 全部 |
README*.md、AGENTS.md、CLAUDE.md、LICENSE、Dockerfile、.gitignore…… | 无 |
新分支、手动触发、被 compare API 截断的列表(300 个文件)或读取失败时检查全部;tests/test_ci.py 覆盖这套映射。
| 作业 | 环境 | 步骤 |
|---|---|---|
host | Ubuntu、Windows、macOS × Python 3.10、3.11、3.12、3.13 | 从 CPU 索引 uv pip install torch,再装 -e . pytest pytest-cov ruff ty httpx tomli(只有 Mizuoto);每个组合都运行 dev.py lint(整个仓库)、dev.py typecheck-py --python-version <该组合> --python-platform <该组合> mizuoto tests scripts(标准库的类型存根随 Python 版本和平台而不同)和 python -m pytest -q tests;三者中一个失败,其余照常运行 |
gated | 范围内的每个包 × Ubuntu、Windows、macOS × Python 3.10、3.13 | -e . -e packages/native/<id>;对 packages/native/<id>(源码和测试)运行 ruff check、ruff format --check 和 typecheck-py,对它的 tests/ 运行 pytest |
oldest-mizuoto | 范围内的每个包,Ubuntu、Python 3.12 | 用该包 pyproject.toml 允许的最旧 Mizuoto(python scripts/ci.py lower-bound <id>,来自 PyPI)测试它:测试在源码目录之外运行,PYTHONPATH 上只有 tests/,所以 import mizuoto 找到的是已发布的那个(不含范围检查,它比较的是范围与本次提交的 Mizuoto) |
torch | Ubuntu、Python 3.12,torch 2.8.0 和最新版 | 用 Mizuoto 支持的最旧和最新的 torch 跑范围内的测试,发现 torch 的接口变化 |
web | Ubuntu、Python 3.11、bun | dev.py web-install、dev.py test-web、dev.py typegen-check |
site | Ubuntu、bun | 在 site/ 里 bun install --frozen-lockfile、bun run check、bun run test、bun run build |
passed("CI passed") | Ubuntu | 上面有作业失败或被取消时失败,都通过或因改动无关而跳过时通过:分支保护只需要求这一项 |
同一分支较新的运行会取消较旧的运行。推送到 main 且改动了某个版本文件时会启动发布工作流,它把 ci.yml 作为自己的检查,检查这次推送改动的内容;CI 自己对这次推送的运行会跳过所有作业(scope 看到了版本文件),所以检查只跑一次。标签只启动发布工作流,它的检查是全部。把文档站推送到 GitHub Pages 由 site.yml 负责,不在 CI 里(见发布)。
python scripts/dev.py check 在本地运行全部内容(只在你的平台和 Python 上,装上需要接受许可的包,基于你检出的 Mizuoto)。契约套件和对照工具需要模型(对照还需要参照实现的环境),权重有几十 GB,所以不进 CI;发布工作流有自己的 wheel 冒烟测试(见发布)。
CI 覆盖的是 CPU
CI 只有 CPU 版 torch。GPU 上的路径(CUDA 图、可选的注意力后端、显存流式加载的异步预取)在 CPU 上测的是逻辑;它们在 GPU 上的表现要在有 GPU 的机器上用契约套件和对照工具检查。