Skip to content
SciStack
Minigame Python Beginner 25 min

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
Download notebook Save Mark as done

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 jupyterlab

Play: 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.

Download the game

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()
Top view of the bare church, 30 by 14 m, 100 ms after a clap at the altar end, the pressure shaded in red: arcs reflected from the side walls cross the nave behind the first front, which has reached the far end wall and is on its way back.

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

\[\Delta t = \frac{d_\mathrm{refl} - d_\mathrm{direct}}{c},\]

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()
Reflections arriving at your ears in dB relative to the direct sound, over the first 200 ms. Living room: the six first reflections within 18 ms, every reflection left of the 50 ms line. Church: reflections out to 143 ms, every one after 50 ms below the ring arriving with it, drawn as a band.

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()
Top view of the bare church 0.4 s after a clap: the pressure fills the whole floor plan as a speckle with no wavefront and no direction left, the diffuse field.

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

\[T_{60} = \frac{24\ln 10}{c}\,\frac{V}{A} = 0.161\,\frac{V}{A}, \qquad A = \sum_i \alpha_i S_i,\]

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()
Energy decay in dB against time in s for the church bare (left) and with an audience (right): the noisy squared impulse response, its smooth backward sum, a straight fit between −5 and −35 dB, and Sabine’s slope dashed. T60 7.70 s and 3.51 s measured, 7.82 s and 3.77 s by Sabine.
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()
Top view of the alley between two bare walls 6 m apart, 80 ms after a clap in its middle: nested arcs, the clap thrown back and forth between the walls, spreading along the alley toward its open ends.

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
The impulse response at your ears in the alley, in dB relative to the direct sound, over 0.4 s. Bare: a return every 17.5 ms that fades slowly. With a curtain on one wall the returns fall 31 dB by the tenth instead of 18.

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.

Was this tutorial helpful? Sign in to tell the author with one click.

Found a mistake, or something unclear? Report a problem (with a free account).

Cite this tutorial

SciStack (2026). Reverberation and Sabine's formula: a clap in a church, then curtains. https://scistack.dev/t/py-room-reverberation/ (accessed 2026-10-19).

@online{scistack-py-room-reverberation,
  author  = {{SciStack}},
  title   = {Reverberation and Sabine's formula: a clap in a church, then curtains},
  date    = {2026-10-19},
  url     = {https://scistack.dev/t/py-room-reverberation/},
  urldate = {2026-10-19},
  note    = {numpy 2.4.3, scipy 1.18.1, matplotlib 3.11.2}
}

Tags

canvasconvolvernodefftconvolveimpulse-responsejavascriptmatplotlibnumpyreverberationroom-acousticssabine-formulascipy.signalwebaudio

Comments

No comments yet.

Sign in to comment, with a free account.