Acoustic Room Mode Analysis in a Rectangular Enclosure
Predicting and measuring standing-wave resonances in a rectangular enclosure using ESP32-based data acquisition and Python signal processing.
This project investigated acoustic standing-wave resonances inside a rectangular enclosure. The resonant frequencies were first predicted theoretically, then experimentally measured using a speaker, microphone, ESP32 data-acquisition system, and a sine sweep generator. Python-based signal processing was used to analyze the measurements and compare an untreated enclosure with an enclosure containing absorptive damping.
Acoustics
ESP32
Python
Signal Processing

Experimental Setup: Speaker, microphone, and ESP32 data-acquisition system inside the enclosure.
Theory & Experimental Design
Sound reflecting between the rigid walls of the enclosure can constructively interfere to form standing waves at specific resonant frequencies. Because these frequencies depend on the enclosure dimensions, the expected axial room modes can be predicted before testing and used as a reference for experimental measurements.

Dimensions: 46 cm × 36 cm × 26 cm
Predicted axial modes (Hz): 373, 476, 660, 746, 953, 1319
Equation for standing-wave resonances in a rigid-walled rectangular enclosure.
Microphone Placement
Microphone position was intentionally selected to avoid pressure nodes and provide strong sensitivity across the predicted modes. A Python grid-search evaluated candidate positions using the acoustic mode-shape function and selected a microphone location of approximately (7.8 cm, 6.1 cm, 4.4 cm).

Acoustic Mode-Shape Equation
Experimental Setup & Data Acquisition

Sine Sweep → Speaker → Enclosure → Microphone → ESP32 → Computer
A 30-second linear sine sweep from 150-1500 Hz was played through a speaker sealed to the enclosure. A MAX9814 microphone module inside the enclosure recorded the acoustic response, while an ESP32 sampled the microphone at 5 kHz and transmitted the measurements to a computer for analysis.
Physical Experimental Setup:Â
Left- External serial monitor logging microphone readings
​
Middle- Sine sweep being generated to the speaker using Room EQ Wizard
​
Right- Cardboard enclosure with speaker sealed to the outside, aligned with box cutout
Test Conditions
Open Air
Baseline Reference
Sealed Enclosure
Room Mode Measurement
Damped Enclosure
Absorptive Towels Added
5 kHz Sampling Rate
30 s Recording
150,000 Samples / Trial
3 Trials / Configuration
The Arduino Serial Monitor initially dropped samples at the required data rate, so the acquisition process was changed to PuTTY serial logging at 1,000,000 baud, allowing the full datasets to be recorded reliably.
Data Acquisition Iteration
Signal Processing & Analysis
Raw Microphone Data
→
Average Trials
→
Deconvolution
→
FFT
→
→
Open-Air Normalization
Resonance Detection
Python was used to process the recorded microphone data. Three trials from each test configuration were averaged to reduce random variation. The recorded sine-sweep response was then deconvolved using the known sweep signal to obtain an impulse-response representation, and an FFT was applied to analyze the response in the frequency domain.
Removing the Measurement-System Response
To isolate the effect of the enclosure itself, the sealed and damped enclosure spectra were normalized against the open-air baseline. This helped reduce the influence of the speaker and microphone frequency responses so that peaks caused by the enclosure geometry could be identified more clearly.
Python
NumPy
SciPy
FFT
Deconvolution
Results & Damping

The processed enclosure frequency response showed clear resonance peaks near the theoretically predicted axial room-mode frequencies, providing experimental evidence of standing-wave behavior inside the enclosure.
Predicted vs. Measured
Measured axial resonances generally showed close agreement with theoretical predictions.


Effect of Acoustic Damping

Average Response
3.74 dB → 3.04 dB
Average Mode Amplitude
11.63 dB → 9.25 dB


Adding absorptive towels generally reduced the amplitudes of the resonant modes while the resonance frequencies remained approximately the same.Â
Not every individual mode decreased in amplitude. Experimental variability, microphone position, enclosure imperfections, and interactions between nearby modes may have contributed to these exceptions.
Limitations & Future Improvements
Limitations
Cardboard Enclosure
Wall flexure and absorption created non-ideal acoustic boundaries.
Next Iteration
Microphone AGC
Automatic gain control could influence measured resonances
Construct a rigid wood enclosure to better approximate acoustic boundaries.
Use a calibrated measurement microphone without automatic gain control.
Use improved acoustic and measurement hardware with a flatter frequency response.
Measurement Hardware
Speaker and microphone did not have perfectly flat or calibrated frequency responses.
Synchronize signal generation and data acquisition to improve repeatability and allow more reliable analysis across the entire sweep.
Experimental Conditions
Environmental noise, imperfect sealing, and manual synchronization introduced additional uncertainty.
Code Appendix
Appendix A:Â Acoustic Mode Shape Function Grid Search
​
import numpy as np
Â
Lx = 46.0Â Â Â
Ly = 36.0Â Â Â
Lz = 26.0
Â
modes = [
    (1, 0, 0),
    (0, 1, 0),
    (0, 0, 1),
    (2, 0, 0),
    (0, 2, 0),
    (0, 0, 2)
]
Â
def mode_pickup(x, y, z, m, n, p):
    return abs(
        np.cos(m * np.pi * x / Lx) *
        np.cos(n * np.pi * y / Ly) *
        np.cos(p * np.pi * z / Lz)
    )
Â
best_score = -1
best_position = None
best_mode_scores = None
Â
xs = np.linspace(0.17 * Lx, 0.80 * Lx, 30)
ys = np.linspace(0.17 * Ly, 0.80 * Ly, 30)
zs = np.linspace(0.17 * Lz, 0.80 * Lz, 30)
Â
for x in xs:
    for y in ys:
        for z in zs:
            scores = []
Â
            for m, n, p in modes:
                scores.append(mode_pickup(x, y, z, m, n, p))
Â
            average_score = np.mean(scores)
Â
            if average_score > best_score:
                best_score = average_score
                best_position = (x, y, z)
                best_mode_scores = scores
Â
print("Best microphone position:")
print("x =", best_position[0])
print("y =", best_position[1])
print("z =", best_position[2])
Â
print("\nScore for each mode:")
for i in range(len(modes)):
    print("Mode", modes[i], "score =", best_mode_scores[i])
Â
print("\nAverage score:")
print(best_score)
Appendix B: Open Air Trial
​
import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import chirp, fftconvolve
Â
fs = 5000Â Â Â Â Â Â Â Â Â
duration = 30Â Â Â Â Â Â Â
f_start = 150Â Â Â Â
f_end = 1500Â Â Â Â Â Â Â Â
Â
t_template = np.linspace(0, duration, int(fs * duration), endpoint=False)
original_sweep = chirp(t_template, f0=f_start, t1=duration, f1=f_end, method='linear')
inverse_filter = original_sweep[::-1]
Â
file_name = "OPEN AIR TRIAL 3.txt"Â
mic_signal = np.loadtxt(file_name, skiprows=0)
mic_signal = mic_signal[:150000]Â
Â
min_len = min(len(original_sweep), len(mic_signal))
inverse_filter = inverse_filter[:min_len]
mic_signal   = mic_signal[:min_len]
Â
mic_signal = mic_signal - np.mean(mic_signal)
Â
impulse_response = fftconvolve(mic_signal, inverse_filter, mode='same')
Â
N = len(impulse_response)
fft_values = np.fft.fft(impulse_response)
Â
half_N = N // 2
magnitude = np.abs(fft_values[:half_N])
frequencies = np.fft.fftfreq(N, 1/fs)[:half_N]
Â
magnitude_db = 20 * np.log10(magnitude + 1e-12)
magnitude_db_normalized = magnitude_db - np.max(magnitude_db)
Â
plt.figure(figsize=(10, 5))
plt.plot(frequencies, magnitude_db_normalized, color='blue', label=f"Response ({file_name})")
plt.title(f"Linear Frequency Response - {file_name}")
plt.xlabel("Frequency (Hz)")
plt.ylabel("Relative Magnitude (dB)")
Â
plt.xscale('linear')Â Â Â Â Â Â Â Â Â Â Â
Â
plt.xlim(f_start, f_end)Â Â Â Â Â Â Â Â Â Â Â Â Â Â
plt.ylim(-40, 5)Â Â Â Â Â Â Â Â Â Â Â Â
plt.grid(True, which="both", ls="--", alpha=0.5)
plt.legend()
plt.tight_layout()
plt.show()
Appendix C: Sealed and Damped Trials
​
fs = 5000Â Â Â Â Â Â Â Â Â Â
duration = 30Â Â Â Â
f_start = 150Â Â Â
f_end = 1500Â Â Â Â Â Â
Â
t_template = np.linspace(0, duration, int(fs * duration), endpoint=False)
Â
original_sweep = chirp(t_template, f0=f_start, t1=duration, f1=f_end, method='linear')
Â
inverse_filter = original_sweep[::-1]
Â
open_1 = np.loadtxt("OPEN AIR TRIAL 1.txt", skiprows=0)
open_2 = np.loadtxt("OPEN AIR TRIAL 2.txt", skiprows=0)
open_3 = np.loadtxt("OPEN AIR TRIAL 3.txt", skiprows=0)
Â
std_1 = np.loadtxt("ENCLOSURE TRIAL 1 REAL.txt", skiprows=0)
std_2 = np.loadtxt("ENCLOSURE TRIAL 2 REAL.txt", skiprows=0)
std_3 = np.loadtxt("ENCLOSURE TRIAL 3 REAL.txt", skiprows=0)
Â
damp_1 = np.loadtxt("DAMPED TRIAL 1.txt", skiprows=0)
damp_2 = np.loadtxt("DAMPED TRIAL 2.txt", skiprows=0)
damp_3 = np.loadtxt("DAMPED TRIAL 3.txt", skiprows=0)
Â
min_len = min(len(original_sweep),Â
              len(open_1), len(open_2), len(open_3),Â
              len(std_1), len(std_2), len(std_3),Â
              len(damp_1), len(damp_2), len(damp_3))
Â
inverse_filter = inverse_filter[:min_len]
open_1, open_2, open_3 = open_1[:min_len], open_2[:min_len], open_3[:min_len]
std_1, std_2, std_3 Â = std_1[:min_len], std_2[:min_len], std_3[:min_len]
damp_1, damp_2, damp_3 = damp_1[:min_len], damp_2[:min_len], damp_3[:min_len]
Â
open_air_averaged = (open_1 + open_2 + open_3) / 3.0
box_std_averaged = (std_1 + std_2 + std_3) / 3.0
box_damp_averaged = (damp_1 + damp_2 + damp_3) / 3.0
Â
open_air_averaged = open_air_averaged - np.mean(open_air_averaged)
box_std_averaged = box_std_averaged - np.mean(box_std_averaged)
box_damp_averaged = box_damp_averaged - np.mean(box_damp_averaged)
Â
def process_spectrum(mic_signal, inv_filter, reference_mag):
    ir = fftconvolve(mic_signal, inv_filter, mode='same')
   Â
    fft_vals = np.fft.fft(ir)
   Â
    half_N = len(ir) // 2
    raw_mag = np.abs(fft_vals[:half_N])
   Â
    isolated_mag = raw_mag / (reference_mag + 1e-12)
   Â
    isolated_db = 20 * np.log10(isolated_mag)
   Â
    isolated_db = isolated_db - np.median(isolated_db)
    return isolated_db, len(ir)
Â
ir_ref = fftconvolve(open_air_averaged, inverse_filter, mode='same')
mag_open = np.abs(np.fft.fft(ir_ref)[:len(ir_ref)//2])
Â
db_standard, N = process_spectrum(box_std_averaged, inverse_filter, mag_open)
db_damped, _ Â = process_spectrum(box_damp_averaged, inverse_filter, mag_open)
Â
frequencies = np.fft.fftfreq(N, 1/fs)[:N // 2]
bins_per_hz = len(frequencies) / (fs / 2)
Â
window_len = int(25 * bins_per_hz)
if window_len % 2 == 0: window_len += 1Â # Window size must be an odd number
Â
smooth_standard = savgol_filter(db_standard, window_length=window_len, polyorder=2)
smooth_damped  = savgol_filter(db_damped, window_length=window_len, polyorder=2)
Â
trimmed_mask = (frequencies >= 200) & (frequencies <= 1400)
Â
avg_amplitude_standard_trimmed = np.mean(smooth_standard[trimmed_mask])
avg_amplitude_damped_trimmed  = np.mean(smooth_damped[trimmed_mask])
trimmed_reduction = avg_amplitude_standard_trimmed - avg_amplitude_damped_trimmed
Â
print(f"Trimmed Standard Enclosure Level:Â {avg_amplitude_standard_trimmed:.2f} dB")
print(f"Trimmed Damped Enclosure Level:Â Â {avg_amplitude_damped_trimmed:.2f} dB")
Â
print(f"Core Broadband Noise Reduction: Â Â {trimmed_reduction:.2f} dB")
Â
plt.figure(figsize=(12, 6))
Â
plt.plot(frequencies, smooth_standard, color='crimson', linewidth=2.5,Â
         label="Averaged Standard Enclosure (Undamped)")
Â
plt.plot(frequencies, smooth_damped, color='dodgerblue', linewidth=2.5, linestyle='--',
         label="Averaged Enclosure (Damped with Material)")
Â
plt.title("Acoustic Spectrum Analysis: Undamped vs. Damped Enclosure", fontsize=14, weight='bold')
plt.xlabel("Frequency (Hz)", fontsize=12)
plt.ylabel("Relative Magnitude (dB)", fontsize=12)
Â
plt.xlim(200, 1400)
Â
ymax = max(np.max(smooth_standard[trimmed_mask]), np.max(smooth_damped[trimmed_mask])) + 6
ymin = min(np.min(smooth_standard[trimmed_mask]), np.min(smooth_damped[trimmed_mask])) - 6
plt.ylim(ymin, ymax)
Â
plt.grid(True, which="both", ls="--", alpha=0.5)
plt.legend(fontsize=11, loc="upper right")
plt.tight_layout()
Â
plt.show()
Â
from scipy.signal import find_peaks
peak_distance_bins = int(60 * bins_per_hz)
peaks_std, _ = find_peaks(smooth_standard, prominence=5.0, distance=peak_distance_bins)
freqs_std = frequencies[peaks_std]
mask_std = (freqs_std >= 200) & (freqs_std <= 1400)
final_peaks_std = peaks_std[mask_std]
final_freqs_std = freqs_std[mask_std]
Â
peaks_damp, _ = find_peaks(smooth_damped, prominence=2.5, distance=peak_distance_bins)
freqs_damp = frequencies[peaks_damp]
mask_damp = (freqs_damp >= 200) & (freqs_damp <= 1400)
final_peaks_damp = peaks_damp[mask_damp]
final_freqs_damp = freqs_damp[mask_damp]
Â
plt.figure(figsize=(11, 4.5))
plt.plot(frequencies, smooth_standard, color='crimson', linewidth=2.5, label="Standard Box Profile")
Â
print(" Â DETECTED STANDARD BOX RESONANT MODES Â ")
for i, f in enumerate(final_freqs_std):
    peak_y = smooth_standard[final_peaks_std][i]
    plt.annotate(f"{f:.1f} Hz", (f, peak_y), textcoords="offset points",Â
                 xytext=(0,12), ha='center', color='crimson', weight='bold', fontsize=10)
    print(f"Mode {i+1}: {f:.2f} Hz")
Â
plt.title("Acoustic Resonances for Sealed Enclosure (Undamped)", fontsize=13, weight='bold')
plt.xlabel("Frequency (Hz)", fontsize=11)
Â
plt.ylabel("Relative Magnitude (dB)", fontsize=11)
plt.xlim(200, 1400)
plt.ylim(np.min(smooth_standard[trimmed_mask]) - 5, np.max(smooth_standard[trimmed_mask]) + 10)
plt.grid(True, which="both", ls="--", alpha=0.5)
plt.tight_layout()
plt.show()
Â
plt.figure(figsize=(11, 4.5))
plt.plot(frequencies, smooth_damped, color='dodgerblue', linewidth=2.5, label="Damped Box Profile")
print("Â Â DETECTED DAMPED BOX RESONANT MODESÂ Â ")
for i, f in enumerate(final_freqs_damp):
    peak_y = smooth_damped[final_peaks_damp][i]
    plt.annotate(f"{f:.1f} Hz", (f, peak_y), textcoords="offset points",Â
                 xytext=(0,12), ha='center', color='dodgerblue', weight='bold', fontsize=10)
    print(f"Damped Mode {i+1}: {f:.2f} Hz")
Â
plt.title("Acoustic Resonances: Enclosure with Sound Damping Material", fontsize=13, weight='bold')
plt.xlabel("Frequency (Hz)", fontsize=11)
plt.ylabel("Relative Magnitude (dB)", fontsize=11)
plt.xlim(200, 1400)
plt.ylim(np.min(smooth_damped[trimmed_mask]) - 5, np.max(smooth_standard[trimmed_mask]) + 10)Â
plt.grid(True, which="both", ls="--", alpha=0.5)
plt.tight_layout()
plt.show()
Â
peak_heights_std = smooth_standard[final_peaks_std]
peak_heights_damp = smooth_damped[final_peaks_damp]
Â
avg_peak_db_standard = np.mean(peak_heights_std)
avg_peak_db_damped  = np.mean(peak_heights_damp)
Â
peak_attenuation = avg_peak_db_standard - avg_peak_db_damped
Â
print(" Â Â ISOLATED RESONANCE PEAK METRICSÂ Â ")
print(f"Average Height of Standard Enclosure Peaks:Â {avg_peak_db_standard:.2f} dB")
print(f"Average Height of Damped Enclosure Peaks:Â Â {avg_peak_db_damped:.2f} dB")
print(f"Average Peak Amplitude Suppression:Â Â Â Â Â {peak_attenuation:.2f} dB")
Â
sealed_freqs = [362.6, 440.3, 650.6, 727.0, 941.9, 1339.4]
sealed_heights = [smooth_standard[np.argmin(np.abs(frequencies - f))] for f in sealed_freqs]
Â
damped_freqs = [371.2, 461.9, 661.3, 746.7, 967.7, 1364.9]
damped_heights = [smooth_damped[np.argmin(np.abs(frequencies - f))] for f in damped_freqs]
Â
plt.figure(figsize=(12, 6))
plt.vlines(sealed_freqs, ymin=0, ymax=sealed_heights, color='crimson',Â
           linewidth=3, label="Standard Enclosure True Modes")
plt.scatter(sealed_freqs, sealed_heights, color='crimson', s=50, zorder=3)
Â
plt.vlines(damped_freqs, ymin=0, ymax=damped_heights, color='dodgerblue',Â
           linewidth=3, label="Damped Enclosure True Modes")
plt.scatter(damped_freqs, damped_heights, color='dodgerblue', s=50, zorder=3)
Â
axis_labels = ['X-Fund', 'Y-Fund', 'Z-Fund', 'X-2nd Harm', 'Y-2nd Harm', 'Z-2nd Harm']
for i, f in enumerate(sealed_freqs):
    plt.annotate(f"{axis_labels[i]}\n({f:.1f} Hz)", (f, sealed_heights[i]),Â
                 textcoords="offset points", xytext=(0,10), ha='center',Â
                 color='crimson', weight='bold', fontsize=9)
Â
plt.title("Acoustic Comparison: Isolated Axial Room Modes", fontsize=14, weight='bold')
plt.xlabel("Frequency (Hz)", fontsize=12)
plt.ylabel("Relative Mode Amplitude (dB)", fontsize=12)
Â
plt.xlim(200, 1400)
plt.ylim(0, max(max(sealed_heights), max(damped_heights)) + 8)
Â
plt.grid(True, which="both", ls="--", alpha=0.4)
plt.legend(fontsize=11, loc="upper right")
plt.tight_layout()
Â
plt.show()