Source code for ezmsg.simbiophys.line_noise

"""Additive mains (line) noise with slow frequency drift.

Adds a sinusoidal 50/60 Hz interference component to every channel of a signal,
emulating power-line pickup on a recording. The peak frequency wanders slowly
(a bounded random walk) to mimic drift of the recording device's clock relative
to the mains. When ``freq`` is None the stage is a pass-through.

The drifting sinusoid is generated by phase accumulation (see
:func:`ezmsg.simbiophys.oscillator.advance_drifting_sine`) so it stays
continuous across chunks and as the frequency changes.
"""

import ezmsg.core as ez
import numpy as np
import numpy.typing as npt
from array_api_compat import get_namespace
from ezmsg.baseproc import (
    BaseStatefulTransformer,
    BaseTransformerUnit,
    processor_state,
)
from ezmsg.util.messages.axisarray import AxisArray
from ezmsg.util.messages.util import replace

from .oscillator import advance_drifting_sine, freq_drift_step_std


[docs] class LineNoiseSettings(ez.Settings): freq: float | None = None """Mains frequency in Hz (typically 50.0 or 60.0). None disables the stage (pass-through).""" amp: float = 1.0 """Amplitude of the added sinusoid (same units as the input signal), shared across all channels.""" drift_rate: float = 0.002 """Frequency drift rate in Hz per second (RMS wander over 1 s), emulating recording-clock drift. Set to 0 for a perfectly fixed frequency.""" drift_bound: float = 1.5 """The line frequency is clamped to ``freq +/- drift_bound`` Hz.""" seed: int | None = None """Random seed for the frequency-drift walk. If None, uses system entropy."""
[docs] @processor_state class LineNoiseState: ang_freq: npt.NDArray[np.float64] | None = None """Base angular frequency ``2*pi*freq``, shape (1, 1).""" amp: npt.NDArray[np.float64] | None = None """Amplitude, shape (1, 1).""" phase: npt.NDArray[np.float64] | None = None """Accumulated phase carried across chunks, shape (1, 1).""" freq_off: npt.NDArray[np.float64] | None = None """Current frequency offset (Hz) carried across chunks, shape (1, 1).""" step_std: float = 0.0 """Per-sample random-walk std for the drift.""" rng: np.random.Generator | None = None """Random number generator for the drift walk.""" dt: float = 0.0 """Sample period (seconds)."""
[docs] class LineNoiseTransformer(BaseStatefulTransformer[LineNoiseSettings, AxisArray, AxisArray, LineNoiseState]): """Add a slowly frequency-drifting mains sinusoid to every channel. The same single-frequency component is added to all channels (mains pickup is a common source). Timing (sample rate) is taken from the input; the number of channels may vary. When ``freq`` is None the input passes through unchanged. """ def _hash_message(self, message: AxisArray) -> int: time_axis = message.axes.get("time") gain = time_axis.gain if time_axis is not None else 0.0 return hash(gain) def _reset_state(self, message: AxisArray) -> None: if self.settings.freq is None: return time_axis = message.axes.get("time") self._state.dt = time_axis.gain if time_axis is not None else 1.0 self._state.ang_freq = np.array([[2.0 * np.pi * self.settings.freq]], dtype=np.float64) self._state.amp = np.array([[self.settings.amp]], dtype=np.float64) self._state.phase = np.zeros((1, 1), dtype=np.float64) self._state.freq_off = np.zeros((1, 1), dtype=np.float64) self._state.step_std = freq_drift_step_std(self.settings.drift_rate, self._state.dt) self._state.rng = np.random.default_rng(self.settings.seed) def _process(self, message: AxisArray) -> AxisArray: if self.settings.freq is None: return message # pass-through xp = get_namespace(message.data) data = np.asarray(message.data, dtype=np.float64) was_1d = data.ndim == 1 if was_1d: data = data[:, np.newaxis] n_samples = data.shape[0] sine, self._state.phase, self._state.freq_off = advance_drifting_sine( n_samples, self._state.dt, self._state.ang_freq, self._state.amp, self._state.phase, self._state.freq_off, self._state.step_std, self.settings.drift_bound, self._state.rng, ) out = data + sine # sine is (n_samples, 1), broadcasts over channels if was_1d: out = out[:, 0] out_data = xp.asarray(out) if xp is not np else out return replace(message, data=out_data)
[docs] class LineNoiseUnit( BaseTransformerUnit[ LineNoiseSettings, AxisArray, AxisArray, LineNoiseTransformer, ] ): """Unit wrapper for LineNoiseTransformer.""" SETTINGS = LineNoiseSettings