Skip to content

Commit 5cde28e

Browse files
feat: save sound composer project (#248)
Co-authored-by: pyansys-ci-bot <[email protected]>
1 parent 437145b commit 5cde28e

20 files changed

+729
-165
lines changed

doc/changelog.d/248.added.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
feat: save sound composer project

pyproject.toml

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies = [
2626
"matplotlib>=3.8.2,<4",
2727
"platformdirs>=3.6.0",
2828
"requests>=2.30.0",
29+
"scipy>=1.15.2",
2930
]
3031

3132
[project.optional-dependencies]

src/ansys/sound/core/signal_processing/filter.py

+169-58
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@
2424

2525
import warnings
2626

27-
from ansys.dpf.core import Field, Operator
27+
from ansys.dpf.core import Field, Operator, TimeFreqSupport, fields_factory, locations
2828
import matplotlib.pyplot as plt
2929
import numpy as np
30+
import scipy
3031

3132
from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning
3233
from ..signal_processing import SignalProcessingParent
@@ -42,10 +43,14 @@ class Filter(SignalProcessingParent):
4243
This class allows designing, loading, and applying a digital filter to a signal. The filter
4344
coefficients can be provided directly, using the attributes :attr:`b_coefficients` and
4445
:attr:`a_coefficients`, or computed from a specific frequency response function (FRF), using
45-
the methods :meth:`design_FIR_from_FRF` or :meth:`design_FIR_from_FRF_file`. In this latter
46-
case, the filter is designed as a minimum-phase FIR filter, and the filter denominator
46+
the attribute :attr:`frf` or the method :meth:`design_FIR_from_FRF_file`. In this latter case,
47+
the filter is designed as a minimum-phase FIR filter, and the filter denominator
4748
(:attr:`a_coefficients`) is set to 1 as a consequence.
4849
50+
Note that only one filter definition source (coefficients, FRF, or FRF file) can be provided
51+
when instantiating the class. After class instantiation, anytime the coefficients are changed,
52+
the FRF is updated accordingly, and vice versa.
53+
4954
Filtering a signal consists in applying the filter coefficients :math:`b[k]` and :math:`a[k]`
5055
in the following difference equation, with :math:`x[n]` the input signal, and :math:`y[n]` the
5156
output signal:
@@ -66,6 +71,7 @@ def __init__(
6671
b_coefficients: list[float] = None,
6772
a_coefficients: list[float] = None,
6873
sampling_frequency: float = 44100.0,
74+
frf: Field = None,
6975
file: str = "",
7076
signal: Field = None,
7177
):
@@ -74,16 +80,21 @@ def __init__(
7480
Parameters
7581
----------
7682
a_coefficients : list[float], default: None
77-
Denominator coefficients of the filter.
83+
Denominator coefficients of the filter. This is mutually exclusive with parameters
84+
``frf`` and ``file``.
7885
b_coefficients : list[float], default: None
79-
Numerator coefficients of the filter.
86+
Numerator coefficients of the filter. This is mutually exclusive with parameters ``frf``
87+
and ``file``.
8088
sampling_frequency : float, default: 44100.0
8189
Sampling frequency associated with the filter coefficients, in Hz.
90+
frf : Field, default: None
91+
Frequency response function (FRF) of the filter, in dB. This is mutually exclusive with
92+
parameters ``a_coefficients``, ``b_coefficients``, and ``file``.
8293
file : str, default: ""
8394
Path to the file containing the frequency response function (FRF) to load. The text
8495
file shall have the same text format (with the header `AnsysSound_FRF`), as supported
85-
by Ansys Sound SAS. If ``file`` is specified, parameters ``a_coefficients`` and
86-
``b_coefficients`` are ignored.
96+
by Ansys Sound SAS. This is mutually exclusive with parameters ``a_coefficients``,
97+
``b_coefficients``, and ``frf``.
8798
signal : Field, default: None
8899
Signal to filter.
89100
"""
@@ -95,18 +106,28 @@ def __init__(
95106

96107
self.__sampling_frequency = sampling_frequency
97108

98-
if file != "":
99-
if a_coefficients is not None or b_coefficients is not None:
100-
warnings.warn(
101-
PyAnsysSoundWarning(
102-
"Specified parameters a_coefficients and b_coefficients are ignored "
103-
"because FRF file is also specified."
104-
)
105-
)
106-
self.design_FIR_from_FRF_file(file)
107-
else:
109+
# Initialize attributes before processing arguments (because of their mutual dependencies).
110+
self.__a_coefficients = None
111+
self.__b_coefficients = None
112+
self.__frf = None
113+
114+
# Check which filter definition source (coefficients, FRF, or FRF file) is provided (there
115+
# should be less than 2).
116+
is_coefficients_specified = not (a_coefficients is None and b_coefficients is None)
117+
is_frf_specified = frf is not None
118+
is_frf_file_specified = file != ""
119+
if (is_coefficients_specified + is_frf_specified + is_frf_file_specified) > 1:
120+
raise PyAnsysSoundException(
121+
"Only one filter definition source (coefficients, FRF, or FRF file) must be "
122+
"provided. Specify either `a_coefficients` and `b_coefficients`, `frf`, or `file`."
123+
)
124+
elif a_coefficients is not None or b_coefficients is not None:
108125
self.a_coefficients = a_coefficients
109126
self.b_coefficients = b_coefficients
127+
elif frf is not None:
128+
self.frf = frf
129+
elif file != "":
130+
self.design_FIR_from_FRF_file(file)
110131

111132
self.signal = signal
112133

@@ -142,6 +163,9 @@ def a_coefficients(self, coefficients: list[float]):
142163
"""Set filter's denominator coefficients."""
143164
self.__a_coefficients = coefficients
144165

166+
# Update the FRF to match the new coefficients (if both are set).
167+
self.__compute_FRF_from_coefficients()
168+
145169
@property
146170
def b_coefficients(self) -> list[float]:
147171
"""Numerator coefficients of the filter's transfer function."""
@@ -152,6 +176,34 @@ def b_coefficients(self, coefficients: list[float]):
152176
"""Set filter's numerator coefficients."""
153177
self.__b_coefficients = coefficients
154178

179+
# Update the FRF to match the new coefficients (if both are set).
180+
self.__compute_FRF_from_coefficients()
181+
182+
@property
183+
def frf(self) -> Field:
184+
"""Frequency response function (FRF) of the filter.
185+
186+
Contains the response magnitude in dB of the filter as a function of frequency.
187+
"""
188+
return self.__frf
189+
190+
@frf.setter
191+
def frf(self, frf: Field):
192+
"""Set frequency response function."""
193+
if frf is not None:
194+
if not (isinstance(frf, Field)):
195+
raise PyAnsysSoundException("Specified FRF must be provided as a DPF field.")
196+
197+
freq_data = frf.time_freq_support.time_frequencies.data
198+
if len(frf.data) < 2 or len(freq_data) < 2:
199+
raise PyAnsysSoundException(
200+
"Specified FRF must have at least two frequency points."
201+
)
202+
self.__frf = frf
203+
204+
# Update coefficients to match the FRF.
205+
self.__compute_coefficients_from_FRF()
206+
155207
@property
156208
def signal(self) -> Field:
157209
"""Input signal."""
@@ -213,61 +265,30 @@ def design_FIR_from_FRF_file(self, file: str):
213265
self.__operator_load.run()
214266

215267
# Get the output.
216-
frf = self.__operator_load.get_output(0, "field")
217-
218-
# Compute the filter coefficients.
219-
self.design_FIR_from_FRF(frf)
220-
221-
def design_FIR_from_FRF(self, frf: Field):
222-
"""Design a minimum-phase FIR filter from a frequency response function (FRF).
223-
224-
Computes the filter coefficients according to the filter sampling frequency and the
225-
provided FRF data.
226-
227-
.. note::
228-
If the maximum frequency specified in the FRF extends beyond half the filter sampling
229-
frequency, the FRF data is truncated to this frequency. If, on the contrary, the FRF
230-
maximum frequency is lower than half the filter sampling frequency, the FRF is
231-
zero-padded between the two.
232-
233-
Parameters
234-
----------
235-
frf : Field
236-
Frequency response function (FRF).
237-
"""
238-
# Set operator inputs.
239-
self.__operator_design.connect(0, frf)
240-
self.__operator_design.connect(1, self.__sampling_frequency)
241-
242-
# Run the operator.
243-
self.__operator_design.run()
244-
245-
# Get the output.
246-
self.b_coefficients = list(map(float, self.__operator_design.get_output(0, "vec_double")))
247-
self.a_coefficients = list(map(float, self.__operator_design.get_output(1, "vec_double")))
268+
self.frf = self.__operator_load.get_output(0, "field")
248269

249270
def process(self):
250271
"""Filter the signal with the current coefficients."""
251272
# Check input signal.
252273
if self.signal is None:
253274
raise PyAnsysSoundException(
254-
f"Input signal is not set. Use {__class__.__name__}.signal."
275+
f"Input signal is not set. Use `{__class__.__name__}.signal`."
255276
)
256277

257278
if self.a_coefficients is None or len(self.a_coefficients) == 0:
258279
raise PyAnsysSoundException(
259280
"Filter's denominator coefficients (a_coefficients) must be defined and cannot be "
260-
f"empty. Use {__class__.__name__}.a_coefficients, or the methods "
261-
f"{__class__.__name__}.design_FIR_from_FRF() or "
262-
f"{__class__.__name__}.design_FIR_from_FRF_file()."
281+
f"empty. Use `{__class__.__name__}.a_coefficients`, "
282+
f"`{__class__.__name__}.frf`, or the "
283+
f"`{__class__.__name__}.design_FIR_from_FRF_file()` method."
263284
)
264285

265286
if self.b_coefficients is None or len(self.b_coefficients) == 0:
266287
raise PyAnsysSoundException(
267288
"Filter's numerator coefficients (b_coefficients) must be defined and cannot be "
268-
f"empty. Use {__class__.__name__}.b_coefficients, or the methods "
269-
f"{__class__.__name__}.design_FIR_from_FRF() or "
270-
f"{__class__.__name__}.design_FIR_from_FRF_file()."
289+
f"empty. Use `{__class__.__name__}.b_coefficients`, "
290+
f"`{__class__.__name__}.frf`, or the "
291+
f"`{__class__.__name__}.design_FIR_from_FRF_file()` method."
271292
)
272293

273294
# Set operator inputs.
@@ -293,7 +314,7 @@ def get_output(self) -> Field:
293314
warnings.warn(
294315
PyAnsysSoundWarning(
295316
"Output is not processed yet. "
296-
f"Use the {__class__.__name__}.process() method."
317+
f"Use the `{__class__.__name__}.process()` method."
297318
)
298319
)
299320
return self._output
@@ -317,7 +338,7 @@ def plot(self):
317338
"""Plot the filtered signal in a figure."""
318339
if self._output == None:
319340
raise PyAnsysSoundException(
320-
f"Output is not processed yet. Use the {__class__.__name__}.process() method."
341+
f"Output is not processed yet. Use the `{__class__.__name__}.process()` method."
321342
)
322343
output = self.get_output()
323344

@@ -329,3 +350,93 @@ def plot(self):
329350
plt.ylabel("Amplitude")
330351
plt.grid(True)
331352
plt.show()
353+
354+
def plot_FRF(self):
355+
"""Plot the frequency response function (FRF) of the filter."""
356+
if self.frf is None:
357+
raise PyAnsysSoundException(
358+
"Filter's frequency response function (FRF) is not set. Use "
359+
f"`{__class__.__name__}.frf`, or `{__class__.__name__}.a_coefficients` and "
360+
f"`{__class__.__name__}.b_coefficients`, or the "
361+
f"`{__class__.__name__}.design_FIR_from_FRF_file()` method."
362+
)
363+
364+
plt.plot(self.frf.time_freq_support.time_frequencies.data, self.frf.data)
365+
plt.title("Frequency response function (FRF) of the filter")
366+
plt.xlabel("Frequency (Hz)")
367+
plt.ylabel("Magnitude (dB)")
368+
plt.grid(True)
369+
plt.show()
370+
371+
def __compute_coefficients_from_FRF(self):
372+
"""Design a minimum-phase FIR filter from the frequency response function (FRF).
373+
374+
Computes the filter coefficients according to the filter sampling frequency and the
375+
currently set FRF.
376+
377+
.. note::
378+
If the maximum frequency in the FRF extends beyond half the filter sampling frequency,
379+
the FRF data is truncated to this frequency to compute the coefficients. If, on the
380+
contrary, the FRF maximum frequency is lower than half the filter sampling frequency,
381+
the FRF data is zero-padded between the two.
382+
"""
383+
if self.frf is None:
384+
self.__a_coefficients = None
385+
self.__b_coefficients = None
386+
else:
387+
self.__operator_design.connect(0, self.frf)
388+
self.__operator_design.connect(1, self.__sampling_frequency)
389+
390+
self.__operator_design.run()
391+
392+
# Bypass the coefficients setters to avoid infinite loops.
393+
self.__b_coefficients = list(
394+
map(float, self.__operator_design.get_output(0, "vec_double"))
395+
)
396+
self.__a_coefficients = list(
397+
map(float, self.__operator_design.get_output(1, "vec_double"))
398+
)
399+
400+
def __compute_FRF_from_coefficients(self):
401+
"""Compute the frequency response function (FRF) from the filter coefficients.
402+
403+
Computes the FRF from the filter coefficients, using the function ``scipy.signal.freqz()``.
404+
If either the numerator or denominator coefficients are empty or not set, the FRF is set to
405+
``None``.
406+
407+
.. note::
408+
The computed FRF length is equal to the number of coefficients in the filter's
409+
numerator.
410+
"""
411+
if (
412+
self.b_coefficients is None
413+
or self.a_coefficients is None
414+
or len(self.b_coefficients) == 0
415+
or len(self.a_coefficients) == 0
416+
):
417+
self.__frf = None
418+
else:
419+
freq, complex_response = scipy.signal.freqz(
420+
b=self.b_coefficients,
421+
a=self.a_coefficients,
422+
worN=len(self.b_coefficients),
423+
whole=False,
424+
plot=None,
425+
fs=self.__sampling_frequency,
426+
include_nyquist=True,
427+
)
428+
429+
f_freq = fields_factory.create_scalar_field(
430+
num_entities=1, location=locations.time_freq
431+
)
432+
f_freq.append(freq, 1)
433+
434+
frf_support = TimeFreqSupport()
435+
frf_support.time_frequencies = f_freq
436+
437+
# Bypass the FRF setter to avoid infinite loops.
438+
self.__frf = fields_factory.create_scalar_field(
439+
num_entities=1, location=locations.time_freq
440+
)
441+
self.__frf.append(20 * np.log10(abs(complex_response)), 1)
442+
self.__frf.time_freq_support = frf_support

0 commit comments

Comments
 (0)