Impulse Response Project Log 1: Raytracing around a Ring

Michael PeattieMichael Peattie
4 min read

Goal

  • Simulate acoustic impulse responses (IRs) for convolutional reverb in different geometries.

  • Start with a simple ring geometry, then move on to more exotic spaces like a Möbius strip.

What I Did

  • Set up a ring geometry with a listener/microphone on one side of the ring and a source on the other

  • Used raytracing to simulate the propagation of sound waves around the ring, allowing for reflection of the walls

  • Tracked cumulative distance travelled by each ray before reaching the listener to add dissipation to the final signal (the rays which travelled further before reaching the listener have a weaker amplitude)

  • Turned it into a soundfile with a small amount of processing

import numpy as np
import scipy.signal as signal
import soundfile as sf

# Constants
c = 344  # speed of sound (m/s)
dt = 0.001  # simulation step size in seconds
t_max = 5.0  # total simulation time
R = 35.0  # source radius
fs = 44100  # sampling rate for IR output

def ring(r, phi):
    x = r * np.cos(phi)
    y = r * np.sin(phi)
    return np.array([x, y])

# Main ray tracing function with cumulative distance tracking
def raytrace():
    rays = []
    for j in np.arange(0, 360, 0.005):
        t = 0
        pos = ring(R, 0)
        theta = np.deg2rad(j)
        direction = np.array([np.cos(theta), np.sin(theta)])
        ray_path = [pos.copy()]
        cumulative_distance = [0.0]
        total_dist = 0.0

        while t < t_max:
            step = direction * c * dt
            pos += step
            total_dist += np.linalg.norm(step)
            t += dt

            r = np.linalg.norm(pos)
            if r <= 31 or r >= 39:
                radial = pos / r
                scatter_angle = np.random.normal(0, np.deg2rad(2))
                cos_a = np.cos(scatter_angle)
                sin_a = np.sin(scatter_angle)
                R_mat = np.array([[cos_a, -sin_a], [sin_a, cos_a]])
                direction = R_mat @ (direction - 2 * np.dot(direction, radial) * radial)

            ray_path.append(pos.copy())
            cumulative_distance.append(total_dist)

        rays.append((ray_path, cumulative_distance))
    return rays

# Build the impulse response using distance-aware hits
def impulseresponse(rays, listener_pos=np.array([0.0, 35.0]), listener_radius=2,
                    fs=44100, t_max=5.0):
    n_samples = int(t_max * fs)
    ir = np.zeros(n_samples)
    impulse_data = []

    for ray_path, distances in rays:
        for i, pos in enumerate(ray_path):
            if np.linalg.norm(pos - listener_pos) < listener_radius:
                distance = distances[i]
                arrival_time = distance / c
                impulse_data.append((arrival_time, distance))
                # allow multiple hits

    print(f"Number of impulses detected: {len(impulse_data)}")

    for arrival_time, distance in impulse_data:
        i = int(arrival_time * fs)
        if i < n_samples:
            T60 = 2.5
            amp = (1.0 / (distance + 1e-6)) * np.exp(-6.91 * arrival_time / T60)
            kernel = amp * signal.windows.gaussian(11, std=1.5)
            start = max(i - 5, 0)
            end = min(i + 6, n_samples)
            ir[start:end] += kernel[:end - start]

    return ir

# Final cleanup and export
def prepare_ir_for_convolution(ir, fs, t_max=5.0, t60=2.5):
    threshold = 1e-6
    nonzero_indices = np.where(np.abs(ir) > threshold)[0]
    if len(nonzero_indices) == 0:
        print("No impulses detected in IR.")
        return ir
    first = nonzero_indices[0]
    ir = np.roll(ir, -first)
    ir[-first:] = 0

    t = np.linspace(0, t_max, len(ir))
    decay_env = np.exp(-6.91 * t / t60)
    ir *= decay_env

    max_amp = np.max(np.abs(ir))
    if max_amp > 0:
        ir /= max_amp

    fade_len = int(0.1 * fs)
    ir[-fade_len:] *= np.linspace(1, 0, fade_len)

    return ir

# Run full pipeline
rays = raytrace()
ir = impulseresponse(rays, t_max=t_max)
ir = prepare_ir_for_convolution(ir, fs)
sf.write("impulse_response_ready3.wav", ir, fs, subtype='PCM_16')

Notes

  • Code was ineffective due to hardware limitation (my laptop is a decade old).

  • Saving the cumulative distance tracking, which is important for the dissipation of the sound waves, and the path of each ray uses too much RAM (around 8GB) which caused my code to run for several minutes before throwing me a memory error.

  • Need dissipation for it to sound realistic and need a high number of rays to generate a decent impulse response.

  • I will need to implement a way to have dissipation but reduce RAM. One way I will considering is only keeping rays in memory which intersect with the listener or are near the listener.

  • Another core issue for the project is how to get an impulse response out of a mobius strip using raytracing since the half twist in the Mobius strip causes phase to invert as waves move around the strip, but rays don’t have phase…

What Next

  • Incorporate a dissipation model which doesn’t eat all my RAM

  • Figure out how to give rays a phase or explore other numerical wave simulation methods, possibly Finite Difference Time Domain (FDTD) (computationally expensive though)

0
Subscribe to my newsletter

Read articles from Michael Peattie directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Michael Peattie
Michael Peattie