tests/fixtures.py3.2 KB
"""Synthetic BRIR / HRTF fixtures for tests and smoke runs.
These are toy signals with deliberately encoded cues — a lateral-dependent interaural delay
and level, plus a front/back spectral difference — so the pipeline can be exercised without
downloading a real (large) HRTF database. They are *not* acoustically realistic.
"""
from __future__ import annotations
import numpy as np
from localizer.audio_io import StereoIR
SAMPLE_RATE = 44100
_HRIR_LEN = 256
_MAX_ITD_SAMPLES = 30.0 # ~0.68 ms at 44.1 kHz, roughly a head-width delay
_MAX_ILD_DB = 12.0
def _decaying_noise(n: int, seed: int) -> np.ndarray:
rng = np.random.default_rng(seed)
env = np.exp(-np.arange(n) / (n / 6.0))
return rng.standard_normal(n) * env
def synth_hrir(azimuth_deg: float, sample_rate: int = SAMPLE_RATE, seed: int = 0) -> StereoIR:
"""Generate a synthetic HRIR encoding ITD, ILD and a front/back spectral difference."""
az = np.deg2rad(azimuth_deg)
n = _HRIR_LEN
base = _decaying_noise(n, seed)
# Lateral cues: positive azimuth (left) => right channel delayed and attenuated.
itd_samples = _MAX_ITD_SAMPLES * np.sin(az)
ild_db = _MAX_ILD_DB * np.sin(az)
ild_gain = 10 ** (-ild_db / 20.0) # right-channel gain
left = base.copy()
delay = int(round(abs(itd_samples)))
right = np.zeros(n)
if itd_samples >= 0: # source on left => delay right
right[delay:] = base[: n - delay] * ild_gain
else: # source on right => delay left
shifted = np.zeros(n)
shifted[delay:] = base[: n - delay]
left = shifted
right = base * ild_gain
# Front/back monaural cue: a spectral notch applied to back positions only.
if abs(((azimuth_deg + 180) % 360) - 180) > 90: # back hemisphere
from scipy.signal import butter, sosfiltfilt
sos = butter(2, [0.28, 0.34], btype="bandstop", output="sos")
left = sosfiltfilt(sos, left)
right = sosfiltfilt(sos, right)
data = np.vstack([left, right])
return StereoIR(data, sample_rate, f"synth_az{azimuth_deg:g}")
def synth_brir(
azimuth_deg: float,
sample_rate: int = SAMPLE_RATE,
tail_len: int = 4096,
leading_silence: int = 20,
seed: int = 123,
) -> StereoIR:
"""Wrap a synthetic HRIR in leading silence + a decaying reverberant tail → a BRIR."""
hrir = synth_hrir(azimuth_deg, sample_rate, seed=seed)
rng = np.random.default_rng(seed + 1)
tail = rng.standard_normal((2, tail_len)) * np.exp(
-np.arange(tail_len) / (tail_len / 8.0)
) * 0.05
silence = np.zeros((2, leading_silence))
data = np.hstack([silence, hrir.data, tail])
return StereoIR(data, sample_rate, f"synth_brir_az{azimuth_deg:g}")
def synth_database_dir(tmp_path, azimuths=None, sample_rate: int = SAMPLE_RATE) -> str:
"""Write a small WAV-folder database (one subject) and return its path."""
from localizer.audio_io import write_wav
if azimuths is None:
azimuths = list(range(-150, 181, 30))
subject = tmp_path / "subject00"
subject.mkdir()
for az in azimuths:
hrir = synth_hrir(az, sample_rate, seed=0)
sign = "" if az < 0 else "+"
write_wav(subject / f"az{sign}{az:g}_el0.wav", hrir)
return str(tmp_path)
