HomeMAL' // MANUFACTORUM
datacrypt
Localizer / tests / fixtures.py
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)