open-standards/wai/tools/wai_int_audio_export.py
Transaction Science 9474a402d8 D2c M3 gate 7: the audio decode's direct guard and fast dispatch go
- int_audio: the direct path's fast dispatch (conv_t_1d over int_kernels) is deleted;
  the test-only direct decode runs each transposed convolution on its scalar kernel
  (reference::conv_t_1d, verbatim, itself test-only now). Its guard layer_in_range,
  and run_layer, live only inside the test-only legacy module: the production audio
  decode's exactness checks are its graph's check ranges, the PCM step's included.
- The kernel_equivalence module (the dispatch against the scalar kernel; retargeted at
  the interpreter at gate 5) is deleted. Its edge geometry moves into int_audio's
  geometry tests, through the lowered graph under every policy: a one-sample input
  upsampled by any stride a WAM1 field holds; several samples whose uncropped length
  is past the cap (too_large, the fast kernel never entered); padding past the fast
  kernels' pad factor (the interpreter computes it); an input that could overflow,
  refused by the check range before any arithmetic.
- int_hyper_synth's docs: igdn_check's audio caller and run_net_checked's are
  test-only now; the conditional codec's checked decode is the last production
  caller (M4). int_kernels_geometry's and the export tool's notes follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 20:25:19 -04:00

501 lines
21 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 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.
**The signal is generated here; the tool reads no audio file.** Both the training
signal and the test signal (the clip the vector codes) are written by `signal()`
below from a seed: 16 kHz, mono, signed 16-bit, a sequence of segments, each a tone
(one to three partials with an attack and a release), a chirp (a linear frequency
sweep), a noise burst (white noise, low-passed by a moving average or high-passed by
a difference) or a speech-like formant sweep (a harmonic series on a gliding,
vibrato-modulated pitch, shaped by three formant resonances that move between two
vowel targets), sometimes two overlaid, with short gaps. The generator is integer
arithmetic only — a SplitMix64 counter stream, a 32-bit phase accumulator, a
4096-entry sine table built by Bhaskara I's rational approximation — so a seed gives
the same samples on every machine and every Python. The training signal is stream 0
of the seed and the test signal stream 1: the codec is trained on one signal and
codes another of the same kind.
Pipeline (mirrors wai_sota_int_synth_export, in 1-D):
1. generate the training and test signals from the seed;
2. train g_a/g_s (Conv1d+GDN / ConvTranspose1d+IGDN) on the training signal (float;
this is the encoder, float is fine here — only the DECODE must be integer), with
torch seeded from the same seed, deterministic algorithms and a fixed thread
count, so a run on one host and torch build gives the same weights every time;
3. quantize the test signal's latent per channel (factorized prior, data-fit CDF +
rANS, the byte-exact match of the Rust RansDecoder);
4. PTQ g_s → integer (WAM1 binary), run an INTEGER reference decode (numpy,
mirroring the Rust i64 arithmetic) → int16 PCM → FNV-1a hash.
python tools/wai_int_audio_export.py <out-dir> [--seed S] [--channels C]
[--samples N] [--steps K] [--train-seconds T]
python tools/wai_int_audio_export.py --signal-only <out.s16> [--seed S] [--stream 0|1]
[--samples N]
`--signal-only` writes one generated signal as raw s16le and needs nothing beyond the
Python standard library. The committed vector (`wai-rs/tests/vectors/int_audio/`) and
the site's demo clip are both exports of this tool; their commands are in that
directory's README.
"""
import argparse
import base64
import json
import math
import os
import struct
import sys
AFRAC = 12; WBITS = 12; SHIFT = 24; GF = 16 # same fixed-point as int_hyper
SR = 16000; T = 2048; K = 5; STR = 2; NLAY = 4 # codec shape (16x downsample)
LR = 1.5e-3; RATE_W = 8e-6
THREADS = 4 # fixed: CPU reductions depend on it
# ---- the generated signal (integer arithmetic only) ----------------------------------
M64 = (1 << 64) - 1
GOLDEN = 0x9E3779B97F4A7C15
TAB_BITS = 12
TAB_N = 1 << TAB_BITS
def _mix64(x):
"""SplitMix64's finalizer."""
x &= M64
x = ((x ^ (x >> 30)) * 0xBF58476D1CE4E5B9) & M64
x = ((x ^ (x >> 27)) * 0x94D049BB133111EB) & M64
return x ^ (x >> 31)
class Rng:
"""A SplitMix64 stream, keyed by (seed, stream)."""
def __init__(self, seed, stream):
self.s = _mix64(seed * GOLDEN + stream * 0xD1B54A32D192ED03)
def next(self):
self.s = (self.s + GOLDEN) & M64
return _mix64(self.s)
def int(self, lo, hi):
"""Uniform integer in [lo, hi]."""
return lo + self.next() % (hi - lo + 1)
def s16(self):
"""Uniform signed 16-bit value."""
return (self.next() >> 48) - 32768
def _sine_table():
"""sin(2π i / TAB_N) × 32767, by Bhaskara I's approximation, rounded half up:
sin(πp) ≈ 16 p (1 − p) / (5 − 4 p (1 − p)), p = u / half."""
half = TAB_N // 2
out = []
for i in range(TAB_N):
u = i % half
a = u * (half - u)
den = 5 * half * half - 4 * a
v = (2 * 32767 * 16 * a + den) // (2 * den)
out.append(v if i < half else -v)
return out
SINE = _sine_table()
def _inc(f_q8):
"""32-bit phase increment per sample for a frequency in Hz × 256."""
return (f_q8 << 24) // SR
def _osc(phase):
return SINE[(phase & 0xFFFFFFFF) >> (32 - TAB_BITS)]
def _envelope(n, attack, release):
"""Linear attack and release, Q15, length n."""
attack, release = max(1, min(attack, n // 2)), max(1, min(release, n // 2))
return [min(32767, (32767 * (i + 1)) // attack, (32767 * (n - i)) // release) for i in range(n)]
def _root64(x):
"""2^(x/64) in Q16, floored: the largest y with y^64 ≤ 2^(x + 16·64). Exact."""
lo, hi = 1 << 16, 1 << 17
while lo < hi:
mid = (lo + hi + 1) // 2
if mid ** 64 <= 1 << (x + 1024):
lo = mid
else:
hi = mid - 1
return lo
ROOT64 = [_root64(x) for x in range(64)]
def _log_freq(r, lo, hi):
"""A frequency in Hz × 256, log-uniform in [lo, hi] Hz: lo · 2^(k/64) for a random
k, drawn again while it is above hi."""
octaves = 0
while (lo << octaves) < hi:
octaves += 1
while True:
o, j = divmod(r.int(0, 64 * octaves), 64)
f = ((lo << o) * ROOT64[j]) >> 8
if f <= hi << 8:
return f
# Formant targets (F1, F2, F3) in Hz for five vowels.
VOWELS = [(730, 1090, 2440), (270, 2290, 3010), (300, 870, 2240), (530, 1840, 2480), (570, 840, 2410)]
def _tone(r, n):
out = [0] * n
for _ in range(r.int(1, 3)):
inc, amp, ph = _inc(_log_freq(r, 80, 3500)), r.int(4000, 16000), r.next() & 0xFFFFFFFF
for i in range(n):
out[i] += (amp * _osc(ph)) >> 15
ph += inc
env = _envelope(n, r.int(SR // 500, SR // 30), r.int(SR // 50, SR // 6))
return [(v * e) >> 15 for v, e in zip(out, env)]
def _chirp(r, n):
i0, i1 = _inc(_log_freq(r, 100, 6000)), _inc(_log_freq(r, 100, 6000))
amp, ph, out = r.int(8000, 20000), r.next() & 0xFFFFFFFF, []
for i in range(n):
out.append((amp * _osc(ph)) >> 15)
ph += i0 + (i1 - i0) * i // n
env = _envelope(n, r.int(SR // 500, SR // 50), r.int(SR // 100, SR // 20))
return [(v * e) >> 15 for v, e in zip(out, env)]
def _noise(r, n):
w = [r.s16() for _ in range(n + 16)]
kind = r.int(0, 2)
if kind == 0: # white
x = w[:n]
elif kind == 1: # low-passed: moving average
L = [2, 3, 5, 8, 13][r.int(0, 4)]
x = [sum(w[i:i + L]) // L for i in range(n)]
else: # high-passed: first difference
x = [(w[i + 1] - w[i]) >> 1 for i in range(n)]
env = _envelope(n, r.int(SR // 1000, SR // 200), r.int(SR // 50, SR // 5))
amp = r.int(4000, 14000)
return [(((v * amp) >> 15) * e) >> 15 for v, e in zip(x, env)]
def _formant(r, n):
"""A harmonic series on a gliding pitch with vibrato, each harmonic weighted by
three resonances 1 / (1 + ((f − F)/B)^2) whose centres move between two vowels."""
f0a = r.int(90, 240) << 8
f0b = (f0a * r.int(80, 125)) // 100
va, vb = VOWELS[r.int(0, 4)], VOWELS[r.int(0, 4)]
bw = (r.int(60, 110), r.int(90, 160), r.int(120, 220))
vib_inc, vib_depth = _inc(r.int(4, 7) << 8), r.int(5, 25) # depth in ‰
nh = max(1, (4800 << 8) // max(f0a, f0b))
ph0, vph, out = r.next() & 0xFFFFFFFF, r.next() & 0xFFFFFFFF, []
amps = [0] * nh
for i in range(n):
f0 = f0a + (f0b - f0a) * i // n
f0 += (f0 * vib_depth * _osc(vph)) // (1000 * 32767)
vph += vib_inc
if i % 32 == 0: # the weights at block rate
F = [va[j] + (vb[j] - va[j]) * i // n for j in range(3)]
for k in range(nh):
fk = ((k + 1) * f0) >> 8
g = 0
for j in range(3):
d = ((fk - F[j]) << 10) // bw[j]
g += (1 << 20) // (1024 + ((d * d) >> 10)) # Q10 resonance gain
amps[k] = (g * 1024) // (1024 + 256 * k) # spectral tilt
ph0 += _inc(f0)
acc = 0
for k in range(nh):
acc += amps[k] * _osc((k + 1) * ph0)
out.append(acc >> 15)
env = _envelope(n, r.int(SR // 200, SR // 20), r.int(SR // 50, SR // 10))
return [(v * e) >> 15 for v, e in zip(out, env)]
KINDS = [_tone, _chirp, _noise, _formant]
def signal(seed, stream, n):
"""n samples of the generated signal (a list of ints in the int16 range)."""
r = Rng(seed, stream)
out = []
while len(out) < n:
m = r.int(SR * 8 // 100, SR // 2) # a segment: 80–500 ms
seg = KINDS[r.int(0, 3)](r, m)
if r.int(0, 3) == 0: # sometimes a second, quieter kind
over = KINDS[r.int(0, 3)](r, m)
seg = [a + (b >> 2) for a, b in zip(seg, over)]
peak = max(1, max(abs(v) for v in seg))
target = r.int(6000, 28000)
seg = [(v * target) // peak for v in seg]
out += seg + [0] * r.int(0, SR // 25) # a gap: 0–40 ms
return [max(-32768, min(32767, v)) for v in out[:n]]
def s16le(x):
return struct.pack(f'<{len(x)}h', *x)
# ---- learned model (float; the encoder) -------------------------------------
def build_codec(C):
import torch
import torch.nn as nn
import torch.nn.functional as F
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
return Codec()
# ---- 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 the Rust decoder's transposed convolution does (integer-operators §13.3,
wam1/1). xi [cin,T]; wi [cin,cout,k]."""
import numpy as np
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."""
import numpy as np
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):
import numpy as np
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."""
import numpy as np
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
import numpy as np
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):
import numpy as np
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(outdir, seed, C, nsamp, steps, train_seconds):
import numpy as np
import torch
import torch.nn.functional as F
from compressai.ans import RansEncoder
assert nsamp % T == 0, f'--samples must be a multiple of {T}'
os.makedirs(outdir, exist_ok=True)
torch.manual_seed(seed)
torch.use_deterministic_algorithms(True)
torch.set_num_threads(THREADS)
ntrain = (train_seconds * SR // T) * T
train = np.array(signal(seed, 0, ntrain), np.float32) / 32768.0
test_i16 = signal(seed, 1, nsamp)
raw = np.array(test_i16, np.float32) / 32768.0
frames = torch.from_numpy(train.reshape(-1, 1, T)) # [Nf,1,T]
m = build_codec(C)
opt = torch.optim.Adam(m.parameters(), LR)
# 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) + RATE_W * 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 on the training signal {float(snr_f):.1f} dB)')
# the test signal's latent → per-channel factorized symbols (s = round(y - median))
with torch.no_grad():
xfull = torch.from_numpy(raw.reshape(1, 1, nsamp))
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
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')
open(f'{outdir}/audio_source.s16', 'wb').write(s16le(test_i16))
print(f'learned 1-D audio codec (seed {seed}): {len(pcm)} samples @ {SR}Hz, C={C} L={L}, '
f'model {len(mb)}B, rANS {len(bits)}B -> integer-decode SNR {snr:.1f} dB, pcm_hash {pcm_hash}')
if __name__ == '__main__':
ap = argparse.ArgumentParser(description='Train and export the integer learned audio codec on a '
'generated signal (no audio input).')
ap.add_argument('outdir', nargs='?', help='where audio_model.bin, audio_bitstream.json and audio.json go')
ap.add_argument('--seed', type=int, default=1)
ap.add_argument('--channels', type=int, default=32, help='latent channels C')
ap.add_argument('--samples', type=int, default=32768, help='test-signal (coded clip) length, a multiple of 2048')
ap.add_argument('--steps', type=int, default=3500, help='training steps')
ap.add_argument('--train-seconds', type=int, default=8, help='training-signal length in seconds')
ap.add_argument('--signal-only', metavar='OUT.s16', help='write one generated signal and exit')
ap.add_argument('--stream', type=int, default=1, help='with --signal-only: 0 training, 1 test')
a = ap.parse_args()
if a.signal_only:
n = a.samples if a.stream == 1 else (a.train_seconds * SR // T) * T
open(a.signal_only, 'wb').write(s16le(signal(a.seed, a.stream, n)))
sys.exit(0)
if not a.outdir:
ap.error('outdir is required unless --signal-only is given')
main(a.outdir, a.seed, a.channels, a.samples, a.steps, a.train_seconds)