Pyroomacoustics MIMO RIR interoperability recipe

Tutorial goal

Convert Pyroomacoustics-style room impulse responses to the MIMO Markov tensor shape used by lattice-dsp.

Note

New to the terminology? See the lattice DSP concept map and the causality/data-use guide for how online, offline, block, and MIMO examples should be read.

Context

Room-acoustics simulators can produce one impulse response for each microphone/source pair. This recipe keeps Pyroomacoustics outside the package dependency tree while documenting the data convention used to bring those paths into MIMO block-Hankel reduction.

Key idea and equations

The mapping is

room.rir[microphone][source][tap]
     -> markov[tap, microphone, source]

How to read the result

Use the printed shape and relative Markov-response error to verify the conversion and reduced MIMO model.

Run command

python examples/pyroomacoustics_mimo_rir_recipe.py

Run status

Return code: 1

Captured stdout

converted shape: (96, 2, 2)
mapping: markov[tap, microphone, source]

Captured stderr

Traceback (most recent call last):
  File "examples/pyroomacoustics_mimo_rir_recipe.py", line 124, in <module>
    main()
  File "examples/pyroomacoustics_mimo_rir_recipe.py", line 111, in main
    print("reduced order:", result["order"])
                            ~~~~~~^^^^^^^^^
KeyError: 'order'

Source code

  1"""Recipe: use Pyroomacoustics-style MIMO room impulse responses.
  2
  3This example is dependency-free: it does not import Pyroomacoustics.  Instead it
  4uses a small fake object with the same ``room.rir[mic][source]`` structure used
  5by Pyroomacoustics after room impulse responses are computed.
  6
  7In a real Pyroomacoustics script, build the room and compute its RIRs there, then
  8pass the resulting room object to ``markov_from_pyroom_rir`` below.
  9"""
 10
 11from __future__ import annotations
 12
 13import numpy as np
 14
 15import lattice_dsp as ld
 16
 17
 18def markov_from_pyroom_rir(room, n_markov: int | None = None, dtype=float) -> np.ndarray:
 19    """Convert ``room.rir[mic][source]`` impulse responses to a MIMO Markov tensor.
 20
 21    Parameters
 22    ----------
 23    room:
 24        Any object with a Pyroomacoustics-style ``rir`` attribute.
 25    n_markov:
 26        Optional number of taps to keep.  If omitted, the longest RIR length is
 27        used and shorter channels are zero-padded.
 28    dtype:
 29        Output dtype.
 30
 31    Returns
 32    -------
 33    numpy.ndarray
 34        Array with shape ``(n_taps, n_mics, n_sources)``.
 35    """
 36
 37    n_outputs = len(room.rir)
 38    if n_outputs == 0:
 39        raise ValueError("room.rir must contain at least one microphone")
 40    n_inputs = len(room.rir[0])
 41    if n_inputs == 0:
 42        raise ValueError("room.rir[0] must contain at least one source")
 43
 44    lengths = [len(room.rir[y][u]) for y in range(n_outputs) for u in range(n_inputs)]
 45    n_taps = max(lengths) if n_markov is None else int(n_markov)
 46    if n_taps <= 0:
 47        raise ValueError("n_markov must be positive")
 48
 49    markov = np.zeros((n_taps, n_outputs, n_inputs), dtype=dtype)
 50    for y in range(n_outputs):
 51        if len(room.rir[y]) != n_inputs:
 52            raise ValueError("all microphones must have the same number of source RIRs")
 53        for u in range(n_inputs):
 54            h = np.asarray(room.rir[y][u], dtype=dtype)
 55            n = min(n_taps, h.size)
 56            markov[:n, y, u] = h[:n]
 57    return markov
 58
 59
 60class FakePyroomRoom:
 61    """Small stand-in for the Pyroomacoustics ``room.rir`` data structure."""
 62
 63    def __init__(self, rir):
 64        self.rir = rir
 65
 66
 67def damped_room_path(delay: int, gain: float, decay: float, n_taps: int) -> np.ndarray:
 68    """Create a simple delayed, damped synthetic room path for the recipe."""
 69
 70    h = np.zeros(n_taps)
 71    tail = gain * decay ** np.arange(n_taps - delay)
 72    h[delay:] = tail
 73    return h
 74
 75
 76def make_fake_room(n_taps: int = 96) -> FakePyroomRoom:
 77    """Create a 2-microphone, 2-source nested RIR list."""
 78
 79    rir = [
 80        [
 81            damped_room_path(delay=3, gain=0.95, decay=0.91, n_taps=n_taps),
 82            damped_room_path(delay=8, gain=0.35, decay=0.88, n_taps=n_taps),
 83        ],
 84        [
 85            damped_room_path(delay=6, gain=0.42, decay=0.89, n_taps=n_taps),
 86            damped_room_path(delay=2, gain=0.82, decay=0.92, n_taps=n_taps),
 87        ],
 88    ]
 89    return FakePyroomRoom(rir)
 90
 91
 92def main() -> None:
 93    room = make_fake_room()
 94    markov = markov_from_pyroom_rir(room, n_markov=96)
 95
 96    result = ld.finite_hankel_reduce_mimo(
 97        markov,
 98        reduced_order=4,
 99        block_rows=16,
100        block_cols=16,
101    )
102    approx = ld.mimo_state_space_markov_response(
103        result["A"], result["B"], result["C"], result["D"], markov.shape[0]
104    )
105
106    relative_error = float(np.linalg.norm(markov - approx) / np.linalg.norm(markov))
107    hsv = np.asarray(result["hankel_singular_values"])
108
109    print("converted shape:", markov.shape)
110    print("mapping: markov[tap, microphone, source]")
111    print("reduced order:", result["order"])
112    print("stable reduced model:", bool(result["stable"]))
113    print("leading block-Hankel singular values:", np.round(hsv[:6], 6).tolist())
114    print("relative Markov-response error:", f"{relative_error:.3e}")
115
116    print("\nReal Pyroomacoustics usage sketch:")
117    print("  # import pyroomacoustics as pra")
118    print("  # room = pra.ShoeBox(...); room.add_source(...); room.add_microphone_array(...)")
119    print("  # room.compute_rir()")
120    print("  # markov = markov_from_pyroom_rir(room, n_markov=512)")
121
122
123if __name__ == "__main__":
124    main()