open-standards/wai/tools/wai_int_audio_export.py
Transaction Science bd1e94481e wai: learned integer-exact audio codec (wai.audio.int_codec) + conformance
The audio analog of int_hyper — a LEARNED audio codec whose decode is byte-exact,
extending "conformance is a hash" to audio. A neural audio codec (SoundStream /
EnCodec family) decodes through a float network, so ~1e-5 round-off makes the
waveform — and its hash — diverge between machines: never receiptable. Here the
synthesis is a stack of integer 1-D transposed convolutions + integer IGDN over a
per-channel factorized-rANS latent, all i64, so the int16 PCM is byte-identical on
every machine (NeuralIntegerExact; sample-equivalence, a portable PCM hash).

- decoder: src/int_audio.rs — WAM1 model parse (int12 transposed-conv weights as
  i16, IGDN gamma as i32), 1-D conv_t + integer IGDN (reuses int_transform::igdn)
  over the factorized-rANS latent (y_q = s·2^afrac + median_q) → int16 PCM. Pure
  i64, no float/ONNX; compiles to wasm32. Feature audio_int = ["neural_int"].
- encoder: tools/wai_int_audio_export.py — trains a small 1-D transform codec
  (Conv1d+GDN / ConvTranspose1d+IGDN) on real captured audio (float; only the
  DECODE must be integer), PTQs g_s, factorized-codes the latent (data-fit CDF +
  CompressAI rANS, the byte-exact match of the Rust RansDecoder), and runs an
  INTEGER reference decode mirroring the Rust arithmetic → PCM FNV-1a.
- capability: wai.audio.int_codec registered (Lossy tier, NeuralIntegerExact
  replicate class; nie set now {int_synth, int_hyper, video.int_hyper, audio}).
- conformance: tests/vectors/int_audio/ (a tiny 8192-sample C=16 clip) +
  int_audio.rs never-skips. The Rust 1-D conv / IGDN / rANS / PCM-quantization
  reproduce the Python integer reference sample-for-sample.
- web: wai-web::decodeIntAudio (audio_int feature) — the same pure-i64 decode in
  the browser; reuses fnv1aHex over the LE PCM bytes for the in-page receipt check.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-27 17:01:31 -04:00

252 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Train a small LEARNED 1-D audio transform codec and export its SYNTHESIS as an
INTEGER-exact decoder — the audio analog of `int_hyper`, extending "conformance is
a hash" to learned audio.
A neural audio codec (SoundStream/EnCodec-class) decodes through a FLOAT network,
so a ~1e-5 round-off makes the reconstructed waveform — and its hash — diverge
between machines: never receiptable. Here the synthesis is a stack of integer
1-D transposed convolutions + integer IGDN (per-out-channel int12 weights,
fixed-point activations, all i64), so the reconstructed PCM is byte-identical on
every machine (NeuralIntegerExact) — *sample-equivalence*, a portable PCM hash.
Pipeline (mirrors wai_sota_int_synth_export, in 1-D):
1. train g_a/g_s (Conv1d+GDN / ConvTranspose1d+IGDN) on a real clip (float; this
is the encoder, float is fine here — only the DECODE must be integer);
2. quantize the latent per channel (factorized prior, data-fit CDF + rANS, the
byte-exact match of the Rust RansDecoder);
3. PTQ g_s → integer (WAM1 binary), run an INTEGER reference decode (numpy,
mirroring the planned Rust i64 arithmetic) → int16 PCM → FNV-1a hash.
ffmpeg -i <real>.aiff -ac 1 -ar 16000 -f s16le audio.s16
python tools/wai_int_audio_export.py audio.s16 <out-dir>
"""
import sys, os, json, math, base64
import numpy as np, torch, torch.nn as nn, torch.nn.functional as F, warnings
warnings.filterwarnings('ignore')
from compressai.ans import RansEncoder
AFRAC = 12; WBITS = 12; SHIFT = 24; GF = 16 # same fixed-point as int_hyper
SR = 16000; T = 2048; C = 32; K = 5; STR = 2; NLAY = 4 # codec shape (16x downsample)
STEPS = 3500; MAXS = 0 # train steps; MAXS>0 caps #samples (small committed vector)
# ---- learned model (float; the encoder) -------------------------------------
class GDN1d(nn.Module):
"""1-D (inverse) generalized divisive normalization with a full C×C gamma.
Forward divides by sqrt(beta + gamma·x²); inverse multiplies — the exact form
crate::int_transform::igdn reproduces in integer (out = x·isqrt(norm))."""
def __init__(self, ch, inverse):
super().__init__()
self.inverse = inverse
self.rg = nn.Parameter(torch.randn(ch, ch) * 0.01) # raw → softplus → γ≥0
self.rb = nn.Parameter(torch.zeros(ch)) # raw → softplus → β≈1
def gb(self):
return F.softplus(self.rg), F.softplus(self.rb + 1.0)
def forward(self, x): # x [N,C,T]
g, b = self.gb()
norm = b[None, :, None] + torch.einsum('ij,njt->nit', g, x * x)
return x * torch.sqrt(norm) if self.inverse else x / torch.sqrt(norm)
class Codec(nn.Module):
def __init__(self):
super().__init__()
ga, gs = [], []
for i in range(NLAY):
ga += [nn.Conv1d(1 if i == 0 else C, C, K, STR, K // 2), GDN1d(C, False)]
for i in range(NLAY):
last = i == NLAY - 1
gs += [nn.ConvTranspose1d(C, 1 if last else C, K, STR, K // 2, output_padding=STR - 1)]
if not last:
gs += [GDN1d(C, True)]
self.g_a, self.g_s = nn.Sequential(*ga), nn.Sequential(*gs)
def forward(self, x):
y = self.g_a(x)
y_hat = y + (torch.rand_like(y) - 0.5) # STE quantization noise
return self.g_s(y_hat), y
# ---- integer reference decode (numpy; mirrors the Rust i64 arithmetic) -------
def conv_t_1d_int(xi, wi, mult, bias, stride, pad, opad):
"""Integer 1-D transposed conv + per-out-channel (mult>>SHIFT)+bias — byte-for-
byte what Rust int_audio::conv_t_1d will do. xi [cin,T]; wi [cin,cout,k]."""
cin, cout, k = wi.shape; Tin = xi.shape[1]
To = (Tin - 1) * stride + k + opad - 2 * pad
Tp = To + 2 * pad
raw = np.zeros((cout, Tp), dtype=object)
for kx in range(k):
contrib = np.tensordot(wi[:, :, kx], xi, axes=([0], [0])) # [cout,Tin]
raw[:, kx:kx + stride * Tin:stride] += contrib
out = (raw[:, pad:pad + To] * mult[:, None]) >> SHIFT
return out + bias[:, None]
def igdn_int_1d(x, gi, bi):
"""Integer IGDN, the EXACT arithmetic of crate::int_transform::igdn (per-term
>>GF before the sum, isqrt at 2^AFRAC, (x·s)>>AFRAC). x [C,T] python-int."""
Cc, Tt = x.shape
x2 = x * x # 2^(2·AFRAC)
norm = np.tile(np.array(bi, dtype=object)[:, None], (1, Tt)) # 2^(2·AFRAC)
for j in range(Cc):
norm = norm + ((gi[:, j:j + 1] * x2[j:j + 1, :]) >> GF)
s = np.vectorize(lambda v: math.isqrt(int(v)) if v > 0 else 0, otypes=[object])(norm)
return (x * s) >> AFRAC
def run_g_s_int(xi, layers):
x = xi.astype(object)
for L in layers:
if L['type'] == 'convT':
wi = np.array(L['weight'], dtype=object).reshape(L['in_ch'], L['out_ch'], L['k'])
x = conv_t_1d_int(x, wi, np.array(L['mult'], dtype=object),
np.array(L['bias'], dtype=object), L['stride'], L['pad'], L['opad'])
else: # igdn
c = L['c']
gi = np.array(L['gamma'], dtype=object).reshape(c, c)
x = igdn_int_1d(x, gi, np.array(L['beta'], dtype=object))
return x # [1,T] @ 2^AFRAC
def to_pcm_i16(x_fixed):
"""[1,T] @ 2^AFRAC (≈[-1,1]) → int16 PCM, round-half-up, clamp. Matches Rust."""
v = x_fixed[0]
num = v * 32767 + (1 << (AFRAC - 1))
pcm = num >> AFRAC # python>> floors (== i64 arith shift)
return np.array([max(-32768, min(32767, int(p))) for p in pcm], dtype=np.int16)
# ---- PTQ + entropy helpers ---------------------------------------------------
def q_weight_pc(w): # ConvTranspose1d [in,out,k] → per-OUT int12
s = np.abs(w).max(axis=(0, 2), keepdims=True) / (2 ** (WBITS - 1) - 1) + 1e-12
wi = np.round(w / s).clip(-(2 ** (WBITS - 1)), 2 ** (WBITS - 1) - 1).astype(np.int64)
return wi, s.reshape(-1)
def fit_cdf(sym, lo, hi):
n = hi - lo + 1
hist = np.bincount(np.asarray(sym, np.int64) - lo, minlength=n).astype(np.float64)
total = 1 << 16
f = np.maximum((hist / hist.sum() * (total - n)).astype(np.int64), 1)
f[f.argmax()] += (total - 1) - int(f.sum())
cdf = [0]
for v in f:
cdf.append(cdf[-1] + int(v))
cdf.append(total)
return cdf
def fnv1a(buf):
h = 14695981039346656037
for b in buf:
h = ((h ^ b) * 1099511628211) & 0xFFFFFFFFFFFFFFFF
return h
def main(src, outdir):
os.makedirs(outdir, exist_ok=True)
torch.manual_seed(0)
raw = np.fromfile(src, np.int16).astype(np.float32) / 32768.0
if MAXS:
raw = raw[:MAXS]
n = (len(raw) // T) * T
raw = raw[:n]
frames = torch.from_numpy(raw.reshape(-1, 1, T)) # [Nf,1,T]
m = Codec()
opt = torch.optim.Adam(m.parameters(), 1.5e-3)
# Keep the rate term tiny: the latent must stay O(1+) so integer rounding (the
# quantizer) preserves it — too much rate pressure collapses it below the grid.
for step in range(STEPS):
xr, y = m(frames)
loss = F.mse_loss(xr, frames) + 8e-6 * y.abs().mean()
opt.zero_grad(); loss.backward(); opt.step()
m.eval()
with torch.no_grad():
xr_f, _ = m(frames)
snr_f = 10 * np.log10((frames ** 2).sum() / ((xr_f - frames) ** 2).sum() + 1e-12)
print(f' (float recon SNR {float(snr_f):.1f} dB)')
# full-signal latent → per-channel factorized symbols (s = round(y - median))
with torch.no_grad():
xfull = torch.from_numpy(raw.reshape(1, 1, n))
y = m.g_a(xfull)[0].numpy() # [C, L]
L = y.shape[1]
median = np.round(np.median(y, axis=1)) # integer-grid median
sym = np.round(y - median[:, None]).astype(np.int64) # [C, L] symbols
median_q = np.round(median * (2 ** AFRAC)).astype(np.int64)
xi = (sym * (2 ** AFRAC) + median_q[:, None]).astype(np.int64) # fixed-point latent
# PTQ g_s → WAM1 integer model
layers = []
for layer in m.g_s.children():
t = type(layer).__name__
if t == 'ConvTranspose1d':
wi, sc = q_weight_pc(layer.weight.detach().numpy())
layers.append({'type': 'convT', 'in_ch': int(wi.shape[0]), 'out_ch': int(wi.shape[1]),
'k': int(wi.shape[2]), 'stride': int(layer.stride[0]), 'pad': int(layer.padding[0]),
'opad': int(layer.output_padding[0]), 'weight': wi.reshape(-1).tolist(),
'mult': np.round(sc * (2 ** SHIFT)).astype(np.int64).tolist(),
'bias': np.round(layer.bias.detach().numpy() * (2 ** AFRAC)).astype(np.int64).tolist()})
else: # GDN1d (inverse)
with torch.no_grad():
g, b = layer.gb()
gi = np.round(g.numpy() * (2 ** GF)).astype(np.int64)
bi = np.round(b.numpy() * (2 ** (2 * AFRAC))).astype(np.int64)
layers.append({'type': 'igdn', 'c': int(b.shape[0]), 'gamma': gi.reshape(-1).tolist(), 'beta': bi.tolist()})
# INTEGER reference decode → PCM (what the Rust port reproduces)
x_fixed = run_g_s_int(xi, layers)
pcm = to_pcm_i16(x_fixed)
snr = 10 * np.log10((raw[:len(pcm)] ** 2).sum() / (((pcm.astype(np.float64) / 32768) - raw[:len(pcm)]) ** 2).sum() + 1e-12)
# factorized rANS over the symbols (channel-major), per-channel data-fit CDF
lo, hi = int(sym.min()), int(sym.max())
cdfs, syms_flat, idx = [], [], []
for ch in range(C):
cdfs.append(fit_cdf(sym[ch], lo, hi))
syms_flat.extend(sym[ch].tolist())
idx.extend([ch] * L)
enc = RansEncoder()
bits = enc.encode_with_indexes(syms_flat, idx, cdfs, [len(cdfs[0])] * C, [lo] * C)
# WAM1 binary model
import struct
mb = bytearray(b'WAM1')
mb += struct.pack('<IIII', AFRAC, GF, SHIFT, len(layers))
for Lr in layers:
if Lr['type'] == 'convT':
mb += struct.pack('<BIIIIII', 0, Lr['in_ch'], Lr['out_ch'], Lr['k'], Lr['stride'], Lr['pad'], Lr['opad'])
mb += np.array(Lr['weight'], dtype='<i2').tobytes()
mb += np.array(Lr['mult'], dtype='<i8').tobytes() + np.array(Lr['bias'], dtype='<i8').tobytes()
else:
c = Lr['c']
mb += struct.pack('<BI', 2, c)
assert np.abs(np.array(Lr['gamma'])).max() < 2 ** 31, 'gamma > i32'
mb += np.array(Lr['gamma'], dtype='<i4').tobytes() + np.array(Lr['beta'], dtype='<i8').tobytes()
open(f'{outdir}/audio_model.bin', 'wb').write(mb)
json.dump({'n_ch': C, 'lat_len': L, 't_out': int(len(pcm)), 'afrac': AFRAC,
'lo': lo, 'cdfs': cdfs, 'median_q': median_q.tolist(),
'rans_b64': base64.b64encode(bytes(bits)).decode()},
open(f'{outdir}/audio_bitstream.json', 'w'))
pcm_hash = format(fnv1a(pcm.tobytes()), '016x')
json.dump({'pcm_fnv1a': pcm_hash, 'n_samples': int(len(pcm)), 'sample_rate': SR},
open(f'{outdir}/audio.json', 'w'))
pcm.tofile(f'{outdir}/audio_decoded.s16')
print(f'learned 1-D audio codec: {len(pcm)} samples @ {SR}Hz, C={C} L={L}, model {len(mb)}B, '
f'rANS {len(bits)}B -> integer-decode SNR {snr:.1f} dB, pcm_hash {pcm_hash}')
if __name__ == '__main__':
# optional: C MAXS STEPS (e.g. tiny committed vector: 16 8192 2500)
if len(sys.argv) > 3:
C = int(sys.argv[3])
if len(sys.argv) > 4:
MAXS = int(sys.argv[4])
if len(sys.argv) > 5:
STEPS = int(sys.argv[5])
main(sys.argv[1], sys.argv[2])