Works out how long sound takes to die away in each room, per octave band, from the room's volume, its surfaces and the air.
How the precision is kept
The arithmetic is 64-bit throughout, on areas in m² and volumes in m³. The accuracy is set by the statistical formulas (Sabine and Eyring), by tabulated absorption coefficients of two decimals, and by constants written with three or four digits (0.161, 4.343, 8.686). Each room is treated as a box of its floor area and one height.
Open item. Replace the literal 4.343 in airM with 10 ÷ ln 10 and write 0.161 from its definition, 24 ln 10 ÷ c.
Calculations under this policy
11 calculations in 2 source files. 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.
The constant both reverberation formulas multiply the volume by.
C = 0.161 s/m
A three-digit literal. The comment gives its origin as 24 ln(10) ÷ c at 20 °C; with c = 343 m/s (the value acoustics-ir.js uses) that is 0.16111, so the literal is about 0.07 % lower.
acoustics.js
line 24
BimCraft 0.8.1
const C = 0.161; // 24 ln(10) / c at 20 °C: the constant of both formulas
Sound absorbed by the air
64-bit
The attenuation of a pure tone in air, in dB per meter, at the room's temperature, humidity and pressure.
One 64-bit expression built from Math.pow, Math.exp and Math.sqrt. Its coefficients are decimal literals of three to five digits (8.686, 1.84e-11, 0.01275, 0.1068, 2239.1, 3352, 6.8346, 4.6151). A pressure of zero is not guarded: it is a divisor.
acoustics.js
lines 57 to 64
BimCraft 0.8.1
/** ISO 9613-1 pure-tone attenuation in dB per metre (temperature °C, relative humidity %, pressure kPa). */
export function airAbsorption(f, { T = 20, RH = 50, p = 101.325 } = {}) {
const Tk = T + 273.15, T0 = 293.15, pr = 101.325, T01 = 273.16;
const Csat = -6.8346 * Math.pow(T01 / Tk, 1.261) + 4.6151, psat = pr * Math.pow(10, Csat), h = RH * (psat / p); // molar concentration of water vapour, %
const frO = (p / pr) * (24 + 4.04e4 * h * (0.02 + h) / (0.391 + h));
const frN = (p / pr) * Math.pow(Tk / T0, -0.5) * (9 + 280 * h * Math.exp(-4.170 * (Math.pow(Tk / T0, -1 / 3) - 1)));
return 8.686 * f * f * (1.84e-11 * (pr / p) * Math.sqrt(Tk / T0) + Math.pow(Tk / T0, -2.5) * (0.01275 * Math.exp(-2239.1 / Tk) / (frO + f * f / frO) + 0.1068 * Math.exp(-3352 / Tk) / (frN + f * f / frN)));
}
Air absorption per meter
64-bit
Turns the dB figure into the energy absorption coefficient the formulas use.
m = α ÷ 4.343
4.343 stands for 10 log₁₀(e) = 4.3429448…. The literal is higher by about 1.3 parts in 100,000, so m is lower by the same share.
acoustics.js
lines 65 to 66
BimCraft 0.8.1
/** Energy absorption of the air in 1/m: the dB figure over 10 log10(e). */
export const airM = (f, env = {}) => airAbsorption(f, env) / 4.343;
Reverberation time per band
64-bit
The Sabine and Eyring times of a room in each of six octave bands, and the mid-frequency mean.
A = Σ Sᵢαᵢ + extra; ᾱ = min(0.999, A ÷ S); Sabine = C V ÷ (A + 4mV); Eyring = C V ÷ (−S ln(1 − ᾱ) + 4mV)
Areas and absorption are summed in list order in 64-bit. The answer T switches from Sabine to Eyring where ᾱ passes 0.2, so T has a step there. ln(1 − ᾱ) is taken with Math.log, not log1p; ᾱ is capped at 0.999 so the logarithm stays finite.
acoustics.js
lines 68 to 88
BimCraft 0.8.1
export function reverb(room, env = {}) {
const e = { T: 20, RH: 50, p: 101.325, air: true, ...env }, V = Number(room.volume_m3), notes = [];
const surfaces = (room.surfaces || []).map((s) => ({ ...s, alpha: s.alpha || MATERIALS[s.key]?.alpha })).filter((s) => s.area_m2 > 0);
if (!(V > 0) || !surfaces.length) return { ok: false, reason: 'noroom', notes };
for (const s of surfaces) if (!s.alpha) return { ok: false, reason: 'nomaterial', notes, key: s.key };
const S = surfaces.reduce((a, s) => a + s.area_m2, 0);
const bands = BANDS.map((f, i) => {
const sa = surfaces.reduce((a, s) => a + s.area_m2 * s.alpha[i], 0) + (room.extra || []).reduce((a, x) => a + (x.a?.[i] || 0), 0);
const alpha = Math.min(0.999, sa / S), air = e.air ? 4 * airM(f, e) * V : 0;
return { f, A: sa, alpha, air, sabine: C * V / (sa + air), eyring: C * V / (-S * Math.log(1 - alpha) + air) };
});
for (const b of bands) b.T = b.alpha > 0.2 ? b.eyring : b.sabine;
const mean = bands.reduce((a, b) => a + b.alpha, 0) / bands.length;
const mid = { sabine: (bands[2].sabine + bands[3].sabine) / 2, eyring: (bands[2].eyring + bands[3].eyring) / 2, T: (bands[2].T + bands[3].T) / 2 };
const dims = Math.cbrt(V);
if (V < 30) notes.push({ key: 'ac.note.small', V: Math.round(V) });
if (Number(room.aspect) > 3) notes.push({ key: 'ac.note.aspect', ratio: Math.round(room.aspect * 10) / 10 });
if (bands.some((b) => b.alpha > 0.2)) notes.push({ key: 'ac.note.dead' });
if (room.uneven) notes.push({ key: 'ac.note.uneven' });
return { ok: true, volume: V, area: S, bands, mean, mid, meanFreePath: 4 * V / S, dims, notes };
}
Reverberation over the target
64-bit
The finding raised when a room's mid-frequency time is over the person's target, with the absorption that would meet it.
T = round(T_mid × 100) ÷ 100; need = round(max(0, V C ÷ target − (A₅₀₀ + A₁₀₀₀) ÷ 2))
The time is rounded to 0.01 s before it is compared with the target (plus 1e-9 s) and before it is stored in the finding. The extra absorption is rounded to a whole m² and comes from Sabine's formula without the air term.
acoustics.js
lines 90 to 105
BimCraft 0.8.1
/** Findings as shared Problems (rule 29): the mid-band reverberation time over the person's own target. */
export function rtProblems(rooms, { target = null, t = null } = {}) {
if (!(target > 0)) return [];
const out = [], say = (key, vars, en) => (t ? t(key, vars) : en);
for (const r of rooms) {
if (!r.res?.ok) continue;
const T = Math.round(r.res.mid.T * 100) / 100;
if (T > target + 1e-9) {
const need = Math.round(Math.max(0, r.res.volume * C / target - (r.res.bands[2].A + r.res.bands[3].A) / 2)); // m² sabins more absorption for the target (Sabine)
out.push({ code: 'acoustics.rt', severity: 'warning', msg: { key: 'ac.p.rt', vars: { room: r.name, t: T, target, a: need } },
text: say('ac.p.rt', { room: r.name, t: T, target, a: need }, r.name + ': reverberation ' + T + ' s at mid frequencies, over your ' + target + ' s target. About ' + need + ' m² more absorption would bring it down.'),
req: { op: '<=', value: target, unit: 's' }, found: { value: T, unit: 's' }, source: { kind: 'engine', name: 'bimcraft.acoustics' }, where: r.at ? { space: '2d', at: r.at } : undefined });
}
}
return out;
}
How far an opening's middle is from the room's outline, to decide which room the opening belongs to.
d = min over edges of |P − (A + t (B − A))|, t clamped to 0…1
64-bit, in plan millimeters. The projection parameter is clamped and a zero-length edge is guarded. Lengths use Math.hypot.
acoustics-model.js
lines 24 to 32
BimCraft 0.8.1
function ringDist(poly, x, y) {
let d = Infinity;
for (let i = 0; i < poly.length; i++) {
const a = poly[i], b = poly[(i + 1) % poly.length], dx = b[0] - a[0], dy = b[1] - a[1], l2 = dx * dx + dy * dy;
const t = l2 ? Math.max(0, Math.min(1, ((x - a[0]) * dx + (y - a[1]) * dy) / l2)) : 0;
d = Math.min(d, Math.hypot(x - a[0] - dx * t, y - a[1] - dy * t));
}
return d;
}
Room perimeter
64-bit
The length of the room's outline, used for the wall area when the walls' finishes are not read from the model.
P = Σ |pᵢ₊₁ − pᵢ|
Edge lengths from Math.hypot, added in order in 64-bit, in millimeters.
acoustics-model.js
line 33
BimCraft 0.8.1
const perimeter = (p) => { let s = 0; for (let i = 0; i < p.length; i++) s += Math.hypot(p[(i + 1) % p.length][0] - p[i][0], p[(i + 1) % p.length][1] - p[i][1]); return s; };
Is a point inside a room
64-bit
Decides whether a ceiling, a floor or a piece of furniture lies in a room.
A crossing-number test in 64-bit with no tolerance: a point exactly on an edge can fall either way. The division by the edge's height is reached only when the edge crosses the point's level, so it is not zero.
acoustics-model.js
line 34
BimCraft 0.8.1
const inPoly = (poly, x, y) => { let c = false; for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { const a = poly[i], b = poly[j]; if ((a[1] > y) !== (b[1] > y) && x < ((b[0] - a[0]) * (y - a[1])) / (b[1] - a[1]) + a[0]) c = !c; } return c; };
Room height and opening areas
64-bit
The height the room is given and the area of the doors and windows in its walls.
H = height ÷ 1000; opening area = (width ÷ 1000) × (height ÷ 1000)
Millimeters are divided by 1000 before the product, in 64-bit. The height is a ceiling's when one over 1500 mm is drawn in the room, else the lowest wall on the level, else the level height or 2700 mm. An opening counts for a room when its middle is within half the wall thickness + 60 mm of the outline; its position divides by max(1, wall length).
acoustics-model.js
lines 69 to 89
BimCraft 0.8.1
const hmm = ceil ? ceil.height : (mine.length ? Math.min(...mine.map((x) => wallHeight(x, levels))) : lv.height || 2700), H = hmm / 1000;
const mine2 = settings.rooms?.[r.id] || {};
// 0.8 EG.49 (bim-sim_PART_3): what the model says is on each surface; the person's pick for a room wins, then the model, then the default
const src = { floor: 'default', wall: 'default', ceiling: 'default' };
const set = { ...finish };
if (fromModel) {
const fl = floors.find((f) => f.storey === lv.id && f.seed && inPoly(r.poly, f.seed[0], f.seed[1])) || floors.find((f) => f.storey === lv.id && f.mode === 'building');
if (fl && !settings.defaultsOnly) { set.floor = fl.kind === 'concrete' ? 'concrete' : 'wood-floor'; src.floor = 'model'; }
if (ceil) { set.ceiling = ceil.pattern ? 'ceiling-tile' : 'ceiling-board'; src.ceiling = 'model'; }
}
for (const k of ['floor', 'wall', 'ceiling']) if (mine2[k]) { set[k] = mine2[k]; src[k] = 'picked'; }
const floor = r.area_m2, per = perimeter(r.poly) / 1000;
let glass = 0, door = 0;
for (const o of ops) {
const wall = byGuid.get(o.wallGuid);
if (!wall || wall.storey !== lv.id) continue;
const [ax, ay, bx, by] = [wall.a[0], wall.a[1], wall.b[0], wall.b[1]], t = (o.offset + o.width / 2) / Math.max(1, wall.len), x = ax + (bx - ax) * t, y = ay + (by - ay) * t;
if (ringDist(r.poly, x, y) > wall.t / 2 + 60) continue;
const a = (o.width / 1000) * (o.height / 1000);
if (o.kind === 'window') glass += a; else door += a;
}
Wall area per finish
64-bit
The area of wall each finish covers in a room, less its doors and windows.
wall area = Σ edge length × H; net = max(0, gross − glass − doors); each finish × net ÷ gross
Edge lengths (Math.hypot ÷ 1000) times the height are summed per finish. Doors and windows are taken off by scaling every finish by net ÷ gross, not from the wall they sit in. The net area is clamped at zero.
acoustics-model.js
lines 91 to 103
BimCraft 0.8.1
const wallArea = new Map();
if (fromModel && !mine2.wall) {
for (let i = 0; i < r.poly.length; i++) {
const p0 = r.poly[i], p1 = r.poly[(i + 1) % r.poly.length], mx = (p0[0] + p1[0]) / 2, my = (p0[1] + p1[1]) / 2, len = Math.hypot(p1[0] - p0[0], p1[1] - p0[1]) / 1000;
let best = null, bd = Infinity;
for (const wl of mine) { const d = segDist(mx, my, wl.a, wl.b); if (d < bd && d <= wl.t / 2 + 80) { bd = d; best = wl; } }
const key = (best && wallFinish(best)) || set.wall;
wallArea.set(key, (wallArea.get(key) || 0) + len * H);
}
if ([...wallArea.keys()].some((k) => k !== finish.wall) || wallArea.size > 1) src.wall = 'model';
} else wallArea.set(set.wall, per * H);
const grossWalls = [...wallArea.values()].reduce((s2, v) => s2 + v, 0), netWalls = Math.max(0, grossWalls - glass - door), keep = grossWalls > 0 ? netWalls / grossWalls : 0;
const surfaces = [{ name: 'floor', key: set.floor, area_m2: floor }, { name: 'ceiling', key: set.ceiling, area_m2: floor }];
Room volume and the figures reported
64-bit
The volume passed to the formulas and the area, height and volume listed for each room.
V = floor area × H; aspect = longest ÷ max(shortest, 0.1)
The volume passed to the reverberation formulas is the unrounded product. The area, height and volume listed with the room are rounded with Math.round to 0.01 m², 0.01 m and 0.1 m³, and the room's center to whole millimeters.
Sabine T = 0.161 V ÷ (A + 4mV) and Eyring T = 0.161 V ÷ (−S ln(1 − ᾱ) + 4mV); the answer per band is Eyring when ᾱ is over 0.2, else Sabine. Air absorption m from the ISO 9613-1 formula, as the code's comments cite it.
Number types
64-bit numbers; plain arrays of six band values.
Tolerances
Mean absorption capped at 0.999. An opening belongs to a room when its middle is within half the wall thickness + 60 mm of the room outline; a room edge takes the finish of a wall within half its thickness + 80 mm. A finding compares the time rounded to 0.01 s with the target + 1e-9 s.
Limits
A diffuse sound field is assumed. Rooms are boxes of one height. The area, height and volume reported with a room are rounded to 0.01 m², 0.01 m and 0.1 m³; the calculation uses the unrounded values.
Approach: 64-bit, stated limit
The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.