#!/usr/bin/env python3
from modusa import excp
from modusa.decorators import immutable_property, validate_args_type
from modusa.signals.base import ModusaSignal
from typing import Self, Any
import numpy as np
import matplotlib.pyplot as plt
[docs]
class FrequencyDomainSignal(ModusaSignal):
"""
Represents a 1D signal in the frequency domain.
Note
----
- The class is not intended to be instantiated directly.
Parameters
----------
spectrum : np.ndarray
The frequency-domain representation of the signal (real or complex-valued).
f : np.ndarray
The frequency axis corresponding to the spectrum values. Must match the shape of `spectrum`.
t0 : float, optional
The time (in seconds) corresponding to the origin of this spectral slice. Defaults to 0.0.
title : str, optional
An optional title for display or plotting purposes.
"""
#--------Meta Information----------
_name = "Frequency Domain Signal"
_description = "Represents Frequency Domain Signal"
_author_name = "Ankit Anand"
_author_email = "ankit0.anand0@gmail.com"
_created_at = "2025-07-09"
#----------------------------------
@validate_args_type()
def __init__(self, spectrum: np.ndarray, f: np.ndarray, t0: float | int = 0.0, title: str | None = None):
super().__init__() # Instantiating `ModusaSignal` class
if spectrum.shape != f.shape:
raise excp.InputValueError(f"`spectrum` and `f` shape must match, got {spectrum.shape} and {f.shape}")
self._spectrum = spectrum
self._f = f
self._t0 = float(t0)
self.title = title or self._name # This title will be used as plot title by default
#----------------------
# Properties
#----------------------
@immutable_property("Create a new object instead.")
def spectrum(self) -> np.ndarray:
"""Complex valued spectrum data."""
return self._spectrum
@immutable_property("Create a new object instead.")
def f(self) -> np.ndarray:
"""frequency array of the spectrum."""
return self._f
@immutable_property("Create a new object instead.")
def t0(self) -> np.ndarray:
"""Time origin (in seconds) of this spectral slice, e.g., from a spectrogram frame."""
return self._t0
#----------------------
# Derived Properties
#----------------------
@immutable_property("Create a new object instead.")
def __len__(self) -> int:
return len(self.spectrum)
@immutable_property("Create a new object instead.")
def ndim(self) -> int:
return self.spectrum.ndim
@immutable_property("Create a new object instead.")
def shape(self) -> tuple:
return self.spectrum.shape
#----------------------
# Methods
#----------------------
[docs]
def print_info(self) -> None:
"""Prints info about the audio."""
print("-" * 50)
print(f"{'Title':<20}: {self.title}")
print(f"{'Type':<20}: {self._name}")
print(f"{'Frequency Range':<20}: ({self.f[0]:.2f}, {self.f[-1]:.2f}) Hz")
print("-" * 50)
def __getitem__(self, key: slice) -> Self:
sliced_spectrum = self._spectrum[key]
sliced_f = self._f[key]
return self.__class__(spectrum=sliced_spectrum, f=sliced_f, t0=self.t0, title=self.title)
[docs]
@validate_args_type()
def plot(
self,
ax: plt.Axes | None = None,
fmt: str = "k-",
title: str | None = None,
label: str | None = None,
ylabel: str | None = "Strength",
xlabel: str | None = "Frequency (Hz)",
ylim: tuple[float, float] | None = None,
xlim: tuple[float, float] | None = None,
highlight: list[tuple[float, float]] | None = None,
vlines: list[float] | None = None,
hlines: list[float] | None = None,
show_grid: bool = False,
stem: bool | None = False,
legend_loc: str | None = None,
) -> plt.Figure | None:
"""
Plot the audio waveform using matplotlib.
.. code-block:: python
from modusa.generators import AudioSignalGenerator
audio_example = AudioSignalGenerator.generate_example()
audio_example.plot(color="orange", title="Example Audio")
Parameters
----------
ax : matplotlib.axes.Axes | None
Pre-existing axes to plot into. If None, a new figure and axes are created.
fmt : str | None
Format of the plot as per matplotlib standards (Eg. "k-" or "blue--o)
title : str | None
Plot title. Defaults to the signal’s title.
label: str | None
Label for the plot, shown as legend.
ylabel : str | None
Label for the y-axis. Defaults to `"Strength"`.
xlabel : str | None
Label for the x-axis. Defaults to `"Frequency (Hz)"`.
ylim : tuple[float, float] | None
Limits for the y-axis.
xlim : tuple[float, float] | None
highlight : list[tuple[float, float]] | None
List of frequency intervals to highlight on the plot, each as (start, end).
vlines: list[float]
List of x values to draw vertical lines. (Eg. [10, 13.5])
hlines: list[float]
List of y values to draw horizontal lines. (Eg. [10, 13.5])
show_grid: bool
If true, shows grid.
stem : bool
If True, use a stem plot instead of a continuous line. Autorejects if signal is too large.
legend_loc : str | None
If provided, adds a legend at the specified location (e.g., "upper right" or "best").
Limits for the x-axis.
Returns
-------
matplotlib.figure.Figure | None
The figure object containing the plot or None in case an axis is provided.
"""
from modusa.tools.plotter import Plotter
title = title or self.title
fig: plt.Figure | None = Plotter.plot_signal(
y=self.spectrum,
x=self.f,
ax=ax,
fmt=fmt,
title=title,
label=label,
ylabel=ylabel,
xlabel=xlabel,
ylim=ylim,
xlim=xlim,
highlight=highlight,
vlines=vlines,
hlines=hlines,
show_grid=show_grid,
stem=stem,
legend_loc=legend_loc,
)
return fig
#----------------------------
# Math ops
#----------------------------
def __array__(self, dtype=None):
return np.asarray(self.spectrum, dtype=dtype)
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
if method == "__call__":
input_arrays = [x.spectrum if isinstance(x, self.__class__) else x for x in inputs]
result = ufunc(*input_arrays, **kwargs)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
return NotImplemented
def __add__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.add(self.spectrum, other_data)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __radd__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.add(other_data, self.spectrum)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __sub__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.subtract(self.spectrum, other_data)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __rsub__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.subtract(other_data, self.spectrum)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __mul__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.multiply(self.spectrum, other_data)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __rmul__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.multiply(other_data, self.spectrum)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __truediv__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.divide(self.spectrum, other_data)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __rtruediv__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.divide(other_data, self.spectrum)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __floordiv__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.floor_divide(self.spectrum, other_data)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __rfloordiv__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.floor_divide(other_data, self.spectrum)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __pow__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.power(self.spectrum, other_data)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __rpow__(self, other):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.power(other_data, self.spectrum)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
def __abs__(self):
other_data = other.spectrum if isinstance(other, self.__class__) else other
result = MathOps.abs(self.spectrum)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
#--------------------------
# Other signal ops
#--------------------------
[docs]
def abs(self) -> Self:
"""Compute the element-wise abs of the signal data."""
result = MathOps.abs(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def sin(self) -> Self:
"""Compute the element-wise sine of the signal data."""
result = MathOps.sin(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def cos(self) -> Self:
"""Compute the element-wise cosine of the signal data."""
result = MathOps.cos(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def exp(self) -> Self:
"""Compute the element-wise exponential of the signal data."""
result = MathOps.exp(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def tanh(self) -> Self:
"""Compute the element-wise hyperbolic tangent of the signal data."""
result = MathOps.tanh(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def log(self) -> Self:
"""Compute the element-wise natural logarithm of the signal data."""
result = MathOps.log(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def log1p(self) -> Self:
"""Compute the element-wise natural logarithm of (1 + signal data)."""
result = MathOps.log1p(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def log10(self) -> Self:
"""Compute the element-wise base-10 logarithm of the signal data."""
result = MathOps.log10(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
[docs]
def log2(self) -> Self:
"""Compute the element-wise base-2 logarithm of the signal data."""
result = MathOps.log2(self.y)
return self.__class__(spectrum=result, f=self.f, t0=self.t0, title=self.title)
#--------------------------
# Aggregation signal ops
#--------------------------
[docs]
def mean(self) -> "np.generic":
"""Compute the mean of the signal data."""
return MathOps.mean(self.spectrum)
[docs]
def std(self) -> "np.generic":
"""Compute the standard deviation of the signal data."""
return MathOps.std(self.spectrum)
[docs]
def min(self) -> "np.generic":
"""Compute the minimum value in the signal data."""
return MathOps.min(self.spectrum)
[docs]
def max(self) -> "np.generic":
"""Compute the maximum value in the signal data."""
return MathOps.max(self.spectrum)
[docs]
def sum(self) -> "np.generic":
"""Compute the sum of the signal data."""
return MathOps.sum(self.spectrum)
#-----------------------------------
# Repr
#-----------------------------------
def __str__(self):
cls = self.__class__.__name__
data = self.spectrum
arr_str = np.array2string(
data,
separator=", ",
threshold=50, # limit number of elements shown
edgeitems=3, # show first/last 3 rows and columns
max_line_width=120, # avoid wrapping
formatter={'float_kind': lambda x: f"{x:.4g}"}
)
return f"Signal({arr_str}, shape={data.shape}, type={cls})"
def __repr__(self):
cls = self.__class__.__name__
data = self.spectrum
arr_str = np.array2string(
data,
separator=", ",
threshold=50, # limit number of elements shown
edgeitems=3, # show first/last 3 rows and columns
max_line_width=120, # avoid wrapping
formatter={'float_kind': lambda x: f"{x:.4g}"}
)
return f"Signal({arr_str}, shape={data.shape}, type={cls})"