Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Tonality ECMA418-2 #208

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
1 change: 1 addition & 0 deletions doc/changelog.d/208.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
feat: Tonality ECMA418-2
1 change: 1 addition & 0 deletions doc/source/api/psychoacoustics.rst
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,4 @@ Psychoacoustics
ToneToNoiseRatio
ToneToNoiseRatioForOrdersOverTime
TonalityDIN45681
TonalityECMA418_2
2 changes: 2 additions & 0 deletions src/ansys/sound/core/psychoacoustics/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from .sharpness import Sharpness
from .spectral_centroid import SpectralCentroid
from .tonality_din_45681 import TonalityDIN45681
from .tonality_ecma_418_2 import TonalityECMA418_2
from .tone_to_noise_ratio import ToneToNoiseRatio
from .tone_to_noise_ratio_for_orders_over_time import ToneToNoiseRatioForOrdersOverTime

Expand All @@ -49,6 +50,7 @@
"FluctuationStrength",
"TonalityDIN45681",
"SpectralCentroid",
"TonalityECMA418_2",
"ToneToNoiseRatioForOrdersOverTime",
"ProminenceRatioForOrdersOverTime",
)
229 changes: 229 additions & 0 deletions src/ansys/sound/core/psychoacoustics/tonality_ecma_418_2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
# Copyright (C) 2023 - 2025 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.

"""Computes ECMA 418-2 tonality."""
import warnings

from ansys.dpf.core import Field, Operator, types
import matplotlib.pyplot as plt
import numpy as np

from . import PsychoacousticsParent
from .._pyansys_sound import PyAnsysSoundException, PyAnsysSoundWarning

# Name of the DPF Sound operator used in this module.
ID_COMPUTE_TONALITY_ECMA_418_2 = "compute_tonality_ecma418_2"


class TonalityECMA418_2(PsychoacousticsParent):
"""Computes ECMA 418-2 tonality.

This class is used to compute the tonality according to the ECMA 418-2 standard (Hearing Model
of Sottek), formerly known as ECMA 74, annex G.
"""

def __init__(self, signal: Field = None):
"""Class instantiation takes the following parameters.

Parameters
----------
signal: Field, default: None
Signal in Pa on which to calculate the tonality, as a DPF field.
"""
super().__init__()
self.signal = signal
self.__operator = Operator(ID_COMPUTE_TONALITY_ECMA_418_2)

def __str__(self):
"""Return the string representation of the object."""
if self._output is None:
str_tonality = "Not processed\n"
else:
str_tonality = f"{self.get_tonality():.2f} tuHMS\n"

return (
f"{__class__.__name__} object.\n"
"Data\n"
f'Signal name: "{self.signal.name}"\n'
f"Tonality: {str_tonality}"
)

@property
def signal(self) -> Field:
"""Signal in Pa, as a DPF field. Default is None."""
return self.__signal

@signal.setter
def signal(self, signal: Field):
"""Set signal."""
if not (isinstance(signal, Field) or signal is None):
raise PyAnsysSoundException("Signal must be specified as a DPF field.")
self.__signal = signal

def process(self):
"""Compute the ECMA 418-2 tonality.

This method calls the appropriate DPF Sound operator.
"""
if self.signal == None:
raise PyAnsysSoundException(
f"No input signal defined. Use ``{__class__.__name__}.signal``."
)

# Connect the operator input(s).
self.__operator.connect(0, self.signal)

# Run the operator.
self.__operator.run()

# Store the operator outputs in a tuple.
self._output = (
self.__operator.get_output(0, types.double),
self.__operator.get_output(1, types.field),
self.__operator.get_output(2, types.field),
)

def get_output(self) -> tuple[float, Field, Field]:
"""Get the ECMA 418-2 tonality data, in a tuple containing data of various types.

Returns
-------
tuple
First element (float) is the ECMA 418-2 tonality, in tuHMS.

Second element (Field) is the ECMA 418-2 tonality over time, in tuHMS.

Third element (Field) is the ECMA 418-2 tone frequency over time, in Hz.
"""
if self._output == None:
warnings.warn(
PyAnsysSoundWarning(
f"Output is not processed yet. "
f"Use the ``{__class__.__name__}.process()`` method."
)
)

return self._output

def get_output_as_nparray(self) -> tuple[float, np.ndarray, np.ndarray]:
"""Get the ECMA 418-2 tonality data, in a tuple of NumPy arrays.

Returns
-------
tuple[numpy.ndarray]
First element is the ECMA 418-2 tonality, in tuHMS.

Second element is the ECMA 418-2 tonality over time, in tuHMS.

Third element is the ECMA 418-2 tone frequency over time, in Hz.

Fourth element is the associated time scale, in s.
"""
output = self.get_output()

if output == None:
return (
np.nan,
np.array([]),
np.array([]),
np.array([]),
)

return (
np.array(output[0]),
np.array(output[1].data),
np.array(output[2].data),
np.array(output[1].time_freq_support.time_frequencies.data),
)

def get_tonality(self) -> float:
"""Get the ECMA 418-2 tonality, in tuHMS.

Returns
-------
float
ECMA 418-2 tonality, in tuHMS.
"""
return self.get_output_as_nparray()[0]

def get_tonality_over_time(self) -> np.ndarray:
"""Get the ECMA 418-2 tonality over time, in tuHMS.

Returns
-------
numpy.ndarray
ECMA 418-2 tonality over time, in tuHMS.
"""
return self.get_output_as_nparray()[1]

def get_tone_frequency_over_time(self) -> np.ndarray:
"""Get the ECMA 418-2 tone frequency over time, in Hz.

Returns
-------
numpy.ndarray
ECMA 418-2 tone frequency over time, in Hz.
"""
return self.get_output_as_nparray()[2]

def get_time_scale(self) -> np.ndarray:
"""Get the ECMA 418-2 time scale, in s.

Returns
-------
numpy.ndarray
Array of the computation times, in seconds, of the ECMA 418-2 tonality over time.
"""
return self.get_output_as_nparray()[3]

def plot(self):
"""Plot the ECMA 418-2's tonality and tone frequency over time.

This method creates a figure window that displays the tonality in dB
and the tone frequency in Hz, over time.
"""
if self._output == None:
raise PyAnsysSoundException(
f"Output is not processed yet. Use the ``{__class__.__name__}.process()`` method."
)

# Get data to plot
tonality_over_time = self.get_tonality_over_time()
ft_over_time = self.get_tone_frequency_over_time()
time_scale_tonality = self.get_time_scale()
time_scale_ft = self.get_output()[2].time_freq_support.time_frequencies.data

# Plot ECMA 418-2 parameters over time.
_, axes = plt.subplots(2, 1, sharex=False)
Copy link
Collaborator

@anshlachamb anshlachamb Jan 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@a-bouth why not sharing X axis?

On the plot you share in the description of the PR, X axes are both vs time, but slightly not synchronized, which looks weird

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by the way, isn't the same time scale (get_time_scale()) shared by both curves ?

If yes: we can use the same, and share it among both plots
If no: why ? and in this case, we probably miss a time_scale getter for the second one

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@anshlachamb I think the question is for me rather than for Antoine

The answer is because both output does not have exactly the same time scale (same behaviour as in SAS)

Concerning the "why" : I have no precise answer. As for the getter I don't know if it's needed, I leave that question to @ansaminard

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"why": I don't know either, I planned to look into the Matlab code, but haven't the chance to do it yet. Anyway, whatever I find there, I doubt it'll have an immediate impact here, since @a-bouth says it's also the case in SAS

axes[0].plot(time_scale_tonality, tonality_over_time)
axes[0].set_title("ECMA418-2 psychoacoustic tonality")
axes[0].set_ylabel(r"T ($\mathregular{tu_{HMS}})$")
axes[0].grid(True)

axes[1].plot(time_scale_ft, ft_over_time)
axes[1].set_title("ECMA418-2 tone frequency")
axes[1].set_ylabel(r"$\mathregular{f_{ton}}$ (Hz)")
axes[1].grid(True)
axes[1].set_xlabel("Time (s)")

plt.tight_layout()
plt.show()
Loading
Loading