open-standards/wai/tools/wai_wavtokenizer_export_onnx.py
Transaction Science 3b6fd99989 Add WAI — Web AI Media Transport & Execution standard
WAI is a container + capability-dispatch standard for media. It does not
re-implement codecs; it dispatches to SOTA standard libraries (PNG, FLAC,
zstd as the mandatory floor; AVIF, JPEG-XL, Opus, AV1, XZ as the
recommended modern set) and adds an envelope that lets a neural
shared-prior path coexist with the model-free floor.

The capability a file requires is named, not supplied. Two paths open
every conforming file: the neural condition (sink regenerates from a
shared ambient prior) and the zeroth condition (registered SOTA codec
menu). Apache-2.0.

Adds:
- wai/SPEC.md — WAI v1.0 spec (envelope, manifest, capability dispatch,
  registered capabilities including v1.1 multi-rendition extension)
- wai/wai-rs/ — Rust reference implementation: lib + cdylib + staticlib,
  C FFI, 11 capability + envelope round-trip tests passing
- wai/wai-web/ — wasm-bindgen wrapper for browser sinks
- wai/wai-neural/ — Python tooling for neural-condition models
- wai/tools/ — codec export + encode helpers (Python)
- wai/corpus/CORPUS.md — third-party test corpus reacquisition guide

Updates README.md to list four standards instead of three.

Known consumer: CommunicationOS uses WAI for media transport over
MoQT/MLS sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-24 13:36:51 -04:00

195 lines
8.2 KiB
Python

"""Export the WavTokenizer-40token decoder to ONNX for the WAI browser.
WavTokenizer has a single-codebook (q=1) VQ at 40 Hz. The model's
`decode` takes `features` (continuous embeddings) — we wire through the
discrete-codes path manually:
code_idx → quantizer.codes_to_embedding (via decode_from_codes)
→ backbone(emb) → head → audio
Same WAI neural-audio ABI as EnCodec/DAC/Mimi: int64 [1, 1, q, t] codes
+ float [1] scales → float [1, 1, samples].
Usage:
python tools/wai_wavtokenizer_export_onnx.py \\
--wavtokenizer-src /tmp/wavtokenizer \\
--config /tmp/wavtokenizer/configs/wavtokenizer_smalldata_frame40_3s_nq1_code4096_dim512_kmeans200_attn.yaml \\
--ckpt /tmp/wavtokenizer_ckpt/large_unify_40.ckpt \\
--out wai-web/demo/models/wavtokenizer/decoder.onnx
"""
from __future__ import annotations
import argparse
import math
import sys
from pathlib import Path
import torch
def _patch_istft_real(model) -> None:
"""ONNX has no complex tensor type. WavTokenizer's ISTFTHead builds a
complex spectrum (`mag * (cos(p) + 1j*sin(p))`) and passes it through
`ISTFT.forward` which calls `torch.fft.irfft`. The torch ONNX tracer
bails with "Unknown number type: complex".
Replace the head with an equivalent real-arithmetic version: precompute
the IDFT cos/sin basis matrices, do the irfft as two real matmuls (one
cosine, one sine), then run the same windowed overlap-add (which is
already ONNX-friendly via Fold).
This is a couple of orders of magnitude slower than FFT for very large
n_fft, but at WavTokenizer's n_fft=2400 / hop=600 it's well under 1 s
per inference on CPU and equally fast on WebGPU (matmul is the GPU's
home turf).
"""
from decoder.heads import ISTFTHead
head = model.head
if not isinstance(head, ISTFTHead):
return
istft = head.istft
n_fft, hop, wl = istft.n_fft, istft.hop_length, istft.win_length
n_freq = n_fft // 2 + 1
pad = (wl - hop) // 2
# IDFT basis: x[n] = (1/N) * sum_k W_k * [Re(S_k)*cos(θ_kn) - Im(S_k)*sin(θ_kn)]
# where θ_kn = 2πkn/N and W_k = 1 for k∈{0, N/2}, 2 otherwise (real-input symmetry).
n_idx = torch.arange(n_fft, dtype=torch.float32).unsqueeze(0) # [1, N]
k_idx = torch.arange(n_freq, dtype=torch.float32).unsqueeze(1) # [n_freq, 1]
angle = 2 * math.pi * k_idx * n_idx / n_fft # [n_freq, N]
cos_b = torch.cos(angle) / n_fft
sin_b = torch.sin(angle) / n_fft
cos_b[1:n_freq - 1, :] *= 2
sin_b[1:n_freq - 1, :] *= 2
# Pre-bake an identity weight for ConvTranspose1d-based overlap-add.
# In-channels=n_fft, out-channels=1, kernel=n_fft, stride=hop. Putting
# `eye(n_fft)` in the weight makes input channel i contribute to output
# samples [t*hop + i] which is exactly OLA. We use this because
# torch.nn.functional.fold's ONNX export (col2im) is broken in
# multiple opsets.
ola_w = torch.eye(n_fft, dtype=torch.float32).unsqueeze(1) # [N, 1, N]
head.register_buffer("_real_cos_b", cos_b) # [n_freq, n_fft]
head.register_buffer("_real_sin_b", sin_b)
head.register_buffer("_window", istft.window.float())
head.register_buffer("_ola_w", ola_w)
head._real_pad = pad
head._real_wl = wl
head._real_hop = hop
head._real_nfft = n_fft
def _real_forward(self, x: torch.Tensor) -> torch.Tensor:
x = self.out(x).transpose(1, 2) # [B, 2*n_freq, T]
mag, p = x.chunk(2, dim=1)
mag = torch.exp(mag).clamp(max=100.0)
re = mag * torch.cos(p) # [B, n_freq, T]
im = mag * torch.sin(p)
# Real IDFT: [n_freq, n_fft]^T @ [B, n_freq, T] → [B, n_fft, T]
ifft = torch.einsum("kn,bkt->bnt", self._real_cos_b, re) \
- torch.einsum("kn,bkt->bnt", self._real_sin_b, im)
ifft = ifft * self._window[None, :, None]
# ConvTranspose1d-based overlap-add (ONNX-friendly).
y = torch.nn.functional.conv_transpose1d(
ifft, self._ola_w, stride=self._real_hop) # [B, 1, out]
y = y.squeeze(1)[:, self._real_pad:-self._real_pad]
# Window envelope: same OLA against window^2 frames.
T = ifft.shape[-1]
win_sq = self._window.square().unsqueeze(0).unsqueeze(-1).expand(1, -1, T) # [1, N, T]
env = torch.nn.functional.conv_transpose1d(
win_sq, self._ola_w, stride=self._real_hop) # [1, 1, out]
env = env.squeeze(1)[:, self._real_pad:-self._real_pad]
return y / env.clamp(min=1e-11)
import types
head.forward = types.MethodType(_real_forward, head)
class WaiWavTokenizerDecoder(torch.nn.Module):
"""Adapts WavTokenizer's three-stage decode to the WAI audio contract.
Inputs:
audio_codes int64 [1, 1, q, t] (q=1 for the 40-token variant)
audio_scales float [1] (no-op, kept for ABI parity)
Output:
audio_values float [1, 1, samples]
"""
def __init__(self, model):
super().__init__()
self.feature_extractor = model.feature_extractor
self.backbone = model.backbone
self.head = model.head
def forward(self, audio_codes: torch.Tensor,
audio_scales: torch.Tensor) -> torch.Tensor:
# [1, 1, q, t] → [q, 1, t] (WavTokenizer convention)
codes = audio_codes.squeeze(1).permute(1, 0, 2)
# decode codes via the residual vector quantizer's codebooks.
# The feature_extractor exposes the quantizer; its decode() takes
# the integer codes and returns the continuous features [B, D, T].
features = self.feature_extractor.encodec.quantizer.decode(codes)
# Backbone is GRU/transformer producing [B, T, D]; head is iSTFT/iMDCT.
bandwidth_id = torch.zeros(1, dtype=torch.long)
x = self.backbone(features, bandwidth_id=bandwidth_id)
wav = self.head(x) # [B, T]
if wav.dim() == 2:
wav = wav.unsqueeze(1)
keep_scales = audio_scales.sum() / audio_scales.sum().clamp(min=1e-9)
return wav * keep_scales
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--wavtokenizer-src", default="/tmp/wavtokenizer")
ap.add_argument("--config", required=True)
ap.add_argument("--ckpt", required=True)
ap.add_argument("--out", required=True)
ap.add_argument("--opset", type=int, default=17)
args = ap.parse_args()
out = Path(args.out); out.parent.mkdir(parents=True, exist_ok=True)
sys.path.insert(0, args.wavtokenizer_src)
from decoder.pretrained import WavTokenizer
print(f"loading WavTokenizer from {Path(args.ckpt).name}…")
m = WavTokenizer.from_pretrained0802(args.config, args.ckpt).eval()
_patch_istft_real(m)
wrap = WaiWavTokenizerDecoder(m).eval()
q = 1
T = 80 # 2 s @ 40 Hz frame rate
dummy_codes = torch.zeros(1, 1, q, T, dtype=torch.long)
dummy_scales = torch.ones (1, dtype=torch.float32)
with torch.no_grad():
ref = wrap(dummy_codes, dummy_scales)
print(f"reference forward pass OK: {tuple(ref.shape)}")
print(f"exporting → {out} (opset {args.opset})…")
torch.onnx.export(
wrap, (dummy_codes, dummy_scales), 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,
)
sz = out.stat().st_size
print(f"wrote {sz:,} B")
import onnxruntime as ort, numpy as np
sess = ort.InferenceSession(out.as_posix(), providers=["CPUExecutionProvider"])
ort_out = sess.run(None, {
"audio_codes": dummy_codes.numpy(),
"audio_scales": dummy_scales.numpy(),
})
diff = float(np.max(np.abs(ort_out[0] - ref.numpy())))
print(f"onnxruntime sanity: shape={ort_out[0].shape} "
f"max |onnx-torch| = {diff:.3e}")
if diff > 1e-3:
print("WARNING: numerical mismatch > 1e-3", file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())