Source code for pyFDN.auxiliary.flamo

"""
Standard wrappers for FLAMO modules that accept numpy arrays and return FLAMO modules.

All functions require flamo to be installed. They take numpy arrays and common
options (nfft, device, etc.) and return configured FLAMO dsp modules with
values assigned.
"""

from __future__ import annotations

import warnings
from typing import Any

import numpy as np

try:
    from flamo.processor import dsp

    _HAS_FLAMO = True
except ImportError:
    _HAS_FLAMO = False


def _get_device(device):
    if device is None and _HAS_FLAMO:
        import torch

        return torch.device("cuda" if torch.cuda.is_available() else "cpu")
    return device


[docs] def flamo_time_response( model, fs: int = 48000, identity: bool = False, ) -> np.ndarray: """Return a FLAMO model's time response as a NumPy array. This is the NumPy-facing counterpart of FLAMO's ``model.get_time_response()``. It detaches the returned tensor from any autograd graph, transfers it to CPU memory, and preserves its dimensions and dtype during conversion. Parameters ---------- model FLAMO model exposing ``get_time_response``. fs : int Sampling frequency passed to FLAMO. identity : bool Whether to request FLAMO's input-free identity response. Returns ------- np.ndarray Time response with the same shape and numeric dtype as FLAMO's tensor. """ response = model.get_time_response(fs=fs, identity=identity) if hasattr(response, "detach"): response = response.detach() if hasattr(response, "cpu"): response = response.cpu() return np.asarray(response)
[docs] def flamo_freq_response( model, fs: int = 48000, identity: bool = False, ) -> np.ndarray: """Return a FLAMO model's (complex) frequency response as a NumPy array. The NumPy-facing counterpart of FLAMO's ``model.get_freq_response()`` and the frequency-domain sibling of :func:`flamo_time_response`. It detaches the returned tensor from any autograd graph, transfers it to CPU memory, and preserves its shape and (complex) dtype. Take ``np.abs(...)`` for the magnitude response, ``np.angle(...)`` for the phase. ``get_freq_response`` evaluates over ``nfft`` DFT bins by temporarily swapping the model's input/output layers to FFT and restoring them before returning, so this is side-effect-free regardless of the model's current output layer. Parameters ---------- model FLAMO model exposing ``get_freq_response`` (e.g. a ``Shell``). fs : int Sampling frequency passed to FLAMO. identity : bool Whether to request FLAMO's input-free identity response. Returns ------- np.ndarray Complex frequency response with the same shape and numeric dtype as FLAMO's tensor. """ response = model.get_freq_response(fs=fs, identity=identity) if hasattr(response, "detach"): response = response.detach() if hasattr(response, "cpu"): response = response.cpu() return np.asarray(response)
[docs] def flamo_process( model, signal: np.ndarray, *, fs: int | None = None, tail_seconds: float = 0.0, dtype=None, ) -> np.ndarray: """Run a 1-D signal through a FLAMO ``Shell`` model offline. Wraps the boilerplate of turning a NumPy signal into the ``(batch, time, channel)`` tensor FLAMO expects, running a no-grad forward pass, and converting the result back to NumPy. The model convolves in the frequency domain over a block of length ``nfft`` (read from the model's input layer), so the signal is truncated or zero-padded to ``nfft``. Because that is a *circular* convolution, a long reverb tail can wrap around onto the start of the block; pass ``tail_seconds`` to reserve that much trailing silence for the tail to decay into (requires ``fs``). Parameters ---------- model FLAMO ``Shell`` whose input layer exposes ``nfft`` (e.g. the output of :func:`pyFDN.dss_to_flamo`). signal : np.ndarray 1-D input signal. fs : int, optional Sampling rate, required only when ``tail_seconds > 0``. tail_seconds : float Trailing silence to reserve so the reverb tail does not wrap around. dtype : torch.dtype or None Tensor dtype for the forward pass; defaults to float32. Returns ------- np.ndarray Squeezed model output on CPU. """ if not _HAS_FLAMO: raise ImportError("flamo_process requires flamo (pip install flamo)") import torch nfft = int(model.get_inputLayer().nfft) sig = np.asarray(signal, dtype=np.float64).ravel() if tail_seconds: if fs is None: raise ValueError("fs is required when tail_seconds > 0") usable = max(0, nfft - int(round(tail_seconds * fs))) else: usable = nfft buf = np.zeros(nfft, dtype=np.float64) n = min(len(sig), usable) buf[:n] = sig[:n] torch_dtype = torch.float32 if dtype is None else dtype x = torch.as_tensor(buf, dtype=torch_dtype).unsqueeze(0).unsqueeze(-1) with torch.no_grad(): wet = model(x) return np.asarray(wet.squeeze().detach().cpu())
[docs] def gain_module( values: np.ndarray, nfft: int, *, device=None, dtype=None, alias_decay_db: float = 0, requires_grad: bool = False, ): """ Build a FLAMO Gain module from a numpy array. Parameters ---------- values : np.ndarray Gain matrix, shape (n_output, n_input). Will be cast to float64. nfft : int FFT size for the FLAMO module. device : torch device or None Device for the module; default is cuda if available else cpu. dtype : torch.dtype or None Optional dtype for module parameters (e.g., torch.float64). If None, uses float32. alias_decay_db : float FLAMO alias decay in dB. requires_grad : bool Whether the gain parameters are trainable. Returns ------- flamo.processor.dsp.Gain FLAMO Gain module with values assigned. """ if not _HAS_FLAMO: raise ImportError("gain_module requires flamo (pip install flamo)") import torch values = np.asarray(values, dtype=np.float64) if values.ndim == 1: values = values.reshape(-1, 1) n_out, n_in = values.shape dev = _get_device(device) torch_dtype = torch.float32 if dtype is None else dtype gain = dsp.Gain( size=(n_out, n_in), nfft=nfft, requires_grad=requires_grad, alias_decay_db=alias_decay_db, device=dev, dtype=torch_dtype, ) gain.assign_value(torch.as_tensor(values, dtype=torch_dtype, device=dev)) return gain
[docs] def delay_module( lengths_seconds: np.ndarray, nfft: int, *, Fs: float, device=None, dtype=None, isint: bool = True, alias_decay_db: float = 0, requires_grad: bool = False, ): """ Build a FLAMO parallelDelay module from delay lengths in seconds. Values are assigned directly (no sample conversion); buffer size is derived from Fs. Parameters ---------- lengths_seconds : np.ndarray 1D array of delay lengths in seconds, one per channel. nfft : int FFT size for the FLAMO module. Fs : float Sampling rate in Hz (used for buffer size max_len = max(lengths_seconds) * Fs). device : torch device or None Device for the module; default is cuda if available else cpu. dtype : torch.dtype or None Optional dtype for module parameters (e.g., torch.float64). If None, uses float32 to preserve previous behavior. isint : bool Whether delays are integer-sample (True) or fractional. alias_decay_db : float FLAMO alias decay in dB. requires_grad : bool Whether the delay parameters are trainable. Returns ------- flamo.processor.dsp.parallelDelay FLAMO parallelDelay module with lengths assigned (in seconds). """ if not _HAS_FLAMO: raise ImportError("delay_module requires flamo (pip install flamo)") import torch lengths = np.asarray(lengths_seconds, dtype=np.float64).ravel() n = len(lengths) max_len = int(np.ceil(np.max(lengths) * Fs)) if n else 1 max_len = max(1, max_len) dev = _get_device(device) torch_dtype = torch.float32 if dtype is None else dtype delays = dsp.parallelDelay( size=(n,), max_len=max_len, nfft=nfft, isint=isint, unit=1, fs=Fs, requires_grad=requires_grad, alias_decay_db=alias_decay_db, device=dev, dtype=torch_dtype, ) delays.assign_value(torch.as_tensor(lengths, dtype=torch_dtype, device=dev)) return delays
[docs] def fir_matrix_module( coeffs: np.ndarray, nfft: int, *, device=None, dtype=None, requires_grad: bool = False, ): """ Build a FLAMO Filter module from a matrix FIR coefficient array. Parameters ---------- coeffs : np.ndarray FIR matrix in z^{-1} convention, shape (n_output, n_input, n_taps) (e.g. a paraunitary feedback matrix). nfft : int FFT size for the FLAMO module. device : torch device or None Device for the module; default is cuda if available else cpu. dtype : torch.dtype or None Optional dtype for module parameters (e.g., torch.float64). If None, uses float32. requires_grad : bool Whether the filter parameters are trainable. Returns ------- flamo.processor.dsp.Filter FLAMO Filter module with coefficients assigned. """ if not _HAS_FLAMO: raise ImportError("fir_matrix_module requires flamo (pip install flamo)") import torch coeffs = np.asarray(coeffs, dtype=np.float64) if coeffs.ndim != 3: raise ValueError("coeffs must have shape (n_output, n_input, n_taps)") n_out, n_in, n_taps = coeffs.shape dev = _get_device(device) torch_dtype = torch.float32 if dtype is None else dtype filt = dsp.Filter( size=(n_taps, n_out, n_in), nfft=nfft, requires_grad=requires_grad, device=dev, dtype=torch_dtype, ) filt.assign_value( torch.as_tensor(coeffs.transpose(2, 0, 1), dtype=torch_dtype, device=dev) ) return filt
[docs] def sos_filter_module( sos: np.ndarray, nfft: int, *, device=None, dtype=None, requires_grad: bool = False, ): """ Build a FLAMO parallelSOSFilter from an SOS coefficient array. Parameters ---------- sos : np.ndarray Shape (n_sections, 6, n_channels). Each section is [b0, b1, b2, a0, a1, a2] (e.g. from SDN wall_filters_sos). nfft : int FFT size for the FLAMO module. device : torch device or None Device for the module; default is cuda if available else cpu. dtype : torch.dtype or None Optional dtype for module parameters (e.g., torch.float64). If None, uses float32 to preserve previous behavior. requires_grad : bool Whether the filter parameters are trainable. Returns ------- flamo.processor.dsp.parallelSOSFilter FLAMO parallelSOSFilter with coefficients assigned. """ if not _HAS_FLAMO: raise ImportError("sos_filter_module requires flamo (pip install flamo)") import torch sos_pad = np.asarray(sos, dtype=np.float64) if sos_pad.ndim != 3 or sos_pad.shape[1] != 6: raise ValueError("sos must have shape (n_sections, 6, n_channels)") n_sections, _, N = sos_pad.shape if N == 0: raise ValueError("sos must have at least one channel") dev = _get_device(device) torch_dtype = torch.float32 if dtype is None else dtype filt = dsp.parallelSOSFilter( size=(N,), n_sections=n_sections, nfft=nfft, device=dev, dtype=torch_dtype, ) filt.assign_value(torch.as_tensor(sos_pad, dtype=torch_dtype, device=dev)) return filt
def _matrix_preimage(values: np.ndarray, matrix_type: str) -> np.ndarray: """Pre-image ``param`` so a flamo ``Matrix.map(param)`` realizes ``values``. * ``"random"`` -- identity map, so the pre-image is ``values`` itself. * ``"orthogonal"`` -- map is ``matrix_exp(skew_matrix(param))``, which spans SO(N). The pre-image is the real matrix logarithm (a skew-symmetric matrix that ``skew_matrix`` reproduces). If ``values`` has ``det < 0`` it is not in SO(N); the last column is sign-flipped to the nearest SO(N) matrix and a warning is emitted. """ if matrix_type == "random": return values if matrix_type == "orthogonal": from scipy.linalg import logm a = np.asarray(values, dtype=np.float64) if np.linalg.det(a) < 0: warnings.warn( "orthogonal feedback matrix has det<0 (not in SO(N)); flipping " "the last column to the nearest SO(N) matrix for the trainable " "orthogonal parametrization", stacklevel=3, ) a = a.copy() a[:, -1] *= -1.0 return np.real(logm(a)) raise ValueError( f"matrix_type must be 'orthogonal' or 'random', got {matrix_type!r}" )
[docs] def matrix_module( values: np.ndarray, nfft: int, *, matrix_type: str = "orthogonal", device: Any = None, dtype: Any = None, alias_decay_db: float = 0, requires_grad: bool = False, ): """ Build a FLAMO ``Matrix`` initialized to ``values`` under a parametrization. Unlike :func:`gain_module` (a plain value container), this preserves the flamo ``map`` that constrains the trainable matrix: ``"orthogonal"`` keeps it on the SO(N) manifold during optimization, ``"random"`` is unconstrained. Parameters ---------- values : np.ndarray Square ``(N, N)`` initial feedback matrix. nfft : int FFT size for the FLAMO module. matrix_type : str ``"orthogonal"`` or ``"random"``. device : torch device or None Device; default is cuda if available else cpu. dtype : torch.dtype or None Module dtype; defaults to float32. alias_decay_db : float FLAMO alias decay in dB. requires_grad : bool Whether the matrix is trainable. Returns ------- flamo.processor.dsp.Matrix Matrix whose realized value (``map(param)``) equals ``values`` (within the parametrization; an SO(N) projection may apply for orthogonal). """ if not _HAS_FLAMO: raise ImportError("matrix_module requires flamo (pip install flamo)") import torch values = np.asarray(values, dtype=np.float64) if values.ndim != 2 or values.shape[0] != values.shape[1]: raise ValueError("matrix values must be square (N, N)") n = values.shape[0] dev = _get_device(device) torch_dtype = torch.float32 if dtype is None else dtype matrix = dsp.Matrix( size=(n, n), nfft=nfft, matrix_type=matrix_type, requires_grad=requires_grad, alias_decay_db=alias_decay_db, device=dev, dtype=torch_dtype, ) preimage = _matrix_preimage(values, matrix_type) matrix.assign_value(torch.as_tensor(preimage, dtype=torch_dtype, device=dev)) return matrix
[docs] def assemble_fdn_core( *, input_gain: Any, feedback: Any, delays: Any, output_gain: Any, direct: Any = None, loop_filter: Any = None, output_filter: Any = None, post_delay_module: Any = None, ) -> Any: """ Wire pre-built FLAMO modules into an FDN core (no FFT/iFFT wrapping). Single source of truth for the FDN signal flow, shared by the render path (:func:`pyFDN.dss_to_flamo`) and the training builder (:func:`pyFDN.train.trainable_from_build`). All arguments are already-built FLAMO ``dsp``/``system`` modules; this only composes them, so leaf names and topology stay identical across both callers (and match the names :func:`pyFDN.extract_build` looks for). Signal flow:: input_gain -> [recursion: delay -> (loop_filter) -> (post_delay_module); fB = feedback] -> output_gain -> (output_filter) with the direct path ``direct`` summed in parallel when provided. Parameters ---------- input_gain, output_gain : FLAMO modules Input gain ``B`` (named ``input_gain``) and output gain ``C`` (named ``output_gain``). feedback : FLAMO module Feedback matrix placed on the recursion feedback branch (``fB``); a plain ``Gain``/``Filter`` (render) or a parametrized ``Matrix`` (training). delays : FLAMO module Delay module on the recursion forward branch (named ``delay``). direct : FLAMO module or None Direct path ``D``. When ``None`` the core is the plain feedforward ``Series`` (no ``Parallel`` wrapper) -- this keeps ``core.feedback_loop`` reachable for losses such as ``sparsity_loss``. When provided the core is ``Parallel(brA=fdn_branch, brB=direct)``. loop_filter : FLAMO module or None Optional in-loop filter after the delays (named ``filter``). output_filter : FLAMO module or None Optional per-output filter after the output gain (named ``output_filter``). post_delay_module : FLAMO module or None Optional module appended after the delay in the recursion. Returns ------- core : flamo.processor.system.Series or Parallel The FDN core, ready for :func:`wrap_fdn_shell`. """ if not _HAS_FLAMO: raise ImportError("assemble_fdn_core requires flamo (pip install flamo)") from collections import OrderedDict from flamo.processor import system if loop_filter is not None: delay_chain = system.Series( OrderedDict({"delay": delays, "filter": loop_filter}) ) else: delay_chain = delays if post_delay_module is not None: delay_chain = system.Series( OrderedDict({"delay": delay_chain, "post_delay_module": post_delay_module}) ) feedback_loop = system.Recursion(fF=delay_chain, fB=feedback) fdn_modules = OrderedDict( { "input_gain": input_gain, "feedback_loop": feedback_loop, "output_gain": output_gain, } ) if output_filter is not None: fdn_modules["output_filter"] = output_filter fdn_branch = system.Series(fdn_modules) if direct is not None: return system.Parallel(brA=fdn_branch, brB=direct, sum_output=True) return fdn_branch
[docs] def output_layer(output: str, nfft: int, dtype: Any = None) -> Any: """Build the FLAMO output layer for an output domain. ``"time"`` -> ``iFFT`` (time response); ``"magnitude"`` -> ``|.|`` of the frequency response. The single source of truth for the ``output``-string -> layer mapping, shared by :func:`wrap_fdn_shell` (build time) and the training output-domain swap (:func:`pyFDN.train_fdn`) so the two cannot disagree. """ if not _HAS_FLAMO: raise ImportError("output_layer requires flamo (pip install flamo)") import torch torch_dtype = torch.float32 if dtype is None else dtype if output == "time": return dsp.iFFT(nfft, dtype=torch_dtype) if output == "magnitude": return dsp.Transform(transform=torch.abs, dtype=torch_dtype) raise ValueError(f"output must be 'time' or 'magnitude', got {output!r}")
[docs] def wrap_fdn_shell( core: Any, *, nfft: int, dtype: Any = None, output: str = "time" ) -> Any: """ Wrap an FDN core in a FLAMO ``Shell`` with an FFT input layer. Parameters ---------- core : FLAMO module FDN core, e.g. from :func:`assemble_fdn_core`. nfft : int FFT size. dtype : torch.dtype or None Dtype for the FFT/iFFT layers; defaults to float32. output : str Output-domain layer: * ``"time"`` -- ``iFFT`` time response (the render default, matching :func:`pyFDN.dss_to_flamo`). * ``"magnitude"`` -- ``|.|`` of the frequency response, for magnitude-domain losses (e.g. colorless training). Returns ------- flamo.processor.system.Shell """ if not _HAS_FLAMO: raise ImportError("wrap_fdn_shell requires flamo (pip install flamo)") import torch from flamo.processor import dsp, system torch_dtype = torch.float32 if dtype is None else dtype return system.Shell( core=core, input_layer=dsp.FFT(nfft, dtype=torch_dtype), output_layer=output_layer(output, nfft, torch_dtype), )