Kokoro TTS v1.1 voices 文件格式逆向分析

Kokoro TTS v1.1 voices 文件格式逆向分析

前言

Kokoro 是一个 8200 万参数的开放权重 TTS 模型,在 GitHub 上有多个实现版本。本文记录了作者在调试 Kokoro TTS 过程中,意外发现 v1.0 和 v1.1 voices 文件格式完全不同,且官方文档语焉不详,最终通过逆向工程完整还原 v1.1 voices 文件结构的过程。

前置条件:Rust 编程语言、Tokio 异步运行时、可选 Rodio 音频播放库。


问题背景

在调试项目时,需要为 Kokoro TTS 找一个声音自然、没有电音(electronic artifact)的女声。测试中发现:

  • v1.0 模型的所有 54 个女声全部存在电音问题
  • v1.1 模型有 103 个 voices,理论上应该有更好的选择
  • 但 v1.1 的 voices 文件(52MB)既不是标准的 NPZ(NumPy ZIP)格式,也不是任何常见二进制格式
  • 官方文档只说了"下载模型资源"一句话,格式一概不提

v1.0 voices 文件格式(基线)

v1.0 的 voices 文件是标准 NPZ 格式,可以用 numpy.load() 直接读取:

python 复制代码
import numpy as np
voices = np.load("voices-v1.0.bin")
# voices 是 dict: {'af_sarah': array(shape=(510,1,256), dtype=float32), ...}
print(voices['af_sarah'].shape)  # (510, 1, 256)

每个 voice 是一个 (510, 1, 256) 的 float32 数组,代表 510 个 phoneme style embeddings,256 维。

这为后来分析 v1.1 提供了重要的参考基线。


v1.1 voices 文件分析

第一步:文件头分析

bash 复制代码
$ xxd voices-v1.1-zh.bin | head -1
00000000: 6706 7a6d 5f30 3334 fbfe 0101 fb00 0122  g.zm_034....

文件开头是 ASCII 字符串 g\x06zm_034,意味着第一个 entry 的 voice name 叫 zm_034。但这不是 ZIP 文件(没有 PK 魔数),也不是 NumPy 的 .npy 文件。

第二步:定位文件结构

通过反复读取文件不同偏移量的数据,可以推断出每个 entry 的结构:

复制代码
Entry N:
  [1 byte: name_length]  ← 名称长度
  [name_length bytes: name]  ← voice 名称,如 "zf_001"
  [524280 bytes: voice_data]  ← voice embedding 数据

第一个 entry:

  • name_length = 0x06
  • name = "zm_034"(6 字节,从偏移 2 开始)
  • voice_data 从偏移 8 开始,占 524280 字节
  • Entry 1 从偏移 524291 开始(524290 = 0x80012)

第三步:解码 voice_data

最初以为 voice_data 是压缩的 float32 数组,但直接用 np.frombuffer() 解读时出现了大量 NaN 值。

尝试了多种解释方式:float32、float16、大端序、小端序......全部失败。

最终通过阅读 crates.io 上的 kokoro-tts 0.3.3 源码才找到答案:

rust 复制代码
// 来源: kokoro-tts/src/lib.rs
pub struct KokoroTts {
    model: Arc<Mutex<Session>>,
    voices: Arc<HashMap<String, Vec<Vec<Vec<f32>>>>>,
}

voices 字段是一个 HashMap<String, Vec<Vec<Vec<f32>>>>。这不是一个简单的 3D 数组,而是嵌套的向量嵌套

用 Rust bincode 库直接解码:

rust 复制代码
use bincode::Decode;

type VoicesV11 = HashMap<String, Vec<Vec<Vec<f32>>>>;
let (voices_v11, _) = bincode::decode_from_slice(&data, bincode::config::standard()).unwrap();

解码成功! v1.1 文件是 bincode 编码HashMap<String, Vec<Vec<Vec<f32>>>>

每个 voice 的结构:

  • Vec<Vec<Vec<f32>>> = 510 个 style pack
  • 每个 pack 包含若干个 256 维向量
  • 这 510 个 pack 对应 510 个 phoneme styles

v1.0 与 v1.1 的本质区别

维度 v1.0 v1.1
文件格式 NPZ(标准 ZIP) bincode 私有二进制
voice 数量 54 个 103 个
embedding 结构 Vec<[[f32; 256]; 1]>(全局,1个向量) Vec<Vec<Vec<f32>>>(510个pack,每pack若乾向量)
模型架构 全局 voice embedding per-phoneme style

这是两种完全不同的架构!v1.0 是全局 voice 风格,v1.1 是给每个 phoneme 分配独立的 style 向量。这就是为什么 v1.0 的女声普遍有电音------全局 embedding 在某些语言上不够细粒度。


如何用 Python 读取 v1.1 voices

python 复制代码
import subprocess, struct, zipfile, io, numpy as np

# 1. 用 Rust bincode 解码(最可靠)
result = subprocess.run(
    ['cargo', 'run', '--release', '--example', 'synth_directly_v11'],
    cwd='kokoro-tts-project'
)

# 2. 如果你只想提取 voice 数据,下面是解析思路:
def parse_v1_1_voices(filepath):
    """手动解析 v1.1 voices 文件结构"""
    with open(filepath, 'rb') as f:
        data = f.read()

    voices = {}
    pos = 0
    while pos < len(data):
        name_len = data[pos]  # 1 byte
        if not (3 <= name_len <= 15):
            break
        name = data[pos+1:pos+1+name_len].decode('ascii')
        voice_start = pos + 1 + name_len
        voice_data = data[voice_start:voice_start + 524280]
        # ... 这里需要 bincode 解码,因为数据是 bincode 编码的
        # 推荐直接用 Rust 解码后输出
    return voices

踩过的坑

坑 1:文件格式误判

最初以为文件可能是某种自定义压缩格式,花了大量时间测试 gzip、zstd、lz4......全是白费。正确答案其实在源码里。

教训:遇到未知二进制格式,先看官方源码,不要自己瞎猜。

坑 2:Python bincode 库安装失败

尝试用 Python 的 bincode 包直接解码:

bash 复制代码
pip install bincode
python -c "import bincode"  # 报错!

pip 安装了但 Python 找不到。检查发现:pip 和系统 Python 版本冲突(/usr/bin/python3 vs /usr/local/bin/python3.12)。最终通过 Rust 来解码。

教训 :多版本 Python 共存时,务必确认 pip 和执行环境是同一个。

坑 3:NPZ 格式生成

成功解码 v1.1 voices 后,想把它转成 v1.0 格式(NPZ)给现有工具用。生成了 NPZ 文件,但被 ndarray-npy 报错 MissingNewline。原因是 NPY 文件头的 header 长度字段计算错误。

rust 复制代码
// 错误的做法:2 字节 header 长度
npy_content.extend_from_slice(b"\x93NUMPY\x01\x00");
npy_content.extend_from_slice(&(header_len as u16).to_le_bytes());

// 正确的做法:NPY header 是 4 字节长度(含自己)
npy_content.extend_from_slice(b"\x93NUMPY\x01\x00");
npy_content.extend_from_slice(&(total_header_len as u32).to_le_bytes());

教训:文件格式细节必须严格遵守规范,差一个字节都不行。


结论

Kokoro v1.1 的 voices 文件采用 bincode 编码的私有格式,与 v1.0 的 NPZ 标准格式完全不兼容。这是 Kokoro 从全局 voice embedding 升级到 per-phoneme style 架构的副产品。由于官方文档几乎没有格式说明,开发者只能靠源码或逆向工程来理解。

如果你的项目需要使用 v1.1 voices,建议直接使用官方的 Rust 实现(artisdom/kokoro-rs),而不是尝试自己解析文件格式。


相关资源

相关推荐
老猿讲编程3 小时前
【Eclipse OpenSOVD学习之五】拓扑引擎(Topology)
学习·rust·eclipse·sovd
object not found4 小时前
Nuxt4去掉body中默认的边距
开发语言·后端·rust
梦醒沉醉20 小时前
4、Rust参考手册——Crate和源文件
rust
chainbees20 小时前
Windows 系统 Rust 运行环境搭建
rust
k4m7v2pz1 天前
从 Python 搬到 Rust:pyglet MIDI DAW 变成 egui 鬼畜采样器的迁移复盘
开发语言·python·rust
小灰灰搞电子1 天前
Rust+Slint 实现ModbusRTU从机调试助手源码分享
开发语言·rust·modbusrtu
Source.Liu1 天前
Tauri 2.0 + Alpine.js 起步笔记(零构建方案)
rust
小灰灰搞电子1 天前
Rust+Slint 实现抽屉式侧边栏源码分享
开发语言·rust·侧边栏