top of page

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

Screenshot 2026-09-29 132757.png

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.

Screenshot 2026-09-29 132852.png

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).

Screenshot 2026-09-29 132905.png

Acoustic Mode-Shape Equation

Experimental Setup & Data Acquisition

Screenshot 2026-09-29 132814.png

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

Screenshot 2026-09-29 132941.png

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.

Screenshot 2026-09-29 132954.png
Screenshot 2026-09-29 133001.png

Effect of Acoustic Damping

Screenshot 2026-09-29 133010.png

Average Response

3.74 dB → 3.04 dB

Average Mode Amplitude

11.63 dB → 9.25 dB

Screenshot 2026-09-29 133016.png
Screenshot 2026-09-29 133022.png

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()

bottom of page