open-standards/wai/tools/wai_neural_audio_export_onnx.py
Transaction Science 32b148cd75
Some checks failed
wai-quantum energy-landscape jobs / does this change touch it (push) Successful in 14s
WAI breaking changes and the conformance runner / WAI breaking · does this change touch WAI (push) Successful in 18s
WAI breaking changes and the conformance runner / WAI breaking · wire, record and API against main · linux (push) Successful in 1m43s
wai-audit collector and player integration / does this change touch it (push) Has been cancelled
wai-audit collector and player integration / collector · linux (push) Has been cancelled
wai-audit collector and player integration / collector, player integration and the browser end to end · macos (push) Has been cancelled
WAI operation catalogue / does this change touch it (push) Has been cancelled
WAI operation catalogue / the catalogue and every door · linux (push) Has been cancelled
wai-quantum for JavaScript / does this change touch it (push) Has been cancelled
wai-quantum for JavaScript / javascript · linux (push) Has been cancelled
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Has been cancelled
wai-quantum studio and desktop / does this change touch it (push) Has been cancelled
wai-quantum studio and desktop / studio · browser (push) Has been cancelled
wai-quantum studio and desktop / desktop app · macos (push) Has been cancelled
wai-quantum studio and desktop / desktop app · linux (push) Has been cancelled
wai-quantum studio and desktop / desktop app · windows (push) Has been cancelled
wai-sink-android native sink library / does this change touch it (push) Has been cancelled
wai-sink-android native sink library / the JNI layer and the platform-free half on the host JVM · linux-arm64 (push) Has been cancelled
wai-sink-android native sink library / every ABI, the AAR, and the instrumented tests in a headless emulator · macos (push) Has been cancelled
wai-sink-swift native sink package / does this change touch it (push) Has been cancelled
wai-sink-swift native sink package / libwai slices, desktop tests, simulator tests · macos (push) Has been cancelled
WAI Studio / does this change touch it (push) Has been cancelled
WAI Studio / the catalogue's actions, the unit tests and the core · linux (push) Has been cancelled
WAI Studio / Studio in Chromium and Firefox · macos (push) Has been cancelled
WAI Studio / Studio in WebKit · linux (push) Has been cancelled
wai-quantum energy-landscape jobs / the module under wasm32-wasip2 (wasmtime) (push) Successful in 2m33s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (push) Has been cancelled
joule-moq-remote host agent / joule-moq-remote, every target (macos) (push) Has been cancelled
joule-moq-room room service / joule-moq-room · does this change touch it (push) Has been cancelled
joule-moq-room room service / joule-moq-room, every target (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · does this change touch it (push) Has been cancelled
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (push) Has been cancelled
wai-app and Create / wai-app · does this change touch it (push) Has been cancelled
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (push) Has been cancelled
wai-app and Create / wai-app · Create, browser end-to-end (macos) (push) Has been cancelled
wai-fleet service / does this change touch it (push) Has been cancelled
wai-fleet service / service · linux (push) Has been cancelled
wai-fleet service / service · macos (push) Has been cancelled
wai-ledger service / does this change touch it (push) Has been cancelled
wai-ledger service / service · linux (push) Has been cancelled
wai-ledger service / service · macos (push) Has been cancelled
wai-quantum energy-landscape jobs / the same bits · macos (push) Successful in 4m6s
wai-quantum energy-landscape jobs / native against WebAssembly, over many seeds (push) Successful in 4m38s
WAI breaking changes and the conformance runner / WAI breaking and conform · their tests, and the independent verifiers through wai conform · linux (push) Successful in 5m17s
wai-key-proxy / does this change touch it (push) Has been cancelled
wai-key-proxy / proxy · linux (push) Has been cancelled
wai-quantum energy-landscape jobs / the same bits · linux-arm64 (push) Has been cancelled
wai-quantum energy-landscape jobs / reader, kernels, answers and the stated result · linux, release (push) Has been cancelled
wai: EnCodec 24/48 kHz and DAC 16/24 kHz registered with a pinned payload; SoundStream a candidate
The neural audio models WAI did not yet name, each at the operating points of its own
publication or release, royalty-free (EnCodec's repository and DAC's weights are MIT),
neural-float, one ONNX file each:

- wai.neural.encodec24: mono, 2/4/8/16/32 codebooks of 1024 at 75 Hz (1.5 to 24 kbps)
- wai.neural.encodec48: stereo, one-second chunks overlapping by 1% with a scale each,
  2/4/8/16 codebooks at 150 Hz (3 to 24 kbps)
- wai.neural.dac16: 1 to 12 codebooks at 50 Hz; wai.neural.dac24: 1 to 32 at 75 Hz
- wai.neural.soundstream: a candidate. The publication's models were not released, and
  the open speech codec built on its architecture ships its own non-ONNX models and
  packet, so no payload format or parameter set is pinned; never dispatched.

SPEC §5 gains *Payloads of the neural audio codecs*: the payload (the layout the route
already read, and a chunked form for the 48 kHz model), the checks made before the
decoder runs, and the decoder contract: the model's full codebook count always, a row
of 1024 marking a codebook not used, so one file decodes every bitrate. DAC's 16/24 kHz
decoders give t*320 - 8 samples, and the bound says so.

neural/audio.rs checks the payload, fills the unused rows, decodes chunk by chunk and
joins the 48 kHz chunks by the model's linear overlap-add; DecodedAudio gains
`channels`, two interleaved. The C ABI has no two-channel form, so encodec48 is not
advertised through it and is refused before decoding.

tools/wai_neural_audio_export_onnx.py exports each decoder from the released weights
and checks it against the model's own decode with the first q codebooks: on Thor all
four agreed within 1.5e-5 at every operating point tested. Vectors in
wai-rs/tests/vectors/neural_audio/ (stdlib generator, its own Python decode as the
second implementation); tests/neural_audio_vectors.rs decodes each through the dispatch
within 1e-5 of it and refuses a payload outside its model, an unpinned decoder, the
candidate and two channels through the C ABI; the neural gates workflow runs it.
Registry exports, wai.h, the glossary links and pin_verify.py's one-file set follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:40:50 -05:00

167 lines
8 KiB
Python

"""Export the decoder of EnCodec 24 kHz, EnCodec 48 kHz, DAC 16 kHz or DAC 24 kHz to the one
ONNX file a sink holds for `wai.neural.encodec24`, `wai.neural.encodec48`, `wai.neural.dac16` or
`wai.neural.dac24` (SPEC §5, *Payloads of the neural audio codecs*).
The decoder takes the model's full codebook count Q for every operating point:
inputs: audio_codes int64 [1, 1, Q, T] row i < q: codebook i's codes; a row the payload
does not carry holds codebook_size (1024)
audio_scales float [1] the chunk's scale (1.0 for a model with no chunks)
output: audio_values float [1, C, T*320 - d] C = 2 for EnCodec 48 kHz, else 1; d = 8 for
DAC 16 and 24 kHz (their upsampling by 5 gives
5L - 1 samples for L), else 0
A row holding codebook_size adds nothing to the quantized latent, which is what the model's own
decode does with fewer codebooks (its residual quantizer sums the codebooks it is given), so one
file decodes every bitrate. The route in wai-rs (neural/audio.rs) checks the payload, fills the
rows and, for EnCodec 48 kHz, decodes chunk by chunk and joins the chunks by the model's linear
overlap-add.
The export uses the checkpoints of the model hub through `transformers`, traces with the legacy
tracer (the codebook loop unrolls at Q; T stays dynamic), and then checks itself: for each
operating point it decodes seeded codes through onnxruntime and through the model's own decode
with the first q codebooks, and fails if they differ by more than 1e-4.
python tools/wai_neural_audio_export_onnx.py --model encodec24 --out models/encodec24/decoder.onnx
Needs torch, transformers, onnx and onnxruntime; the weights are downloaded by `transformers`.
EnCodec's code and weights are MIT; DAC's are MIT.
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
MODELS = {
# name: (model-hub id, kind, sample rate, channels, codebooks, operating points, d)
"encodec24": ("facebook/encodec_24khz", "encodec", 24_000, 1, 32, [2, 4, 8, 16, 32], 0), # allow-name: model-hub id the tool defaults to
"encodec48": ("facebook/encodec_48khz", "encodec", 48_000, 2, 16, [2, 4, 8, 16], 0), # allow-name: model-hub id the tool defaults to
"dac16": ("descript/dac_16khz", "dac", 16_000, 1, 12, [1, 6, 12], 8), # allow-name: model-hub id the tool defaults to
"dac24": ("descript/dac_24khz", "dac", 24_000, 1, 32, [1, 16, 32], 8), # allow-name: model-hub id the tool defaults to
}
CODEBOOK_SIZE = 1024
HOP = 320
def wrapper(kind, model, q_full):
import torch
class Encodec(torch.nn.Module):
"""EnCodec's residual-quantizer decode over the rows that hold a code, then its decoder."""
def __init__(self, m):
super().__init__()
self.m = m
def forward(self, audio_codes, audio_scales):
codes = audio_codes[0] # [1, Q, T]
emb = None
for i in range(q_full):
idx = codes[:, i, :]
present = (idx < CODEBOOK_SIZE).to(torch.float32).unsqueeze(-1) # [1, T, 1]
safe = torch.where(idx < CODEBOOK_SIZE, idx, torch.zeros_like(idx))
e = torch.nn.functional.embedding(safe, self.m.quantizer.layers[i].codebook.embed) * present
e = e.permute(0, 2, 1) # [1, D, T]
emb = e if emb is None else emb + e
return self.m.decoder(emb) * audio_scales.view(-1, 1, 1)
class Dac(torch.nn.Module):
"""DAC's from_codes over the rows that hold a code (each codebook's out_proj, bias
included, only where it is used), then its decoder."""
def __init__(self, m):
super().__init__()
self.m = m
def forward(self, audio_codes, audio_scales):
codes = audio_codes[0] # [1, Q, T]
z = None
for i in range(q_full):
idx = codes[:, i, :]
present = (idx < CODEBOOK_SIZE).to(torch.float32).unsqueeze(1) # [1, 1, T]
safe = torch.where(idx < CODEBOOK_SIZE, idx, torch.zeros_like(idx))
qz = self.m.quantizer.quantizers[i]
zi = qz.out_proj(qz.codebook(safe).transpose(1, 2)) * present
z = zi if z is None else z + zi
wav = self.m.decoder(z)
if wav.dim() == 2:
wav = wav.unsqueeze(1)
return wav * audio_scales.view(-1, 1, 1)
return (Encodec if kind == "encodec" else Dac)(model).eval()
def reference(kind, model, codes_q, scale):
"""The model's own decode of one chunk with the codebooks it is given."""
import torch
with torch.no_grad():
if kind == "encodec":
# EncodecModel._decode_frame: codes [1, q, T] -> [1, C, samples], times the scale.
emb = model.quantizer.decode(codes_q.transpose(0, 1))
return model.decoder(emb) * scale.view(-1, 1, 1)
wav = model.decode(audio_codes=codes_q, return_dict=True).audio_values
return (wav.unsqueeze(1) if wav.dim() == 2 else wav) * scale.view(-1, 1, 1)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--model", required=True, choices=sorted(MODELS))
ap.add_argument("--out", required=True, help="output .onnx path")
ap.add_argument("--source", help="a local checkpoint directory instead of the model hub id")
ap.add_argument("--opset", type=int, default=17)
ap.add_argument("--frames", type=int, default=40, help="frames of the self-check's codes")
args = ap.parse_args()
import numpy as np
import onnxruntime as ort
import torch
from transformers import DacModel, EncodecModel
hub, kind, sr, channels, q_full, points, shrink = MODELS[args.model]
src = args.source or hub
model = (EncodecModel if kind == "encodec" else DacModel).from_pretrained(src).eval()
cfg = model.config
got = (cfg.sampling_rate, cfg.codebook_size)
if got != (sr, CODEBOOK_SIZE):
sys.exit(f"{src}: sampling rate and codebook size {got}, expected {(sr, CODEBOOK_SIZE)}")
held = len(model.quantizer.layers) if kind == "encodec" else len(model.quantizer.quantizers)
if held != q_full:
sys.exit(f"{src}: {held} codebooks, expected {q_full}")
wrap = wrapper(kind, model, q_full)
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
dummy = torch.full((1, 1, q_full, 64), CODEBOOK_SIZE, dtype=torch.long)
dummy[0, 0, :2] = 0
torch.onnx.export(
wrap, (dummy, torch.ones(1)), out.as_posix(),
input_names=["audio_codes", "audio_scales"], output_names=["audio_values"],
dynamic_axes={"audio_codes": {3: "T"}, "audio_values": {2: "samples"}},
opset_version=args.opset, do_constant_folding=True, dynamo=False,
)
print(f"{args.model}: wrote {out} ({out.stat().st_size:,} B)")
sess = ort.InferenceSession(out.as_posix(), providers=["CPUExecutionProvider"])
rng = np.random.default_rng(20261109)
worst = 0.0
for q in points:
codes = rng.integers(0, CODEBOOK_SIZE, size=(1, q, args.frames), dtype=np.int64)
padded = np.full((1, 1, q_full, args.frames), CODEBOOK_SIZE, dtype=np.int64)
padded[0, 0, :q] = codes[0]
scale = np.array([0.5 if kind == "encodec" and channels == 2 else 1.0], dtype=np.float32)
got = sess.run(None, {"audio_codes": padded, "audio_scales": scale})[0]
want = reference(kind, model, torch.from_numpy(codes), torch.from_numpy(scale)).numpy()
length = args.frames * HOP - shrink
if got.shape != (1, channels, length) or got.shape != want.shape:
sys.exit(f"{args.model} q={q}: shapes {got.shape} and {want.shape}, expected (1, {channels}, {length})")
d = float(np.max(np.abs(got - want)))
worst = max(worst, d)
print(f" q={q:2d}: max |onnx - model| = {d:.2e}")
if worst > 1e-4:
sys.exit(f"{args.model}: the export differs from the model's decode by {worst:.2e}")
return 0
if __name__ == "__main__":
raise SystemExit(main())