diff --git a/docs/pyttadoc/ecma418.rst b/docs/pyttadoc/ecma418.rst new file mode 100644 index 0000000..3ab3f06 --- /dev/null +++ b/docs/pyttadoc/ecma418.rst @@ -0,0 +1,7 @@ +.. currentmodule:: pytta + +ECMA-418-1 +========== + +.. automodule:: pytta.ecma418 + :members: diff --git a/docs/pyttadoc/index.rst b/docs/pyttadoc/index.rst index ca3da8d..79e6ad3 100644 --- a/docs/pyttadoc/index.rst +++ b/docs/pyttadoc/index.rst @@ -57,6 +57,10 @@ Modules: * pytta.iso3741: calculations according to the standard; + * pytta.ecma418: + prominent discrete tones according to ECMA-418-1 (tone-to-noise + ratio and prominence ratio); + .. toctree:: classes/index utils/index @@ -64,4 +68,5 @@ Modules: functions generate rooms + ecma418 properties diff --git a/pytta/__init__.py b/pytta/__init__.py index d8740aa..97361ea 100644 --- a/pytta/__init__.py +++ b/pytta/__init__.py @@ -72,6 +72,10 @@ * pytta.iso3741: calculations according to the standard; + * pytta.ecma418: + prominent discrete tones according to ECMA-418-1 (tone-to-noise + ratio and prominence ratio); + You can find usage examples in the toolbox's examples folder. @@ -108,6 +112,7 @@ from . import utils from . import rooms from . import iso3741 +from . import ecma418 from .functions import read_wav, write_wav, merge, split, \ list_devices, print_devices, get_device_from_user,\ @@ -130,6 +135,7 @@ 'generate', 'utils', 'iso3741', + 'ecma418', 'rooms', # Functions diff --git a/pytta/ecma418.py b/pytta/ecma418.py new file mode 100644 index 0000000..41bcd1b --- /dev/null +++ b/pytta/ecma418.py @@ -0,0 +1,330 @@ +""" +PyTTa ECMA-418-1 Prominent Discrete Tones: +------------------------------------------ + +This module implements the two procedures of ECMA-418-1:2024 (3rd edition, +"Psychoacoustic metrics for ITT equipment, Part 1: prominent discrete tones") +for deciding whether a noise contains prominent discrete tones. The same +methods appear in ECMA-74, Annex D. + + * Tone-to-noise ratio, TNR (Clause 11 and Annex A): the power of the tone + over the power of the masking noise in the critical band centred on it; + + * Prominence ratio, PR (Clause 12 and Annex B): the power of the critical + band centred on the tone over the mean power of the two adjacent + critical bands. + +The calculations follow the mean-square formulation of Annexes A and B, which +the standard gives equal footing with the level formulation of Clauses 11 and +12. The spectrum is an averaged power spectrum (Welch method, Hann window, +linear averaging, Clause 7) with a frequency resolution finer than the one the +standard asks for. + +Available functions: + + >>> pytta.ecma418.tone_to_noise_ratio(signal, tone_frequency=None) + >>> pytta.ecma418.prominence_ratio(signal, tone_frequency=None) + >>> pytta.ecma418.critical_band(frequency) + +The audibility requirements (11.8, 12.8) and the screening tests near the +threshold of hearing (Clauses 8 and 9) need a listener or a calibrated level +and are left to the user: the returned 'prominent' flag only says whether the +ratio meets the criterion of 11.5 or 12.6. + +For further information, check the function specific documentation. + +""" + +import numpy as np +from scipy import signal as ss + +from pytta import SignalObj + +# Discrete tone frequency range in which the criteria apply (4.1.1, 11.5, 12.6) +_F_MIN, _F_MAX = 89.1, 11200.0 + +# A spectral line is a tone candidate when it is the highest line within 15 % +# of the critical band centred on it and at least 10 dB above the median line +# of that critical band. The standard leaves the identification of tones to the +# operator (8.2 c); this rule only selects which lines are evaluated. +_CANDIDATE_DB = 10.0 + + +def critical_band(frequency): + """ + Lower and upper edges of the critical band centred on a frequency, + ECMA-418-1, Clause 10. + + The bandwidth is Formula (2), + + Delta f_c = 25 + 75 * [1 + 1.4 * (f0 / 1000)**2]**0.69, + + with arithmetic edges up to 500 Hz (Formulae 4 and 5) and geometric edges + above (Formulae 7 and 8). + + Parameters + ---------- + frequency : float + Centre frequency f0, in hertz. + + Returns + ------- + f1, f2 : float + Lower and upper band-edge frequencies, in hertz. + + """ + bandwidth = 25.0 + 75.0 * (1.0 + 1.4 * (frequency / 1000.0)**2)**0.69 + if frequency <= 500.0: + f1 = frequency - bandwidth / 2 + else: + f1 = (-bandwidth + np.sqrt(bandwidth**2 + 4 * frequency**2)) / 2 + return f1, f1 + bandwidth + + +def _adjacent_band_edges(frequency): + """Lower edge of the lower band and upper edge of the upper band, Formulae (21), (22), + Tables 2 and 3. Below 171,4 Hz the lower band starts at 20 Hz.""" + ft = frequency + if ft <= 171.4: + f1_lower = 20.0 + elif ft <= 1600.0: + f1_lower = -149.5 + 1.001 * ft - 6.90e-5 * ft**2 + else: + f1_lower = 6.8 + 0.806 * ft - 8.20e-6 * ft**2 + if ft <= 1600.0: + f2_upper = 149.5 + 1.035 * ft + 7.70e-5 * ft**2 + else: + f2_upper = 3.3 + 1.215 * ft + 2.16e-5 * ft**2 + return f1_lower, f2_upper + + +def _proximity_spacing(frequency): + """Proximity spacing of two tones in a critical band, Formula (14); infinite from 1 kHz, + where the criterion is always met.""" + if frequency >= 1000.0: + return np.inf + return 21.0 * 10**(1.2 * np.abs(np.log10(frequency / 212.0))**1.8) + + +def _tnr_criterion(frequency): + """TNR criterion of 11.5, Formulae (12) and (13); NaN outside 89,1 Hz to 11 200 Hz.""" + if not _F_MIN <= frequency < _F_MAX: + return np.nan + return 8.0 + 8.33 * np.log10(1000.0 / frequency) if frequency < 1000.0 else 8.0 + + +def _pr_criterion(frequency): + """PR criterion of 12.6, Formulae (25) and (26); NaN outside 89,1 Hz to 11 200 Hz.""" + if not _F_MIN <= frequency < _F_MAX: + return np.nan + return 9.0 + 10.0 * np.log10(1000.0 / frequency) if frequency < 1000.0 else 9.0 + + +def _power_spectrum(signal, resolution): + """Welch power per spectral line (Hann, 50 % overlap, linear averaging), with a line + spacing strictly finer than `resolution`.""" + if not isinstance(signal, SignalObj): + raise TypeError("signal must be a pytta.SignalObj.") + if signal.numChannels != 1: + raise ValueError("signal must have exactly one channel.") + x = np.asarray(signal.timeSignal[:, 0], dtype=float) + fs = signal.samplingRate + nperseg = 2**(int(np.floor(np.log2(fs / resolution))) + 1) + if nperseg > x.size: + raise ValueError(f"A frequency resolution below {resolution:.3g} Hz needs blocks " + f"of {nperseg / fs:.2f} s; the signal lasts {x.size / fs:.2f} s.") + freq, psd = ss.welch(x, fs, window='hann', nperseg=nperseg, + noverlap=nperseg // 2, detrend=False) + return freq, psd * (freq[1] - freq[0]) + + +def _band(freq, f1, f2): + """Lines in [f1, f2), so that contiguous bands never share a line.""" + return slice(*np.searchsorted(freq, [f1, f2])) + + +def _peak(freq, p, frequency): + """Highest line within 15 % of the critical band around `frequency`.""" + f1, f2 = critical_band(frequency) + lines = _band(freq, frequency - 0.075 * (f2 - f1), frequency + 0.075 * (f2 - f1)) + return lines.start + int(np.argmax(p[lines])) + + +def _candidates(freq, p, top): + """Indices of the tone candidates between 89,1 Hz and 11 200 Hz whose bands end below `top`.""" + k = np.flatnonzero((p[1:-1] > p[:-2]) & (p[1:-1] >= p[2:])) + 1 + k = k[(freq[k] >= _F_MIN) & (freq[k] < _F_MAX)] + found = [] + for i in k: + f1, f2 = critical_band(freq[i]) + if f2 > top: + break + half = 0.075 * (f2 - f1) + if p[i] < p[_band(freq, freq[i] - half, freq[i] + half)].max(): + continue + if p[i] >= 10**(_CANDIDATE_DB / 10) * np.median(p[_band(freq, f1, f2)]): + found.append(i) + return np.array(found, dtype=int) + + +def _tone(freq, p, k): + """Tone band end points and tone power above the line joining them, A.2, Formula (A.1).""" + f1, f2 = critical_band(freq[k]) + window = _band(freq, freq[k] - 0.075 * (f2 - f1), freq[k] + 0.075 * (f2 - f1)) + lo, hi = window.start, window.stop - 1 + i1 = lo + int(np.argmin(p[lo:k + 1])) + if i1 == lo: # minimum on the window edge: take the next local minimum beyond it + while i1 > 0 and p[i1 - 1] < p[i1]: + i1 -= 1 + i2 = k + int(np.argmin(p[k:hi + 1])) + if i2 == hi: + while i2 < p.size - 1 and p[i2 + 1] < p[i2]: + i2 += 1 + n = i2 - i1 + 1 + return i1, i2, p[i1:i2 + 1].sum() - n * (p[i1] + p[i2]) / 2 + + +def _tnr(freq, p, k, others): + """TNR of the tone at line k, A.2 to A.6. `others` are the other tone candidates; the + strongest one in the critical band is the secondary tone. Returns the result and whether + the secondary tone is a stronger proximate tone that absorbs this one.""" + f1, f2 = critical_band(freq[k]) + lines = _band(freq, f1, f2) + i1, i2, x_tone = _tone(freq, p, k) + x_tones = x_tone # all tone power removed from the critical band + inside = [j for j in others + if lines.start <= j < lines.stop and not i1 <= j <= i2] + absorbed = False + if inside: + j = max(inside, key=lambda m: p[m]) + x_sec = _tone(freq, p, j)[2] + x_tones += x_sec # the secondary tone always leaves the noise, (A.8) + if abs(freq[j] - freq[k]) < _proximity_spacing(freq[k]): + absorbed = x_sec > x_tone + # perceived as one tone: the powers add, Formula (15) and the text of A.6 + # (Formula A.7 is printed with a minus sign) + x_tone += x_sec + x_noise = (p[lines].sum() - x_tones) \ + * (f2 - f1) / ((lines.stop - lines.start) * (freq[1] - freq[0])) + with np.errstate(divide='ignore', invalid='ignore'): + ratio = 10 * np.log10(x_tone / x_noise) + return _result(freq[k], ratio, _tnr_criterion(freq[k])), absorbed + + +def _pr(freq, p, k): + """PR of the tone at line k, B.2 to B.5.""" + ft = freq[k] + f1m, f2m = critical_band(ft) + f1l, f2u = _adjacent_band_edges(ft) + x_mid = p[_band(freq, f1m, f2m)].sum() + x_low = p[_band(freq, f1l, f1m)].sum() + x_up = p[_band(freq, f2m, f2u)].sum() + if ft <= 171.4: # truncated lower band referred to 100 Hz, Formula (B.8) + x_low *= 100.0 / (f1m - f1l) + ratio = 10 * np.log10(x_mid / (0.5 * (x_low + x_up))) # Formulae (B.7), (B.8) + return _result(ft, ratio, _pr_criterion(ft)) + + +def _result(frequency, ratio, criterion): + return {'frequency': float(frequency), 'ratio': float(ratio), + 'criterion': float(criterion), 'prominent': bool(ratio >= criterion)} + + +def tone_to_noise_ratio(signal, tone_frequency=None, resolution=None): + """ + Tone-to-noise ratio of discrete tones, ECMA-418-1, Clause 11 and Annex A. + + The tone band is bounded by the minimal points on both sides of the tone + peak within 15 % of the critical band (or the next local minimum beyond, + when the minimum falls on that limit), and the tone power is the power + above the line joining them (Formula A.1). The masking noise is the + critical band power minus the tone, scaled from the bandwidth of the + spectral lines to the critical bandwidth (Formula A.2). The strongest + other tone in the critical band is the secondary tone of A.6: it is always + removed from the noise and is added to the tone when closer than the + proximity spacing of Formula (14) (always from 1 kHz). + + Parameters + ---------- + signal : SignalObj + One-channel sound pressure signal. + tone_frequency : float, optional + Frequency of the tone, in hertz. The tone peak is the highest spectral + line within 15 % of the critical band around it. If None, the spectrum + between 89,1 Hz and 11 200 Hz is searched for tone candidates: lines + that are the highest within 15 % of their critical band and at least + 10 dB above the median line of it. A candidate merged into a stronger + proximate tone is not reported on its own. + resolution : float, optional + Upper bound of the spectral line spacing, in hertz. Default 0,25 % of + the tone frequency (of 89,1 Hz when searching), as Clause 7 + recommends for this method. + + Returns + ------- + dict or list of dict + One dict per tone, with keys 'frequency' (tone peak, Hz), 'ratio' + (TNR, dB), 'criterion' (dB, Formulae 12 and 13; NaN outside 89,1 Hz to + 11 200 Hz) and 'prominent' (ratio >= criterion). A list sorted by + frequency when tone_frequency is None. + + """ + f_ref = _F_MIN if tone_frequency is None else tone_frequency + freq, p = _power_spectrum(signal, resolution or 0.0025 * f_ref) + top = freq[-1] + found = _candidates(freq, p, top) + if tone_frequency is not None: + if critical_band(tone_frequency)[1] > top: + raise ValueError("The critical band exceeds half the sampling rate.") + k = _peak(freq, p, tone_frequency) + return _tnr(freq, p, k, [j for j in found if j != k])[0] + results = [] + for k in found: + res, absorbed = _tnr(freq, p, k, [j for j in found if j != k]) + if not absorbed: + results.append(res) + return results + + +def prominence_ratio(signal, tone_frequency=None, resolution=None): + """ + Prominence ratio of discrete tones, ECMA-418-1, Clause 12 and Annex B. + + The ratio of the power in the critical band centred on the tone to the + mean power of the lower and upper contiguous critical bands, with the + outer edges from Formulae (21) and (22). For tones up to 171,4 Hz the + lower band starts at 20 Hz and its power is referred to a 100 Hz + bandwidth (Formula B.8). + + Parameters + ---------- + signal : SignalObj + One-channel sound pressure signal. + tone_frequency : float, optional + Frequency of the tone, in hertz. The tone peak is the highest spectral + line within 15 % of the critical band around it. If None, the spectrum + between 89,1 Hz and 11 200 Hz is searched for tone candidates as in + tone_to_noise_ratio, and every candidate is reported. + resolution : float, optional + Upper bound of the spectral line spacing, in hertz. Default 1 % of the + tone frequency (of 89,1 Hz when searching), Clause 7. + + Returns + ------- + dict or list of dict + One dict per tone, with keys 'frequency' (Hz), 'ratio' (PR, dB), + 'criterion' (dB, Formulae 25 and 26; NaN outside 89,1 Hz to + 11 200 Hz) and 'prominent' (ratio >= criterion). A list sorted by + frequency when tone_frequency is None. + + """ + f_ref = _F_MIN if tone_frequency is None else tone_frequency + freq, p = _power_spectrum(signal, resolution or 0.01 * f_ref) + top = freq[-1] + if tone_frequency is not None: + if _adjacent_band_edges(tone_frequency)[1] > top: + raise ValueError("The upper critical band exceeds half the sampling rate.") + return _pr(freq, p, _peak(freq, p, tone_frequency)) + found = [k for k in _candidates(freq, p, top) + if _adjacent_band_edges(freq[k])[1] <= top] + return [_pr(freq, p, k) for k in found] diff --git a/tests/test_ecma418.py b/tests/test_ecma418.py new file mode 100644 index 0000000..6c4ab18 --- /dev/null +++ b/tests/test_ecma418.py @@ -0,0 +1,128 @@ +"""Tests for pytta.ecma418 (ECMA-418-1:2024, tone-to-noise ratio and prominence ratio). + +Each test pins one rule. The synthetic signals are tones of known amplitude in white +Gaussian noise of known one-sided power spectral density, so the band powers the +standard asks for are known analytically: + + tone power A**2 / 2 + noise in a band N0 * bandwidth, with N0 = 2 * sigma**2 / fs +""" +import numpy as np +import pytest + +import pytta +from pytta import ecma418 + +FS = 48000 + + +def _signal(duration, tones=(), seed=0): + """Unit-variance white noise plus sines given as (frequency, amplitude) pairs.""" + rng = np.random.default_rng(seed) + t = np.arange(int(duration * FS)) / FS + x = rng.standard_normal(t.size) + for freq, amp in tones: + x += amp * np.sin(2 * np.pi * freq * t + rng.uniform(0, 2 * np.pi)) + return pytta.SignalObj(signalArray=x, domain='time', samplingRate=FS) + + +N0 = 2.0 / FS # one-sided PSD of unit-variance white noise + + +def test_band_formulas_match_standard_examples(): + """Clause 10 and 12.2 EXAMPLES: critical bandwidth and band edges; 11.6 EXAMPLE: proximity.""" + f1, f2 = ecma418.critical_band(1000.0) + print('Delta f_c(1000) =', f2 - f1, ' f1,M =', f1, ' f2,M =', f2) + assert f2 - f1 == pytest.approx(162.2, abs=0.05) + assert f1 == pytest.approx(922.2, abs=0.05) + assert f2 == pytest.approx(1084.4, abs=0.05) + f1, f2 = ecma418.critical_band(500.0) + print('Delta f_c(500) =', f2 - f1) + assert f2 - f1 == pytest.approx(117.3, abs=0.05) + assert (f1 + f2) / 2 == pytest.approx(500.0) # arithmetic edges up to 500 Hz + for fp, expected in ((150.0, 23.0), (850.0, 63.8)): + got = ecma418._proximity_spacing(fp) + print(f'Delta f_prox({fp}) = {got:.2f} (standard {expected})') + assert got == pytest.approx(expected, abs=0.05) + + +def test_prominence_criteria_follow_formulas_12_13_25_26(): + """TNR: 8 dB from 1 kHz, 8 + 8.33 lg(1000/f) below; PR: 9 dB and 9 + 10 lg(1000/f).""" + cases = {100.0: (16.33, 19.0), 500.0: (8.0 + 8.33 * np.log10(2), 9.0 + 10 * np.log10(2)), + 1000.0: (8.0, 9.0), 5000.0: (8.0, 9.0)} + for freq, (tnr, pr) in cases.items(): + got = ecma418._tnr_criterion(freq), ecma418._pr_criterion(freq) + print(f'{freq} Hz: TNR criterion {got[0]:.3f} (expected {tnr:.3f}),' + f' PR criterion {got[1]:.3f} (expected {pr:.3f})') + assert got == pytest.approx((tnr, pr), abs=1e-3) + # outside 89,1 Hz <= f < 11 200 Hz the criteria do not apply (Clause 1, NOTE 1) + assert np.isnan(ecma418._tnr_criterion(11200.0)) + assert np.isnan(ecma418._pr_criterion(80.0)) + + +def test_tnr_of_tone_in_white_noise_is_analytic(): + """TNR = 10 lg[(A^2/2) / (N0 * Delta f_c)], with arithmetic (300 Hz) and geometric (2 kHz) edges.""" + for freq, amp in ((300.0, 0.3), (2000.0, 0.5)): + res = ecma418.tone_to_noise_ratio(_signal(30.0, [(freq, amp)]), tone_frequency=freq) + f1, f2 = ecma418.critical_band(freq) + expected = 10 * np.log10(amp**2 / 2 / (N0 * (f2 - f1))) + print(f'{freq} Hz: TNR {res["ratio"]:.3f} dB, analytic {expected:.3f} dB,' + f' difference {res["ratio"] - expected:+.3f} dB') + assert res['frequency'] == pytest.approx(freq, abs=1.0) + assert res['ratio'] == pytest.approx(expected, abs=0.5) + + +def test_pr_of_tone_in_white_noise_is_analytic(): + """PR = 10 lg X_M - 10 lg[(X_L + X_U)/2], with the 100 Hz lower band rule at 150 Hz.""" + for freq, amp in ((150.0, 0.2), (1500.0, 0.3)): + res = ecma418.prominence_ratio(_signal(30.0, [(freq, amp)]), tone_frequency=freq) + f1m, f2m = ecma418.critical_band(freq) + f1l, f2u = ecma418._adjacent_band_edges(freq) + xm = amp**2 / 2 + N0 * (f2m - f1m) + xl = N0 * (100.0 if freq <= 171.4 else f1m - f1l) + xu = N0 * (f2u - f2m) + expected = 10 * np.log10(xm / (0.5 * (xl + xu))) + print(f'{freq} Hz: PR {res["ratio"]:.3f} dB, analytic {expected:.3f} dB,' + f' difference {res["ratio"] - expected:+.3f} dB') + assert res['ratio'] == pytest.approx(expected, abs=0.5) + + +def test_two_tones_in_a_critical_band_combine_only_when_proximate(): + """Clause 11.6: a secondary tone closer than Delta f_prox adds to the primary; it always leaves the noise.""" + fp, ap, a_s = 600.0, 0.3, 0.2 # Delta f_prox(600) = 40.7 Hz, band about 538 Hz to 662 Hz + f1, f2 = ecma418.critical_band(fp) + noise = N0 * (f2 - f1) + for fs_, combined in ((fp + 25.0, True), (fp - 55.0, False)): + res = ecma418.tone_to_noise_ratio(_signal(30.0, [(fp, ap), (fs_, a_s)]), tone_frequency=fp) + tone = ap**2 / 2 + (a_s**2 / 2 if combined else 0.0) + expected = 10 * np.log10(tone / noise) + print(f'secondary at {fs_} Hz: TNR {res["ratio"]:.3f} dB, analytic {expected:.3f} dB,' + f' difference {res["ratio"] - expected:+.3f} dB') + assert res['ratio'] == pytest.approx(expected, abs=0.5) + + +def test_search_flags_the_tone_and_not_the_noise(): + """With tone_frequency None the spectrum is searched; pure noise yields no prominent tone.""" + noisy = _signal(30.0, seed=1) + for method in (ecma418.tone_to_noise_ratio, ecma418.prominence_ratio): + found = method(noisy) + print(method.__name__, 'on noise: ratios', [round(r['ratio'], 2) for r in found]) + assert not any(r['prominent'] for r in found) + at = method(noisy, tone_frequency=1000.0) + print(method.__name__, 'on noise at 1 kHz:', at) + assert not at['prominent'] + found = [r for r in method(_signal(30.0, [(2000.0, 0.5)], seed=2)) if r['prominent']] + print(method.__name__, 'with a 2 kHz tone:', found) + assert len(found) == 1 and found[0]['frequency'] == pytest.approx(2000.0, abs=1.0) + + +def test_rejects_invalid_input(): + """One-channel SignalObj only, long enough for one FFT block at the required resolution.""" + label = 'SignalObj inspects the caller frame; its first local must not be an array' + two = pytta.SignalObj(signalArray=np.zeros((FS, 2)), domain='time', samplingRate=FS) + with pytest.raises(ValueError): + ecma418.tone_to_noise_ratio(two, tone_frequency=1000.0) + with pytest.raises(ValueError): # 0.25 % of 100 Hz needs blocks longer than 1 s + ecma418.tone_to_noise_ratio(_signal(1.0), tone_frequency=100.0) + with pytest.raises(TypeError): + ecma418.prominence_ratio(np.zeros(FS), tone_frequency=1000.0)