Resources Numerical accuracy Calculation policy

Room modes and low-frequency wave solver

Lists the standing-wave frequencies of a box-shaped room, the frequency below which the room behaves as separate modes, and runs a small wave simulation to find the resonances at a listener.

How the precision is kept

The mode list and the Schroeder frequency are closed-form 64-bit expressions with a fixed speed of sound of 343 m/s. The wave solver steps the pressure on a regular grid; its three pressure grids and the trace recorded at the listener are 32-bit arrays, so the spectrum and the peak frequencies read from it start from 32-bit values. The grid spacing and time step set the solver's accuracy.

Open item. Hold the three pressure grids and the listener trace in Float64Array so the spectrum and its peak frequencies are computed from 64-bit values.

Calculations under this policy

5 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.

modes.js BimCraft 0.8.1 32-bit in part 5 calculations Reviewed

Tests None found by the review

Mode frequencies of a box room

64-bit

Every standing-wave frequency of a rigid box up to a top frequency, with its kind.

f = c ÷ 2 × √((i ÷ L_x)² + (j ÷ L_y)² + (k ÷ L_z)²), c = 343 m/s

One Math.hypot of three quotients per mode, so nothing cancels. The speed of sound is the fixed value 343 m/s and is not adjusted for temperature. The index limit is Math.floor(2 L f_max ÷ c) per axis.

  • modes.js
  • lines 15 to 23
  • BimCraft 0.8.1
export function boxModes({ lx, ly, lz }, { fmax = 200 } = {}) {
  const out = [], nmax = (l) => Math.floor(2 * l * fmax / C);
  for (let i = 0; i <= nmax(lx); i++) for (let j = 0; j <= nmax(ly); j++) for (let k = 0; k <= nmax(lz); k++) {
    if (!i && !j && !k) continue;
    const f = C / 2 * Math.hypot(i / lx, j / ly, k / lz);
    if (f <= fmax) out.push({ n: [i, j, k], f, kind: [i, j, k].filter(Boolean).length === 1 ? 'axial' : [i, j, k].filter(Boolean).length === 2 ? 'tangential' : 'oblique' });
  }
  return out.sort((a, b) => a.f - b.f);
}

Schroeder frequency

64-bit

The frequency below which the sound field is separate modes, from reverberation time and volume.

f_S = 2000 × √(T ÷ V)

One division and one square root. The factor 2000 is the published round coefficient for T in seconds and V in cubic meters. V = 0 is not guarded.

  • modes.js
  • line 24
  • BimCraft 0.8.1
export const schroederFrequency = (T, V) => 2000 * Math.sqrt(T / V);

Smallest gap between modes

64-bit

The smallest difference between neighboring mode frequencies below a limit.

min(fᵢ − fᵢ₋₁)

Differences of consecutive entries of the sorted list. Two modes with equal frequency give 0; fewer than two modes give Infinity.

  • modes.js
  • lines 25 to 30
  • BimCraft 0.8.1
export function modalGap(modes, fmax) {
  const f = modes.filter((m) => m.f <= fmax).map((m) => m.f);
  let g = Infinity;
  for (let i = 1; i < f.length; i++) g = Math.min(g, f[i] - f[i - 1]);
  return g;
}

Wave simulation of a box room

32-bit in part

The pressure over time at a listener after a pulse at a source, and its spectrum.

pⁿ⁺¹ = damp × (2pⁿ − pⁿ⁻¹) + Σ_axes (c·Δt ÷ Δ)² × (p₋ + p₊ − 2pⁿ); Δt = 0.9 × Δ_min ÷ (c √3)

Each update is evaluated in 64-bit and then stored in a Float32Array grid, so every step rounds the field to 32-bit, and the listener trace is a Float32Array too. The Hann-windowed copy and the FFT are 64-bit, but they start from the 32-bit trace. Damping is the first-order factor 1 − damping × Δt.

  • modes.js
  • lines 32 to 66
  • BimCraft 0.8.1
/** FDTD of a rigid box. opts: h (m, default so that fmax has 8 cells per wavelength), fmax (Hz, default 150), seconds (default 3), damping (1/s energy loss, default 2). */
export function fdtdBox(box, source, listener, { fmax = 150, h = 0, seconds = 3, damping = 2 } = {}) {
  const hh = h || C / fmax / 8, nx = Math.max(3, Math.round(box.lx / hh)), ny = Math.max(3, Math.round(box.ly / hh)), nz = Math.max(3, Math.round(box.lz / hh));
  const dx = box.lx / nx, dy = box.ly / ny, dz = box.lz / nz, hmin = Math.min(dx, dy, dz);
  const perWave = C / fmax / Math.max(dx, dy, dz);
  const dt = 0.9 * hmin / (C * Math.sqrt(3)), steps = Math.ceil(seconds / dt), sr = 1 / dt;
  const N = nx * ny * nz, p0 = new Float32Array(N), p1 = new Float32Array(N), p2 = new Float32Array(N);
  let a = p0, b = p1, d = p2;                           // a = p(t−dt), b = p(t), d = p(t+dt)
  const id = (i, j, k) => (k * ny + j) * nx + i, cell = (pt) => [Math.min(nx - 1, Math.floor(pt[0] / dx)), Math.min(ny - 1, Math.floor(pt[1] / dy)), Math.min(nz - 1, Math.floor(pt[2] / dz))];
  const s = cell(source), l = cell(listener), si = id(...s), li = id(...l);
  const cx = (C * dt / dx) ** 2, cy = (C * dt / dy) ** 2, cz = (C * dt / dz) ** 2, damp = 1 - damping * dt;
  const trace = new Float32Array(steps), sig = 2 / (2 * Math.PI * fmax);
  for (let n = 0; n < steps; n++) {
    const t = n * dt, g = -(t - 4 * sig) / sig * Math.exp(-0.5 * ((t - 4 * sig) / sig) ** 2);   // a derivative-of-Gaussian pulse has no DC, so no field builds up in the closed box
    for (let k = 0; k < nz; k++) for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) {
      const q = id(i, j, k), c0 = b[q];
      // rigid walls on the cell faces: the missing neighbour has the cell's own pressure (zero gradient across the face)
      const xm = i > 0 ? b[q - 1] : c0, xp = i < nx - 1 ? b[q + 1] : c0;
      const ym = j > 0 ? b[q - nx] : c0, yp = j < ny - 1 ? b[q + nx] : c0;
      const zm = k > 0 ? b[q - nx * ny] : c0, zp = k < nz - 1 ? b[q + nx * ny] : c0;
      d[q] = damp * (2 * c0 - a[q]) + cx * (xm + xp - 2 * c0) + cy * (ym + yp - 2 * c0) + cz * (zm + zp - 2 * c0);
    }
    d[si] += g;
    trace[n] = d[li];
    const t0 = a; a = b; b = d; d = t0;
  }
  // spectrum of the trace
  let M = 1; while (M < steps) M <<= 1;
  const re = new Float64Array(M), im = new Float64Array(M);
  for (let n = 0; n < steps; n++) re[n] = trace[n] * 0.5 * (1 - Math.cos(2 * Math.PI * n / (steps - 1)));    // Hann window
  fft(re, im);
  const spec = new Float64Array(M / 2);
  for (let q = 0; q < M / 2; q++) spec[q] = Math.hypot(re[q], im[q]);
  return { sr, steps, cells: [nx, ny, nz], cellsPerWavelength: perWave, trace, spectrum: spec, df: sr / M, fmaxOk: perWave >= 6 };
}

Resonance peaks of the spectrum

32-bit in part

The frequencies and sizes of the local maxima of the spectrum.

offset = ½ (y₀ − y₂) ÷ (y₀ − 2y₁ + y₂); f = (i + offset) × Δf

A parabola through three neighboring bins refines each peak; a zero denominator gives offset 0. The spectrum it reads comes from the 32-bit trace of the wave simulation.

  • modes.js
  • lines 68 to 78
  • BimCraft 0.8.1
/** Peaks of a spectrum (local maxima above `rel` of the largest) below fmax → [{ f, mag }]. */
export function spectrumPeaks(res, fmax, { rel = 0.05, fmin = 20 } = {}) {
  const s = res.spectrum, df = res.df, top = Math.min(s.length - 2, Math.floor(fmax / df));
  let mx = 0; for (let i = Math.floor(fmin / df); i <= top; i++) mx = Math.max(mx, s[i]);
  const out = [];
  for (let i = Math.max(1, Math.floor(fmin / df)); i <= top; i++) if (s[i] > s[i - 1] && s[i] >= s[i + 1] && s[i] > rel * mx) {
    const y0 = s[i - 1], y1 = s[i], y2 = s[i + 1], den = y0 - 2 * y1 + y2, off = den ? 0.5 * (y0 - y2) / den : 0;   // parabolic interpolation
    out.push({ f: (i + off) * df, mag: y1 });
  }
  return out;
}

In detail

Method
Rigid box modes f = c ÷ 2 × √((n_x ÷ L_x)² + (n_y ÷ L_y)² + (n_z ÷ L_z)²); f_S = 2000 √(T ÷ V), cited in the file as Schroeder and Kuttruff 1962; explicit leap-frog finite differences with a 7-point stencil and rigid walls; Hann window, FFT, parabolic peak interpolation.
Number types
64-bit numbers for the mode list; Float32Array for the pressure grids and the listener trace; Float64Array for the windowed signal and the spectrum.
Tolerances
Time step = 0.9 × smallest cell ÷ (c √3). Default grid: 8 cells per wavelength at the top frequency; the result flags fewer than 6. Peaks must exceed 5 % of the largest value.
Limits
The speed of sound is the constant 343 m/s. Source and listener are moved to the cell that contains them. Peak frequencies are resolved to the sample rate divided by the FFT length, refined by a parabola through three bins.

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.

Every policy that takes this approach