Active noise cancellation: why headphones silence the drone, not the voices
Afterwards you can explain active noise cancellation by superposition and compute in decibels how a fixed delay limits it to low frequencies.
- Topic
- Signal processing
- Field
- Engineering, Physics
- Prerequisites
- none beyond Python basics
- Libraries
matplotlib 3.11.2numpy 2.4.3scipy 1.18.1
py-noise-cancellation.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: switch off the engine drone in an airliner cabin
A passenger in an airliner cabin wears noise canceling headphones. The engines drone at 100 Hz, with tones at 200 and 300 Hz on top, and two people in the row ahead are talking. Active noise cancellation records the noise with a microphone on the earcup and plays it back upside down, so that noise and anti-noise add to nothing at the ear: superposition. When the game opens, the waves move and cancellation is off. Press Cancel, then step the delay with − and + up to 5 ms, where even the drone gets louder, flip the sign, and pick drone, voices, or both. Sound starts with your first tap. Watch the three waves in the enlarged earcup (the noise arriving, the anti-noise, their sum at the ear) and the level at the ear in dB. The model gives the electronics 50 µs, and with that delay the drone drops by 27 dB and the voices by only 9 dB. The sounds are a model of cabin noise, not a recording. On a keyboard, C switches cancellation, [ and ] step the delay, F flips the sign, and 1, 2, 3 pick the noise.
Show code
// game.js: a passenger with noise canceling headphones in an airliner cabin. Switch cancellation on, step the
// delay, flip the sign, pick the noise, and watch the three waves in the earcup and the level at the ear.
// The notebook writes the two sound loops, the band powers, and the delay steps into #data.
"use strict";
const DATA = JSON.parse(document.getElementById("data").textContent);
const FS = DATA.fs, SLOW = DATA.slow, WINDOW = DATA.window_ms / 1000;
const VOICES_GAIN = 10 ** (-DATA.drone_over_voices_db / 20); // in "both" the voices sit 10 dB under the drone
// ---- the loops: base64 text that stands for 16-bit samples, turned back into numbers
function decode(text) {
const bytes = Uint8Array.from(atob(text), (ch) => ch.charCodeAt(0));
return Float32Array.from(new Int16Array(bytes.buffer), (v) => v / 32767);
}
const drone = decode(DATA.loops.drone), voices = decode(DATA.loops.voices);
const loops = {drone, voices, both: drone.map((d, i) => d + VOICES_GAIN * voices[i])};
const rms = Object.fromEntries(Object.entries(loops).map(([k, x]) => [k, Math.sqrt(x.reduce((m, v) => m + v * v, 0) / x.length)]));
// ---- the state: everything that changes, in one object
const DEFAULTS = {cancel: false, delay: DATA.start_delay, sign: 1, noise: "drone", t: 1};
const state = {...DEFAULTS, sound: false, running: true};
const tau = (s) => DATA.delays_us[s.delay] * 1e-6; // seconds
// The level at the ear relative to no cancellation, from the notebook's band powers: each band's power is
// multiplied by |1 − s e^(−iθ)|² = 2 − 2s cos θ with θ = 2π f τ, and the powers add.
function levelDb(s) {
if (!s.cancel) return 0;
const {f, p} = DATA.bands[s.noise];
let before = 0, after = 0;
f.forEach((fk, k) => {
before += p[k];
after += p[k] * (2 - 2 * s.sign * Math.cos(2 * Math.PI * fk * tau(s)));
});
return 10 * Math.log10(after / before); // −Infinity when nothing is left
}
// the loop's value at time t in seconds, on a straight line between samples; the loop repeats
function sample(x, t) {
const u = ((t * FS) % x.length + x.length) % x.length, i = Math.floor(u), w = u - i;
return (1 - w) * x[i] + w * x[(i + 1) % x.length];
}
// ---- sound through the Web Audio API, allowed only after a tap
let audio = null, master, delay, antiGain;
const noiseGains = {};
function wakeAudio() {
if (audio) return;
audio = new (window.AudioContext || window.webkitAudioContext)();
master = audio.createGain();
const noise = audio.createGain();
delay = audio.createDelay(0.01);
antiGain = audio.createGain();
noise.connect(master); // the noise, straight to the ear
noise.connect(delay).connect(antiGain).connect(master); // the anti-noise: late, and inverted
master.connect(audio.destination); // the output adds the two
const start = audio.currentTime + 0.05; // both loops together, so "both" stays in step
for (const name of ["drone", "voices"]) {
const buffer = audio.createBuffer(1, loops[name].length, FS);
buffer.getChannelData(0).set(loops[name]);
const src = audio.createBufferSource();
noiseGains[name] = audio.createGain();
src.buffer = buffer;
src.loop = true;
src.connect(noiseGains[name]).connect(noise);
src.start(start);
}
state.sound = true;
updateAudio();
}
function updateAudio() {
if (!audio) return;
const now = audio.currentTime, glide = 0.01; // short glides, so that a switch does not click
noiseGains.drone.gain.setTargetAtTime(state.noise === "voices" ? 0 : 1, now, glide);
noiseGains.voices.gain.setTargetAtTime({drone: 0, voices: 1, both: VOICES_GAIN}[state.noise], now, glide);
delay.delayTime.setTargetAtTime(tau(state), now, glide);
antiGain.gain.setTargetAtTime(state.cancel ? -state.sign : 0, now, glide);
master.gain.setTargetAtTime(state.sound && state.running ? DATA.master_gain : 0, now, glide);
}
// ---- drawing
const $ = (id) => document.getElementById(id);
const canvas = $("scene");
let dpr = 1;
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();
}
function colors() {
const css = getComputedStyle(document.documentElement);
return Object.fromEntries(["bg", "fg", "ink", "accent", "second", "muted", "pale"].map((k) => [k, css.getPropertyValue(`--${k}`).trim()]));
}
// Arcs that spread from a source and fade: sound on its way. `age` is real time in seconds.
function ripples(ctx, x, y, from, to, age, start, end) {
for (let k = 0; k < 3; k++) {
const q = (age * 0.8 + k / 3) % 1;
ctx.globalAlpha = 0.6 * (1 - q);
ctx.beginPath(); ctx.arc(x, y, from + q * (to - from), start, end); ctx.stroke();
}
ctx.globalAlpha = 1;
}
// The cabin seen from the aisle, in units of its height: the window with wing and engine, the passenger
// with headphones, the row ahead. Returns where the passenger's earcup is on the canvas.
function drawCabin(ctx, c, box) {
const u = Math.min(box.w / 1.6, box.h), x0 = box.x + (box.w - 1.6 * u) / 2, y0 = box.y + (box.h - u) / 2;
const X = (x) => x0 + x * u, Y = (y) => y0 + y * u, age = state.t * SLOW;
const drone = state.noise !== "voices", talk = state.noise !== "drone";
ctx.lineWidth = Math.max(1.5, u / 140);
ctx.strokeStyle = c.muted;
ctx.beginPath(); ctx.moveTo(X(0.02), Y(0.12)); ctx.quadraticCurveTo(X(0.8), Y(0.02), X(1.58), Y(0.12)); ctx.stroke(); // bins
ctx.beginPath(); ctx.moveTo(X(0.02), Y(0.94)); ctx.lineTo(X(1.58), Y(0.94)); ctx.stroke(); // floor
ctx.beginPath(); ctx.roundRect(X(0.1), Y(0.2), 0.36 * u, 0.3 * u, 0.09 * u); ctx.stroke(); // window
ctx.save(); ctx.clip();
ctx.strokeStyle = c.ink; ctx.fillStyle = c.pale;
ctx.beginPath(); ctx.moveTo(X(0.08), Y(0.36)); ctx.lineTo(X(0.48), Y(0.3)); ctx.stroke(); // wing
ctx.beginPath(); ctx.ellipse(X(0.27), Y(0.4), 0.1 * u, 0.045 * u, -0.1, 0, 2 * Math.PI); ctx.fill(); ctx.stroke(); // engine
ctx.restore();
ctx.strokeStyle = c.muted;
if (drone) ripples(ctx, X(0.37), Y(0.4), 0.06 * u, 0.3 * u, age, -0.7, 0.7);
for (const [sx, h] of [[0.52, 0.0], [1.0, 0.0]]) { // seat backs
ctx.beginPath(); ctx.roundRect(X(sx), Y(0.42 + h), 0.07 * u, 0.42 * u, 0.03 * u); ctx.stroke();
}
const person = (hx, hy, r, color) => { // head, back, thighs, shins, facing right
ctx.strokeStyle = color; ctx.fillStyle = color;
ctx.beginPath(); ctx.arc(X(hx), Y(hy), r * u, 0, 2 * Math.PI); ctx.fill();
ctx.beginPath(); ctx.moveTo(X(hx - 0.02), Y(hy + r + 0.02)); ctx.lineTo(X(hx - 0.03), Y(0.74));
ctx.lineTo(X(hx + 0.15), Y(0.74)); ctx.lineTo(X(hx + 0.17), Y(0.92)); ctx.stroke();
};
person(0.64, 0.4, 0.065, c.ink);
person(1.12, 0.4, 0.06, c.ink);
person(1.25, 0.43, 0.055, c.muted); // the farther head in the row ahead
ctx.strokeStyle = c.ink; ctx.lineWidth = Math.max(2, u / 90);
ctx.beginPath(); ctx.arc(X(0.64), Y(0.4), 0.085 * u, Math.PI * 1.05, Math.PI * 1.95); ctx.stroke(); // headband
ctx.fillStyle = c.ink; ctx.strokeStyle = c.bg; // a rim in the background color sets it off the head
ctx.beginPath(); ctx.roundRect(X(0.6), Y(0.36), 0.05 * u, 0.09 * u, 0.02 * u); ctx.fill(); ctx.stroke(); // earcup
ctx.lineWidth = Math.max(1.5, u / 140); ctx.strokeStyle = c.muted;
if (talk) ripples(ctx, X(1.06), Y(0.4), 0.05 * u, 0.3 * u, age * 1.3, Math.PI - 0.6, Math.PI + 0.6);
return {x: X(0.625), y: Y(0.405)};
}
// The earcup, enlarged: noise, anti-noise, and their sum at the ear, WINDOW seconds around the display time.
function drawEarcup(ctx, c, box, ear) {
const x = loops[state.noise], d = tau(state), g = state.cancel ? state.sign : 0;
const ylim = DATA.ylim_rms * rms[state.noise];
const r = Math.min(28, box.h / 6);
ctx.strokeStyle = c.muted; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.moveTo(ear.x, ear.y); // to the top edge below, or to the top left corner beside
if (ear.y < box.y) ctx.lineTo(Math.min(Math.max(ear.x, box.x + r), box.x + box.w - r), box.y);
else ctx.lineTo(box.x + 0.3 * r, box.y + 0.3 * r);
ctx.stroke();
ctx.fillStyle = c.bg;
ctx.beginPath(); ctx.roundRect(box.x, box.y, box.w, box.h, r); ctx.fill(); ctx.stroke();
const pad = 10, font = Math.max(14, Math.min(17, box.h / 14)), line = 1.25 * font;
const left = box.x + pad, width = box.w - 2 * pad, top = box.y + pad, row = (box.h - 2 * pad - font) / 3;
const height = row - line; // each strip: its label on a line of its own, then the wave
const strips = [
["noise", c.ink, (t) => sample(x, t)],
[state.cancel ? "anti-noise" : "anti-noise off", c.second, (t) => -g * sample(x, t - d)],
["at the ear", c.accent, (t) => sample(x, t) - g * sample(x, t - d)],
];
ctx.font = `${font}px system-ui, sans-serif`; ctx.textBaseline = "top"; ctx.textAlign = "left";
strips.forEach(([label, color, wave], k) => {
const y0 = top + k * row + line, mid = y0 + height / 2, n = Math.max(2, Math.round(width));
ctx.strokeStyle = c.muted; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(left, mid); ctx.lineTo(left + width, mid); ctx.stroke(); // zero
ctx.save(); ctx.beginPath(); ctx.rect(left, y0, width, height); ctx.clip();
ctx.strokeStyle = color; ctx.lineWidth = 2;
ctx.beginPath();
for (let i = 0; i <= n; i++) {
const t = state.t - WINDOW / 2 + WINDOW * i / n;
const y = mid - wave(t) / ylim * height / 2;
if (i) ctx.lineTo(left + i * width / n, y); else ctx.moveTo(left, y);
}
ctx.stroke(); ctx.restore();
ctx.fillStyle = color;
ctx.fillText(label, left, top + k * row);
});
ctx.fillStyle = c.muted; ctx.textAlign = "right"; ctx.textBaseline = "bottom";
ctx.fillText(`${DATA.window_ms} ms, slowed ${SLOW}×`, box.x + box.w - pad, box.y + box.h - 4);
}
function drawScene(c) {
const 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);
const wide = W > 1.3 * H; // side by side, or the cabin above the earcup
const cabin = wide ? {x: 0, y: 0, w: 0.54 * W, h: H} : {x: 0, y: 0, w: W, h: 0.36 * H};
const cup = wide ? {x: 0.56 * W, y: 0.06 * H, w: 0.44 * W - 2, h: 0.88 * H} : {x: 2, y: 0.39 * H, w: W - 4, h: 0.61 * H - 2};
const ear = drawCabin(ctx, c, cabin);
drawEarcup(ctx, c, cup, ear);
}
const minus = (v) => String(v).replace("-", "−");
function render() {
drawScene(colors());
const L = levelDb(state);
$("level").textContent = !state.cancel ? "at the ear 0 dB" : L === -Infinity ? "at the ear silent"
: `at the ear ${L > 0 ? "+" : ""}${minus(L.toFixed(0))} dB`;
$("delay-val").textContent = `${DATA.delays_us[state.delay]} µs`;
$("cancel").textContent = state.cancel ? "Cancel on" : "Cancel off";
$("cancel").setAttribute("aria-pressed", state.cancel);
$("normal").setAttribute("aria-pressed", state.sign === 1);
$("flipped").setAttribute("aria-pressed", state.sign === -1);
for (const name of ["drone", "voices", "both"]) $(name).setAttribute("aria-pressed", state.noise === name);
$("shorter").disabled = state.delay === 0;
$("longer").disabled = state.delay === DATA.delays_us.length - 1;
$("play").textContent = state.running ? "Pause" : "Play";
$("sound").textContent = !audio ? "Tap for sound" : state.sound ? "Sound on" : "Sound off";
}
// ---- controls, keys, and the frame
function set(partial) {
Object.assign(state, partial);
updateAudio();
render();
}
const step = (k) => set({delay: Math.min(DATA.delays_us.length - 1, Math.max(0, state.delay + k))});
const actions = {
cancel: () => set({cancel: !state.cancel}), normal: () => set({sign: 1}), flipped: () => set({sign: -1}),
shorter: () => step(-1), longer: () => step(1), drone: () => set({noise: "drone"}), voices: () => set({noise: "voices"}),
both: () => set({noise: "both"}), play: () => set({running: !state.running}), reset: () => set({...DEFAULTS}),
sound: () => { if (audio) set({sound: !state.sound}); else { wakeAudio(); render(); } },
};
for (const [id, act] of Object.entries(actions)) $(id).addEventListener("click", act);
document.addEventListener("pointerdown", (e) => { if (e.target !== $("sound")) wakeAudio(); });
const keys = {c: "cancel", f: () => set({sign: -state.sign}), "[": "shorter", "]": "longer", 1: "drone", 2: "voices",
3: "both", " ": "play", r: "reset", s: "sound"};
document.addEventListener("keydown", (e) => {
const key = keys[e.key.toLowerCase()];
if (!key || (e.target instanceof HTMLButtonElement && e.key === " ")) return;
e.preventDefault();
if (key !== "sound") wakeAudio(); // a key press counts as a tap, except the Sound key
(typeof key === "string" ? actions[key] : key)();
});
let last = null;
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.t += elapsed / SLOW; // the display runs SLOW times slower than the sound
render();
requestAnimationFrame(frame);
}
window.addEventListener("resize", layout);
layout();
requestAnimationFrame(frame);
window.game = {state, set, render};
Inverted noise adds to silence
With the drone playing and cancellation off, the sum at the ear is the noise itself. Press Cancel: the anti-noise appears as the mirror image of the noise, the sum at the ear flattens to a line, the readout falls to −27 dB, and the drone goes nearly quiet.
Show code
import base64
import json
from pathlib import Path
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import freqs
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"
FS, SECONDS = 22050, 3 # sample rate / Hz, loop length / s
N = FS * SECONDS
TAU = 50e-6 # the model's electronics delay / s
DELAYS_US = [0, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000] # the game's delay steps / µs
DRONE = {100: 1.0, 200: 0.5, 300: 0.25} # engine tones / Hz: amplitude
VOICE_BAND, VOICE_KNEE = (200, 4000), 500 # Hz: speech-shaped noise, falling above the knee
SYLLABLES = [3, 4, 13 / 3, 5] # Hz: the rhythm of the voices' envelope
DRONE_OVER_VOICES_DB = 10
C_SOUND = 343 # m/s
WINDOW_MS, SLOW, YLIM_RMS = 20, 100, 4 # the earcup's view: 20 ms, slowed 100×, ±4 rms
f = np.fft.rfftfreq(N, 1 / FS) # the loop's tones: every 1/3 Hz
t = np.arange(N) / FS
rng = np.random.default_rng(423)
DRONE_PHASES = rng.uniform(0, 2 * np.pi, len(DRONE))
def drone_tones():
"""The drone's three tones, each a whole number of periods in the loop, scaled so the sum has rms 1."""
tones = [a * np.cos(2 * np.pi * fk * t + phi) for (fk, a), phi in zip(DRONE.items(), DRONE_PHASES)]
return [tone / np.sqrt(np.mean(sum(tones) ** 2)) for tone in tones]
def make_voices(rng):
"""Speech-shaped noise: random phases on every tone from 200 Hz to 4 kHz, amplitude falling as 1/f
above 500 Hz, times an envelope with a syllable rhythm near 4 Hz. Rms 1."""
amplitude = np.where((f >= VOICE_BAND[0]) & (f <= VOICE_BAND[1]), np.minimum(1, VOICE_KNEE / np.maximum(f, 1)), 0)
x = np.fft.irfft(amplitude * np.exp(2j * np.pi * rng.uniform(size=f.size)), n=N)
envelope = sum(np.cos(2 * np.pi * fs * t + 2 * np.pi * rng.uniform()) for fs in SYLLABLES)
x *= np.maximum(envelope, 0) + 0.1
return x / np.sqrt(np.mean(x**2))
def delayed(x, tau):
"""The loop shifted later by tau seconds; exact, because the loop repeats."""
if tau == 0:
return x
return np.fft.irfft(np.fft.rfft(x) * np.exp(-2j * np.pi * f * tau), n=N)
def level_db(x, tau, sign=1, gain=1):
"""Level at the ear relative to no cancellation, in dB: noise x plus anti-noise −sign·gain·x(t − tau)."""
ear = x - sign * gain * delayed(x, tau)
ratio = np.mean(ear**2) / np.mean(x**2)
return -np.inf if ratio == 0 else 10 * np.log10(ratio)
def reduction_db(fk, tau, sign=1):
"""The same for one tone of frequency fk: 20 log10 |1 − s e^(−i2πfτ)|."""
return 20 * np.log10(np.abs(1 - sign * np.exp(-2j * np.pi * fk * tau)))
def bands(x):
"""Power of x in narrow bands of frequency, each 2.9 % wider than the last (24 to a doubling),
from 20 Hz to 11 kHz: the power-weighted mean frequency and the power of every band that has any."""
power = np.abs(np.fft.rfft(x)) ** 2 / N**2
power[1:-1] *= 2 # positive and negative frequencies together
edges = 20 * 2 ** (np.arange(0, 24 * np.log2(FS / 2 / 20) + 1) / 24)
which = np.digitize(f, edges)
fc, pc = [], []
for b in range(1, len(edges)):
p = power[which == b].sum()
if p > 1e-9 * power.sum():
fc.append(np.sum(power[which == b] * f[which == b]) / p)
pc.append(p)
return np.array(fc), np.array(pc)
def tenth_wavelength_cm(fk):
return 100 * C_SOUND / fk / 10
drone = sum(drone_tones())
voices = make_voices(rng)
both = drone + voices * 10 ** (-DRONE_OVER_VOICES_DB / 20)
NOISES = {"drone": drone, "voices": voices, "both": both}
def draw_ear(axes, x, tau=TAU, sign=1, cancel=True, t0=1.0, setting="", level=None):
"""The game's earcup in Matplotlib: noise, anti-noise, and the sum at the ear, 20 ms around t0."""
window = (t >= t0 - WINDOW_MS / 2000) & (t <= t0 + WINDOW_MS / 2000)
anti = -sign * cancel * delayed(x, tau)
ylim = YLIM_RMS * np.sqrt(np.mean(x**2))
ms = 1000 * (t[window] - t0 + WINDOW_MS / 2000)
rows = [("noise", INK, x), ("anti-noise" if cancel else "anti-noise off", SECOND, anti), ("at the ear", ACCENT, x + anti)]
for ax, (label, color, y) in zip(axes, rows):
ax.axhline(0, color=MUTED, lw=1)
ax.plot(ms, y[window], color=color)
ax.text(0, 1.03, label, color=color, transform=ax.transAxes, va="bottom") # above the strip, off the wave
ax.set(ylim=(-ylim, ylim), xlim=(0, WINDOW_MS), yticks=[-2, 0, 2], xticks=range(0, WINDOW_MS + 1, 5))
axes[0].text(1, 1.03, setting, color=MUTED, transform=axes[0].transAxes, ha="right", va="bottom")
if level is not None:
text = "silent" if level == -np.inf else "0 dB" if level == 0 else f"{level:+.0f} dB".replace("-", "−")
axes[2].text(1, 1.03, text, color=ACCENT, transform=axes[2].transAxes, ha="right", va="bottom", weight="bold")
axes[2].set_xlabel("t / ms")
print(f"two loops of {SECONDS} s at {FS} Hz, {N} samples each; tones every {1 / SECONDS:.3f} Hz")
print(f"in 'both' the drone is {10 * np.log10(np.mean(drone**2) / np.mean((both - drone)**2)):.1f} dB above the voices")
two loops of 3 s at 22050 Hz, 66150 samples each; tones every 0.333 Hz in 'both' the drone is 10.0 dB above the voices
Show code
fig, axes = plt.subplots(3, 2, figsize=(7, 4.6), sharex=True, sharey=True, gridspec_kw=dict(hspace=0.4, wspace=0.08))
draw_ear(axes[:, 0], drone, cancel=False, setting="drone · cancel off", level=0.0)
draw_ear(axes[:, 1], drone, setting="drone · cancel on · 50 µs", level=level_db(drone, TAU))
fig.supylabel("pressure / rms of the noise", fontsize=11)
plt.show()
Sound at the ear is a small swing of air pressure around the pressure of the cabin. Two sounds at the same place add, pressure by pressure, instant by instant, and that rule is superposition. The same rule builds the noise: the drone is three tones added together, at 100, 200, and 300 Hz, and the voices are thousands of tones added together between 200 Hz and 4 kHz. The microphone records the pressure \(p\), the speaker plays \(-p\), and at the ear
at every instant, for every tone at once.
Levels are compared in decibels, 20 log₁₀ of the ratio of two pressure amplitudes: −20 dB is a tenth of the pressure, −40 dB a hundredth. The game does not reach silence at its start because its anti-noise arrives 50 µs late, the subject of the last effect. What it plays is a model, three exact tones for the drone and noise shaped like speech for the voices, each a 3 s loop that repeats without a seam.
Two helpers in the hidden cell do the work. delayed(x, tau) returns the loop shifted later by τ, exactly, because the loop repeats. level_db(x, tau, sign) returns the level at the ear in dB, relative to no cancellation.
p = drone
print(f"largest |p + (−p)|: {np.abs(p + (-p)).max():.1f}")
print(f"drone at the ear, no delay: {level_db(drone, 0):.1f} dB")
L50 = level_db(drone, TAU)
print(f"drone at the ear, 50 µs late: {L50:.1f} dB, a pressure ratio of 1/{10 ** (-L50 / 20):.0f}")
largest |p + (−p)|: 0.0 drone at the ear, no delay: -inf dB drone at the ear, 50 µs late: -27.2 dB, a pressure ratio of 1/23
With no delay the sum is zero to the last bit, and the level is minus infinity. At 50 µs the pressure left at the ear is 1/23 of the noise, −27.2 dB. Silence needs an anti-noise that is exactly on time, and no electronics are that fast.
The wrong sign doubles the noise
Leave cancellation on and press flipped. The anti-noise now rises and falls with the noise, the sum at the ear is twice as tall, and the readout says +6 dB: the drone is louder than with no headphones at all.
Show code
fig, axes = plt.subplots(3, 1, figsize=(7, 3.8), sharex=True, gridspec_kw=dict(hspace=0.4))
draw_ear(axes, drone, sign=-1, setting="drone · cancel on · 50 µs · sign flipped", level=level_db(drone, TAU, sign=-1))
fig.supylabel("pressure / rms of the noise", fontsize=11)
plt.show()
A wire reversed at the microphone or at the speaker plays \(+p\) instead of \(-p\), and the ear gets \(p + p = 2p\). Twice the pressure is 20 log₁₀ 2 = 6.02 dB. The power of a sound, the energy it carries per second, goes with the square of the pressure, so twice the pressure is four times the power, and 10 log₁₀ 4 is the same 6.02 dB.
Power is also what adds when the tones differ. The drone's tones have amplitudes 1, 0.5, and 0.25, so their powers stand as 1 : 0.25 : 0.0625, and the power of the drone is their sum, \(P = P_1 + P_2 + P_3\). Squaring a sum of pressures gives the squares plus products of two different tones, and such a product is positive as often as negative and averages to zero over the loop, so only the squares are left. The cell checks it on the loop.
print(f"20 log10(2) = {20 * np.log10(2):.2f} dB 10 log10(4) = {10 * np.log10(4):.2f} dB")
power = [np.mean(tone**2) for tone in drone_tones()]
print("tone powers " + " + ".join(f"{pk:.4f}" for pk in power) + f" = {sum(power):.4f}")
print(f"power of the loop {np.mean(drone**2):.4f}")
print(f"drone, sign flipped, 50 µs: {level_db(drone, TAU, sign=-1):+.1f} dB")
print(f"voices, sign flipped, 0 µs: {level_db(voices, 0, sign=-1):+.2f} dB 50 µs: {level_db(voices, TAU, sign=-1):+.2f} dB")
20 log10(2) = 6.02 dB 10 log10(4) = 6.02 dB tone powers 0.7619 + 0.1905 + 0.0476 = 1.0000 power of the loop 1.0000 drone, sign flipped, 50 µs: +6.0 dB voices, sign flipped, 0 µs: +6.02 dB 50 µs: +5.89 dB
The three powers add up to the power of the loop to the fourth digit. With no delay a flipped sign doubles every tone and multiplies every power by four, so any mixture, drone or voices, gains 6 dB. At 50 µs the voices gain a little less, 5.9 dB. The delay does not treat the tones that evenly.
A small delay ruins the high notes
Set the sign back to normal at 50 µs and switch to voices: the sum at the ear, flat for the drone, becomes a smaller copy of the noise, −9 dB.
Show code
fig, axes = plt.subplots(3, 2, figsize=(7, 4.6), sharex=True, sharey=True, gridspec_kw=dict(hspace=0.4, wspace=0.08))
draw_ear(axes[:, 0], drone, setting="drone · cancel on · 50 µs", level=level_db(drone, TAU))
draw_ear(axes[:, 1], voices, setting="voices · cancel on · 50 µs", level=level_db(voices, TAU))
fig.supylabel("pressure / rms of the noise", fontsize=11)
plt.show()
A fixed delay τ is a different share of the period at each frequency: 50 µs is 1/200 of a period at 100 Hz and half of one at 10 kHz. A trigonometric identity turns a tone minus its late copy back into one tone:
Its amplitude is multiplied by \(2|\sin(\pi f\tau)|\). Powers add, so each tone's power takes its own factor, and the level is the power-weighted mean of the factors, taken before the logarithm:
for fk in [100, 300, 1 / (6 * TAU), 1 / (2 * TAU)]:
print(f"{fk:6.0f} Hz fτ = {fk * TAU:.4f} {np.round(reduction_db(fk, TAU), 1) + 0:+5.1f} dB") # + 0 turns −0.0 into 0.0
factor = {fk: (2 * np.sin(np.pi * fk * TAU)) ** 2 for fk in DRONE}
weight = {fk: a**2 for fk, a in DRONE.items()}
mean = sum(weight[fk] * factor[fk] for fk in DRONE) / sum(weight.values())
print("drone factors " + ", ".join(f"{factor[fk]:.5f}" for fk in DRONE)
+ f"; weighted mean {mean:.5f} = {10 * np.log10(mean):.2f} dB; level_db {level_db(drone, TAU):.2f} dB")
for name, x in NOISES.items():
print(f"{name:6s} at 50 µs: {level_db(x, TAU):6.1f} dB")
voices_in_both = both - drone
before = 10 * np.log10(np.mean(drone**2) / np.mean(voices_in_both**2))
after = before + level_db(drone, TAU) - level_db(voices_in_both, TAU)
print(f"in 'both', drone against voices: {before:+.1f} dB before, {after:+.1f} dB after")
print(f"1 cm of air: {1e6 * 0.01 / C_SOUND:.0f} µs; a tenth of a wavelength: "
f"{tenth_wavelength_cm(1000):.1f} cm at 1 kHz, {tenth_wavelength_cm(1 / (6 * TAU)):.1f} cm at {1 / (6 * TAU):.0f} Hz")
100 Hz fτ = 0.0050 -30.1 dB 300 Hz fτ = 0.0150 -20.5 dB 3333 Hz fτ = 0.1667 +0.0 dB 10000 Hz fτ = 0.5000 +6.0 dB drone factors 0.00099, 0.00395, 0.00888; weighted mean 0.00193 = -27.15 dB; level_db -27.15 dB drone at 50 µs: -27.2 dB voices at 50 µs: -9.3 dB both at 50 µs: -19.0 dB in 'both', drone against voices: +10.0 dB before, -7.9 dB after 1 cm of air: 29 µs; a tenth of a wavelength: 3.4 cm at 1 kHz, 1.0 cm at 3333 Hz
One tone loses 30 dB at 100 Hz and nothing at \(f\tau = 1/6\), 3.3 kHz; above that, cancellation adds noise, +6 dB at 10 kHz, the wrong sign again. The drone's factors 0.00099, 0.00395, and 0.00888, weighted 1, 0.25, and 0.0625, average to 0.00193, −27.2 dB, the game's number. The voices, the same sum over thousands of tones, lose 9.3 dB.
To see those tones, the notebook adds up the powers of the loop's tones in narrow bands of frequency, 24 to a doubling. The upper panel draws that power per band against frequency, before and after cancellation, a picture called a spectrum. The lower panel draws the change for one tone.
Show code
fc, pc = bands(both)
after_pc = pc * (2 * np.sin(np.pi * fc * TAU)) ** 2
edges = 20 * 2 ** (np.arange(0, 24 * np.log2(FS / 2 / 20) + 1) / 24) # the band edges of bands()
which = np.digitize(fc, edges) - 1
def per_band(p, floor=-200):
"""Every band's power in dB, the empty ones far below the axes, so each tone stands as a spike from the floor."""
db = np.full(len(edges) - 1, floor, dtype=float)
db[which] = 10 * np.log10(p)
return db
fig, (ax, ax2) = plt.subplots(2, 1, figsize=(7.2, 4.6), sharex=True, gridspec_kw=dict(hspace=0.12, height_ratios=[1.2, 1]))
ax.stairs(per_band(pc), edges, baseline=-200, fill=True, color=INK, alpha=0.2, lw=0)
ax.stairs(per_band(pc), edges, baseline=None, color=INK, lw=1)
ax.stairs(per_band(after_pc), edges, baseline=None, color=ACCENT, lw=1.4)
ax.text(1300, -22, "cancel off", color=INK, ha="center")
ax.text(1300, -53, "cancel on, 50 µs", color=ACCENT, ha="center", va="top")
ax.set(ylabel="power per band / dB", ylim=(-75, 8))
fr = np.geomspace(20, 11000, 2000)
ax2.axhline(0, color=MUTED, ls="--", lw=1)
ax2.plot(fr, reduction_db(fr, TAU), color=ACCENT)
for fk, dx, dy, ha in [(100, 6, 6, "left"), (1 / (6 * TAU), 6, -16, "left"), (1 / (2 * TAU), -8, 4, "right")]:
r = np.round(reduction_db(fk, TAU), 0) + 0
ax2.plot(fk, r, "o", ms=6, color=ACCENT)
text = f"{r:+.0f} dB".replace("-", "−") if r else "0 dB"
ax2.annotate(f"{text} at {fk:,.0f} Hz", (fk, r), xytext=(dx, dy), textcoords="offset points", color=ACCENT, ha=ha)
ax2.set(xscale="log", xlim=(20, 11000), ylim=(-45, 12), xlabel="f / Hz", ylabel="change / dB")
ax2.set_xticks([20, 100, 1000, 10000], ["20", "100", "1,000", "10,000"])
ax2.set_xticks([], minor=True)
plt.show()
The drone is three spikes, the voices a broad hump, and each band drops by the lower panel's amount at its frequency. In "both" the drone goes from 10 dB above the voices to 7.9 dB below.
Real headphones reach 20 to 30 dB at low frequencies and little above about 1 kHz, so 3.3 kHz is only an upper bound on where cancellation helps. The model puts microphone, speaker, and eardrum at one point; in an earcup they sit a centimeter or two apart, and sound takes 29 µs per centimeter, more than half the model's delay. Cancellation by 10 dB or more holds only in a region about a tenth of a wavelength across: 3.4 cm at 1 kHz, 1.0 cm at 3.3 kHz.
How the game computes it
The state. One object holds everything that changes: whether cancellation is on, the delay as an index into the list of steps, the sign (1 normal, −1 flipped, so the anti-noise is \(-s\,p(t-\tau)\)), the noise, the display time, and whether sound and motion run:
const DEFAULTS = {cancel: false, delay: DATA.start_delay, sign: 1, noise: "drone", t: 1};
const state = {...DEFAULTS, sound: false, running: true};
The sound. The two loops travel as 16-bit numbers written in base64, text that stands for bytes. On the first tap the game hands each to the browser's Web Audio API as a buffer that plays over and over. The noise goes straight to the output, and a copy goes through a delay of τ and a gain of −1, or +1 when flipped, or 0 with cancellation off. The output adds the two, so the speaker does the superposition:
noise.connect(master); // the noise, straight to the ear
noise.connect(delay).connect(antiGain).connect(master); // the anti-noise: late, and inverted
master.connect(audio.destination); // the output adds the two
// ...
delay.delayTime.setTargetAtTime(tau(state), now, glide);
antiGain.gain.setTargetAtTime(state.cancel ? -state.sign : 0, now, glide);
Browsers allow sound only after a tap inside the frame, so the game runs silent until the first one.
The level on screen. It is not measured from the sound. It is the weighted mean of the last section, computed from the band powers the notebook wrote, with the sign in it: each band's power is multiplied by \(|1 - s\,e^{-i\theta}|^2 = 2 - 2s\cos\theta\) with \(\theta = 2\pi f\tau\), which for \(s = 1\) is the factor \((2\sin(\theta/2))^2\) from before:
f.forEach((fk, k) => {
before += p[k];
after += p[k] * (2 - 2 * s.sign * Math.cos(2 * Math.PI * fk * tau(s)));
});
return 10 * Math.log10(after / before); // −Infinity when nothing is left
So the readout is the same with sound or without.
The waves. The browser calls frame once per screen refresh. It advances the display time by the elapsed time divided by 100, and the earcup draws 20 ms of the loop around it: the noise, the anti-noise, and their sum. The anti-noise is the noise τ earlier, and 50 µs is not a whole number of samples, which lie 1/22050 s apart, about 45 µs. So the game reads the loop between the two nearest samples, on the straight line that joins them. The earcup says "slowed 100×", because at full speed a 100 Hz wave is a blur.
From the notebook into the game. The two loops, the band frequencies and powers of each noise, the delay steps, the sample rate, and the drawing scale go into one JSON object, a plain text format for numbers and lists, inside a <script type="application/json" id="data"> element. Nothing is fetched, and the file runs from disk.
The game against the notebook. Fewer than two hundred bands instead of the loop's 33,076 tones make the formula cheap enough to run on every tap, but each band's mean frequency stands in for every tone inside it, and that is the error to bound. The cell runs the game's formula in NumPy against level_db on the full loops:
def band_level_db(fc, pc, tau, sign=1, gain=1):
"""The game's levelDb in NumPy: every band's power times |1 − s·g·e^(−iθ)|², θ = 2π f τ."""
ratio = np.sum(pc * (1 + gain**2 - 2 * sign * gain * np.cos(2 * np.pi * fc * tau))) / pc.sum()
return -np.inf if ratio == 0 else 10 * np.log10(ratio)
band_list = {name: bands(x) for name, x in NOISES.items()}
worst = 0.0
for name, x in NOISES.items():
for d in DELAYS_US:
for sign in [1, -1]:
game, notebook = band_level_db(*band_list[name], d * 1e-6, sign), level_db(x, d * 1e-6, sign)
if np.isfinite(notebook):
worst = max(worst, abs(game - notebook))
print("bands: " + ", ".join(f"{name} {len(band_list[name][0])}" for name in NOISES) + f", from the loop's {f.size:,} tones")
print(f"largest difference, 10 delays × 2 signs × 3 noises: {worst:.3f} dB")
bands: drone 3, voices 171, both 128, from the loop's 33,076 tones largest difference, 10 delays × 2 signs × 3 noises: 0.005 dB
Five thousandths of a decibel at worst, far below the whole decibels the readout shows.
The cell that writes the game. It escapes </ in the data as <\/, since an HTML parser ends the script element at any </script. Controls, layout, and both color schemes come from the template above it.
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>Noise canceling headphones in an airliner cabin</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: 4px; padding: 4px 6px 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; align-items: baseline; gap: 0 10px;
font-variant-numeric: tabular-nums; }
#level { color: var(--accent); font-weight: 600; font-size: 22px; white-space: nowrap; }
#relative { color: var(--muted); font-size: 14px; }
#controls { grid-area: controls; display: grid; gap: 4px; align-content: end; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
.label { color: var(--muted); }
#delay-val { min-width: 4.2em; text-align: center; font-variant-numeric: tabular-nums; }
button { font: inherit; color: var(--second); background: none; border: 1.5px solid var(--second); border-radius: 4px;
padding: 0.2em 0.6em; }
button[aria-pressed="true"], #play { color: var(--bg); background: var(--second); }
#cancel[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); }
button:disabled { opacity: 0.4; }
.seg { display: flex; } .seg button + button { margin-left: -1.5px; }
button.step { padding: 0; width: 1.9em; font-weight: 600; }
.push { margin-left: auto; }
button { touch-action: manipulation; }
@media (pointer: coarse) { button { min-height: 44px; min-width: 44px; } }
@media (min-width: 600px) {
body { gap: 8px 16px; padding: 10px; }
#readouts { display: grid; align-content: start; }
#level { font-size: 26px; }
.push { margin-left: 0; }
}
@media (min-width: 600px) and (min-height: 400px) { /* the column's 16:10: scene across, controls in a band below */
body { grid-template-columns: auto minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto;
grid-template-areas: "scene scene" "readouts controls"; }
#controls { display: flex; flex-wrap: wrap; gap: 6px 18px; align-content: start; }
}
@media (min-width: 600px) and (max-height: 399.98px) { /* a phone held sideways: controls in a panel beside */
body { grid-template-columns: minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr);
grid-template-areas: "scene readouts" "scene controls"; }
#controls { align-content: start; gap: 8px; }
}
</style>
</head>
<body>
<div id="view"><canvas id="scene" aria-label="An airliner cabin and the passenger's earcup, enlarged, with three waves"></canvas></div>
<div id="readouts"><span id="level"></span><span id="relative">relative to no cancellation</span></div>
<div id="controls">
<div class="row"><button id="cancel" title="C"></button><span class="label">sign</span>
<div class="seg" role="group" aria-label="Sign of the anti-noise" title="F"><button id="normal">normal</button><button id="flipped">flipped</button></div></div>
<div class="row"><span class="label">delay</span><button class="step" id="shorter" aria-label="Shorter delay" title="[">−</button>
<span id="delay-val"></span><button class="step" id="longer" aria-label="Longer delay" title="]">+</button></div>
<div class="row"><div class="seg" role="group" aria-label="Noise"><button id="drone" title="1">drone</button><button id="voices" title="2">voices</button><button id="both" title="3">both</button></div>
<button id="sound" class="push" title="S"></button></div>
<div class="row"><button id="play" title="Space"></button><button id="reset" title="R">Reset</button></div>
</div>
<script type="application/json" id="data">{{DATA}}</script>
<script>{{SCRIPT}}</script>
</body>
</html>
"""
# one scale for both loops: the loudest sample at 90 % of the 16-bit range
scale = 0.9 * 32767 / max(np.abs(drone).max(), np.abs(voices).max())
def to_base64(x):
return base64.b64encode(np.round(x * scale).astype("<i2").tobytes()).decode("ascii")
game_data = {
"fs": FS, "slow": SLOW, "window_ms": WINDOW_MS, "ylim_rms": YLIM_RMS, "master_gain": 0.5,
"delays_us": DELAYS_US, "start_delay": DELAYS_US.index(50), "drone_over_voices_db": DRONE_OVER_VOICES_DB,
"loops": {"drone": to_base64(drone), "voices": to_base64(voices)},
"bands": {name: {"f": [float(f"{v:.4g}") for v in fc], "p": [float(f"{v:.4g}") for v in pc]}
for name, (fc, pc) in band_list.items()},
}
# 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/cabin/game.js").read_text(encoding="utf-8")
html = TEMPLATE.replace("{{DATA}}", data).replace("{{SCRIPT}}", script)
Path("assets").mkdir(exist_ok=True)
Path("assets/cabin.html").write_text(html, encoding="utf-8", newline="\n")
print(f"assets/cabin.html: {Path('assets/cabin.html').stat().st_size / 1024:.0f} kB, "
f"of which {sum(len(v) for v in game_data['loops'].values()) / 1024:.0f} kB are the two loops")
assets/cabin.html: 367 kB, of which 345 kB are the two loops
The file is 367 kB, 345 kB of it the two loops.
Variations
- One period late. Add 10,000 µs to
delays_us;createDelay(0.01)already admits it. The drone repeats every 10 ms, so a copy one period late matches it exactly and the readout says silent, while the voices, which do not repeat, gain 2.9 dB. - Listen at the ear instead. A feedback design puts the microphone next to the ear, amplifies what it hears there by 10 through a 200 Hz low-pass, and plays it back inverted. In
levelDb, replace each band's factor by \(|1/(1 + G)|^2\), with \(G\) that path's gain at the band's frequency, whichscipy.signal.freqscomputes: the drone drops by 19.1 dB, and above 2 kHz the noise grows, by up to 5.0 dB near 4 kHz. - A transformer's hum. Replace the drone by 50 Hz with tones at 150 and 250 Hz, same amplitudes. At 50 µs it drops by 30.4 dB, three more than the engines, because every tone is lower.
- An anti-noise 10 % too quiet. Set
antiGainto 0.9 times the sign, and the factor to \(1.81 - 1.8\,s\cos\theta\). With no delay the drone drops by only 20.0 dB, since \(|1 - 0.9| = 0.1\), a floor that no short delay beats: −19.3 dB at 50 µs.
Further reading
- S. J. Elliott and P. A. Nelson, "Active noise control", IEEE Signal Processing Magazine 10(4), 12 (1993), the overview of the field, with the zone of quiet around the point of cancellation.
- S. M. Kuo and D. R. Morgan, "Active noise control: a tutorial review", Proceedings of the IEEE 87, 943 (1999), for feedforward and feedback designs in headphones.
- C. H. Hansen, Understanding Active Noise Cancellation (Spon Press, 2001), the subject as a book, with little mathematics.
- SciPy's documentation of
scipy.signal.freqs, for the feedback Variation. - MDN on the Web Audio API and its
DelayNode, which play the noise and the anti-noise ingame.js. - Related tutorials on this site: Filtering with scipy.signal: mains hum and noise out of an ECG, for a filter's response at each frequency, the next step after the factor here; and python-control from the ground up: a car's suspension with a worn damper, for feedback and how it is kept stable.
- Download the notebook. It was executed with the library versions in the header.
- Download the game, one HTML file of 367 kB that runs offline in any browser.