diff --git a/doc/changelog.d/191.added.md b/doc/changelog.d/191.added.md new file mode 100644 index 00000000..c2f92cf2 --- /dev/null +++ b/doc/changelog.d/191.added.md @@ -0,0 +1 @@ +feat: Sound Composer track \ No newline at end of file diff --git a/doc/source/api/sound_composer.rst b/doc/source/api/sound_composer.rst index dc1e5e3c..782f4791 100644 --- a/doc/source/api/sound_composer.rst +++ b/doc/source/api/sound_composer.rst @@ -6,6 +6,7 @@ Sound composer .. autosummary:: :toctree: _autosummary + Track SourceSpectrum SourceBroadbandNoise SourceBroadbandNoiseTwoParameters diff --git a/src/ansys/sound/core/sound_composer/__init__.py b/src/ansys/sound/core/sound_composer/__init__.py index 890654bb..a1ab1fdc 100644 --- a/src/ansys/sound/core/sound_composer/__init__.py +++ b/src/ansys/sound/core/sound_composer/__init__.py @@ -36,8 +36,10 @@ from .source_harmonics import SourceHarmonics from .source_harmonics_two_parameters import SourceHarmonicsTwoParameters from .source_spectrum import SourceSpectrum +from .track import Track __all__ = ( + "Track", "SoundComposerParent", "SourceParent", "SourceControlParent", @@ -48,6 +50,7 @@ "SourceBroadbandNoise", "SourceBroadbandNoiseTwoParameters", "SourceHarmonics", + "SourceHarmonicsTwoParameters", "SourceControlTime", "SourceAudio", ) diff --git a/src/ansys/sound/core/sound_composer/_source_parent.py b/src/ansys/sound/core/sound_composer/_source_parent.py index 2c8be91d..3f371cba 100644 --- a/src/ansys/sound/core/sound_composer/_source_parent.py +++ b/src/ansys/sound/core/sound_composer/_source_parent.py @@ -34,3 +34,7 @@ class SourceParent(SoundComposerParent): def is_source_control_valid(self) -> bool: """Check if the source control is valid.""" return False + + def plot_control(self): + """Plot the source control(s) in a figure.""" + pass diff --git a/src/ansys/sound/core/sound_composer/source_broadband_noise.py b/src/ansys/sound/core/sound_composer/source_broadband_noise.py index f01f2abe..f6272bda 100644 --- a/src/ansys/sound/core/sound_composer/source_broadband_noise.py +++ b/src/ansys/sound/core/sound_composer/source_broadband_noise.py @@ -293,6 +293,29 @@ def plot(self): plt.grid(True) plt.show() + def plot_control(self): + """Plot the source control(s) in a figure.""" + if not self.is_source_control_valid(): + raise PyAnsysSoundException( + "Broadband noise source control is not set. " + f"Use ``{__class__.__name__}.source_control``." + ) + + data = self.source_control.control.data + time = self.source_control.control.time_freq_support.time_frequencies.data + unit = self.source_control.control.unit + name = self.source_control.control.name + unit_str = f" ({unit})" if len(unit) > 0 else "" + name_str = name if len(name) > 0 else "Amplitude" + plt.plot(time, data) + plt.title("Control profile 1") + plt.ylabel(f"{name_str}{unit_str}") + plt.xlabel("Time (s)") + plt.grid(True) + + plt.tight_layout() + plt.show() + def __extract_bbn_info(self) -> tuple[str, float, str, str, list[float]]: """Extract the broadband noise source information. diff --git a/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py b/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py index b245b2bd..85cec586 100644 --- a/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py +++ b/src/ansys/sound/core/sound_composer/source_broadband_noise_two_parameters.py @@ -48,8 +48,8 @@ class SourceBroadbandNoiseTwoParameters(SourceParent): def __init__( self, file: str = "", - control1: SourceControlTime = None, - control2: SourceControlTime = None, + source_control1: SourceControlTime = None, + source_control2: SourceControlTime = None, ): """Class instantiation takes the following parameters. @@ -58,16 +58,16 @@ def __init__( file : str, default: "" Path to the broadband noise with two parameters file. Supported files are text files with the header `AnsysSound_BBN_MultipleParameters`. - control1 : SourceControlTime, default: None + source_control1 : SourceControlTime, default: None First Source control, consisting of the control parameter values over time, to use when generating the sound from this source. - control2 : SourceControlTime, default: None + source_control2 : SourceControlTime, default: None Second source control, consisting of the control parameter values over time, to use when generating the sound from this source. """ super().__init__() - self.source_control1 = control1 - self.source_control2 = control2 + self.source_control1 = source_control1 + self.source_control2 = source_control2 # Define DPF Sound operators. self.__operator_load = Operator(ID_COMPUTE_LOAD_SOURCE_BBN_2PARAMS) @@ -279,8 +279,8 @@ def process(self, sampling_frequency: float = 44100.0): if not self.is_source_control_valid(): raise PyAnsysSoundException( - "At least one source control for broadband noise with two parameters is not set. " - f"Use ``{__class__.__name__}.source_control1`` and/or " + "At least one source control for broadband noise source with two parameters is " + f"not set. Use ``{__class__.__name__}.source_control1`` and/or " f"``{__class__.__name__}.source_control2``." ) @@ -353,6 +353,43 @@ def plot(self): plt.grid(True) plt.show() + def plot_control(self): + """Plot the source control(s) in a figure.""" + if not self.is_source_control_valid(): + raise PyAnsysSoundException( + "At least one source control for broadband noise source with two parameters is " + f"not set. Use ``{__class__.__name__}.source_control1`` and/or " + f"``{__class__.__name__}.source_control2``." + ) + + _, axes = plt.subplots(2, 1, sharex=True) + + data = self.source_control1.control.data + time = self.source_control1.control.time_freq_support.time_frequencies.data + unit = self.source_control1.control.unit + name = self.source_control1.control.name + unit_str = f" ({unit})" if len(unit) > 0 else "" + name_str = name if len(name) > 0 else "Amplitude" + axes[0].plot(time, data) + axes[0].set_title("Control profile 1") + axes[0].set_ylabel(f"{name_str}{unit_str}") + axes[0].grid(True) + + data = self.source_control2.control.data + time = self.source_control2.control.time_freq_support.time_frequencies.data + unit = self.source_control2.control.unit + name = self.source_control2.control.name + unit_str = f" ({unit})" if len(unit) > 0 else "" + name_str = name if len(name) > 0 else "Amplitude" + axes[1].plot(time, data) + axes[1].set_title("Control profile 2") + axes[1].set_ylabel(f"{name_str}{unit_str}") + axes[1].set_xlabel("Time (s)") + axes[1].grid(True) + + plt.tight_layout() + plt.show() + def __extract_bbn_two_parameters_info( self, ) -> tuple[str, float, str, str, tuple[float], str, str, tuple[float]]: diff --git a/src/ansys/sound/core/sound_composer/source_harmonics.py b/src/ansys/sound/core/sound_composer/source_harmonics.py index db0d471a..1f82292f 100644 --- a/src/ansys/sound/core/sound_composer/source_harmonics.py +++ b/src/ansys/sound/core/sound_composer/source_harmonics.py @@ -313,6 +313,29 @@ def plot(self): plt.grid(True) plt.show() + def plot_control(self): + """Plot the source control(s) in a figure.""" + if not self.is_source_control_valid(): + raise PyAnsysSoundException( + "Harmonics source control is not set. " + f"Use ``{__class__.__name__}.source_control``." + ) + + data = self.source_control.control.data + time = self.source_control.control.time_freq_support.time_frequencies.data + unit = self.source_control.control.unit + name = self.source_control.control.name + unit_str = f" ({unit})" if len(unit) > 0 else "" + name_str = name if len(name) > 0 else "Amplitude" + plt.plot(time, data) + plt.title("Control profile 1") + plt.ylabel(f"{name_str}{unit_str}") + plt.xlabel("Time (s)") + plt.grid(True) + + plt.tight_layout() + plt.show() + def __extract_harmonics_info(self) -> tuple[list[float], str, list[float]]: """Extract the harmonics source information. diff --git a/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py b/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py index 61fa0606..04f0e279 100644 --- a/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py +++ b/src/ansys/sound/core/sound_composer/source_harmonics_two_parameters.py @@ -296,8 +296,8 @@ def process(self, sampling_frequency: float = 44100.0): if not self.is_source_control_valid(): raise PyAnsysSoundException( "At least one source control for harmonics source with two parameters is not set. " - f"Use ``{__class__.__name__}.control_rpm`` and/or " - f"``{__class__.__name__}.control2``." + f"Use ``{__class__.__name__}.source_control_rpm`` and/or " + f"``{__class__.__name__}.source_control2``." ) if self.source_harmonics_two_parameters is None: @@ -369,6 +369,43 @@ def plot(self): plt.grid(True) plt.show() + def plot_control(self): + """Plot the source control(s) in a figure.""" + if not self.is_source_control_valid(): + raise PyAnsysSoundException( + "At least one source control for harmonics source with two parameters is not set. " + f"Use ``{__class__.__name__}.source_control_rpm`` and/or " + f"``{__class__.__name__}.source_control2``." + ) + + _, axes = plt.subplots(2, 1, sharex=True) + + data = self.source_control_rpm.control.data + time = self.source_control_rpm.control.time_freq_support.time_frequencies.data + unit = self.source_control_rpm.control.unit + name = self.source_control_rpm.control.name + unit_str = f" ({unit})" if len(unit) > 0 else "" + name_str = name if len(name) > 0 else "Amplitude" + axes[0].plot(time, data) + axes[0].set_title("Control profile 1") + axes[0].set_ylabel(f"{name_str}{unit_str}") + axes[0].grid(True) + + data = self.source_control2.control.data + time = self.source_control2.control.time_freq_support.time_frequencies.data + unit = self.source_control2.control.unit + name = self.source_control2.control.name + unit_str = f" ({unit})" if len(unit) > 0 else "" + name_str = name if len(name) > 0 else "Amplitude" + axes[1].plot(time, data) + axes[1].set_title("Control profile 2") + axes[1].set_ylabel(f"{name_str}{unit_str}") + axes[1].set_xlabel("Time (s)") + axes[1].grid(True) + + plt.tight_layout() + plt.show() + def __extract_harmonics_two_parameters_info( self, ) -> tuple[list[float], str, tuple[float], str, str, tuple[float]]: diff --git a/src/ansys/sound/core/sound_composer/track.py b/src/ansys/sound/core/sound_composer/track.py new file mode 100644 index 00000000..43f14d10 --- /dev/null +++ b/src/ansys/sound/core/sound_composer/track.py @@ -0,0 +1,223 @@ +# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Sound Composer's track.""" +from typing import Union +import warnings + +from ansys.dpf.core import Field +from matplotlib import pyplot as plt +import numpy as np + +from ansys.sound.core.signal_processing import Filter +from ansys.sound.core.signal_utilities.apply_gain import ApplyGain +from ansys.sound.core.sound_composer import SoundComposerParent, SourceParent + +from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning + +# Define the typing Union of all possible source types, as a global variable (for typing only). +AnySourceType = Union[tuple(SourceParent.__subclasses__())] + + +class Track(SoundComposerParent): + """Sound Composer's track class. + + This class creates a track for the Sound Composer. A track is made of a source (including its + source control) and a filter. A tracks allows the synthesis of the source's sound, filtered + with its associated filter. + """ + + def __init__( + self, + name: str = "", + gain: float = 0.0, + source: AnySourceType = None, + filter: Filter = None, + ): + """Class instantiation takes the following parameters. + + Parameters + ---------- + name : str, default: "" + Name of the track. + gain : float, default: 0.0 + Gain of the track, in dB. + source : SourceSpectrum, SourceBroadbandNoise, SourceBroadbandNoiseTwoParameters, \ + SourceHarmonics, SourceHarmonicsTwoParameters or SourceAudio, default: None + Source of the track. + filter : Filter, default: None, + Filter of the track. + """ + super().__init__() + self.name = name + self.gain = gain + self.source = source + self.filter = filter + + def __str__(self) -> str: + """Return the string representation of the object.""" + return ( + f"{self.name if len(self.name) > 0 else "Unnamed track" }\n" + f"\tSource:{f"\n{self.source.__str__()}" if self.source is not None else " Not set"}\n" + f"\tFilter: {"Set" if self.filter is not None else "Not set"}" + ) + + @property + def name(self) -> str: + """Name of the track.""" + return self.__name + + @name.setter + def name(self, string: str): + """Set the track name.""" + self.__name = string + + @property + def gain(self) -> float: + """Track gain in dB. + + Gain in dB to apply to the synthesized signal of the track. + """ + return self.__gain + + @gain.setter + def gain(self, value: float): + """Set the track gain.""" + self.__gain = value + + @property + def source(self) -> AnySourceType: + """Source object associated with the track. + + The source of the track is used to synthesize the corresponding signal. Its type can be + either :class:`SourceSpectrum`, :class:`SourceBroadbandNoise`, + :class:`SourceBroadbandNoiseTwoParameters`, :class:`SourceHarmonics`, + :class:`SourceHarmonicsTwoParameters`, or :class:`SourceAudio`. + """ + return self.__source + + @source.setter + def source(self, obj: AnySourceType): + """Set the track source.""" + if (obj is not None) and (not (isinstance(obj, AnySourceType))): + raise PyAnsysSoundException( + "Specified source must have a valid type (SourceSpectrum, SourceBroadbandNoise, " + "SourceBroadbandNoiseTwoParameters, SourceHarmonics, " + "SourceHarmoncisTwoParameters, or SourceAudio)." + ) + self.__source = obj + + @property + def filter(self) -> Filter: + """Filter object of the track.""" + return self.__filter + + @filter.setter + def filter(self, obj: Filter): + """Set the track filter.""" + if (obj is not None) and (not (isinstance(obj, Filter))): + raise PyAnsysSoundException("Specified filter must be of type Filter.") + self.__filter = obj + + def process(self, sampling_frequency: float = 44100.0): + """Generate the signal of the track, using the current source and filter. + + Parameters + ---------- + sampling_frequency : float, default: 44100.0 + Sampling frequency of the generated sound in Hz. + """ + if sampling_frequency <= 0.0: + raise PyAnsysSoundException("Sampling frequency must be strictly positive.") + + if self.source is None: + raise PyAnsysSoundException(f"Source is not set. Use {__class__.__name__}.source.") + + self.source.process(sampling_frequency) + signal = self.source.get_output() + + if self.filter is not None: + self.filter.signal = signal + self.filter.process() + signal = self.filter.get_output() + + if self.gain != 0.0: + gain_obj = ApplyGain(signal=signal, gain=self.gain, gain_in_db=True) + gain_obj.process() + signal = gain_obj.get_output() + + self._output = signal + + def get_output(self) -> Field: + """Get the generated signal as a DPF field. + + Returns + ------- + Field + Generated signal as a DPF field. + """ + if self._output == None: + warnings.warn( + PyAnsysSoundWarning( + "Output is not processed yet. " + f"Use the {__class__.__name__}.process() method." + ) + ) + return self._output + + def get_output_as_nparray(self) -> np.ndarray: + """Get the generated signal as a NumPy array. + + Returns + ------- + numpy.ndarray + Generated signal as a NumPy array. + """ + output = self.get_output() + + if output == None: + return np.array([]) + + return np.array(output.data) + + def plot(self): + """Plot the resulting signal in a figure.""" + if self._output == None: + raise PyAnsysSoundException( + f"Output is not processed yet. Use the {__class__.__name__}.process() method." + ) + output = self.get_output() + + output_time = output.time_freq_support.time_frequencies.data + + plt.plot(output_time, output.data) + plt.title( + f"{self.name if len(self.name) > 0 else "Generated signal"} " + f"({type(self.source).__name__})" + ) + plt.xlabel("Time (s)") + plt.ylabel(f"Amplitude{f" ({output.unit})" if len(output.unit) > 0 else ""}") + plt.grid(True) + plt.tight_layout() + plt.show() + + self.source.plot_control() diff --git a/tests/tests_sound_composer/test_sound_composer__source_parent.py b/tests/tests_sound_composer/test_sound_composer__source_parent.py index c1f7121e..37413108 100644 --- a/tests/tests_sound_composer/test_sound_composer__source_parent.py +++ b/tests/tests_sound_composer/test_sound_composer__source_parent.py @@ -25,7 +25,13 @@ def test__source_parent_is_source_control_valid(): """Test SourceParent's is_source_control_valid method.""" - control = SourceParent() + source = SourceParent() - result = control.is_source_control_valid() + result = source.is_source_control_valid() assert result is False + + +def test__source_parent_plot_control(): + """Test SourceParent's plot_control method.""" + source = SourceParent() + source.plot_control() diff --git a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py index c48aea57..18da5cf1 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py +++ b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise.py @@ -414,8 +414,8 @@ def test_source_broadband_noise_plot(mock_show): source_control_obj.control = f_source_control source_bbn_obj = SourceBroadbandNoise( - pytest.data_path_sound_composer_bbn_source_in_container, - source_control_obj, + file=pytest.data_path_sound_composer_bbn_source_in_container, + source_control=source_control_obj, ) source_bbn_obj.process() source_bbn_obj.plot() @@ -431,6 +431,44 @@ def test_source_broadband_noise_plot_exceptions(): source_bbn_obj.plot() +@patch("matplotlib.pyplot.show") +def test_source_broadband_noise_plot_control(mock_show): + """Test SourceBroadbandNoise plot_control method.""" + # Create a field to use in a SourceControlTime object. + f_source_control = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + f_source_control.append([1, 3, 6, 10], 1) + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1, 2, 3], 1) + support.time_frequencies = f_time + f_source_control.time_freq_support = support + + # Create a SourceControlTime object. + source_control_obj = SourceControlTime() + source_control_obj.control = f_source_control + + source_bbn_obj = SourceBroadbandNoise( + source_control=source_control_obj, + ) + + source_bbn_obj.plot_control() + + +def test_source_broadband_noise_plot_control_exceptions(): + """Test SourceBroadbandNoise plot method's exception.""" + source_bbn_obj = SourceBroadbandNoise() + with pytest.raises( + PyAnsysSoundException, + match=( + "Broadband noise source control is not set. " + "Use ``SourceBroadbandNoise.source_control``." + ), + ): + source_bbn_obj.plot_control() + + def test_source_broadband_noise___extract_bbn_info(): """Test SourceBroadbandNoise __extract_bbn_info method.""" source_bbn_obj = SourceBroadbandNoise() diff --git a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py index a2c8dfd0..1dc98d77 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py +++ b/tests/tests_sound_composer/test_sound_composer_source_broadband_noise_two_parameters.py @@ -114,8 +114,8 @@ def test_source_broadband_noise_two_parameters___str___all_set(): # source controls. source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters( file=pytest.data_path_sound_composer_bbn_source_2p_in_container, - control1=source_control1, - control2=source_control2, + source_control1=source_control1, + source_control2=source_control2, ) assert str(source_bbn_two_parameters_obj) == EXP_STR_ALL_SET @@ -249,7 +249,7 @@ def test_source_broadband_noise_two_parameters_is_source_control_valid(): assert source_bbn_two_parameters_obj.is_source_control_valid() is True -def test_source_specrum_load_source_bbn_two_parameters(): +def test_source_broadband_noise_two_parameters_load_source_bbn_two_parameters(): """Test SourceBroadbandNoiseTwoParameters load_source_bbn method.""" source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters() source_bbn_two_parameters_obj.load_source_bbn_two_parameters( @@ -297,8 +297,8 @@ def test_source_broadband_noise_two_parameters_process(): # source controls. source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters( file=pytest.data_path_sound_composer_bbn_source_2p_in_container, - control1=source_control1, - control2=source_control2, + source_control1=source_control1, + source_control2=source_control2, ) source_bbn_two_parameters_obj.process() @@ -314,8 +314,8 @@ def test_source_broadband_noise_two_parameters_process_exceptions(): with pytest.raises( PyAnsysSoundException, match=( - "At least one source control for broadband noise with two parameters is not set. " - "Use ``SourceBroadbandNoiseTwoParameters.source_control1`` and/or " + "At least one source control for broadband noise source with two parameters is not " + "set. Use ``SourceBroadbandNoiseTwoParameters.source_control1`` and/or " "``SourceBroadbandNoiseTwoParameters.source_control2``." ), ): @@ -387,8 +387,8 @@ def test_source_broadband_noise_two_parameters_get_output(): # controls. source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters( file=pytest.data_path_sound_composer_bbn_source_2p_in_container, - control1=source_control1, - control2=source_control2, + source_control1=source_control1, + source_control2=source_control2, ) source_bbn_two_parameters_obj.process(sampling_frequency=44100.0) @@ -479,8 +479,8 @@ def test_source_broadband_noise_two_parameters_get_output_as_nparray(): # source controls. source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters( file=pytest.data_path_sound_composer_bbn_source_2p_in_container, - control1=source_control1, - control2=source_control2, + source_control1=source_control1, + source_control2=source_control2, ) source_bbn_two_parameters_obj.process(sampling_frequency=44100.0) @@ -536,12 +536,12 @@ def test_source_broadband_noise_two_parameters_plot(mock_show): source_control2 = SourceControlTime() source_control2.control = f_source_control - # Create a SourceBroadbandNoiseTwoParameters object test source file with less and created - # source controls. + # Create a SourceBroadbandNoiseTwoParameters object with test source file and created source + # controls. source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters( file=pytest.data_path_sound_composer_bbn_source_2p_in_container, - control1=source_control1, - control2=source_control2, + source_control1=source_control1, + source_control2=source_control2, ) source_bbn_two_parameters_obj.process() @@ -561,6 +561,62 @@ def test_source_broadband_noise_two_parameters_plot_exceptions(): source_bbn_two_parameters_obj.plot() +@patch("matplotlib.pyplot.show") +def test_source_broadband_noise_two_parameters_plot_control(mock_show): + """Test SourceBroadbandNoiseTwoParameters plot_control method.""" + # Create a field to use in a SourceControlTime object. + f_source_control = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + f_source_control.append([3, 4, 35, 38], 1) + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1, 2, 3], 1) + support.time_frequencies = f_time + f_source_control.time_freq_support = support + + # Create a first SourceControlTime object. + source_control1 = SourceControlTime() + source_control1.control = f_source_control + + # Create another field to use in a SourceControlTime object. + f_source_control = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + f_source_control.append([9.5, 9, 1, 0.5], 1) + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1, 2, 3], 1) + support.time_frequencies = f_time + f_source_control.time_freq_support = support + + # Create a second SourceControlTime object. + source_control2 = SourceControlTime() + source_control2.control = f_source_control + + # Create a SourceBroadbandNoiseTwoParameters object with created source controls. + source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters( + source_control1=source_control1, + source_control2=source_control2, + ) + + source_bbn_two_parameters_obj.plot_control() + + +def test_source_broadband_noise_two_parameters_plot_control_exceptions(): + """Test SourceBroadbandNoiseTwoParameters plot_control method's exception.""" + source_bbn_two_parameters_obj = SourceBroadbandNoiseTwoParameters() + with pytest.raises( + PyAnsysSoundException, + match=( + "At least one source control for broadband noise source with two parameters is not " + "set. Use ``SourceBroadbandNoiseTwoParameters.source_control1`` and/or " + "``SourceBroadbandNoiseTwoParameters.source_control2``." + ), + ): + source_bbn_two_parameters_obj.plot_control() + + def test_source_broadband_noise_two_parameters___extract_bbn_two_parameters_info(): """Test SourceBroadbandNoiseTwoParameters __extract_bbn_two_parameters_info method.""" source = SourceBroadbandNoiseTwoParameters() diff --git a/tests/tests_sound_composer/test_sound_composer_source_harmonics.py b/tests/tests_sound_composer/test_sound_composer_source_harmonics.py index b1110b15..99febf7a 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_harmonics.py +++ b/tests/tests_sound_composer/test_sound_composer_source_harmonics.py @@ -503,6 +503,42 @@ def test_source_harmonics_plot_exceptions(): source_harmonics_obj.plot() +@patch("matplotlib.pyplot.show") +def test_source_harmonics_plot_control(mock_show): + """Test SourceHarmonics plot_control method.""" + # Create a field to use in a SourceControlTime object. + f_source_control = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + f_source_control.append([500, 2000, 3000, 3500], 1) + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1, 2, 3], 1) + support.time_frequencies = f_time + f_source_control.time_freq_support = support + + # Create a SourceControlTime object. + source_control = SourceControlTime() + source_control.control = f_source_control + + # Create a SourceHarmonics object with the created source control. + source_obj = SourceHarmonics( + source_control=source_control, + ) + + source_obj.plot_control() + + +def test_source_harmonics_plot_control_exceptions(): + """Test SourceHarmonics plot_control method's exception.""" + source_obj = SourceHarmonics() + with pytest.raises( + PyAnsysSoundException, + match="Harmonics source control is not set. Use ``SourceHarmonics.source_control``.", + ): + source_obj.plot_control() + + def test_source_harmonics___extract_harmonics_info(): """Test SourceHarmonics __extract_harmonics_info method.""" source_harmonics_obj = SourceHarmonics() diff --git a/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py b/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py index 4ab0c737..ede05d09 100644 --- a/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py +++ b/tests/tests_sound_composer/test_sound_composer_source_harmonics_two_parameters.py @@ -384,8 +384,8 @@ def test_source_harmonics_two_parameters_process_exceptions(): PyAnsysSoundException, match=( "At least one source control for harmonics source with two parameters is not set. Use " - "``SourceHarmonicsTwoParameters.control_rpm`` and/or " - "``SourceHarmonicsTwoParameters.control2``." + "``SourceHarmonicsTwoParameters.source_control_rpm`` and/or " + "``SourceHarmonicsTwoParameters.source_control2``." ), ): source_obj.process() @@ -672,6 +672,62 @@ def test_source_harmonics_two_parameters_plot_exceptions(): source_obj.plot() +@patch("matplotlib.pyplot.show") +def test_source_harmonics_two_parameters_plot_control(mock_show): + """Test SourceHarmonicsTwoParameters plot_control method.""" + # Create a field to use in a SourceControlTime object. + f_source_control = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + f_source_control.append([500, 1250, 2000, 3000], 1) + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1, 2, 3], 1) + support.time_frequencies = f_time + f_source_control.time_freq_support = support + + # Create a first SourceControlTime object. + source_control_rpm = SourceControlTime() + source_control_rpm.control = f_source_control + + # Create another field to use in a SourceControlTime object. + f_source_control = fields_factory.create_scalar_field( + num_entities=1, location=locations.time_freq + ) + f_source_control.append([9.5, 9, 1, 0.5], 1) + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1, 2, 3], 1) + support.time_frequencies = f_time + f_source_control.time_freq_support = support + + # Create a second SourceControlTime object. + source_control2 = SourceControlTime() + source_control2.control = f_source_control + + # Create a SourceHarmonicsTwoParameters object with created source controls. + source_obj = SourceHarmonicsTwoParameters( + source_control_rpm=source_control_rpm, + source_control2=source_control2, + ) + + source_obj.plot_control() + + +def test_source_harmonics_two_parameters_plot_control_exceptions(): + """Test SourceHarmonicsTwoParameters plot_control method's exception.""" + source_obj = SourceHarmonicsTwoParameters() + with pytest.raises( + PyAnsysSoundException, + match=( + "At least one source control for harmonics source with two parameters is not set. " + "Use ``SourceHarmonicsTwoParameters.source_control_rpm`` and/or " + "``SourceHarmonicsTwoParameters.source_control2``." + ), + ): + source_obj.plot_control() + + def test_source_harmonics_two_parameters___extract_harmonics_two_parameters_info(): """Test SourceHarmonicsTwoParameters __extract_harmonics_two_parameters_info method.""" source = SourceHarmonicsTwoParameters() diff --git a/tests/tests_sound_composer/test_sound_composer_track.py b/tests/tests_sound_composer/test_sound_composer_track.py new file mode 100644 index 00000000..31945ae1 --- /dev/null +++ b/tests/tests_sound_composer/test_sound_composer_track.py @@ -0,0 +1,284 @@ +# Copyright (C) 2023 - 2024 ANSYS, Inc. and/or its affiliates. +# SPDX-License-Identifier: MIT +# +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from unittest.mock import patch + +from ansys.dpf.core import Field, TimeFreqSupport, fields_factory, locations +import numpy as np +import pytest + +from ansys.sound.core._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning +from ansys.sound.core.signal_processing.filter import Filter +from ansys.sound.core.sound_composer import ( + SourceAudio, + SourceBroadbandNoise, + SourceControlSpectrum, + SourceControlTime, + SourceSpectrum, + Track, +) +from ansys.sound.core.spectral_processing.power_spectral_density import PowerSpectralDensity + +REF_ACOUSTIC_POWER = 4e-10 + +EXP_STR_NOT_SET = "Unnamed track\n\tSource: Not set\n\tFilter: Not set" +EXP_STR_ALL_SET = "MyTrack\n\tSource:\nAudio source: Not set\n\tFilter: Set" +EXP_LEVEL_BAND_3RD_250_Hz = 76.05368 +EXP_LEVEL_BAND_3RD_500_Hz = 73.08566 +EXP_LEVEL_BAND_3RD_1000_Hz = 69.42302 + + +def test_track_instantiation_no_arg(): + """Test Track instantiation without arguments.""" + # Test instantiation. + track = Track() + assert isinstance(track, Track) + assert track.name == "" + assert track.gain == 0.0 + assert track.source is None + assert track.filter is None + + +def test_track_instantiation_all_args(): + """Test Track instantiation with all arguments.""" + # Test instantiation. + track = Track(name="track", gain=3.0, source=SourceAudio(), filter=Filter()) + assert isinstance(track, Track) + assert track.name == "track" + assert track.gain == 3.0 + assert isinstance(track.source, SourceAudio) + assert isinstance(track.filter, Filter) + + +def test_track___str___not_set(): + """Test Track __str__ method when nothing is set.""" + track = Track() + assert str(track) == EXP_STR_NOT_SET + + +def test_track___str___all_set(): + """Test Track __str__ method when all data are set.""" + track = Track(name="MyTrack", source=SourceAudio(), filter=Filter()) + assert str(track) == EXP_STR_ALL_SET + + +def test_track_properties(): + """Test Track properties.""" + track = Track() + + # Test name property. + track.name = "track" + assert track.name == "track" + + # Test gain property. + track.gain = 3.0 + assert track.gain == 3.0 + + # Test source property. + track.source = SourceAudio() + assert isinstance(track.source, SourceAudio) + + # Test filter property. + track.filter = Filter() + assert isinstance(track.filter, Filter) + + +def test_track_properties_exceptions(): + """Test Track properties' exceptions.""" + track = Track() + + # Test source setter exception (str instead a valid source type). + with pytest.raises( + PyAnsysSoundException, + match=( + "Specified source must have a valid type \\(SourceSpectrum, SourceBroadbandNoise, " + "SourceBroadbandNoiseTwoParameters, SourceHarmonics, SourceHarmoncisTwoParameters, or " + "SourceAudio\\)." + ), + ): + track.source = "InvalidType" + + # Test filter setter exception (str instead Filter). + with pytest.raises(PyAnsysSoundException, match="Specified filter must be of type Filter."): + track.filter = "InvalidType" + + +def test_track_process(): + """Test Track process method (no resample needed).""" + track = Track( + gain=3.0, + source=SourceSpectrum( + file_source=pytest.data_path_sound_composer_spectrum_source_in_container, + source_control=SourceControlSpectrum(duration=3.0, method=1), + ), + filter=Filter(a_coefficients=[1.0], b_coefficients=[1.0, 0.5]), + ) + track.process() + assert track._output is not None + + +def test_track_process_exceptions(): + """Test Track process method exceptions.""" + # Test process method exception1 (source not set). + track = Track() + with pytest.raises( + PyAnsysSoundException, + match="Source is not set. Use Track.source.", + ): + track.process() + + # Test process method exception2 (invalid sampling frequency value). + track = Track() + with pytest.raises( + PyAnsysSoundException, match="Sampling frequency must be strictly positive." + ): + track.process(sampling_frequency=0.0) + + +def test_track_get_output(): + """Test Track get_output method.""" + track = Track( + gain=3.0, + source=SourceSpectrum( + file_source=pytest.data_path_sound_composer_spectrum_source_in_container, + source_control=SourceControlSpectrum(duration=3.0, method=1), + ), + filter=Filter(a_coefficients=[1.0], b_coefficients=[1.0, 0.5]), + ) + track.process(sampling_frequency=44100.0) + + output_signal = track.get_output() + time = output_signal.time_freq_support.time_frequencies.data + fs = 1.0 / (time[1] - time[0]) + assert isinstance(output_signal, Field) + assert fs == pytest.approx(44100.0) + + # Compute the power spectral density over the output signal. + psd = PowerSpectralDensity( + input_signal=output_signal, + fft_size=8192, + window_type="HANN", + window_length=8192, + overlap=0.75, + ) + psd.process() + psd_squared, psd_freq = psd.get_PSD_squared_linear_as_nparray() + delat_f = psd_freq[1] - psd_freq[0] + + # Check the sound power level in the 1/3-octave bands centered at 250, 500, and 1000 Hz. + # Due to the non-deterministic nature of the produced signal, tolerance is set to 3 dB. + # Differences are typically of a few tenths of dB, but can sometimes reach larger values. + psd_squared_250 = psd_squared[ + (psd_freq >= 250 * 2 ** (-1 / 6)) & (psd_freq < 250 * 2 ** (1 / 6)) + ] + level_250 = 10 * np.log10(psd_squared_250.sum() * delat_f / REF_ACOUSTIC_POWER) + assert level_250 == pytest.approx(EXP_LEVEL_BAND_3RD_250_Hz, abs=3.0) + + psd_squared_500 = psd_squared[ + (psd_freq >= 500 * 2 ** (-1 / 6)) & (psd_freq < 500 * 2 ** (1 / 6)) + ] + level_500 = 10 * np.log10(psd_squared_500.sum() * delat_f / REF_ACOUSTIC_POWER) + assert level_500 == pytest.approx(EXP_LEVEL_BAND_3RD_500_Hz, abs=3.0) + + psd_squared_1000 = psd_squared[ + (psd_freq >= 1000 * 2 ** (-1 / 6)) & (psd_freq < 1000 * 2 ** (1 / 6)) + ] + level_1000 = 10 * np.log10(psd_squared_1000.sum() * delat_f / REF_ACOUSTIC_POWER) + assert level_1000 == pytest.approx(EXP_LEVEL_BAND_3RD_1000_Hz, abs=3.0) + + +def test_track_get_output_unprocessed(): + """Test Track get_output method's exception.""" + track = Track() + with pytest.warns( + PyAnsysSoundWarning, + match="Output is not processed yet. Use the Track.process\\(\\) method.", + ): + output_signal = track.get_output() + assert output_signal is None + + +def test_track_get_output_as_nparray(): + """Test Track get_output_as_nparray method.""" + track = Track( + gain=3.0, + source=SourceSpectrum( + file_source=pytest.data_path_sound_composer_spectrum_source_in_container, + source_control=SourceControlSpectrum(duration=3.0, method=1), + ), + filter=Filter(a_coefficients=[1.0], b_coefficients=[1.0, 0.5]), + ) + track.process(sampling_frequency=44100.0) + + output_signal = track.get_output_as_nparray() + assert isinstance(output_signal, np.ndarray) + assert len(output_signal) == pytest.approx(44100.0 * 3.0) + + +def test_track_get_output_as_nparray_unprocessed(): + """Test Track get_output_as_nparray method's exception.""" + track = Track() + with pytest.warns( + PyAnsysSoundWarning, + match="Output is not processed yet. Use the Track.process\\(\\) method.", + ): + output_signal = track.get_output_as_nparray() + assert isinstance(output_signal, np.ndarray) + assert len(output_signal) == 0 + + +@patch("matplotlib.pyplot.show") +def test_track_plot(mock_show): + """Test Track plot method.""" + # We need create a suitable source control first. + f_control = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_control.append([1, 3, 6, 10], 1) + support = TimeFreqSupport() + f_time = fields_factory.create_scalar_field(num_entities=1, location=locations.time_freq) + f_time.append([0, 1, 2, 3], 1) + support.time_frequencies = f_time + f_control.time_freq_support = support + + # Create a SourceControlTime object. + source_control = SourceControlTime() + source_control.control = f_control + + track = Track( + gain=3.0, + source=SourceBroadbandNoise( + file=pytest.data_path_sound_composer_bbn_source_in_container, + source_control=source_control, + ), + filter=Filter(a_coefficients=[1.0], b_coefficients=[1.0, 0.5]), + ) + track.process(sampling_frequency=44100.0) + track.plot() + + +def test_track_plot_exceptions(): + """Test Track plot method's exception.""" + track = Track() + with pytest.raises( + PyAnsysSoundException, + match="Output is not processed yet. Use the Track.process\\(\\) method.", + ): + track.plot()