Reverberation and Sabine's formula: a clap in a church, then curtains
Afterwards you can compute a room's reverberation time with Sabine's formula, measure it from a decay curve, and tell echoes, flutter, and reverberation apart.
- Field
- Engineering, Physics
- Prerequisites
- none beyond Python basics
- Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
py-room-reverberation.ipynb, executed with the versions above. The download needs a free account
Run it yourself. In a terminal, this installs exactly the versions above:
pip install numpy==2.4.3 scipy==1.18.1 matplotlib==3.11.2 jupyterlabPlay: clap in a church, then hang curtains
Clap once in an empty stone church and the sound hangs in the air for seconds; clap in a furnished living room and it is gone in under half a second. The game puts you in four places seen from above: a living room of 50 m³, a classroom, a church of 5880 m³, and an alley between two house walls. Tap Clap and watch the wave leave your hands, strike the walls, and come back, slowed down 40 to 150 times; you hear the clap as it would sound in that room. Sound starts with your first tap, as browsers require. Then hang curtains over the windows, lay carpet, or seat an audience, and clap again. The picture shows the walls only; the sound has floor and ceiling too. Watch T60, the time the sound takes to fall by 60 dB, to a millionth of its energy: 7.8 s in the bare church by Sabine's formula, 0.44 s in the living room with everything on.
Show code
// game.js: clap in four places seen from above, watch the sound spread and bounce in slow motion,
// and hear the clap as it sounds there. Units: meters, seconds. The notebook writes all data into #data.
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const {c: C, courant: COURANT, env_dt: ENV_DT, alpha: ALPHA, gain: GAIN} = DATA;
const ROOMS = ["living", "classroom", "church", "alley"];
const RANGE_DB = 40; // the picture shades the pressure over 40 dB
const T_MAX = 1.5; // the picture stops after 1.5 s of sound
// ---- the state: everything that changes, in one object
const state = {scene: "church", curtains: false, carpet: false, audience: false,
t: 0, running: true, p: null, pOld: null, grid: null};
const scene = () => DATA.scenes[state.scene];
const switches = () => `${+state.curtains}${+(state.carpet && state.scene !== "alley")}${+(state.audience && state.scene !== "alley")}`;
const current = () => scene().states[switches()];
// ---- the grid of the picture, as in the notebook's grid(): the walls' α cell by cell
const wallBeta = (alpha) => { const R = Math.sqrt(1 - alpha); return (1 - R) / (1 + R); };
function makeGrid() {
const s = scene(), [L, W] = s.size;
const dx = Math.max(L / 159, W / 99);
const nx = Math.round(L / dx) + 1, ny = Math.round(W / dx) + 1;
const a = {x0: new Array(ny).fill(ALPHA[s.wall]), x1: new Array(ny).fill(ALPHA[s.wall]),
y0: new Array(nx).fill(ALPHA[s.wall]), y1: new Array(nx).fill(ALPHA[s.wall])};
if (state.scene === "alley") {
a.x0.fill(ALPHA.open); a.x1.fill(ALPHA.open);
if (state.curtains) a.y1.fill(ALPHA.curtain);
}
for (const [face, s0, s1] of s.windows) {
a[face].forEach((_, i) => { const along = i * dx; if (along >= s0 && along <= s1) a[face][i] = ALPHA[state.curtains ? "curtain" : "glass"]; });
}
const loss = new Float64Array(nx * ny); // λβ, summed over the walls a cell lies on
for (let j = 0; j < ny; j++) { loss[j] += COURANT * wallBeta(a.x0[j]); loss[(nx - 1) * ny + j] += COURANT * wallBeta(a.x1[j]); }
for (let i = 0; i < nx; i++) { loss[i * ny] += COURANT * wallBeta(a.y0[i]); loss[i * ny + ny - 1] += COURANT * wallBeta(a.y1[i]); }
const dt = COURANT * dx / C;
return {dx, dt, nx, ny, loss, damp: current().gamma * dt / 2};
}
// the clap: a ring-shaped bump of width 3 Δx around the hands, at rest
function clapField() {
const g = state.grid, [sx, sy] = scene().clap, sigma = 3 * g.dx;
const p = new Float64Array(g.nx * g.ny);
for (let i = 0; i < g.nx; i++) for (let j = 0; j < g.ny; j++) {
const r2 = ((i * g.dx - sx) ** 2 + (j * g.dx - sy) ** 2) / (2 * sigma * sigma);
p[i * g.ny + j] = (1 - r2) * Math.exp(-r2);
}
state.p = p; state.pOld = p.slice(); state.t = 0;
}
// one leapfrog step of p_tt + γ p_t = c² ∇²p; beyond a wall the missing neighbor mirrors the inner one
function step() {
const g = state.grid, {nx, ny} = g, p = state.p, pOld = state.pOld, pNew = new Float64Array(nx * ny);
for (let x = 0; x < nx; x++) {
for (let y = 0; y < ny; y++) {
const i = x * ny + y;
const left = p[i + (x > 0 ? -ny : ny)], right = p[i + (x < nx - 1 ? ny : -ny)];
const down = p[i + (y > 0 ? -1 : 1)], up = p[i + (y < ny - 1 ? 1 : -1)];
const k = g.loss[i] + g.damp;
pNew[i] = (2 * p[i] - (1 - k) * pOld[i] + COURANT ** 2 * (left + right + down + up - 4 * p[i])) / (1 + k);
}
}
state.pOld = p; state.p = pNew; state.t += g.dt;
}
// ---- sound: the impulse response at the context's rate, convolved with a dry clap
let audio = null;
const buffers = {};
function generator(seed) { // a small seeded generator, Gaussian numbers by Box-Muller
let s = seed >>> 0;
const uniform = () => { s = (s + 0x6D2B79F5) >>> 0; let z = s;
z = Math.imul(z ^ (z >>> 15), z | 1); z ^= z + Math.imul(z ^ (z >>> 7), z | 61);
return (((z ^ (z >>> 14)) >>> 0) + 0.5) / 4294967296; };
return {uniform, gauss: () => Math.sqrt(-2 * Math.log(uniform())) * Math.cos(2 * Math.PI * uniform())};
}
function impulseResponse(st, fs) {
const last = st.refl[st.refl.length - 1][0] / 1000;
const n = Math.floor(Math.max(st.env.length * ENV_DT, last + 0.01) * fs) + 1;
const h = new Float32Array(n);
for (const [ms, a] of st.refl) h[Math.round(ms / 1000 * fs)] += a;
const {gauss} = generator(st.seed);
for (let i = 0; i < n; i++) {
const x = i / fs / ENV_DT, j = Math.floor(x);
if (j + 1 >= st.env.length) break;
const level = st.env[j] + (x - j) * (st.env[j + 1] - st.env[j]); // dB, between grid points
h[i] += gauss() * Math.sqrt(10 ** (level / 10) / fs);
}
return h;
}
function dryClap(fs) { // 10 ms of noise that dies in 2 ms
const {uniform} = generator(1898), n = Math.round(0.010 * fs);
const b = audio.createBuffer(1, n, fs), d = b.getChannelData(0);
for (let i = 0; i < n; i++) d[i] = (2 * uniform() - 1) * Math.exp(-i / fs / 0.002);
return b;
}
function wakeAudio() {
if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)();
if (audio.state === "suspended") audio.resume(); // iOS wants this inside the tap
}
function playClap() {
const fs = audio.sampleRate, key = state.scene + switches();
if (!buffers[key]) {
const h = impulseResponse(current(), fs);
buffers[key] = audio.createBuffer(1, h.length, fs);
buffers[key].copyToChannel(h, 0);
}
const convolver = audio.createConvolver();
convolver.normalize = false; // else every room would sound equally loud
convolver.buffer = buffers[key];
const source = audio.createBufferSource(), gain = audio.createGain();
source.buffer = dryClap(fs);
gain.gain.value = GAIN;
source.connect(convolver).connect(gain).connect(audio.destination);
source.start();
}
// ---- drawing
const $ = (id) => document.getElementById(id);
const canvas = $("scene"), image = document.createElement("canvas");
let dpr = 1;
function colors() {
const css = getComputedStyle(document.documentElement);
return Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted", "pale"].map((k) => [k, css.getPropertyValue(`--${k}`).trim()]));
}
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
function layout() {
dpr = window.devicePixelRatio || 1;
const box = canvas.getBoundingClientRect();
canvas.width = Math.round(box.width * dpr);
canvas.height = Math.round(box.height * dpr);
render();
}
// meters to pixels; a long room stands upright in an upright frame
function view(W, H) {
const [L, Wd] = scene().size, pad = 22;
const k0 = Math.min((W - 2 * pad) / L, (H - 2 * pad) / Wd), k1 = Math.min((W - 2 * pad) / Wd, (H - 2 * pad) / L);
const turned = k1 > 1.1 * k0, k = turned ? k1 : k0;
const ox = (W - k * (turned ? Wd : L)) / 2, oy = (H - k * (turned ? L : Wd)) / 2;
const at = turned ? (x, y) => [ox + y * k, oy + x * k] : (x, y) => [ox + x * k, oy + (Wd - y) * k];
return {k, turned, at};
}
function drawField(ctx, v, c) {
const g = state.grid, ref = scene().ref, [r, gr, b] = rgb(c.accent);
const w = v.turned ? g.ny : g.nx, h = v.turned ? g.nx : g.ny;
image.width = w; image.height = h;
const ictx = image.getContext("2d"), img = ictx.createImageData(w, h);
for (let x = 0; x < g.nx; x++) for (let y = 0; y < g.ny; y++) {
const level = 20 * Math.log10(Math.abs(state.p[x * g.ny + y]) / ref + 1e-12);
const px = v.turned ? x * w + y : (g.ny - 1 - y) * w + x;
img.data.set([r, gr, b, 255 * Math.min(1, Math.max(0, 1 + level / RANGE_DB))], 4 * px);
}
ictx.putImageData(img, 0, 0);
const [x0, y0] = v.turned ? v.at(-g.dx / 2, -g.dx / 2) : v.at(-g.dx / 2, (g.ny - 0.5) * g.dx);
const [L, W] = scene().size, [ax, ay] = v.at(0, 0), [bx, by] = v.at(L, W);
ctx.save(); // the cells' outer halves would show past the walls
ctx.beginPath(); ctx.rect(Math.min(ax, bx), Math.min(ay, by), Math.abs(bx - ax), Math.abs(by - ay)); ctx.clip();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(image, x0, y0, w * g.dx * v.k, h * g.dx * v.k);
ctx.restore();
}
function line(ctx, v, x0, y0, x1, y1) {
ctx.beginPath(); ctx.moveTo(...v.at(x0, y0)); ctx.lineTo(...v.at(x1, y1)); ctx.stroke();
}
function drawRoom(ctx, v, c) {
const s = scene(), [L, W] = s.size;
ctx.lineCap = "butt";
if (state.scene === "alley") { // two house walls; the ends and the sky are open
ctx.strokeStyle = c.ink; ctx.lineWidth = 2.5;
line(ctx, v, 0, 0, L, 0);
ctx.strokeStyle = state.curtains ? c.muted : c.ink; ctx.lineWidth = state.curtains ? 8 : 2.5;
line(ctx, v, 0, W, L, W);
} else {
ctx.strokeStyle = c.ink; ctx.lineWidth = 2.5;
ctx.beginPath(); [[0, 0], [L, 0], [L, W], [0, W]].forEach(([x, y], i) => ctx[i ? "lineTo" : "moveTo"](...v.at(x, y))); ctx.closePath(); ctx.stroke();
for (const [face, a, b] of s.windows) { // glass: a gap in the wall; curtains: a thick band
const fixed = {x0: 0, x1: L, y0: 0, y1: W}[face];
ctx.strokeStyle = state.curtains ? c.muted : c.bg; ctx.lineWidth = state.curtains ? 8 : 1.2;
if (face[0] === "x") line(ctx, v, fixed, a, fixed, b); else line(ctx, v, a, fixed, b, fixed);
}
const [x0, x1, y0, y1] = s.seats, [ax, ay] = v.at(x0, y0), [bx, by] = v.at(x1, y1);
ctx.strokeStyle = c.muted; ctx.lineWidth = 1;
ctx.strokeRect(Math.min(ax, bx), Math.min(ay, by), Math.abs(bx - ax), Math.abs(by - ay));
ctx.fillStyle = c.muted; ctx.font = "14px system-ui, sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "top";
ctx.fillText("seats", Math.min(ax, bx) + 4, Math.min(ay, by) + 3);
if (state.audience) { // the audience: the seats filled
ctx.fillStyle = c.muted; ctx.globalAlpha = 0.35;
ctx.fillRect(Math.min(ax, bx), Math.min(ay, by), Math.abs(bx - ax), Math.abs(by - ay));
ctx.globalAlpha = 1;
}
if (state.carpet) { // the carpet: a hatch over the floor
ctx.save(); ctx.strokeStyle = c.muted; ctx.globalAlpha = 0.35; ctx.lineWidth = 1;
const [px, py] = v.at(0, W), [qx, qy] = v.at(L, 0), x = Math.min(px, qx), y = Math.min(py, qy);
const w = Math.abs(qx - px), h = Math.abs(qy - py);
ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();
ctx.beginPath();
for (let d = -h; d < w; d += 12) { ctx.moveTo(x + d, y + h); ctx.lineTo(x + d + h, y); }
ctx.stroke(); ctx.restore();
}
}
const [hx, hy] = v.at(s.clap[0], s.clap[1]); // your hands
ctx.fillStyle = c.ink;
ctx.beginPath(); ctx.arc(hx, hy, 5, 0, 2 * Math.PI); ctx.fill();
ctx.font = "14px system-ui, sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "bottom";
ctx.fillText("you", hx + 7, hy - 4);
}
function render() {
const c = colors(), ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const W = canvas.width / dpr, H = canvas.height / dpr;
ctx.fillStyle = c.bg; ctx.fillRect(0, 0, W, H);
if (W > 0 && H > 0) {
const v = view(W, H);
if (state.p) drawField(ctx, v, c);
drawRoom(ctx, v, c);
ctx.fillStyle = c.muted; ctx.font = "14px system-ui, sans-serif"; ctx.textAlign = "left"; ctx.textBaseline = "top";
ctx.fillText(`t = ${Math.round(1000 * state.t)} ms, slowed ${scene().slow}×`, 4, 2);
}
const st = current();
if (state.scene === "alley") {
$("t60").textContent = `return every ${DATA.round_trip} ms`;
$("t60m").textContent = `${Math.round(1000 / DATA.round_trip)} returns a second`;
$("volume").textContent = "no closed volume";
$("area").textContent = "";
} else {
$("t60").textContent = `T60 ${st.sabine.toFixed(2)} s by Sabine`;
$("t60m").textContent = `${st.measured.toFixed(2)} s measured`;
$("volume").textContent = `V ${st.V.toLocaleString("en-US")} m³`;
$("area").textContent = `A ${st.A.toFixed(1)} m²`;
}
for (const r of ROOMS) $(r).setAttribute("aria-pressed", state.scene === r);
for (const f of ["curtains", "carpet", "audience"]) $(f).setAttribute("aria-pressed", state[f] && !$(f).disabled);
$("carpet").disabled = $("audience").disabled = state.scene === "alley";
}
// ---- controls, keys, and the loop
function set(partial) { // game.set({scene: "church", t: 0.1}) draws the still at 100 ms
const sceneChanged = partial.scene !== undefined && partial.scene !== state.scene;
Object.assign(state, partial);
state.grid = makeGrid();
if (sceneChanged && partial.t === undefined) { state.p = null; state.t = 0; }
if (partial.t !== undefined) {
const t = partial.t;
clapField();
while (state.t + state.grid.dt / 2 < t) step();
state.running = partial.running ?? false;
}
render();
}
function clap() {
wakeAudio();
playClap();
clapField();
state.running = true;
}
const toggle = (f) => { if (!$(f).disabled) set({[f]: !state[f]}); };
$("clap").addEventListener("click", clap);
for (const r of ROOMS) $(r).addEventListener("click", () => set({scene: r}));
for (const f of ["curtains", "carpet", "audience"]) $(f).addEventListener("click", () => toggle(f));
canvas.addEventListener("pointerdown", clap); // a tap on the room claps too
document.addEventListener("keydown", (e) => {
const key = e.key.toLowerCase();
const actions = {" ": clap, c: () => toggle("curtains"), k: () => toggle("carpet"), a: () => toggle("audience"),
1: () => set({scene: "living"}), 2: () => set({scene: "classroom"}), 3: () => set({scene: "church"}), 4: () => set({scene: "alley"})};
if (actions[key] && !(e.target instanceof HTMLButtonElement && key === " ")) { e.preventDefault(); actions[key](); }
});
let last = null, owed = 0;
function frame(now) { // called by the browser once per screen refresh
const elapsed = last === null ? 0 : Math.min((now - last) / 1000, 0.05); // capped: a background tab does not jump
last = now;
if (state.running && state.p) {
for (owed += elapsed / scene().slow; owed >= state.grid.dt; owed -= state.grid.dt) step();
if (state.t > T_MAX) state.running = false;
render();
}
requestAnimationFrame(frame);
}
// start in the church with a silent clap already spreading, so the frame is never empty
state.grid = makeGrid();
clapField();
while (state.t < 0.015) step();
window.addEventListener("resize", layout);
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", render);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render};
A room's late reflections drown in the ring
Clap in the living room and you hear one clap, a little fuller than outdoors. Clap in the bare church and no second clap comes, only a ring.
Show code
import itertools
import json
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.colors import to_rgb
from scipy.signal import fftconvolve
plt.rcParams.update({
"figure.figsize": (7, 3.6), "figure.dpi": 110,
"axes.spines.top": False, "axes.spines.right": False,
"axes.grid": True, "grid.alpha": 0.25,
"font.size": 11, "lines.linewidth": 1.8,
})
INK, ACCENT, SECOND, MUTED = "#1f2a44", "#c8553d", "#2a7f9e", "#8a8f98"
C = 343.0 # speed of sound in air at 20 °C, m/s
FS = 16_000 # sample rate of the notebook's impulse responses, Hz
# Absorption coefficients at 500 Hz, the fraction of the sound energy a surface takes on each hit,
# from the table at sengpielaudio.com/calculator-RT60Coeff.htm: stone as unglazed brick, marble as
# marble or tile, plaster on brick, window glass, wood floor on joists, a 14 oz/yd² curtain, carpet
# on foam, and "audience" per m² of occupied wooden pews. Empty seats count as wood floor; "open" is a gap.
ALPHA = dict(stone=0.03, marble=0.01, plaster=0.02, glass=0.18, wood=0.10,
curtain=0.49, carpet=0.57, audience=0.75, open=1.0)
# Box faces: walls x0, x1, y0, y1, floor z0, ceiling z1; x along the room, y across, z up, in m.
# Windows: (face, from, to, height), along the wall. Seats: (x0, x1, y0, y1) on the floor.
SCENES = {
"living": dict(name="living room", size=(5, 4, 2.5), wall="plaster", floor="wood", ceiling="plaster",
windows=[("y0", 1.0, 4.0, 2.0), ("x1", 1.0, 3.0, 2.0)], seats=(0.5, 4.5, 3.0, 4.0),
seat="wood", clap=(1.6, 2.2, 1.6), slow=150),
"classroom": dict(name="classroom", size=(9, 7, 3), wall="plaster", floor="wood", ceiling="plaster",
windows=[("y0", 0.8, 8.2, 2.2)], seats=(3.0, 8.4, 0.6, 6.4),
seat="wood", clap=(1.2, 3.1, 1.6), slow=80),
"church": dict(name="church", size=(30, 14, 14), wall="stone", floor="marble", ceiling="plaster",
windows=[(f, a, a + 2, 10.5) for f in ("y0", "y1") for a in (4, 9, 14, 19, 24, 27.5)]
+ [("x0", 5, 9, 10.5), ("x1", 2, 5, 10.5), ("x1", 9, 12, 10.5)],
seats=(9, 29, 2, 12), seat="wood", clap=(5, 6, 1.6), slow=40),
"alley": dict(name="alley", size=(30, 6, 10), wall="stone", floor="stone", ceiling="open",
windows=[], seats=None, seat=None, clap=(15, 3, 1.6), slow=40),
}
ROOMS = ["living", "classroom", "church"]
for key, s in SCENES.items():
s["key"] = key
EAR = np.array([0.4, 0.0, 0.0]) # your ears, 0.4 m from your hands
COMBOS = list(itertools.product((0, 1), repeat=3)) # curtains, carpet, audience: off or on
FACES = ["x0", "x1", "y0", "y1", "z0", "z1"]
def face_areas(scene):
L, W, H = scene["size"]
return np.array([W * H, W * H, L * H, L * H, L * W, L * W])
def absorption(scene, curtains=0, carpet=0, audience=0):
"""Per face the area-weighted absorption coefficient, and the absorption area A in m²."""
if scene["name"] == "alley": # open at both ends and to the sky
far_wall = ALPHA["curtain"] if curtains else ALPHA["stone"]
return np.array([1.0, 1.0, ALPHA["stone"], far_wall, ALPHA["stone"], 1.0]), None
areas = face_areas(scene)
absorbed = areas * ALPHA[scene["wall"]]
for face, a, b, h in scene["windows"]:
absorbed[FACES.index(face)] += (b - a) * h * (ALPHA["curtain" if curtains else "glass"] - ALPHA[scene["wall"]])
x0, x1, y0, y1 = scene["seats"]
seats = (x1 - x0) * (y1 - y0)
absorbed[4] = ((areas[4] - seats) * ALPHA["carpet" if carpet else scene["floor"]]
+ seats * ALPHA["audience" if audience else scene["seat"]])
absorbed[5] = areas[5] * ALPHA[scene["ceiling"]]
return absorbed / areas, absorbed.sum()
def sabine(V, A):
return 24 * np.log(10) / C * V / A # = 0.161 V/A
def eyring(V, S, A):
return 24 * np.log(10) / C * V / (-S * np.log(1 - A / S))
for s in (SCENES[name] for name in ROOMS):
L, W, H = s["size"]
windows = sum((b - a) * h for _, a, b, h in s["windows"])
seats = (s["seats"][1] - s["seats"][0]) * (s["seats"][3] - s["seats"][2])
print(f"{s['name']:12s} {L:2g} × {W:2g} × {H:4g} m V = {L * W * H:5.0f} m³ "
f"windows {windows:4.0f} m² seating {seats:4.0f} m²")
print(f"alley two walls {SCENES['alley']['size'][1]:g} m apart, open at the ends and to the sky")
print(f"Sabine's constant 24 ln 10 / c = {24 * np.log(10) / C:.4f} s/m")
living = SCENES["living"] # A summed by hand, surface by surface
L, W, H = living["size"]
windows = sum((b - a) * h for _, a, b, h in living["windows"])
terms = [("plaster wall", 2 * (L + W) * H - windows, "plaster"), ("window", windows, "glass"),
("wood floor", L * W, "wood"), ("plaster ceiling", L * W, "plaster")]
for part, S, material in terms:
print(f"living room {part:16s} {S:4.0f} m² × α {ALPHA[material]:.2f} = {S * ALPHA[material]:.1f} m²")
print(f"{'':28s}A = {sum(S * ALPHA[m] for _, S, m in terms):.1f} m², "
f"absorption() gives {absorption(living)[1]:.1f} m²")
living room 5 × 4 × 2.5 m V = 50 m³ windows 10 m² seating 4 m²
classroom 9 × 7 × 3 m V = 189 m³ windows 16 m² seating 31 m²
church 30 × 14 × 14 m V = 5880 m³ windows 357 m² seating 200 m²
alley two walls 6 m apart, open at the ends and to the sky
Sabine's constant 24 ln 10 / c = 0.1611 s/m
living room plaster wall 35 m² × α 0.02 = 0.7 m²
living room window 10 m² × α 0.18 = 1.8 m²
living room wood floor 20 m² × α 0.10 = 2.0 m²
living room plaster ceiling 20 m² × α 0.02 = 0.4 m²
A = 4.9 m², absorption() gives 4.9 m²
Show code
COURANT = 0.5 # c Δt / Δx; the scheme is stable up to 1/√2 in two dimensions
def wall_beta(alpha):
"""Loss of a wall cell: a wave that meets it head-on keeps the pressure fraction R = √(1 − α)."""
R = np.sqrt(1 - alpha)
return (1 - R) / (1 + R)
def grid(scene, curtains=0, carpet=0, audience=0):
"""The game's 2D grid of the floor plan: at most 160 × 100 cells, the walls' α cell by cell."""
L, W, H = scene["size"]
dx = max(L / 159, W / 99)
nx, ny = int(round(L / dx)) + 1, int(round(W / dx)) + 1
x, y = np.arange(nx) * dx, np.arange(ny) * dx
a = {f: np.full(n, ALPHA[scene["wall"]]) for f, n in [("x0", ny), ("x1", ny), ("y0", nx), ("y1", nx)]}
if scene["name"] == "alley":
a["x0"][:] = a["x1"][:] = ALPHA["open"]
if curtains:
a["y1"][:] = ALPHA["curtain"]
for face, s0, s1, _ in scene["windows"]:
along = y if face[0] == "x" else x
a[face][(along >= s0) & (along <= s1)] = ALPHA["curtain" if curtains else "glass"]
loss = np.zeros((nx, ny)) # λβ, summed over the walls a cell lies on
loss[0, :] += COURANT * wall_beta(a["x0"])
loss[-1, :] += COURANT * wall_beta(a["x1"])
loss[:, 0] += COURANT * wall_beta(a["y0"])
loss[:, -1] += COURANT * wall_beta(a["y1"])
alpha, _ = absorption(scene, curtains, carpet, audience)
gamma = C * (alpha[4] + alpha[5]) / (4 * H) # floor and ceiling, spread over the picture
dt = COURANT * dx / C
return dict(dx=dx, dt=dt, nx=nx, ny=ny, loss=loss, damp=gamma * dt / 2, gamma=gamma)
def clap_field(scene, g):
"""The clap: a ring-shaped bump of width 3 Δx around the hands, at rest."""
sx, sy = scene["clap"][:2]
X, Y = np.meshgrid(np.arange(g["nx"]) * g["dx"], np.arange(g["ny"]) * g["dx"], indexing="ij")
r2 = ((X - sx) ** 2 + (Y - sy) ** 2) / (2 * (3 * g["dx"]) ** 2)
p = (1 - r2) * np.exp(-r2)
return p, p.copy() # now and one step ago
def step(p, p_old, g):
"""One leapfrog step of p_tt + γ p_t = c² ∇²p; beyond a wall the missing neighbor mirrors the inner one."""
q = np.pad(p, 1, mode="reflect")
neighbors = q[:-2, 1:-1] + q[2:, 1:-1] + q[1:-1, :-2] + q[1:-1, 2:]
k = g["loss"] + g["damp"]
return (2 * p - (1 - k) * p_old + COURANT**2 * (neighbors - 4 * p)) / (1 + k), p
def field(scene, combo, t):
g = grid(scene, *combo)
p, p_old = clap_field(scene, g)
for _ in range(int(round(t / g["dt"]))):
p, p_old = step(p, p_old, g)
return p, g
# the picture's 0 dB: the front's height 1 m from the hands, before any wall answers
REF = {name: np.abs(field(s, (0, 0, 0), 1 / C)[0]).max() for name, s in SCENES.items()}
def draw_room(ax, scene, combo=(0, 0, 0), p=None, g=None):
"""The game's top view: the pressure shaded over 40 dB, walls, windows or curtains, seats, your hands."""
L, W, _ = scene["size"]
if p is not None:
shade = np.clip(1 + 20 * np.log10(np.abs(p) / REF[scene["key"]] + 1e-12) / 40, 0, 1)
rgba = np.zeros((g["ny"], g["nx"], 4))
rgba[..., :3] = to_rgb(ACCENT)
rgba[..., 3] = shade.T
h = g["dx"] / 2
ax.imshow(rgba, origin="lower", extent=(-h, (g["nx"] - 1) * g["dx"] + h, -h, (g["ny"] - 1) * g["dx"] + h),
interpolation="bilinear")
if scene["name"] == "alley":
ax.plot([0, L], [0, 0], color=INK, lw=2)
ax.plot([0, L], [W, W], color=MUTED if combo[0] else INK, lw=6 if combo[0] else 2)
else:
ax.plot([0, L, L, 0, 0], [0, 0, W, W, 0], color=INK, lw=2)
for face, a, b, _ in scene["windows"]:
fixed = {"x0": 0, "x1": L, "y0": 0, "y1": W}[face]
xs, ys = ([fixed, fixed], [a, b]) if face[0] == "x" else ([a, b], [fixed, fixed])
ax.plot(xs, ys, color=MUTED if combo[0] else "white", lw=6 if combo[0] else 1.2, solid_capstyle="butt")
x0, x1, y0, y1 = scene["seats"]
ax.add_patch(plt.Rectangle((x0, y0), x1 - x0, y1 - y0, fill=bool(combo[2]), color=MUTED,
alpha=0.35 if combo[2] else 1, lw=1))
ax.text(x0 + 0.02 * L, y1 - 0.02 * L, "seats", ha="left", va="top", color=MUTED)
ax.plot(*scene["clap"][:2], "o", ms=6, color=INK)
ax.set(xlim=(-0.03 * L, 1.03 * L), ylim=(-0.03 * L, W + 0.03 * L), aspect="equal")
ax.set_axis_off()
church = SCENES["church"]
p, g = field(church, (0, 0, 0), 0.100)
fig, ax = plt.subplots(figsize=(7, 3.6))
draw_room(ax, church, p=p, g=g)
ax.annotate("you", church["clap"][:2], xytext=(1.2, 9.5), color=INK, arrowprops=dict(arrowstyle="-", color=INK, lw=1))
ax.text(30.4, 7, "far end wall", rotation=90, va="center", color=INK)
ax.text(0, -1.1, "t = 100 ms", color=MUTED, va="top")
plt.show()
The game's church at 100 ms: the first front is returning from the far end wall.
What reaches your ears after an ideal click is the room's impulse response: a spike for the direct sound, then one per reflection, later by its longer path and weaker. Any sound arrives as itself repeated at every spike, scaled by it; that sum, the convolution of the sound with the impulse response, is what the game plays when you clap.
Show code
def image_sources(scene, alpha, max_order=3, t_max=None):
"""Delay (s), amplitude (1/m), and order of every specular path from your hands to your ears:
mirror the hands in the walls, and in the mirrored walls, and so on (Allen and Berkley)."""
size, src = np.array(scene["size"], float), np.array(scene["clap"], float)
ear, beta = src + EAR, np.sqrt(1 - alpha)
axes = []
for k in range(3): # per axis: (position, hits on face 0, hits on face 1)
if scene["name"] == "alley" and k != 1: # the alley mirrors only in its walls and the ground
axes.append([(src[k], 0, 0)] + ([(-src[k], 1, 0)] if k == 2 else []))
continue
m_max = max_order if t_max is None else int(C * t_max / (2 * size[k])) + 1
axes.append([((1 - 2 * q) * src[k] + 2 * m * size[k], abs(m - q), abs(m))
for m in range(-m_max, m_max + 1) for q in (0, 1)])
paths = []
for (px, a0, a1), (py, b0, b1), (pz, c0, c1) in itertools.product(*axes):
hits = np.array([a0, a1, b0, b1, c0, c1])
d = np.linalg.norm(np.array([px, py, pz]) - ear)
if (t_max is None and hits.sum() > max_order) or (t_max is not None and d > C * t_max):
continue
paths.append((d / C, np.prod(beta**hits) / d, hits.sum()))
return np.array(sorted(paths))
def particles(scene, alpha, t_end, rng, n=20_000, early=3, dt=1e-3):
"""Energy left in the room, on a 1 ms grid, as a fraction of the clap's: n sound particles fly
straight to the next surface, lose α there, and leave in a random direction (Lambert's law).
Only particles past their first `early` hits count; the image sources carry those."""
size = np.array(scene["size"], float)
pos = np.tile(np.array(scene["clap"], float), (n, 1))
u = rng.normal(size=(n, 3))
u /= np.linalg.norm(u, axis=1)[:, None]
e, t, hits, rows = np.full(n, 1 / n), np.zeros(n), np.zeros(n, int), np.arange(n)
diff = np.zeros(int(t_end / dt) + 2)
while ((t < t_end) & (e > 1e-7 / n)).any():
with np.errstate(divide="ignore", invalid="ignore"):
reach = np.where(u > 0, (size - pos) / u, np.where(u < 0, -pos / u, np.inf))
axis = np.argmin(reach, axis=1)
dist = reach[rows, axis]
t_next = t + dist / C
keep = (t < t_end) & (hits >= early) # each particle's energy, from now to its next hit
i0 = np.minimum((t / dt).astype(int), len(diff) - 1)
i1 = np.minimum((t_next / dt).astype(int), len(diff) - 1)
diff += np.bincount(i0[keep], e[keep], len(diff)) - np.bincount(i1[keep], e[keep], len(diff))
pos += u * dist[:, None]
side = (u[rows, axis] > 0).astype(int)
e *= 1 - alpha[2 * axis + side]
t, hits = t_next, hits + 1
cos_t, phi = np.sqrt(rng.random(n)), 2 * np.pi * rng.random(n)
sin_t = np.sqrt(1 - cos_t**2)
new = np.empty_like(u)
for k in range(3): # the normal component points back into the room
m = axis == k
o1, o2 = [j for j in range(3) if j != k]
new[m, k] = np.where(side[m] == 1, -1.0, 1.0) * cos_t[m]
new[m, o1], new[m, o2] = sin_t[m] * np.cos(phi[m]), sin_t[m] * np.sin(phi[m])
u = new
return np.maximum(np.cumsum(diff)[:-1], 0)
ENV_DT = 0.005 # the envelope's grid, s
def envelope(e, V, dt=1e-3, drop_db=65):
"""The ring's energy arriving per second, against the direct sound at 1 m, in dB on a 5 ms grid:
a diffuse energy density w gives the intensity c w, the clap's energy E reaches 1 m as E/4π."""
k = int(round(ENV_DT / dt))
e5 = e[: len(e) // k * k].reshape(-1, k).mean(axis=1)
level = 10 * np.log10(4 * np.pi * C * np.maximum(e5, 1e-300) / V)
return np.round(level[: np.flatnonzero(level > level.max() - drop_db)[-1] + 1], 1)
def impulse_response(refl, env, fs, rng):
"""Direct sound and reflections as spikes, then the ring as noise whose energy follows env."""
n = int(max(len(env) * ENV_DT, refl[-1, 0] + 0.01) * fs) + 1
h = np.zeros(n)
np.add.at(h, np.round(refl[:, 0] * fs).astype(int), refl[:, 1])
if len(env):
level = np.interp(np.arange(n) / fs, np.arange(len(env)) * ENV_DT, env, right=-300)
h += rng.standard_normal(n) * np.sqrt(10 ** (level / 10) / fs)
return h
STATES = {}
for si, (name, s) in enumerate(SCENES.items()):
for ci, combo in enumerate(COMBOS):
if name == "alley" and (combo[1] or combo[2]): # no carpet, no audience in the alley
continue
alpha, A = absorption(s, *combo)
rng = np.random.default_rng([1898, si, ci]) # 1898: Sabine's earliest papers on acoustics
if name == "alley":
refl, env = image_sources(s, alpha, t_max=1.0), np.array([])
else:
V = np.prod(s["size"])
refl = image_sources(s, alpha)
env = envelope(particles(s, alpha, 1.4 * sabine(V, A) * 70 / 60, rng), V)
STATES[name, combo] = dict(alpha=alpha, A=A, refl=refl, env=env, seed=1898 + 8 * si + ci,
h=impulse_response(refl, env, FS, rng))
print(f"{len(STATES)} impulse responses: {len(ROOMS)} rooms × {len(COMBOS)} furnishings, the alley bare and curtained")
26 impulse responses: 3 rooms × 8 furnishings, the alley bare and curtained
The ear fuses a copy that follows the original closely, the precedence effect (Wallach, Newman, and Rosenzweig, 1949; Haas, 1951). How closely depends on the sound: two isolated clicks are heard as two beyond a lag of 2 to 10 ms (Litovsky and colleagues, 1999), speech beyond about 50 ms. A clap in a room is neither, with dozens of copies arriving together. So the page uses its own working rule, simpler than Kuttruff's published echo criteria: a reflection counts as an echo if it arrives more than 50 ms after the direct sound and carries more energy than the rest of the sound in the 20 ms around it. A reflection is late by
with \(d_\mathrm{refl}\) the distance from your ears to your hands' mirror image in the wall and \(d_\mathrm{direct}\) to the hands: 50 ms is a detour of 17 m.
Show code
def stands_out(h, t, fs, own=0.0015, around=0.010):
"""Energy of the return at time t (within ±1.5 ms) against everything else within ±10 ms, in dB."""
energy, i = h**2, int(round(t * fs))
k, w = int(round(own * fs)), int(round(around * fs))
mine = energy[i - k:i + k + 1].sum()
rest = energy[max(i - w, 0):i + w + 1].sum() - mine
return np.inf if rest == 0 else 10 * np.log10(mine / rest)
print(f"50 ms is a detour of {0.050 * C:.1f} m")
for name in ROOMS:
st = STATES[name, (0, 0, 0)]
refl, h = st["refl"], st["h"]
delay = refl[:, 0] - refl[0, 0]
print(f"{SCENES[name]['name']:12s} first-order reflections after "
+ ", ".join(f"{1e3 * d:.0f}" for d in delay[refl[:, 2] == 1]) + " ms")
late = delay > 0.050
levels = np.array([stands_out(h, t, FS) for t in refl[late, 0]])
for d, order, level in zip(delay[late], refl[late, 2], levels):
if order == 1 or level > 0:
print(f"{'':14s}{1e3 * d:5.0f} ms, order {order:.0f}: {level:+5.1f} dB against the 20 ms around it, "
+ ("an echo" if level > 0 else "in the ring"))
print(f"{'':14s}{late.sum()} reflections after 50 ms, {(levels > 0).sum()} stand out")
50 ms is a detour of 17.2 m
living room first-order reflections after 4, 8, 9, 9, 12, 17 ms
0 reflections after 50 ms, 0 stand out
classroom first-order reflections after 7, 7, 8, 17, 22, 43 ms
16 reflections after 50 ms, 0 stand out
church first-order reflections after 8, 29, 34, 45, 71, 143 ms
71 ms, order 1: -10.7 dB against the 20 ms around it, in the ring
143 ms, order 1: -5.9 dB against the 20 ms around it, in the ring
53 reflections after 50 ms, 0 stand out
Show code
fig, axes = plt.subplots(2, 1, figsize=(7, 4.4), sharex=True)
for ax, name in zip(axes, ["living", "church"]):
st = STATES[name, (0, 0, 0)]
refl = st["refl"]
delay, level = 1e3 * (refl[1:, 0] - refl[0, 0]), 20 * np.log10(refl[1:, 1] / refl[0, 1])
t_ring = np.arange(len(st["env"])) * ENV_DT - refl[0, 0]
ring = 10 * np.log10(10 ** (st["env"] / 10) * 0.020 / refl[0, 1] ** 2) # ring energy per 20 ms
ax.fill_between(1e3 * t_ring, -60, ring, color=ACCENT, alpha=0.15, lw=0)
ax.vlines(delay, -60, level, color=ACCENT, lw=1.2)
ax.plot(delay, level, "o", ms=4, color=ACCENT)
ax.axvline(50, color=MUTED, ls="--", lw=1)
ax.text(198, -2, SCENES[name]["name"], ha="right", va="top", color=INK)
ax.set(ylabel="level / dB", ylim=(-50, 0), xlim=(0, 200))
axes[1].text(47, -2, "no echo\nbefore 50 ms", ha="right", va="top", color=MUTED)
axes[1].text(198, -20, "the ring, per 20 ms", ha="right", va="top", color=ACCENT)
axes[1].set(xlabel="time after the direct sound / ms")
plt.show()
Each stem is one computed reflection, in decibels against the direct sound; the band is the ring, sound that has struck more than three surfaces. Both come from the 3D model of the next section, not from the picture or a recording. The living room's six first reflections arrive within 18 ms, too early. In the church the vault answers after 71 ms and the far end wall after 143 ms, but 11 and 6 dB under the sound around them, and none of the 53 reflections after 50 ms stands out. A clean train of echoes needs the alley.
The ring dies in Sabine's time
Seat an audience in the bare church and the ring shrinks from nearly 8 s to under 4. By 0.4 s the picture has lost its fronts.
Show code
p, g = field(church, (0, 0, 0), 0.400)
fig, ax = plt.subplots(figsize=(7, 3.6))
draw_room(ax, church, p=p, g=g)
ax.text(0, -1.1, "t = 400 ms", color=MUTED, va="top")
plt.show()
Sound arriving from every direction at once is a diffuse field, all Sabine's formula needs. A wall of area \(S\) then receives the energy \(c\,w\,S/4\) per second, \(w\) the energy density, and takes the fraction \(\alpha\). Summed over the surfaces and divided by the energy \(wV\), the energy falls as \(e^{-cAt/(4V)}\), and 60 dB takes
in s, with \(V\) in m³ and the absorption area \(A\) in m², the open window that would absorb as much.
Handbooks list α per material and octave band, measured in a reverberation room; the page uses 500 Hz. For one room, sum area times α: in the bare living room, 35 m² of plaster wall at 0.02, 10 m² of window at 0.18, and 20 m² each of wood floor at 0.10 and plaster ceiling at 0.02 give 4.9 m².
The measured T60 comes from a 3D impulse response: image sources for up to three reflections, then sound particles that lose α at each hit and leave in a random direction, as rough stone and furniture scatter sound. They know nothing of Sabine's formula.
To measure T60, square the impulse response and sum it from the end backward: the energy still to come, equal to the average of many noisy decays (Schroeder, 1965). Start after the clap, which at 0.4 m outweighs all that follows (by 14 dB in the church with an audience) and would bend the curve. In dB against the start, fit a line from −5 to −35 dB, or to −25 dB over background noise; T60 is 60 dB over its slope.
def t60_schroeder(h, fs, skip=0.005, lo=-5, hi=-35):
start = np.argmax(np.abs(h)) + int(skip * fs) # after the clap itself, the loudest sample
energy = h[start:] ** 2
left = np.cumsum(energy[::-1])[::-1] # energy still to come after each sample
level = 10 * np.log10(left / left[0])
t = (start + np.arange(len(energy))) / fs
slope, offset = np.polyfit(t[(level <= lo) & (level >= hi)], level[(level <= lo) & (level >= hi)], 1)
return -60 / slope, slope, offset, t, level
T, slope, *_ = t60_schroeder(STATES["church", (0, 0, 0)]["h"], FS)
print(f"church, bare: slope {slope:.2f} dB/s, T60 = {T:.2f} s")
h = STATES["church", (0, 0, 1)]["h"]
direct = h[np.argmax(np.abs(h))] ** 2
print(f"church, audience: the direct sound outweighs everything after it by {10 * np.log10(direct / ((h**2).sum() - direct)):.0f} dB")
church, bare: slope -7.79 dB/s, T60 = 7.70 s church, audience: the direct sound outweighs everything after it by 14 dB
Show code
V_church = np.prod(church["size"])
fig, axes = plt.subplots(1, 2, figsize=(7.6, 3.6), sharey=True)
for ax, combo, label in zip(axes, [(0, 0, 0), (0, 0, 1)], ["bare", "audience"]):
st = STATES["church", combo]
T, slope, offset, t, level = t60_schroeder(st["h"], FS)
T_sab = sabine(V_church, st["A"])
start = int(round(t[0] * FS))
tau = T / (6 * np.log(10)) # energy per sample × τ fs lies on the backward sum
raw = 10 * np.log10(st["h"][start:] ** 2 * tau * FS / (st["h"][start:] ** 2).sum() + 1e-30)
ax.plot(t[::8], raw[::8], color=ACCENT, alpha=0.15, lw=0.4)
ax.plot(t, level, color=ACCENT)
fit = np.array([(-5 - offset) / slope, (-35 - offset) / slope]) # the fit, over its range only
ax.plot(fit, offset + slope * fit, color=MUTED, lw=1.2)
ax.plot([0, T_sab], [offset, offset - 60], color=SECOND, ls="--", lw=1.2)
for guide in (-5, -35):
ax.axhline(guide, color=MUTED, ls="--", lw=1)
for row, (text, color) in enumerate([(label, INK), (f"measured {T:.2f} s", ACCENT), (f"Sabine {T_sab:.2f} s", SECOND)]):
ax.text(0.03, 0.03 + 0.085 * (2 - row), text, transform=ax.transAxes, ha="left", va="bottom", color=color)
ax.set(xlabel="t / s", xlim=(0, 1.1 * T_sab), ylim=(-70, 3))
axes[0].set(ylabel="level / dB")
plt.show()
Show code
FURNISHINGS = [((0, 0, 0), "bare"), ((1, 0, 0), "curtains"), ((0, 1, 0), "carpet"), ((0, 0, 1), "audience"),
((1, 1, 1), "all three")]
print(f"{'':12s}{'':10s}{'V / m³':>8s}{'A / m²':>8s}{'mean α':>8s} T60 / s: {'Sabine':>6s}{'Eyring':>8s}{'measured':>10s}"
f"{'meas/Sab':>10s}")
for name in ROOMS:
s = SCENES[name]
V, S = np.prod(s["size"]), face_areas(s).sum()
for combo, label in FURNISHINGS:
st = STATES[name, combo]
T_sab, T_eyr, T_meas = sabine(V, st["A"]), eyring(V, S, st["A"]), t60_schroeder(st["h"], FS)[0]
print(f"{s['name'] if label == 'bare' else '':12s}{label:10s}{V:8.0f}{st['A']:8.1f}{st['A'] / S:8.2f}"
f"{'':11s}{T_sab:6.2f}{T_eyr:8.2f}{T_meas:10.2f}{T_meas / T_sab:10.2f}")
V / m³ A / m² mean α T60 / s: Sabine Eyring measured meas/Sab
living room bare 50 4.9 0.06 1.64 1.60 1.61 0.98
curtains 50 8.0 0.09 1.01 0.96 0.98 0.98
carpet 50 12.4 0.15 0.65 0.60 0.57 0.88
audience 50 7.5 0.09 1.07 1.03 1.03 0.96
all three 50 18.1 0.21 0.44 0.40 0.38 0.86
classroom bare 189 12.1 0.05 2.52 2.45 2.47 0.98
curtains 189 17.1 0.08 1.78 1.71 1.73 0.97
carpet 189 27.0 0.12 1.13 1.06 1.05 0.93
audience 189 32.4 0.15 0.94 0.87 0.85 0.90
all three 189 52.4 0.24 0.58 0.51 0.46 0.80
church bare 5880 121.1 0.06 7.82 7.59 7.70 0.98
curtains 5880 231.8 0.11 4.09 3.85 3.94 0.96
carpet 5880 244.3 0.12 3.88 3.64 3.64 0.94
audience 5880 251.1 0.12 3.77 3.54 3.51 0.93
all three 5880 485.0 0.23 1.95 1.71 1.67 0.85
Bare, the measured time is within 2 % of Sabine's in all three rooms; with everything on it is 14 to 20 % shorter. Eyring's formula accounts for most of that: since each reflection leaves \(1-\bar\alpha\) of the energy, \(\bar\alpha = A/S\) the mean absorption, it puts \(-S\ln(1-\bar\alpha)\) in place of \(A\). Sabine's continuous loss fails once \(\bar\alpha\) passes about 0.2.
Two bare walls make the clap flutter
Clap in the middle of the alley and you hear a rattle, a buzz rather than a ring. Hang a curtain along one wall and the rattle fades sooner: the tenth return arrives 53 dB under the direct sound instead of 39.
Show code
alley = SCENES["alley"]
p, g = field(alley, (0, 0, 0), 0.080)
fig, ax = plt.subplots(figsize=(7, 1.9))
draw_room(ax, alley, p=p, g=g)
ax.text(0, -0.6, "t = 80 ms", color=MUTED, va="top")
plt.show()
From the middle of an alley 6 m wide, each wall is 3 m away. The two halves of the clap, one sent toward each wall, come back past your ears at the same moment, travel on to the opposite walls, and return again: every \(d/c\) = 6 m / 343 m/s = 17.5 ms, 57 times a second, each time weaker by the spreading and by one more reflection. Read with the page's echo rule: from the third return on, each arrives more than 50 ms after the clap and alone within 10 ms, so each is an echo; by the 2 to 10 ms threshold for clicks, the first two, at 17.5 and 35 ms, count as well. A regular train of echoes, tens per second, is heard as one rattle: a flutter echo is a train of echoes, not reverberation. Sabine's formula does not apply here. There is no closed volume, and nothing mixes the sound into a diffuse field.
Show code
round_trip = alley["size"][1] / C # from the middle to a wall and back
print(f"round trip d/c = {1e3 * round_trip:.1f} ms, {1 / round_trip:.1f} returns a second")
fig, axes = plt.subplots(2, 1, figsize=(7, 4.2), sharex=True, sharey=True)
for ax, (combo, label) in zip(axes, [((0, 0, 0), "bare"), ((1, 0, 0), "curtain on one wall")]):
refl, h = STATES["alley", combo]["refl"], STATES["alley", combo]["h"]
levels = [10 * np.log10((refl[np.abs(refl[:, 0] - k * round_trip) < 1e-3, 1] ** 2).sum() / refl[0, 1] ** 2)
for k in (1, 5, 10)]
late = refl[(refl[:, 0] - refl[0, 0] > 0.050) & (refl[:, 0] < 0.4), 0]
late = late[np.r_[True, np.diff(late) > 3e-3]] # one time per return
echoes = sum(stands_out(h, t, FS) > 0 for t in late)
print(f"{label:20s} returns 1, 5, 10: " + ", ".join(f"{l:5.1f}" for l in levels)
+ f" dB, a fall of {levels[0] - levels[2]:.1f} dB; {echoes} of {len(late)} returns between 50 and 400 ms stand out")
t = np.arange(len(h)) / FS
big = np.abs(h) > 1e-4
ax.vlines(t[big], -70, 20 * np.log10(np.abs(h[big]) / refl[0, 1]), color=ACCENT, lw=1)
ax.plot(round_trip * np.arange(1, 23), np.full(22, -3), "|", ms=6, color=MUTED)
ax.text(0.395, -8, label, ha="right", va="top", color=INK)
ax.set(ylabel="level / dB", ylim=(-70, 0), xlim=(0, 0.4))
axes[0].text(0.395, -17, f"ticks every {1e3 * round_trip:.1f} ms", ha="right", va="top", color=MUTED)
axes[1].set(xlabel="t / s")
plt.show()
round trip d/c = 17.5 ms, 57.2 returns a second bare returns 1, 5, 10: -20.7, -32.2, -38.9 dB, a fall of 18.2 dB; 20 of 20 returns between 50 and 400 ms stand out curtain on one wall returns 1, 5, 10: -21.8, -39.0, -52.9 dB, a fall of 31.0 dB; 20 of 20 returns between 50 and 400 ms stand out
From the first return to the tenth, 175 ms after the clap, the rattle falls 18 dB in the bare alley and 31 dB with the curtain, which takes half the energy at every second reflection. The same happens between the bare side walls of a corridor, or of a room whose floor and ceiling absorb what the walls do not.
How the game computes it
The state. One object holds what changes: the scene, the three switches, the picture's clock in seconds of sound, the pressure field now and one step ago, and the grid.
const state = {scene: "church", curtains: false, carpet: false, audience: false,
t: 0, running: true, p: null, pOld: null, grid: null};
The picture. At every screen refresh requestAnimationFrame calls frame. The elapsed time, capped at 50 ms so that a background tab does not jump, is divided by the slow-motion factor and cut into time steps of the 2D wave equation \(p_{tt} + \gamma p_t = c^2\nabla^2 p\) on at most 160 × 100 cells. Each step is leapfrog: the new pressure at a cell from its value now, one step ago, and its four neighbors. The Courant number \(c\,\Delta t/\Delta x\), the cells the wave crosses per step, is 0.5, under the scheme's stability limit of \(1/\sqrt 2\).
A wall cell lacks one neighbor and counts the inner one twice, a mirror, plus a loss set so that a wave meeting it head-on returns \(\sqrt{1-\alpha}\) of its pressure, \(1-\alpha\) of its energy: Sabine's α. Floor and ceiling of area \(F\), absent from the top view, drain \(c\,w\,(\alpha_\mathrm{floor} + \alpha_\mathrm{ceiling})F/4\) per second from the energy \(wFH\) between them, which the picture spreads as the damping \(\gamma = c(\alpha_\mathrm{floor} + \alpha_\mathrm{ceiling})/(4H)\); carpet and audience dim it. The alley's ceiling is the sky, α = 1. With k a cell's wall loss plus damping:
const k = g.loss[i] + g.damp;
pNew[i] = (2 * p[i] - (1 - k) * pOld[i] + COURANT ** 2 * (left + right + down + up - 4 * p[i])) / (1 + k);
The sound. The first tap creates the browser's AudioContext. The game builds the impulse response at the context's sample rate as the notebook does: the stored reflections as spikes, then the ring as seeded random numbers scaled to the stored envelope, in dB against the direct sound at 1 m. That sound passes once, \(E/4\pi\) per m² with \(E\) the clap's energy; the ring, the fraction \(e(t)\) of \(E\) spread over \(V\), passes \(c\,E\,e(t)/V\) per m² every second. Their ratio, \(4\pi c\,e(t)/V\) per second, divided by the sample rate is the energy of one sample of the ring:
for (let i = 0; i < n; i++) {
const x = i / fs / ENV_DT, j = Math.floor(x);
if (j + 1 >= st.env.length) break;
const level = st.env[j] + (x - j) * (st.env[j + 1] - st.env[j]); // dB, between grid points
h[i] += gauss() * Math.sqrt(10 ** (level / 10) / fs);
}
A ConvolverNode, the browser's built-in convolution, plays a synthetic clap through it, with normalize = false, else every room would sound equally loud. So three models run behind the game: the 2D grid draws the picture, image sources and particles from the notebook make the sound. The grid is too coarse and flat for sound you hear, and image sources alone ring too long (Variations). The notebook convolves the same clap with scipy.signal.fftconvolve to set the volume.
Show code
def dry_clap(fs, rng):
"""A clap without a room: 10 ms of noise that dies in 2 ms."""
t = np.arange(int(0.010 * fs)) / fs
return rng.uniform(-1, 1, len(t)) * np.exp(-t / 0.002)
clap = dry_clap(FS, np.random.default_rng(1898))
peaks = {key: np.abs(fftconvolve(clap, st["h"])).max() for key, st in STATES.items()}
GAIN = round(0.8 / max(peaks.values()), 3)
print(f"gain {GAIN}: the loudest clap peaks at {20 * np.log10(GAIN * max(peaks.values())):.1f} dB of full scale")
gain 0.415: the loudest clap peaks at -1.9 dB of full scale
From the notebook into the game. Per scene the plan, windows, seats, and slow-motion factor; per combination of scene and switches, 26 in all, the reflections, the envelope on a 5 ms grid in dB to one decimal, V, A, γ, and both T60s. It travels as 92 kB of JSON in a <script type="application/json" id="data"> element, so the game fetches nothing. Fixed rounding and sorted keys give the same bytes on every run, and </ is written <\/ so the data cannot end the element early. The page itself waits in the hidden cell below.
Show code
# The page around the game: the site's light and dark colors, the scene, the readouts, and the controls.
# game.js fills in every number; the notebook fills in DATA and SCRIPT.
TEMPLATE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>A clap in a church, then curtains</title>
<style>
:root { --bg: #ffffff; --fg: #1f2a44; --ink: #1f2a44; --accent: #c8553d; --second: #2a7f9e; --muted: #8a8f98;
--pale: #e7e9f0; }
@media (prefers-color-scheme: dark) {
:root { --bg: #141a2a; --fg: #e9ecf3; --ink: #c9d0e3; --accent: #e3826b; --second: #6ec1de; --muted: #8e97ab;
--pale: #232b40; }
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg); color: var(--fg);
font: 15px/1.3 system-ui, sans-serif; -webkit-text-size-adjust: 100%; }
body { display: grid; gap: 6px; padding: 6px; grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr) auto auto; grid-template-areas: "scene" "readouts" "controls"; }
#view { grid-area: scene; position: relative; min-height: 0; }
#scene { position: absolute; inset: 0; width: 100%; height: 100%; touch-action: manipulation; }
#readouts { grid-area: readouts; display: flex; flex-wrap: wrap; gap: 2px 14px; font-variant-numeric: tabular-nums; }
#readouts span { white-space: nowrap; }
#t60 { color: var(--accent); font-weight: 600; font-size: 1.25em; }
#controls { grid-area: controls; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.seg { display: flex; flex-wrap: wrap; gap: 4px; }
button { font: inherit; color: var(--second); background: none; border: 1.5px solid var(--second); border-radius: 4px;
padding: 0.25em 0.6em; white-space: nowrap; touch-action: manipulation; }
button[aria-pressed="true"] { color: var(--bg); background: var(--second); }
button:disabled { opacity: 0.35; }
#clap { color: var(--bg); background: var(--accent); border-color: var(--accent); font-weight: 600; padding: 0.3em 1.4em; }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }
@media (min-width: 600px) and (min-aspect-ratio: 5/4) {
body { grid-template-columns: minmax(0, 1fr) 15em; grid-template-rows: auto minmax(0, 1fr);
grid-template-areas: "scene readouts" "scene controls"; gap: 10px 12px; padding: 10px; }
#readouts { display: grid; align-content: start; gap: 2px; }
#controls { align-content: start; }
#clap { width: 100%; font-size: 1.15em; }
.seg { width: 100%; } .seg button { flex: 1 1 45%; }
}
</style>
</head>
<body>
<div id="view"><canvas id="scene" aria-label="Top view of the room with the sound of the clap spreading"></canvas></div>
<div id="readouts"><span id="t60"></span><span id="t60m"></span><span id="volume"></span><span id="area"></span></div>
<div id="controls">
<button id="clap" title="Space">Clap</button>
<div class="seg" role="group" aria-label="Room">
<button id="living" title="1">Living room</button><button id="classroom" title="2">Classroom</button>
<button id="church" title="3">Church</button><button id="alley" title="4">Alley</button></div>
<div class="seg" role="group" aria-label="Furnishing">
<button id="curtains" title="C">Curtains</button><button id="carpet" title="K">Carpet</button>
<button id="audience" title="A">Audience</button></div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
scenes = {}
for name, s in SCENES.items():
g = grid(s)
states = {}
for combo in COMBOS:
if (name, combo) not in STATES:
continue
st = STATES[name, combo]
entry = dict(refl=[[round(1e3 * t, 3), round(a, 6)] for t, a, _ in st["refl"]], env=st["env"].tolist(),
gamma=round(grid(s, *combo)["gamma"], 4), seed=st["seed"])
if name != "alley":
V = float(np.prod(s["size"]))
entry.update(V=V, A=round(st["A"], 1), sabine=round(sabine(V, st["A"]), 2),
measured=round(t60_schroeder(st["h"], FS)[0], 2))
states["".join(map(str, combo))] = entry
scenes[name] = dict(name=s["name"], size=list(s["size"]), wall=s["wall"], clap=list(s["clap"]),
windows=[list(w) for w in s["windows"]], seats=s["seats"] and list(s["seats"]),
slow=s["slow"], ref=round(float(REF[name]), 5), states=states)
game_data = dict(c=C, courant=COURANT, env_dt=ENV_DT, alpha=ALPHA, gain=GAIN, scenes=scenes,
round_trip=round(1e3 * round_trip, 1))
# sorted keys and fixed rounding: two runs write the same bytes; "<\/" keeps "</" out of the script element
data = json.dumps(game_data, sort_keys=True, separators=(",", ":")).replace("</", "<\\/")
script = Path("minigame/reverb/game.js").read_text(encoding="utf-8")
html = TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script)
Path("assets").mkdir(exist_ok=True)
Path("assets/reverb.html").write_text(html, encoding="utf-8", newline="\n")
print(f"assets/reverb.html: {len(data) / 1024:.0f} kB of data, {Path('assets/reverb.html').stat().st_size / 1024:.0f} kB in all")
assets/reverb.html: 92 kB of data, 109 kB in all
Variations
- A concert hall's 2 s. A hall of 18 000 m³ that should ring for 2 s with its audience needs A = 1450 m² by Sabine; 1650 m² of audience in upholstered seats, at α = 0.88, alone gets there, so walls and ceiling must absorb little.
- A recording booth. 2 × 2 × 2.4 m with every surface at α = 0.8: Sabine says 0.071 s, Eyring 0.035 s, half as long. Here Eyring's formula is the one to use.
- The bathroom where singing sounds good. Tiles at α = 0.01 in 2.5 × 2 × 2.5 m give 6.2 s; two towels of 0.75 m² bring it to 1.9 s. The lowest room modes, where a whole number of half wavelengths fits between two walls, start at c/2L, 69 Hz and 86 Hz, so low notes boom.
- A box that scatters nothing. Image sources of every order, 2.5 s of them at 8 kHz, in place of the particles: the living room with everything on rings for 0.86 s instead of Sabine's 0.44 s, because the bare walls between curtains and carpet keep their sound.
Further reading
- H. Kuttruff, Room Acoustics, 6th ed. (CRC Press, 2016), for the diffuse field, Sabine's and Eyring's formulas, the subjective effect of reflections, and flutter echoes.
- M. Long, Architectural Acoustics, 2nd ed. (Academic Press, 2014), for tables of absorption coefficients by material and frequency; the page's 500 Hz values come from the table at sengpielaudio.com, which reproduces the standard handbook values.
- W. C. Sabine, Collected Papers on Acoustics (Harvard University Press, 1922), which opens with "Reverberation" (1900); his earliest papers, in the Proceedings of the American Institute of Architects of 1898 on experiments begun in 1895, are folded into that chapter.
- M. R. Schroeder, "New method of measuring reverberation time", J. Acoust. Soc. Am. 37, 409 (1965); J. B. Allen and D. A. Berkley, "Image method for efficiently simulating small-room acoustics", J. Acoust. Soc. Am. 65, 943 (1979); K. Kowalczyk and M. van Walstijn, "Formulation of locally reacting surfaces in FDTD/K-DWM modelling of acoustic spaces", Acta Acustica united with Acustica 94, 891 (2008), for the walls of the picture; ISO 3382-1 for T30, C50, and how reverberation is measured in practice.
- H. Wallach, E. B. Newman, and M. R. Rosenzweig, "The precedence effect in sound localization", Am. J. Psychol. 62, 315 (1949), and H. Haas, "Über den Einfluss eines Einfachechos auf die Hörsamkeit von Sprache", Acustica 1, 49 (1951), the classic studies of the precedence effect; R. Y. Litovsky, H. S. Colburn, W. A. Yost, and S. J. Guzman, "The precedence effect", J. Acoust. Soc. Am. 106, 1633 (1999), a review with the thresholds for clicks.
- MDN on the ConvolverNode of the Web Audio API and on the Canvas API, which play and draw everything in
game.js. - Related tutorials on this site: The wave equation with leapfrog finite differences, the scheme behind the picture; Finite differences, for why the time step has a limit; Convolution; Monte Carlo transport, for particles that lose energy at each hit; Filtering with scipy.signal; and another Minigame, Metronome synchronization, whose ticks also come through Web Audio.
- Download the notebook. It was executed with the library versions in the header.
- Download the game, one HTML file of 109 kB that runs offline in any browser.