diff --git a/CHANGELOG.rst b/CHANGELOG.rst index db086602..6e047752 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,7 +11,14 @@ adheres to `Semantic Versioning `_. `Unreleased`_ ------------- -Nothing yet +During the testing PR moving sound source #335, I found some issues, which I tried to improve with this PR: + +- It was not possible to simulate rooms of standard dimensions, but only rooms with significantly unrealistic measurements (e.g., 2D room 100 * 100 m, which was in the test_moving_sound.py file). +- Step size is always the same for the x and y dimensions ‒ movement is possible only in four directions. +- Parameters for the x and y directions are Boolean variables that indicate the direction of a movement (True for forward movement and False for backward movement). +- The sound between the simulation steps is not smoothed, in which case you can hear the transitions between the steps. + +Method simulate_moving_source was implemented. `0.8.4`_ - 2025-05-19 --------------------- diff --git a/pyroomacoustics/room.py b/pyroomacoustics/room.py index c3c1bcaa..f4ca7450 100644 --- a/pyroomacoustics/room.py +++ b/pyroomacoustics/room.py @@ -2186,6 +2186,74 @@ def add_soundsource(self, sndsrc, directivity=None): if directivity is not None: sndsrc.set_directivity(directivity) return self.add(sndsrc) + + def simulate_moving_source( + self, + start_position, + end_position, + signal=None, + delay=0, + fs=8000, + ): + """ + Simulates a moving source in the room by given trajectory. + + Parameters + ---------- + start_position: array_like or ndarray + The start position of the source. The position needs to be within the room dimension. + end_position: array_like or ndarray + The end position of the source. The position needs to be within the room dimension. + signal: ndarray, shape: (n_samples,), optional + The signal played by the source + delay: float, optional + A time delay until the source signal starts in the simulation + fs: int + The sampling frequency in Hz. Default is 8000. + + Returns + ------- + recorded_signal: ndarray + The recorded signals at the microphone array with the moving source, shape (n_channels, n_samples). + """ + + start_position = np.array(start_position) + end_position = np.array(end_position) + + num_steps = len(signal) // fs # Calculate the number of simulation steps based on the signal length and sampling frequency + trajectory = np.linspace(start_position, end_position, + num_steps + 1) # Compute linear trajectory coordinates between the start and end position, including the end position as the last simulation step + + first = True + recorded_signal = None + + for step in range(num_steps + 1): + source_location = trajectory[step] + distance = np.linalg.norm(source_location - self.mic_array.R[:, 0]) # Use the position of the first microphone for distance calculation; currently, only one microphone is supported + + attenuation = 1 / max(distance, 0.1) # Attenuation is calculated according to the inverted distance value; the minimum distance is limited to 10 cm + + self.sources = [] + self.add_source( + position=source_location, + delay=delay, + signal=signal[step * fs: (step + 1) * fs] * attenuation, # Scale the signal based on the calculated attenuation + ) + + self.simulate() + new_signal = self.mic_array.signals[:, :fs] + + if first: + recorded_signal = new_signal # Initialization of recorded_signal + first = False + else: + recorded_signal = np.concatenate((recorded_signal, new_signal), axis=1) + window_size = 5 # Window size for smoothing (constant 5 was chosen based on a psychoacoustic point of view; in the future, it is possible to make it a parameter) + window = np.hanning(window_size) / np.sum(np.hanning(window_size)) # Create and normalize the Hanning window of the specified size + for channel in range(recorded_signal.shape[0]): # Apply the smoothing window to each channel + recorded_signal[channel, :] = np.convolve(recorded_signal[channel, :], window, mode='same') # The convolution smooths the recorded signal by averaging nearby values using the created window + + return recorded_signal def image_source_model(self): if not self.simulator_state["ism_needed"]: diff --git a/pyroomacoustics/tests/test_moving_source.py b/pyroomacoustics/tests/test_moving_source.py new file mode 100644 index 00000000..b691928f --- /dev/null +++ b/pyroomacoustics/tests/test_moving_source.py @@ -0,0 +1,29 @@ +import pyroomacoustics as pra +import numpy as np + +def test_simulate_moving_source(): + fs = 16000 + signal = np.arange(10) + + rt60 = 0.5 + room_dim = [6, 3.5, 2.5] + e_absorption, max_order = pra.inverse_sabine(rt60, room_dim) + + room = pra.ShoeBox(room_dim, fs=fs, materials=pra.Material(e_absorption), max_order=max_order) + + room.add_microphone(np.array([3, 1, 0.95]), room.fs) + + move = room.simulate_moving_source( + start_position=[1, 1, 1.47], + end_position=[5, 1, 1.47], + signal=signal, + delay=0, + fs=fs, + ) + + move = move / np.max(np.abs(move)) # normalize recording + print(move) + + +if __name__ == "__main__": + test_simulate_moving_source()