Builds a room's impulse response from its reverberation times, and the sound of a clap, speech or music heard through it, to play or save.
How the precision is kept
Filtering, decay and scaling run in Float64Array. The finished impulse response and every convolved sound are then stored in Float32Array, and the WAV file holds 16-bit whole numbers. The reverberation times, the distance and the critical distance the function returns stay 64-bit.
Open item. Return the impulse response and the convolved sound as Float64Array, and write the WAV file with more than 16 bits per sample.
Calculations under this policy
9 calculations in 1 source file. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
acoustics-ir.jsBimCraft 0.8.1 32-bit in part9 calculationsReviewed
The transform behind the band filters and the convolution.
X_k = Σ x_n e^(∓2πi kn ÷ N), N a power of two
Works in place on the arrays it is given; every caller in this file passes Float64Array. Within a stage the rotation factor is advanced by repeated multiplication, not by a fresh cos and sin per step, so rounding builds up over as many as N ÷ 2 steps. The inverse is not scaled; callers divide by N.
acoustics-ir.js
lines 29 to 49
BimCraft 0.8.1
/** In-place radix-2 FFT (inverse = true gives the unscaled inverse). */
export function fft(re, im, inverse = false) {
const n = re.length;
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) { [re[i], re[j]] = [re[j], re[i]]; [im[i], im[j]] = [im[j], im[i]]; }
}
for (let len = 2; len <= n; len <<= 1) {
const ang = (2 * Math.PI / len) * (inverse ? 1 : -1), wr = Math.cos(ang), wi = Math.sin(ang), half = len >> 1;
for (let i = 0; i < n; i += len) {
let cr = 1, ci = 0;
for (let k = 0; k < half; k++) {
const a = i + k, b = a + half, tr = re[b] * cr - im[b] * ci, ti = re[b] * ci + im[b] * cr;
re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] += tr; im[a] += ti;
const nr = cr * wr - ci * wi; ci = cr * wi + ci * wr; cr = nr;
}
}
}
}
Octave band weight
64-bit
How much of a frequency belongs to one octave band.
w = cos(π ÷ 2 × |log₂(f ÷ f_band)|) within one octave, else 0
One 64-bit expression per frequency. The band centers are one octave apart, so the squared weights of two neighbors are cos² and sin² of the same angle and add to one. The lowest band keeps weight 1 below its center and the highest above it.
acoustics-ir.js
lines 51 to 58
BimCraft 0.8.1
/** Weight of octave band i at frequency f: cos over one octave either side, so the squares of neighbours add to one (the bands share energy, none is lost). */
function bandWeight(f, i) {
if (f <= 0) return 0;
const p = Math.log2(f / BANDS[i]);
if (i === 0 && p < 0) return 1;
if (i === BANDS.length - 1 && p > 0) return 1;
return Math.abs(p) >= 1 ? 0 : Math.cos(Math.PI / 2 * Math.abs(p));
}
Zero-phase band filter
64-bit
Keeps one octave band of a signal.
The input is copied into a Float64Array padded with zeros to a power of two, both halves of the spectrum are multiplied by the real band weight, and the inverse is divided by N. The result is a Float64Array.
acoustics-ir.js
lines 59 to 73
BimCraft 0.8.1
/** A zero-phase band-pass by FFT. `pad` doubles the buffer so the filter's ringing before time zero is cut off, not wrapped to the end of the response. */
export function bandFilter(x, sr, i, pad = false) {
const n = pow2(pad ? x.length * 2 : x.length), re = new Float64Array(n), im = new Float64Array(n);
re.set(x);
fft(re, im);
for (let k = 0; k <= n / 2; k++) {
const w = bandWeight((k * sr) / n, i);
re[k] *= w; im[k] *= w;
if (k > 0 && k < n / 2) { re[n - k] *= w; im[n - k] *= w; }
}
fft(re, im, true);
const out = new Float64Array(x.length);
for (let k = 0; k < x.length; k++) out[k] = re[k] / n;
return out;
}
Decaying reverberant tail
64-bit
The diffuse part of the response: noise that falls 60 dB in each band's reverberation time.
tail(t) = Σ_bands noise_b(t) × e^(−k t), k = 6.907755 ÷ T_b; × (t ÷ onset)² up to onset = max(0.002, 2 × mean free path ÷ 343)
64-bit in Float64Array. The decay constant 6.907755 is 3 ln 10 written to seven digits. Time is n ÷ sample rate for each sample, not a running sum.
acoustics-ir.js
lines 79 to 87
BimCraft 0.8.1
const rnd = mulberry(seed), noise = new Float64Array(N);
for (let i = 0; i < N; i++) noise[i] = gauss(rnd);
const tail = new Float64Array(N);
for (let b = 0; b < BANDS.length; b++) {
const x = bandFilter(noise, sr, b), k = 6.907755 / rt[b]; // amplitude falls 60 dB in rt[b]: exp(−k t) with k = 3 ln 10 / T
for (let n = 0; n < N; n++) tail[n] += x[n] * Math.exp(-k * (n / sr));
}
const onset = Math.max(0.002, 2 * res.meanFreePath / SOUND); // the field builds up over about two mean free paths
for (let n = 0; n < N; n++) { const u = Math.min(1, n / sr / onset); tail[n] *= u * u; }
First reflections and the tail's level
32-bit in part
Places the six first reflections and sets the level of the tail against the direct sound.
R = S a ÷ (1 − a); reflection gain = √(1 − a) × d₀ ÷ d at sample round((d − d₀) ÷ 343 × sr); tail energy = 16π d₀² ÷ R less the reflections; r_c = √(R ÷ 16π)
Computed in a Float64Array, then copied sample by sample into a Float32Array, which is what is returned, played and saved. Each reflection's delay is rounded to a whole sample. The distance and critical distance returned beside it are 64-bit.
acoustics-ir.js
lines 88 to 115
BimCraft 0.8.1
const aMid = Math.min(0.95, Math.max(0.02, (res.bands[2].alpha + res.bands[3].alpha) / 2)), R = res.area * aMid / (1 - aMid);
const ir = new Float64Array(N);
ir[0] = 1;
// six first reflections of a box (a room of this volume and floor area when no box is given), source and listener `distance` apart
const V = res.volume, H = box?.h || Math.min(4, Math.cbrt(V)), base = V / H, lx = box?.lx || Math.sqrt(base * 1.4), ly = box?.ly || base / lx;
// 0.8 EG.49 (bim-sim_PART_3): a source and a listener the person placed (metres from the room's low corner, inside the box) replace the default pair
const inBox = (p) => (p ? [Math.min(lx - 0.05, Math.max(0.05, p[0])), Math.min(ly - 0.05, Math.max(0.05, p[1])), Math.min(H - 0.05, Math.max(0.05, p[2] ?? 1.2))] : null);
let src = inBox(source) || [0.3 * lx, 0.45 * ly, Math.min(1.5, H * 0.5)], lis = inBox(listener);
if (!lis) {
lis = [src[0] + distance, src[1], src[2]];
if (lis[0] > lx - 0.1) lis = [Math.max(0.1, src[0] - distance), src[1], src[2]];
}
const g = Math.sqrt(1 - aMid), d0 = Math.max(0.3, Math.hypot(lis[0] - src[0], lis[1] - src[1], lis[2] - src[2]) || distance);
let want = 16 * Math.PI * d0 * d0 / R; // tail energy with the direct sound's energy as 1
let er = 0;
const images = [[-src[0], src[1], src[2]], [2 * lx - src[0], src[1], src[2]], [src[0], -src[1], src[2]], [src[0], 2 * ly - src[1], src[2]], [src[0], src[1], -src[2]], [src[0], src[1], 2 * H - src[2]]];
for (const im of images) {
const d = Math.hypot(lis[0] - im[0], lis[1] - im[1], lis[2] - im[2]), idx = Math.round(((d - d0) / SOUND) * sr);
if (idx > 0 && idx < N) { const a = g * d0 / d; ir[idx] += a; er += a * a; }
}
want = Math.max(want - er, want * 0.2);
let e = 0;
for (let n = 0; n < N; n++) e += tail[n] * tail[n];
const scale = e > 0 ? Math.sqrt(want / e) : 0;
for (let n = 0; n < N; n++) ir[n] += tail[n] * scale;
const out = new Float32Array(N);
for (let n = 0; n < N; n++) out[n] = ir[n];
return { ir: out, sr, rt, distance: d0, source: src, listener: lis, criticalDistance: Math.sqrt(R / (16 * Math.PI)) };
Reverberation time measured from a response
64-bit
The reverberation time of an impulse response in one band, by Schroeder's backward integration.
E(t) = Σ over τ ≥ t of x²; L = 10 log₁₀(E(t) ÷ E(0)); T = 60 ÷ −slope of L between −5 and −35 dB
The squared samples are summed from the end into a Float64Array. The line is fitted by the normal equations on raw times, (m Σtx − Σt Σx) ÷ (m Σt² − (Σt)²), not on centered values. It returns null with fewer than 20 samples in range or a slope that is not negative.
acoustics-ir.js
lines 118 to 134
BimCraft 0.8.1
/** The reverberation time of a response in one octave band by Schroeder's backward integration (T30: a fit from −5 to −35 dB, scaled to 60 dB). */
export function measureRT(ir, sr, band, { from = -5, to = -35 } = {}) {
const x = bandFilter(ir, sr, band, true), n = x.length, cum = new Float64Array(n);
let s = 0;
for (let i = n - 1; i >= 0; i--) { s += x[i] * x[i]; cum[i] = s; }
if (!(cum[0] > 0)) return null;
let sx = 0, sy = 0, sxx = 0, sxy = 0, m = 0;
for (let i = 0; i < n; i++) {
const db = 10 * Math.log10(cum[i] / cum[0] + 1e-30);
if (db > from || db < to) continue;
const t = i / sr;
sx += t; sy += db; sxx += t * t; sxy += t * db; m++;
}
if (m < 20) return null;
const slope = (m * sxy - sx * sy) / (m * sxx - sx * sx);
return slope < 0 ? 60 / -slope : null;
}
Convolution
32-bit in part
A dry sound passed through the room's impulse response.
y = IFFT(FFT(x) × FFT(h)) ÷ N
The transforms and the complex product are in Float64Array. The result is stored in a Float32Array.
acoustics-ir.js
lines 184 to 193
BimCraft 0.8.1
export function convolve(x, h) {
const n = pow2(x.length + h.length - 1), ar = new Float64Array(n), ai = new Float64Array(n), br = new Float64Array(n), bi = new Float64Array(n);
ar.set(x); br.set(h);
fft(ar, ai); fft(br, bi);
for (let i = 0; i < n; i++) { const r = ar[i] * br[i] - ai[i] * bi[i]; ai[i] = ar[i] * bi[i] + ai[i] * br[i]; ar[i] = r; }
fft(ar, ai, true);
const out = new Float32Array(x.length + h.length - 1);
for (let i = 0; i < out.length; i++) out[i] = ar[i] / n;
return out;
}
Level matching
32-bit in part
Brings the sound in the room to the loudness of the dry sound, so switching between them changes the room and not the volume.
k = rms(dry) ÷ rms(wet over the dry sound's length)
The 32-bit convolved sound is scaled in place by a 64-bit gain. The length kept for the tail uses a fixed 44100 samples per second, whatever sample rate the sound has.
acoustics-ir.js
lines 196 to 203
BimCraft 0.8.1
/** The dry sound in the room, its loudness matched to the dry sound's (over the dry sound's own length) so a switch between them changes the room, not the volume. */
export function listen(dry, ir, { tail = 0.5 } = {}) {
const y = convolve(dry, ir), keep = Math.min(y.length, dry.length + Math.round(tail * 44100 * 4));
const out = y.slice(0, keep), a = rms(dry), b = rms(out.subarray(0, dry.length));
const k = b > 0 ? a / b : 1;
for (let i = 0; i < out.length; i++) out[i] *= k;
return out;
}
WAV file samples
32-bit in part
The numbers written to the downloaded sound file.
s = round(clamp(x × k, −1, 1) × 32767), k = 1 ÷ peak when peak > 1
Each sample is clamped to −1…1, multiplied by 32767 and rounded to a 16-bit whole number, which is narrower than the 32-bit array it comes from. The signal is scaled down by its peak when the peak is over 1, never up.
acoustics-ir.js
lines 205 to 215
BimCraft 0.8.1
export function wavBytes(x, sr = 44100) {
const n = x.length, buf = new ArrayBuffer(44 + n * 2), v = new DataView(buf);
const str = (o, s) => { for (let i = 0; i < s.length; i++) v.setUint8(o + i, s.charCodeAt(i)); };
str(0, 'RIFF'); v.setUint32(4, 36 + n * 2, true); str(8, 'WAVE'); str(12, 'fmt '); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, 1, true);
v.setUint32(24, sr, true); v.setUint32(28, sr * 2, true); v.setUint16(32, 2, true); v.setUint16(34, 16, true); str(36, 'data'); v.setUint32(40, n * 2, true);
let peak = 0;
for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(x[i]));
const k = peak > 1 ? 1 / peak : 1; // never clip: scale down when the response is hot
for (let i = 0; i < n; i++) v.setInt16(44 + i * 2, Math.round(Math.max(-1, Math.min(1, x[i] * k)) * 32767), true);
return new Uint8Array(buf);
}
In detail
Method
A statistical impulse response: the direct sound, six first reflections of a box by image sources, and band-filtered noise decaying as e^(−3 ln 10 · t ÷ T) in each octave band, scaled so direct and reverberant energy are equal at the critical distance r = √(R ÷ 16π), R = S ᾱ ÷ (1 − ᾱ). Radix-2 FFT. Schroeder backward integration for a measured time.
Number types
Float64Array for spectra, noise and the tail; Float32Array for the returned impulse response and the convolved sound; 16-bit integers in the WAV file.
Tolerances
Mid-band absorption clamped to 0.02…0.95. Source and listener kept 0.05 m inside the box; their distance is at least 0.3 m. The decay fit needs at least 20 samples between −5 and −35 dB; 1e-30 is added inside the logarithm.
Limits
Reflections land on whole samples (1 ÷ sample rate), only six first reflections are placed, and the tail is noise. The speed of sound is 343 m/s.
Approach: 32-bit step, flagged
One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.