deepmark-anni 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepMark Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: deepmark-anni
3
+ Version: 0.1.0
4
+ Summary: ANNI (Auditory Nerve Noticeability Index): how noticeable the difference between two audio signals is
5
+ Author: DeepMark Inc.
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/deepmark/anni
8
+ Project-URL: Issues, https://github.com/deepmark/anni/issues
9
+ Keywords: audio,quality,audibility,auditory nerve,neurogram,psychoacoustics,watermarking
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Sound/Audio
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy>=1.21
17
+ Requires-Dist: torch>=2.4.1
18
+ Requires-Dist: soxr>=0.4
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7.0; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ # ANNI: Auditory Nerve Noticeability Index
24
+
25
+ ANNI is a full-reference measure of how noticeable the difference between a reference and a test audio signal is.
26
+ It simulates how the auditory nerve responds to each signal and compares the two responses the way an ideal observer
27
+ would. The score is in d′ units, the standard unit of detectability: **0 means no difference, and larger values mean
28
+ a more noticeable difference.** ANNI is built for small differences near the threshold of hearing, such as audio
29
+ watermarks.
30
+
31
+ > **Status: research prototype (v0.1).** ANNI is calibrated so that D = 1 marks a just-noticeable difference: the
32
+ > point at which listeners in classic laboratory experiments start to detect a change. A score below 1 should
33
+ > therefore mean the difference is hard to hear, even when comparing directly. This has not yet been confirmed by
34
+ > listening tests on real-world audio.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install deepmark-anni
40
+ ```
41
+
42
+ Requires Python 3.9 or newer, PyTorch, NumPy and soxr.
43
+
44
+ For development, install from a clone in editable mode:
45
+
46
+ ```bash
47
+ git clone https://github.com/deepmark/anni.git
48
+ cd anni
49
+ python -m pip install -e ".[test]"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ import soundfile as sf # any audio loader works
56
+ from anni import ANNI
57
+
58
+ reference, sr = sf.read("original.wav")
59
+ test, _ = sf.read("watermarked.wav")
60
+
61
+ anni = ANNI()
62
+ anni(reference, test, sr=sr)
63
+ # e.g. {'D_peak': 1.49, 'D_med': 0.81}
64
+ ```
65
+
66
+ | Score | Meaning |
67
+ |---|---|
68
+ | `D_peak` | how noticeable the difference is at the most noticeable 200 ms moment |
69
+ | `D_med` | how noticeable it is at a typical moment (median over 200 ms windows) |
70
+
71
+ Lower is better. To compare two processed versions, score both against the same reference. A difference between two
72
+ scores can be expressed in dB as 20·log₁₀(D_B / D_A).
73
+
74
+ ## Inputs
75
+
76
+ - **Mono signals** at the same sample rate. The model runs at 44.1 kHz; other rates are resampled to it.
77
+ - **Playback level:** the reference is played at 65 dB SPL, about conversational speech (`ANNI(level=...)` changes
78
+ this). Both signals get the same gain, so a level change in the test signal counts as a difference.
79
+ - **Timing:** a constant delay of up to ±10 ms is removed. If the lengths differ, the longer signal is cut.
80
+ - **Length:** clips from 0.5 s to about 10 s, as for PESQ and ViSQOL. Memory grows with length: a 5 s pair takes about
81
+ 1 s and 1.5 GB on a laptop CPU. On an NVIDIA GPU, use `ANNI(device="cuda")`.
82
+
83
+ ANNI warns whenever it changes the inputs (resampling, cutting, removing a delay or a constant offset between them)
84
+ and when the test signal is polarity-inverted. It also warns when the two signals correlate weakly (below 0.9), for
85
+ example after a speech codec: the scores may then not be meaningful.
86
+
87
+ ## How it works
88
+
89
+ 1. A filter models the outer and middle ear.
90
+ 2. An auditory-nerve model turns each signal into the firing rates of 128 nerve fibres tuned from 80 Hz to 16 kHz:
91
+ cochlear (gammatone) filters, inner-hair-cell rectification and smoothing, and a sigmoid firing-rate curve.
92
+ 3. Two observers compare the firing rates. Each change is weighed against the natural randomness of nerve firing,
93
+ which is what makes loud sounds mask quieter ones. One observer looks at slow changes in firing rate (level and
94
+ colouration), the other at changes in how the rate fluctuates (roughness, flutter).
95
+ 4. The evidence is added up over fibres and over 200 ms windows. `D_peak` is the largest window, `D_med` the median.
96
+
97
+ Four constants set the scale so that classic laboratory thresholds score about D = 1: the threshold of hearing and
98
+ the 1 dB level difference at 1 kHz (following Taal et al. 2012), noise and tone masking (ITU-R BS.1387), and
99
+ amplitude-modulation detection on tones (He et al. 2008) and on noise (O'Connor et al. 2011).
100
+
101
+ A function-by-function walk-through of the code is in [docs.md](https://github.com/deepmark/anni/blob/main/docs.md).
102
+ For a comparison with PESQ and ViSQOL on controlled changes, with audio, see
103
+ [examples/](https://github.com/deepmark/anni/tree/main/examples).
104
+
105
+ ## Evaluation
106
+
107
+ On [ODAQ](https://zenodo.org/records/10405774), an open dataset of music, film sound and speech over music rated by 26
108
+ listeners (210 versions of 30 excerpts; Torcoli et al. 2024), `D_med` orders the versions like the listeners with a
109
+ Spearman correlation of 0.85 (95 % interval 0.79–0.89). That is on par with Zimtohrli (0.83) and above ViSQOL (0.65 in
110
+ audio mode) and PESQ (0.42). On degraded speech, in the TCD-VoIP dataset (Harte et al. 2015; packet loss, clipping,
111
+ echo, competing speakers and noise, rated by 24 listeners), PESQ and ViSQOL follow listeners better.
112
+
113
+ ## Limitations
114
+
115
+ - It compares the signals moment by moment, so it does not suit audio whose timing or pitch has changed
116
+ (time-stretching, pitch-shifting) or that went through a speech codec.
117
+ - It doesn't see energy above 16 kHz or below 80 Hz, very fast fluctuations or fine timing structure.
118
+ - Mono only, normal hearing.
119
+
120
+ ## Credits and licence
121
+
122
+ ANNI is developed by DeepMark Inc. and released under the
123
+ [MIT License](https://github.com/deepmark/anni/blob/main/LICENSE).
124
+
125
+ The auditory-nerve model is adapted from Mark R. Saddler's
126
+ [auditory_machine_learning](https://github.com/msaddler/auditory_machine_learning). If you use ANNI, please also cite:
127
+
128
+ - Saddler, M. R., & McDermott, J. H. (2024). Models optimized for real-world tasks reveal the task-dependent necessity
129
+ of precise temporal coding in hearing. *Nature Communications*.
130
+ [doi:10.1038/s41467-024-54700-5](https://doi.org/10.1038/s41467-024-54700-5)
131
+
132
+ Listening-test data:
133
+
134
+ - Torcoli, M., Wu, C.-W., Dick, S., Williams, P. A., Halimeh, M. M., Wolcott, W., & Habets, E. A. P. (2024). ODAQ: Open
135
+ Dataset of Audio Quality. *Proc. IEEE ICASSP*. [arXiv:2401.00197](https://arxiv.org/abs/2401.00197)
136
+ - Harte, N., Gillen, E., & Hines, A. (2015). TCD-VoIP, a research database of degraded speech for assessing quality in
137
+ VoIP applications. *Proc. QoMEX*.
138
+
139
+ Calibration references:
140
+
141
+ - Taal, C. H., Hendriks, R. C., & Heusdens, R. (2012). A low-complexity spectro-temporal distortion measure for audio
142
+ processing applications. *IEEE Transactions on Audio, Speech, and Language Processing*, 20(5).
143
+ [doi:10.1109/TASL.2012.2184753](https://doi.org/10.1109/TASL.2012.2184753)
144
+ - ITU-R BS.1387: Method for objective measurements of perceived audio quality (PEAQ).
145
+ - He, N., Mills, J. H., Ahlstrom, J. B., & Dubno, J. R. (2008). Age-related differences in the temporal modulation
146
+ transfer function with pure-tone carriers. *The Journal of the Acoustical Society of America*, 124(6), 3841–3849.
147
+ [doi:10.1121/1.2998779](https://doi.org/10.1121/1.2998779)
148
+ - O'Connor, K. N., Johnson, J. S., Niwa, M., Noriega, N. C., Marshall, E. A., & Sutter, M. L. (2011). Amplitude
149
+ modulation detection as a function of modulation frequency and stimulus duration: comparisons between macaques and
150
+ humans. *Hearing Research*, 277, 37–43. [doi:10.1016/j.heares.2011.03.014](https://doi.org/10.1016/j.heares.2011.03.014)
@@ -0,0 +1,128 @@
1
+ # ANNI: Auditory Nerve Noticeability Index
2
+
3
+ ANNI is a full-reference measure of how noticeable the difference between a reference and a test audio signal is.
4
+ It simulates how the auditory nerve responds to each signal and compares the two responses the way an ideal observer
5
+ would. The score is in d′ units, the standard unit of detectability: **0 means no difference, and larger values mean
6
+ a more noticeable difference.** ANNI is built for small differences near the threshold of hearing, such as audio
7
+ watermarks.
8
+
9
+ > **Status: research prototype (v0.1).** ANNI is calibrated so that D = 1 marks a just-noticeable difference: the
10
+ > point at which listeners in classic laboratory experiments start to detect a change. A score below 1 should
11
+ > therefore mean the difference is hard to hear, even when comparing directly. This has not yet been confirmed by
12
+ > listening tests on real-world audio.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ pip install deepmark-anni
18
+ ```
19
+
20
+ Requires Python 3.9 or newer, PyTorch, NumPy and soxr.
21
+
22
+ For development, install from a clone in editable mode:
23
+
24
+ ```bash
25
+ git clone https://github.com/deepmark/anni.git
26
+ cd anni
27
+ python -m pip install -e ".[test]"
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```python
33
+ import soundfile as sf # any audio loader works
34
+ from anni import ANNI
35
+
36
+ reference, sr = sf.read("original.wav")
37
+ test, _ = sf.read("watermarked.wav")
38
+
39
+ anni = ANNI()
40
+ anni(reference, test, sr=sr)
41
+ # e.g. {'D_peak': 1.49, 'D_med': 0.81}
42
+ ```
43
+
44
+ | Score | Meaning |
45
+ |---|---|
46
+ | `D_peak` | how noticeable the difference is at the most noticeable 200 ms moment |
47
+ | `D_med` | how noticeable it is at a typical moment (median over 200 ms windows) |
48
+
49
+ Lower is better. To compare two processed versions, score both against the same reference. A difference between two
50
+ scores can be expressed in dB as 20·log₁₀(D_B / D_A).
51
+
52
+ ## Inputs
53
+
54
+ - **Mono signals** at the same sample rate. The model runs at 44.1 kHz; other rates are resampled to it.
55
+ - **Playback level:** the reference is played at 65 dB SPL, about conversational speech (`ANNI(level=...)` changes
56
+ this). Both signals get the same gain, so a level change in the test signal counts as a difference.
57
+ - **Timing:** a constant delay of up to ±10 ms is removed. If the lengths differ, the longer signal is cut.
58
+ - **Length:** clips from 0.5 s to about 10 s, as for PESQ and ViSQOL. Memory grows with length: a 5 s pair takes about
59
+ 1 s and 1.5 GB on a laptop CPU. On an NVIDIA GPU, use `ANNI(device="cuda")`.
60
+
61
+ ANNI warns whenever it changes the inputs (resampling, cutting, removing a delay or a constant offset between them)
62
+ and when the test signal is polarity-inverted. It also warns when the two signals correlate weakly (below 0.9), for
63
+ example after a speech codec: the scores may then not be meaningful.
64
+
65
+ ## How it works
66
+
67
+ 1. A filter models the outer and middle ear.
68
+ 2. An auditory-nerve model turns each signal into the firing rates of 128 nerve fibres tuned from 80 Hz to 16 kHz:
69
+ cochlear (gammatone) filters, inner-hair-cell rectification and smoothing, and a sigmoid firing-rate curve.
70
+ 3. Two observers compare the firing rates. Each change is weighed against the natural randomness of nerve firing,
71
+ which is what makes loud sounds mask quieter ones. One observer looks at slow changes in firing rate (level and
72
+ colouration), the other at changes in how the rate fluctuates (roughness, flutter).
73
+ 4. The evidence is added up over fibres and over 200 ms windows. `D_peak` is the largest window, `D_med` the median.
74
+
75
+ Four constants set the scale so that classic laboratory thresholds score about D = 1: the threshold of hearing and
76
+ the 1 dB level difference at 1 kHz (following Taal et al. 2012), noise and tone masking (ITU-R BS.1387), and
77
+ amplitude-modulation detection on tones (He et al. 2008) and on noise (O'Connor et al. 2011).
78
+
79
+ A function-by-function walk-through of the code is in [docs.md](https://github.com/deepmark/anni/blob/main/docs.md).
80
+ For a comparison with PESQ and ViSQOL on controlled changes, with audio, see
81
+ [examples/](https://github.com/deepmark/anni/tree/main/examples).
82
+
83
+ ## Evaluation
84
+
85
+ On [ODAQ](https://zenodo.org/records/10405774), an open dataset of music, film sound and speech over music rated by 26
86
+ listeners (210 versions of 30 excerpts; Torcoli et al. 2024), `D_med` orders the versions like the listeners with a
87
+ Spearman correlation of 0.85 (95 % interval 0.79–0.89). That is on par with Zimtohrli (0.83) and above ViSQOL (0.65 in
88
+ audio mode) and PESQ (0.42). On degraded speech, in the TCD-VoIP dataset (Harte et al. 2015; packet loss, clipping,
89
+ echo, competing speakers and noise, rated by 24 listeners), PESQ and ViSQOL follow listeners better.
90
+
91
+ ## Limitations
92
+
93
+ - It compares the signals moment by moment, so it does not suit audio whose timing or pitch has changed
94
+ (time-stretching, pitch-shifting) or that went through a speech codec.
95
+ - It doesn't see energy above 16 kHz or below 80 Hz, very fast fluctuations or fine timing structure.
96
+ - Mono only, normal hearing.
97
+
98
+ ## Credits and licence
99
+
100
+ ANNI is developed by DeepMark Inc. and released under the
101
+ [MIT License](https://github.com/deepmark/anni/blob/main/LICENSE).
102
+
103
+ The auditory-nerve model is adapted from Mark R. Saddler's
104
+ [auditory_machine_learning](https://github.com/msaddler/auditory_machine_learning). If you use ANNI, please also cite:
105
+
106
+ - Saddler, M. R., & McDermott, J. H. (2024). Models optimized for real-world tasks reveal the task-dependent necessity
107
+ of precise temporal coding in hearing. *Nature Communications*.
108
+ [doi:10.1038/s41467-024-54700-5](https://doi.org/10.1038/s41467-024-54700-5)
109
+
110
+ Listening-test data:
111
+
112
+ - Torcoli, M., Wu, C.-W., Dick, S., Williams, P. A., Halimeh, M. M., Wolcott, W., & Habets, E. A. P. (2024). ODAQ: Open
113
+ Dataset of Audio Quality. *Proc. IEEE ICASSP*. [arXiv:2401.00197](https://arxiv.org/abs/2401.00197)
114
+ - Harte, N., Gillen, E., & Hines, A. (2015). TCD-VoIP, a research database of degraded speech for assessing quality in
115
+ VoIP applications. *Proc. QoMEX*.
116
+
117
+ Calibration references:
118
+
119
+ - Taal, C. H., Hendriks, R. C., & Heusdens, R. (2012). A low-complexity spectro-temporal distortion measure for audio
120
+ processing applications. *IEEE Transactions on Audio, Speech, and Language Processing*, 20(5).
121
+ [doi:10.1109/TASL.2012.2184753](https://doi.org/10.1109/TASL.2012.2184753)
122
+ - ITU-R BS.1387: Method for objective measurements of perceived audio quality (PEAQ).
123
+ - He, N., Mills, J. H., Ahlstrom, J. B., & Dubno, J. R. (2008). Age-related differences in the temporal modulation
124
+ transfer function with pure-tone carriers. *The Journal of the Acoustical Society of America*, 124(6), 3841–3849.
125
+ [doi:10.1121/1.2998779](https://doi.org/10.1121/1.2998779)
126
+ - O'Connor, K. N., Johnson, J. S., Niwa, M., Noriega, N. C., Marshall, E. A., & Sutter, M. L. (2011). Amplitude
127
+ modulation detection as a function of modulation frequency and stimulus duration: comparisons between macaques and
128
+ humans. *Hearing Research*, 277, 37–43. [doi:10.1016/j.heares.2011.03.014](https://doi.org/10.1016/j.heares.2011.03.014)
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "deepmark-anni"
7
+ dynamic = ["version"]
8
+ description = "ANNI (Auditory Nerve Noticeability Index): how noticeable the difference between two audio signals is"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "DeepMark Inc." }]
14
+ keywords = ["audio", "quality", "audibility", "auditory nerve", "neurogram", "psychoacoustics", "watermarking"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Topic :: Multimedia :: Sound/Audio",
18
+ "Topic :: Scientific/Engineering",
19
+ ]
20
+
21
+ dependencies = [
22
+ "numpy>=1.21",
23
+ "torch>=2.4.1",
24
+ "soxr>=0.4",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/deepmark/anni"
29
+ Issues = "https://github.com/deepmark/anni/issues"
30
+
31
+ [project.optional-dependencies]
32
+ test = ["pytest>=7.0"]
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.setuptools.dynamic]
38
+ version = { attr = "anni.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """ANNI: Auditory Nerve Noticeability Index."""
2
+ from .measure import ANNI
3
+
4
+ __all__ = ["ANNI"]
5
+ __version__ = "0.1.0"
@@ -0,0 +1,111 @@
1
+ """Adapted from https://github.com/msaddler/auditory_machine_learning, under the following licence:
2
+
3
+ MIT License
4
+
5
+ Copyright (c) 2026 Mark R. Saddler
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
24
+ """
25
+ import math
26
+
27
+ import numpy as np
28
+ import soxr
29
+ import torch
30
+
31
+
32
+ def freq2erb(freq):
33
+ """Frequency in Hz to ERB-number."""
34
+ return 9.2645 * np.sign(freq) * np.log(1 + np.abs(freq) * 0.00437)
35
+
36
+
37
+ def erb2freq(erb):
38
+ """ERB-number to frequency in Hz."""
39
+ return (1.0 / 0.00437) * np.sign(erb) * (np.exp(np.abs(erb) / 9.2645) - 1)
40
+
41
+
42
+ def erbspace(start, stop, num):
43
+ """num frequencies (Hz) evenly spaced on the ERB-number scale."""
44
+ return erb2freq(np.linspace(freq2erb(start), freq2erb(stop), num=num))
45
+
46
+
47
+ def gammatone_filterbank_fir(sr, cfs, fir_dur=0.05, order=4, bw_mult=1.0):
48
+ """Impulse responses of a gammatone filterbank, shape (n_filters, int(sr * fir_dur))."""
49
+ cfs = np.array(cfs).reshape([-1])
50
+ bw = 2 * np.pi * bw_mult * (24.7 + cfs / 9.265)
51
+ wc = 2 * np.pi * cfs
52
+ t = np.arange(0, fir_dur, 1 / sr)
53
+ a = 2 / math.factorial(order - 1) / np.abs(1 / bw ** order + 1 / (bw + 2j * wc) ** order) / sr
54
+ return a[:, None] * t ** (order - 1) * np.exp(-bw[:, None] * t[None, :]) * np.cos(wc[:, None] * t[None, :])
55
+
56
+
57
+ def ihc_lowpass_filter_fir(sr, fir_dur, cutoff=3e3, order=7):
58
+ """Impulse response of the inner-hair-cell low-pass filter (from bez2018model/model_IHC_BEZ2018.c)."""
59
+ sr_bez2018 = 100e3
60
+
61
+ def resample(x):
62
+ return soxr.resample(x, in_rate=sr_bez2018, out_rate=sr, quality="VHQ")
63
+
64
+ n_taps = int(sr_bez2018 * fir_dur)
65
+ while len(resample(np.zeros(n_taps))) % 2 == 0:
66
+ n_taps = n_taps + 1
67
+ impulse = np.zeros(n_taps)
68
+ impulse[0] = 1
69
+ fir = np.zeros(n_taps)
70
+ ihc = np.zeros(order + 1)
71
+ ihcl = np.zeros(order + 1)
72
+ c1LP = (sr_bez2018 - 2 * np.pi * cutoff) / (sr_bez2018 + 2 * np.pi * cutoff)
73
+ c2LP = (np.pi * cutoff) / (sr_bez2018 + 2 * np.pi * cutoff)
74
+ for n in range(n_taps):
75
+ ihc[0] = impulse[n]
76
+ for i in range(order):
77
+ ihc[i + 1] = (c1LP * ihcl[i + 1]) + c2LP * (ihc[i] + ihcl[i])
78
+ ihcl = ihc
79
+ fir[n] = ihc[order]
80
+ fir = fir * np.hanning(n_taps)
81
+ fir = resample(fir)
82
+ fir[fir < 0] = 0
83
+ return fir / fir.sum()
84
+
85
+
86
+ class GradientStableSigmoid(torch.autograd.Function):
87
+ """Sigmoid with a NaN-free gradient (avoids overflow in the rate-level function)."""
88
+
89
+ @staticmethod
90
+ def forward(ctx, x, k, x0):
91
+ ctx.save_for_backward(x, k, x0)
92
+ return 1.0 / (1.0 + torch.exp(-k * (x - x0)))
93
+
94
+ @staticmethod
95
+ def backward(ctx, grad_output):
96
+ x, k, x0 = ctx.saved_tensors
97
+ grad = k * torch.exp(-k * (x - x0))
98
+ grad = grad / (torch.exp(-k * (x - x0)) + 1.0) ** 2
99
+ grad = torch.nan_to_num(grad, nan=0.0, posinf=None, neginf=None)
100
+ return grad_output * grad, None, None
101
+
102
+
103
+ def rate_level(x, rate_spont=0.0, rate_max=250.0, threshold=0.0, dynamic_range=80.0, dynamic_range_interval=0.95):
104
+ """Sigmoid rate-level function: half-wave rectified, low-passed subbands (Pa) -> firing rate (spikes/s)."""
105
+ y_threshold = (1 - dynamic_range_interval) / 2
106
+ k = math.log((1 / y_threshold) - 1) / (dynamic_range / 2)
107
+ x0 = threshold - (math.log((1 / y_threshold) - 1) / (-k))
108
+ k_t = torch.tensor(k, dtype=x.dtype, device=x.device)
109
+ x0_t = torch.tensor(x0, dtype=x.dtype, device=x.device)
110
+ s = GradientStableSigmoid.apply(20 * torch.log10(x / 20e-6), k_t, x0_t)
111
+ return rate_spont + (rate_max - rate_spont) * s
@@ -0,0 +1,287 @@
1
+ """ANNI's computation, in PyTorch.
2
+
3
+ Steps: presentation level (one gain for both signals) -> alignment (constant delay) -> outer/middle-ear filter ->
4
+ auditory-nerve model (polarity-averaged, delay-compensated) -> 1.9 ms bins -> pattern and texture observers ->
5
+ 200 ms windows -> D_peak, D_med.
6
+ """
7
+ import cmath
8
+ import math
9
+ from functools import lru_cache
10
+
11
+ import torch
12
+
13
+ from . import _auditory_model
14
+
15
+ SR = 44100 # the model's input rate
16
+ FS_N = 11025 # nerve output rate (stride 4)
17
+ P_REF = 20e-6 # Pa at 0 dB SPL
18
+ DT = torch.float32
19
+ # calibration constants: internal-noise floor r0 (spikes/s), pattern gain kP, texture gain kS, texture floor e0
20
+ CONSTANTS = dict(r0=66.56840796512564, kP=7.440094426024921, kS=0.05248517778798395, e0=2.3703160271327422e-05)
21
+
22
+
23
+ def next_fast_len(n):
24
+ """Smallest 2^a 3^b 5^c >= n."""
25
+ best = 1 << max(0, (n - 1).bit_length())
26
+ p5 = 1
27
+ while p5 < best:
28
+ p35 = p5
29
+ while p35 < best:
30
+ q = p35
31
+ while q < n:
32
+ q *= 2
33
+ best = min(best, q)
34
+ p35 *= 3
35
+ p5 *= 5
36
+ return best
37
+
38
+
39
+ # ------------------------------------------------------------------------------------------------ presentation
40
+
41
+ def tq(f):
42
+ """Terhardt threshold in quiet (dB SPL)."""
43
+ f = torch.clamp(torch.as_tensor(f, dtype=DT), min=1.0) / 1000.0
44
+ return 3.64 * f ** -0.8 - 6.5 * torch.exp(-0.6 * (f - 3.3) ** 2) + 1e-3 * f ** 4
45
+
46
+
47
+ def ear_filter(x, pad=8192):
48
+ """Zero-phase outer/middle-ear filter: gain clip(-(Tq(f) - Tq(1 kHz)), -100, 20) dB, H(0) = 0; x [..., T]."""
49
+ T = x.shape[-1]
50
+ n = next_fast_len(T + 2 * pad)
51
+ xp = torch.nn.functional.pad(x, (pad, n - T - pad))
52
+ f = torch.fft.rfftfreq(n, 1.0 / SR, dtype=DT)
53
+ H = 10 ** (torch.clamp(-(tq(f) - tq(1000.0)), -100.0, 20.0) / 20.0)
54
+ H[0] = 0.0
55
+ return torch.fft.irfft(torch.fft.rfft(xp, dim=-1) * H.to(device=x.device, dtype=x.dtype), n, dim=-1)[..., pad:pad + T]
56
+
57
+
58
+ def active_rms(x, frame=0.02, rel_db=40.0):
59
+ """RMS over 20 ms frames whose power is within rel_db of the 99th-percentile frame power (mean removed)."""
60
+ x = x - x.mean()
61
+ L = int(SR * frame)
62
+ n = x.shape[-1] // L
63
+ e = (x[:n * L].reshape(n, L) ** 2).mean(1)
64
+ thr = torch.quantile(e, 0.99) * 10 ** (-rel_db / 10)
65
+ return torch.sqrt(e[e > thr].mean())
66
+
67
+
68
+ def pa_gain(x, level=65.0):
69
+ """Pa per digital unit that puts x's active level at `level` dB SPL."""
70
+ return P_REF * 10 ** (level / 20) / active_rms(x)
71
+
72
+
73
+ def shift(y, tau, pad=4096):
74
+ """y(t + tau) by an FFT phase ramp (zero-padded)."""
75
+ T = y.shape[-1]
76
+ N = next_fast_len(T + 2 * pad)
77
+ yp = torch.nn.functional.pad(y, (pad, N - T - pad))
78
+ f = torch.fft.rfftfreq(N, dtype=DT, device=y.device)
79
+ ramp = torch.exp(2j * math.pi * f * torch.as_tensor(tau, dtype=DT))
80
+ return torch.fft.irfft(torch.fft.rfft(yp) * ramp, N)[..., pad:pad + T]
81
+
82
+
83
+ def align(x, y, max_lag=441, min_shift=0.05):
84
+ """Removes a constant delay of up to max_lag samples: integer lag by FFT cross-correlation (largest |c|),
85
+ parabolic sub-sample refinement, phase-ramp shift of y. x and y have equal length. Returns (y, tau, rho), tau
86
+ being the shift applied in samples (0.0 when the delay found is below min_shift and y is left as it is)."""
87
+ n = next_fast_len(2 * x.shape[-1])
88
+ with torch.no_grad():
89
+ c = torch.fft.irfft(torch.conj(torch.fft.rfft(x, n)) * torch.fft.rfft(y, n), n)
90
+ lags = torch.cat([torch.arange(0, max_lag + 1), torch.arange(-max_lag, 0)])
91
+ cv = c[lags % n]
92
+ i = int(torch.argmax(cv.abs()))
93
+ l0 = int(lags[i])
94
+ sgn = 1.0 if float(cv[i]) >= 0 else -1.0
95
+ cm, c0, cp = sgn * float(c[(l0 - 1) % n]), sgn * float(cv[i]), sgn * float(c[(l0 + 1) % n])
96
+ den = cm - 2 * c0 + cp
97
+ tau = l0 + (0.5 * (cm - cp) / den if den != 0 else 0.0)
98
+ rho = float(cv[i]) / math.sqrt(float((x ** 2).sum() * (y ** 2).sum()) + 1e-30)
99
+ if abs(tau) < min_shift:
100
+ return y, 0.0, rho
101
+ return shift(y, tau), float(tau), rho
102
+
103
+
104
+ # ------------------------------------------------------------------------------------------------ auditory nerve
105
+
106
+ def analytic(g):
107
+ """Analytic signal along the last axis."""
108
+ N = g.shape[-1]
109
+ h = torch.zeros(N, dtype=DT)
110
+ if N % 2 == 0:
111
+ h[0] = h[N // 2] = 1
112
+ h[1:N // 2] = 2
113
+ else:
114
+ h[0] = 1
115
+ h[1:(N + 1) // 2] = 2
116
+ return torch.fft.ifft(torch.fft.fft(g, dim=-1) * h, dim=-1)
117
+
118
+
119
+ class Nerve:
120
+ """Saddler's simple auditory-nerve model in a full-band configuration: 44.1 kHz in, 128 ERB-spaced fibres from
121
+ 80 Hz to 16 kHz, 4th-order gammatone filters (1 ERB), IHC low-pass (nominal 3 kHz, order 7), sigmoid rate-level
122
+ function (0 dB SPL threshold, 80 dB range, max 250 spikes/s), 11025 Hz output. FIR filtering by FFT."""
123
+
124
+ def __init__(self, device="cpu"):
125
+ self.device = torch.device(device)
126
+ cfs = _auditory_model.erbspace(80.0, 16000.0, 128)
127
+ g = torch.as_tensor(_auditory_model.gammatone_filterbank_fir(SR, cfs, fir_dur=0.05, bw_mult=1.0), dtype=DT)
128
+ lp = torch.as_tensor(_auditory_model.ihc_lowpass_filter_fir(SR, 0.05, cutoff=3000.0, order=7), dtype=DT)
129
+ self.cfs = torch.as_tensor(cfs, dtype=DT)
130
+ self.g, self.lp = g.to(device), lp.to(device)
131
+ self.stride = SR // FS_N
132
+ tau_g = torch.argmax(analytic(g).abs(), dim=-1).to(DT) / SR # gammatone envelope peak
133
+ tau_l = (torch.arange(len(lp), dtype=DT) * lp).sum() / lp.sum() / SR # low-pass centroid
134
+ self.delays = torch.round((tau_g + tau_l) * FS_N).long() # per-fibre delay, output samples
135
+
136
+ def forward(self, x):
137
+ """x [B, T] (Pa) -> firing rates [B, 128, ceil(T / 4)] (spikes/s)."""
138
+ T = x.shape[-1]
139
+ n1 = next_fast_len(T + self.g.shape[-1] - 1)
140
+ n2 = next_fast_len(T + self.lp.shape[-1] - 1)
141
+ G = torch.fft.rfft(self.g, n=n1)
142
+ LP = torch.fft.rfft(self.lp, n=n2)
143
+ out = []
144
+ for xb in x: # one row at a time keeps the memory down
145
+ y = torch.fft.irfft(torch.fft.rfft(xb, n=n1)[None, :] * G, n=n1)[:, :T]
146
+ y = torch.relu(y)
147
+ y = torch.fft.irfft(torch.fft.rfft(y, n=n2) * LP, n=n2)[:, :T:self.stride]
148
+ out.append(torch.relu(y))
149
+ return _auditory_model.rate_level(torch.stack(out))
150
+
151
+ def compensate(self, r):
152
+ """Removes each fibre's own delay, so all fibres line up in time."""
153
+ cols = []
154
+ for c in range(r.shape[-2]):
155
+ s = int(self.delays[c])
156
+ cols.append(torch.nn.functional.pad(r[..., c, s:], (0, s)))
157
+ return torch.stack(cols, dim=-2)
158
+
159
+ def rates(self, p):
160
+ """p [B, T] pressure (Pa) at 44.1 kHz -> ear filter -> polarity-averaged, delay-compensated rates, returned
161
+ on the CPU for the observers."""
162
+ x = ear_filter(p.to(device=self.device, dtype=DT))
163
+ r = self.forward(torch.cat([x, -x]))
164
+ r = 0.5 * (r[:len(x)] + r[len(x):])
165
+ return self.compensate(r).cpu()
166
+
167
+
168
+ # ------------------------------------------------------------------------------------------------ observers
169
+
170
+ def _bilinear(zs, ps, k, fs):
171
+ fs2 = 2.0 * fs
172
+ zd = [(fs2 + z) / (fs2 - z) for z in zs]
173
+ pd = [(fs2 + p) / (fs2 - p) for p in ps]
174
+ zd += [-1.0] * (len(ps) - len(zs))
175
+ num = 1.0
176
+ for z in zs:
177
+ num *= (fs2 - z)
178
+ den = 1.0
179
+ for p in ps:
180
+ den *= (fs2 - p)
181
+ return zd, pd, k * (num / den).real
182
+
183
+
184
+ def _poly2(roots):
185
+ r1, r2 = roots
186
+ return [1.0, (-(r1 + r2)).real, (r1 * r2).real]
187
+
188
+
189
+ def butter2(kind, wn, fs):
190
+ """One second-order Butterworth section (b, a), as scipy.signal.butter designs it: 'lowpass' (order 2, wn Hz) or
191
+ 'bandpass' (order 1, wn = (lo, hi) Hz)."""
192
+ fsn = 2.0
193
+ warp = lambda w: 2 * fsn * math.tan(math.pi * (2 * w / fs) / fsn) # noqa: E731
194
+ if kind == "lowpass":
195
+ p_proto = [complex(-math.sqrt(0.5), math.sqrt(0.5)), complex(-math.sqrt(0.5), -math.sqrt(0.5))]
196
+ wo = warp(wn)
197
+ zs, ps, k = [], [wo * p for p in p_proto], wo ** 2
198
+ elif kind == "bandpass":
199
+ lo, hi = warp(wn[0]), warp(wn[1])
200
+ bw, wo = hi - lo, math.sqrt(lo * hi)
201
+ disc = cmath.sqrt((-bw / 2) ** 2 - wo ** 2)
202
+ zs, ps, k = [0.0], [-bw / 2 + disc, -bw / 2 - disc], bw
203
+ else:
204
+ raise ValueError(kind)
205
+ zd, pd, kd = _bilinear(zs, ps, k, fsn)
206
+ return [kd * c for c in _poly2(zd)], _poly2(pd)
207
+
208
+
209
+ @lru_cache(maxsize=64)
210
+ def _impulse(sec, n):
211
+ """Impulse response (n samples) of one biquad (b0, b1, b2, a0, a1, a2), from rest."""
212
+ b0, b1, b2, _, a1, a2 = sec
213
+ h = [0.0] * n
214
+ y1 = y2 = 0.0
215
+ for i in range(n):
216
+ y0 = (b0 if i == 0 else b1 if i == 1 else b2 if i == 2 else 0.0) - a1 * y1 - a2 * y2
217
+ h[i] = y0
218
+ y2, y1 = y1, y0
219
+ return tuple(h)
220
+
221
+
222
+ class Observer:
223
+ """Compares two neurograms: the pattern observer (rate changes slower than ~3 Hz) and the texture observer
224
+ (changes in rate fluctuation, octave bands 4-128 Hz), evidence in d'^2 units pooled over fibres and 200 ms."""
225
+
226
+ def __init__(self, cfs, bin_n=21, bands=(4., 8., 16., 32., 64., 128.), win_s=0.1995, pad_s=1.0,
227
+ trim_s=(0.1, 0.05)):
228
+ self.bin_n, self.dt = bin_n, bin_n / FS_N
229
+ fsb = 1.0 / self.dt
230
+ secs = [butter2("lowpass", 2.0 * math.sqrt(2.0), fsb)]
231
+ secs += [butter2("bandpass", (fc / math.sqrt(2), fc * math.sqrt(2)), fsb) for fc in bands]
232
+ self.secs = [tuple(b) + tuple(a) for b, a in secs]
233
+ fk = torch.tensor(bands, dtype=DT)
234
+ self.W = int(round(win_s / self.dt))
235
+ self.padc = int(round(pad_s / self.dt))
236
+ cfs = torch.as_tensor(cfs, dtype=DT)
237
+ self.dz = float(torch.diff(torch.as_tensor(_auditory_model.freq2erb(cfs.numpy()))).mean()) # fibre spacing
238
+ self.mask = (fk[:, None] <= cfs[None, :] / 4.0).to(DT) # a band counts only up to CF / 4
239
+ self.trim_s = trim_s
240
+
241
+ def bin(self, r):
242
+ nb = r.shape[-1] // self.bin_n
243
+ return r[..., :nb * self.bin_n].reshape(*r.shape[:-1], nb, self.bin_n).mean(-1)
244
+
245
+ def split(self, a):
246
+ """Causal filter bank along the bin axis (start-padded with the first bin): [1 + K, ..., nb]."""
247
+ ap = torch.cat([a[..., :1].expand(*a.shape[:-1], self.padc), a], -1)
248
+ L = ap.shape[-1]
249
+ n = next_fast_len(2 * L)
250
+ A = torch.fft.rfft(ap, n)
251
+ out = []
252
+ for sec in self.secs:
253
+ h = torch.tensor(_impulse(sec, L), dtype=a.dtype, device=a.device)
254
+ out.append(torch.fft.irfft(A * torch.fft.rfft(h, n), n)[..., :L][..., self.padc:])
255
+ return torch.stack(out)
256
+
257
+ def movmean(self, a):
258
+ W = self.W
259
+ c = torch.cumsum(torch.nn.functional.pad(a, (1, 0)), -1)
260
+ return (c[..., W:] - c[..., :-W]) / W
261
+
262
+ def valid(self, npos, nb):
263
+ """Windows clear of the trimmed start and end of the signal."""
264
+ t0b, t1b = math.ceil(self.trim_s[0] / self.dt), math.ceil(self.trim_s[1] / self.dt)
265
+ pos = torch.arange(npos)
266
+ return (pos >= t0b) & (pos + self.W <= nb - t1b)
267
+
268
+ def window_d2(self, Rx, Ry, p):
269
+ """Total evidence (d'^2) in each 200 ms window: pattern + texture."""
270
+ rbar = 0.5 * (Rx + Ry)
271
+ db, xb, yb = self.split(Ry - Rx), self.split(Rx), self.split(Ry)
272
+ d2P = p["kP"] * self.dz * self.dt * db[0] ** 2 / (rbar + p["r0"])
273
+ P_w = self.movmean(d2P.sum(0)) * self.W
274
+ mx, my = self.movmean(Rx), self.movmean(Ry)
275
+ ex = self.movmean(xb[1:] ** 2) / (mx + p["r0"]) ** 2
276
+ ey = self.movmean(yb[1:] ** 2) / (my + p["r0"]) ** 2
277
+ lr2 = p["kS"] * self.dz * torch.log((ey + p["e0"]) / (ex + p["e0"])) ** 2 * self.mask[:, :, None]
278
+ return P_w + lr2.sum((0, 1))
279
+
280
+
281
+ def scores(ob, Rx, Ry, p=CONSTANTS):
282
+ """D_peak (most noticeable 200 ms window) and D_med (median window) as tensors."""
283
+ bx, by = ob.bin(Rx), ob.bin(Ry)
284
+ tot = ob.window_d2(bx, by, p)
285
+ ok = ob.valid(len(tot), bx.shape[-1])
286
+ d2 = tot[ok]
287
+ return dict(D_peak=torch.sqrt(d2.max()), D_med=torch.median(torch.sqrt(torch.clamp(d2, min=0))))
@@ -0,0 +1,103 @@
1
+ """The ANNI measure."""
2
+ import warnings
3
+
4
+ import numpy as np
5
+ import soxr
6
+ import torch
7
+
8
+ from . import _core
9
+
10
+ MIN_SECONDS = 0.5
11
+
12
+
13
+ class ANNI:
14
+ """Auditory Nerve Noticeability Index: how noticeable the difference between a reference and a test signal
15
+ is, in d' units (0 = no difference; larger = more noticeable).
16
+
17
+ Args:
18
+ level: playback level of the reference, in dB SPL of its active (non-silent) parts. Default 65 dB SPL,
19
+ about conversational speech.
20
+ device: "cpu" (default) or "cuda".
21
+
22
+ Example:
23
+ >>> anni = ANNI()
24
+ >>> anni(reference, test, sr=44100)
25
+ {'D_peak': 1.49, 'D_med': 0.81}
26
+ """
27
+
28
+ def __init__(self, level=65.0, device="cpu"):
29
+ self.level = float(level)
30
+ if torch.device(device).type not in ("cpu", "cuda"):
31
+ raise ValueError(f'device must be "cpu" or "cuda"; got {device!r}')
32
+ self._nerve = _core.Nerve(device=device)
33
+ self._observer = _core.Observer(self._nerve.cfs)
34
+
35
+ def __call__(self, reference, test, sr):
36
+ """Scores `test` against `reference`.
37
+
38
+ Args:
39
+ reference, test: mono signals (1-D NumPy arrays or torch tensors) at sample rate `sr`.
40
+ sr: sample rate in Hz. The model runs at 44.1 kHz; other rates are resampled, with a warning.
41
+
42
+ Returns:
43
+ {"D_peak": float, "D_med": float}: D_peak is the most noticeable 200 ms moment, D_med the typical one.
44
+ """
45
+ with torch.no_grad():
46
+ px, py = self._prepare(reference, test, sr)
47
+ d = self._forward(px, py)
48
+ return {k: float(v) for k, v in d.items()}
49
+
50
+ def _prepare(self, reference, test, sr):
51
+ """Inputs -> pressure signals (Pa) at 44.1 kHz: offsets removed, equal length, resampling, constant-delay
52
+ alignment, one playback gain for both (so a level change in `test` is kept and scored). Warns about every
53
+ change it makes to the inputs."""
54
+ if not sr > 0:
55
+ raise ValueError(f"sr must be a positive sample rate; got {sr}")
56
+ x, y = self._check(reference, "reference"), self._check(test, "test")
57
+ x, y, dc = x - x.mean(), y - y.mean(), y.mean() - x.mean()
58
+ if len(x) != len(y):
59
+ warnings.warn(f"reference and test differ in length ({len(x)} vs {len(y)} samples); the longer one is "
60
+ "cut to the shorter.", stacklevel=3)
61
+ m = min(len(x), len(y))
62
+ x, y = x[:m], y[:m]
63
+ if len(x) < MIN_SECONDS * sr:
64
+ raise ValueError(f"signals must be at least {MIN_SECONDS} s long")
65
+ if np.ptp(x) == 0.0:
66
+ raise ValueError("reference is silent (or constant)")
67
+ if abs(dc) > 1e-3 * np.sqrt(np.mean(x ** 2)):
68
+ warnings.warn(f"test has a constant (DC) offset of {dc:+.3g} relative to reference; removed, as it is "
69
+ "inaudible.", stacklevel=3)
70
+ if sr != _core.SR:
71
+ warnings.warn(f"resampling from {sr:g} Hz to {_core.SR} Hz, the model's input rate.", stacklevel=3)
72
+ x, y = (soxr.resample(s, sr, _core.SR, quality="VHQ") for s in (x, y))
73
+ x, y = torch.as_tensor(x, dtype=_core.DT), torch.as_tensor(y, dtype=_core.DT)
74
+ y, tau, rho = _core.align(x, y)
75
+ shifted = f"test was shifted by {tau:+.2f} samples ({1000 * tau / _core.SR:+.3f} ms) to line up with reference"
76
+ if abs(rho) < 0.9: # the delay and polarity estimates are unreliable too: one warning only
77
+ warnings.warn(f"reference and test correlate weakly (|rho| = {abs(rho):.2f} < 0.9) after alignment: they "
78
+ "may be too different for a sample-by-sample comparison, so the scores may not be "
79
+ "meaningful." + (f" Also, {shifted}." if tau != 0.0 else ""), stacklevel=3)
80
+ else:
81
+ if tau != 0.0:
82
+ warnings.warn(shifted + ".", stacklevel=3)
83
+ if rho < 0:
84
+ warnings.warn("test is polarity-inverted relative to reference.", stacklevel=3)
85
+ g = _core.pa_gain(x, self.level)
86
+ return g * x, g * y
87
+
88
+ def _forward(self, px, py):
89
+ """Pressure signals -> {"D_peak", "D_med"} tensors."""
90
+ R = self._nerve.rates(torch.stack([px, py]))
91
+ return _core.scores(self._observer, R[0], R[1])
92
+
93
+ @staticmethod
94
+ def _check(s, name):
95
+ """A mono, finite NumPy array (float64 for the input steps; the model itself runs in 32-bit)."""
96
+ if isinstance(s, torch.Tensor):
97
+ s = s.detach().cpu().numpy()
98
+ s = np.asarray(s, dtype=np.float64)
99
+ if s.ndim != 1:
100
+ raise ValueError(f"{name} must be mono (1-D); got shape {s.shape}")
101
+ if not np.all(np.isfinite(s)):
102
+ raise ValueError(f"{name} contains NaN or infinite values")
103
+ return s
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.4
2
+ Name: deepmark-anni
3
+ Version: 0.1.0
4
+ Summary: ANNI (Auditory Nerve Noticeability Index): how noticeable the difference between two audio signals is
5
+ Author: DeepMark Inc.
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/deepmark/anni
8
+ Project-URL: Issues, https://github.com/deepmark/anni/issues
9
+ Keywords: audio,quality,audibility,auditory nerve,neurogram,psychoacoustics,watermarking
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Sound/Audio
12
+ Classifier: Topic :: Scientific/Engineering
13
+ Requires-Python: >=3.9
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: numpy>=1.21
17
+ Requires-Dist: torch>=2.4.1
18
+ Requires-Dist: soxr>=0.4
19
+ Provides-Extra: test
20
+ Requires-Dist: pytest>=7.0; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ # ANNI: Auditory Nerve Noticeability Index
24
+
25
+ ANNI is a full-reference measure of how noticeable the difference between a reference and a test audio signal is.
26
+ It simulates how the auditory nerve responds to each signal and compares the two responses the way an ideal observer
27
+ would. The score is in d′ units, the standard unit of detectability: **0 means no difference, and larger values mean
28
+ a more noticeable difference.** ANNI is built for small differences near the threshold of hearing, such as audio
29
+ watermarks.
30
+
31
+ > **Status: research prototype (v0.1).** ANNI is calibrated so that D = 1 marks a just-noticeable difference: the
32
+ > point at which listeners in classic laboratory experiments start to detect a change. A score below 1 should
33
+ > therefore mean the difference is hard to hear, even when comparing directly. This has not yet been confirmed by
34
+ > listening tests on real-world audio.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install deepmark-anni
40
+ ```
41
+
42
+ Requires Python 3.9 or newer, PyTorch, NumPy and soxr.
43
+
44
+ For development, install from a clone in editable mode:
45
+
46
+ ```bash
47
+ git clone https://github.com/deepmark/anni.git
48
+ cd anni
49
+ python -m pip install -e ".[test]"
50
+ ```
51
+
52
+ ## Usage
53
+
54
+ ```python
55
+ import soundfile as sf # any audio loader works
56
+ from anni import ANNI
57
+
58
+ reference, sr = sf.read("original.wav")
59
+ test, _ = sf.read("watermarked.wav")
60
+
61
+ anni = ANNI()
62
+ anni(reference, test, sr=sr)
63
+ # e.g. {'D_peak': 1.49, 'D_med': 0.81}
64
+ ```
65
+
66
+ | Score | Meaning |
67
+ |---|---|
68
+ | `D_peak` | how noticeable the difference is at the most noticeable 200 ms moment |
69
+ | `D_med` | how noticeable it is at a typical moment (median over 200 ms windows) |
70
+
71
+ Lower is better. To compare two processed versions, score both against the same reference. A difference between two
72
+ scores can be expressed in dB as 20·log₁₀(D_B / D_A).
73
+
74
+ ## Inputs
75
+
76
+ - **Mono signals** at the same sample rate. The model runs at 44.1 kHz; other rates are resampled to it.
77
+ - **Playback level:** the reference is played at 65 dB SPL, about conversational speech (`ANNI(level=...)` changes
78
+ this). Both signals get the same gain, so a level change in the test signal counts as a difference.
79
+ - **Timing:** a constant delay of up to ±10 ms is removed. If the lengths differ, the longer signal is cut.
80
+ - **Length:** clips from 0.5 s to about 10 s, as for PESQ and ViSQOL. Memory grows with length: a 5 s pair takes about
81
+ 1 s and 1.5 GB on a laptop CPU. On an NVIDIA GPU, use `ANNI(device="cuda")`.
82
+
83
+ ANNI warns whenever it changes the inputs (resampling, cutting, removing a delay or a constant offset between them)
84
+ and when the test signal is polarity-inverted. It also warns when the two signals correlate weakly (below 0.9), for
85
+ example after a speech codec: the scores may then not be meaningful.
86
+
87
+ ## How it works
88
+
89
+ 1. A filter models the outer and middle ear.
90
+ 2. An auditory-nerve model turns each signal into the firing rates of 128 nerve fibres tuned from 80 Hz to 16 kHz:
91
+ cochlear (gammatone) filters, inner-hair-cell rectification and smoothing, and a sigmoid firing-rate curve.
92
+ 3. Two observers compare the firing rates. Each change is weighed against the natural randomness of nerve firing,
93
+ which is what makes loud sounds mask quieter ones. One observer looks at slow changes in firing rate (level and
94
+ colouration), the other at changes in how the rate fluctuates (roughness, flutter).
95
+ 4. The evidence is added up over fibres and over 200 ms windows. `D_peak` is the largest window, `D_med` the median.
96
+
97
+ Four constants set the scale so that classic laboratory thresholds score about D = 1: the threshold of hearing and
98
+ the 1 dB level difference at 1 kHz (following Taal et al. 2012), noise and tone masking (ITU-R BS.1387), and
99
+ amplitude-modulation detection on tones (He et al. 2008) and on noise (O'Connor et al. 2011).
100
+
101
+ A function-by-function walk-through of the code is in [docs.md](https://github.com/deepmark/anni/blob/main/docs.md).
102
+ For a comparison with PESQ and ViSQOL on controlled changes, with audio, see
103
+ [examples/](https://github.com/deepmark/anni/tree/main/examples).
104
+
105
+ ## Evaluation
106
+
107
+ On [ODAQ](https://zenodo.org/records/10405774), an open dataset of music, film sound and speech over music rated by 26
108
+ listeners (210 versions of 30 excerpts; Torcoli et al. 2024), `D_med` orders the versions like the listeners with a
109
+ Spearman correlation of 0.85 (95 % interval 0.79–0.89). That is on par with Zimtohrli (0.83) and above ViSQOL (0.65 in
110
+ audio mode) and PESQ (0.42). On degraded speech, in the TCD-VoIP dataset (Harte et al. 2015; packet loss, clipping,
111
+ echo, competing speakers and noise, rated by 24 listeners), PESQ and ViSQOL follow listeners better.
112
+
113
+ ## Limitations
114
+
115
+ - It compares the signals moment by moment, so it does not suit audio whose timing or pitch has changed
116
+ (time-stretching, pitch-shifting) or that went through a speech codec.
117
+ - It doesn't see energy above 16 kHz or below 80 Hz, very fast fluctuations or fine timing structure.
118
+ - Mono only, normal hearing.
119
+
120
+ ## Credits and licence
121
+
122
+ ANNI is developed by DeepMark Inc. and released under the
123
+ [MIT License](https://github.com/deepmark/anni/blob/main/LICENSE).
124
+
125
+ The auditory-nerve model is adapted from Mark R. Saddler's
126
+ [auditory_machine_learning](https://github.com/msaddler/auditory_machine_learning). If you use ANNI, please also cite:
127
+
128
+ - Saddler, M. R., & McDermott, J. H. (2024). Models optimized for real-world tasks reveal the task-dependent necessity
129
+ of precise temporal coding in hearing. *Nature Communications*.
130
+ [doi:10.1038/s41467-024-54700-5](https://doi.org/10.1038/s41467-024-54700-5)
131
+
132
+ Listening-test data:
133
+
134
+ - Torcoli, M., Wu, C.-W., Dick, S., Williams, P. A., Halimeh, M. M., Wolcott, W., & Habets, E. A. P. (2024). ODAQ: Open
135
+ Dataset of Audio Quality. *Proc. IEEE ICASSP*. [arXiv:2401.00197](https://arxiv.org/abs/2401.00197)
136
+ - Harte, N., Gillen, E., & Hines, A. (2015). TCD-VoIP, a research database of degraded speech for assessing quality in
137
+ VoIP applications. *Proc. QoMEX*.
138
+
139
+ Calibration references:
140
+
141
+ - Taal, C. H., Hendriks, R. C., & Heusdens, R. (2012). A low-complexity spectro-temporal distortion measure for audio
142
+ processing applications. *IEEE Transactions on Audio, Speech, and Language Processing*, 20(5).
143
+ [doi:10.1109/TASL.2012.2184753](https://doi.org/10.1109/TASL.2012.2184753)
144
+ - ITU-R BS.1387: Method for objective measurements of perceived audio quality (PEAQ).
145
+ - He, N., Mills, J. H., Ahlstrom, J. B., & Dubno, J. R. (2008). Age-related differences in the temporal modulation
146
+ transfer function with pure-tone carriers. *The Journal of the Acoustical Society of America*, 124(6), 3841–3849.
147
+ [doi:10.1121/1.2998779](https://doi.org/10.1121/1.2998779)
148
+ - O'Connor, K. N., Johnson, J. S., Niwa, M., Noriega, N. C., Marshall, E. A., & Sutter, M. L. (2011). Amplitude
149
+ modulation detection as a function of modulation frequency and stimulus duration: comparisons between macaques and
150
+ humans. *Hearing Research*, 277, 37–43. [doi:10.1016/j.heares.2011.03.014](https://doi.org/10.1016/j.heares.2011.03.014)
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/anni/__init__.py
5
+ src/anni/_auditory_model.py
6
+ src/anni/_core.py
7
+ src/anni/measure.py
8
+ src/deepmark_anni.egg-info/PKG-INFO
9
+ src/deepmark_anni.egg-info/SOURCES.txt
10
+ src/deepmark_anni.egg-info/dependency_links.txt
11
+ src/deepmark_anni.egg-info/requires.txt
12
+ src/deepmark_anni.egg-info/top_level.txt
13
+ tests/test_anni.py
@@ -0,0 +1,6 @@
1
+ numpy>=1.21
2
+ torch>=2.4.1
3
+ soxr>=0.4
4
+
5
+ [test]
6
+ pytest>=7.0
@@ -0,0 +1,93 @@
1
+ import numpy as np
2
+ import pytest
3
+ import torch
4
+
5
+ from anni import ANNI
6
+
7
+ SR = 44100
8
+
9
+
10
+ @pytest.fixture(scope="module")
11
+ def measure():
12
+ return ANNI()
13
+
14
+
15
+ @pytest.fixture(scope="module")
16
+ def ref():
17
+ t = np.arange(int(1.5 * SR)) / SR
18
+ rng = np.random.default_rng(0)
19
+ return 0.1 * np.sin(2 * np.pi * 440 * t) * (1 + 0.5 * np.sin(2 * np.pi * 3 * t)) + 0.02 * rng.standard_normal(len(t))
20
+
21
+
22
+ def noisy(x, amp):
23
+ return x + amp * np.random.default_rng(1).standard_normal(len(x))
24
+
25
+
26
+ def test_identical_signals_score_zero(measure, ref):
27
+ assert measure(ref, ref, sr=SR) == {"D_peak": 0.0, "D_med": 0.0}
28
+
29
+
30
+ def test_regression_values(measure, ref):
31
+ s = measure(ref, noisy(ref, 0.003), sr=SR)
32
+ assert s["D_peak"] == pytest.approx(0.5433951020240784, rel=1e-5)
33
+ assert s["D_med"] == pytest.approx(0.5027734041213989, rel=1e-5)
34
+
35
+
36
+ def test_larger_difference_scores_higher(measure, ref):
37
+ d = [measure(ref, noisy(ref, a), sr=SR)["D_peak"] for a in (0.001, 0.003, 0.01)]
38
+ assert d[0] < d[1] < d[2]
39
+
40
+
41
+ def test_level_change_is_scored(measure, ref):
42
+ assert measure(ref, ref * 10 ** (1 / 20), sr=SR)["D_peak"] > 0.5
43
+
44
+
45
+ def test_tensor_input_and_constant_delay(measure, ref):
46
+ y = noisy(ref, 0.003)
47
+ a = measure(ref, y, sr=SR)
48
+ with pytest.warns(UserWarning, match="shifted by"):
49
+ b = measure(torch.tensor(ref), torch.tensor(np.concatenate([np.zeros(20), y[:-20]])), sr=SR)
50
+ assert b["D_med"] == pytest.approx(a["D_med"], rel=0.1)
51
+
52
+
53
+ def test_no_warnings_for_clean_input(measure, ref):
54
+ import warnings
55
+ with warnings.catch_warnings():
56
+ warnings.simplefilter("error")
57
+ measure(ref, noisy(ref, 0.003), sr=SR)
58
+
59
+
60
+ def test_other_sample_rate_is_resampled_with_a_warning(measure):
61
+ sr = 48000
62
+ t = np.arange(int(1.5 * sr)) / sr
63
+ x = 0.1 * np.sin(2 * np.pi * 440 * t)
64
+ with pytest.warns(UserWarning, match="resampling"):
65
+ assert measure(x, x * 1.1, sr=sr)["D_peak"] > 0
66
+
67
+
68
+ def test_dc_offset_is_removed_with_a_warning(measure, ref):
69
+ with pytest.warns(UserWarning, match="DC"):
70
+ s = measure(ref, ref + 0.05, sr=SR)
71
+ assert s["D_peak"] < 1e-6
72
+
73
+
74
+ def test_pauses_do_not_change_the_playback_level(ref):
75
+ from anni import _core
76
+ x = torch.tensor(ref)
77
+ padded = torch.cat([torch.zeros(SR), x])
78
+ assert float(_core.pa_gain(padded)) == pytest.approx(float(_core.pa_gain(x)), rel=1e-5)
79
+
80
+
81
+ def test_input_checks(measure, ref):
82
+ with pytest.raises(ValueError):
83
+ measure(np.stack([ref, ref]), np.stack([ref, ref]), sr=SR)
84
+ with pytest.raises(ValueError):
85
+ measure(ref[:1000], ref[:1000], sr=SR)
86
+ with pytest.raises(ValueError, match="silent"):
87
+ measure(np.full(len(ref), 0.3), ref, sr=SR)
88
+ with pytest.warns(UserWarning, match="correlate weakly"):
89
+ measure(ref, np.random.default_rng(2).standard_normal(len(ref)) * 0.05, sr=SR)
90
+ with pytest.warns(UserWarning, match="differ in length"):
91
+ measure(ref, noisy(ref, 0.003)[:-100], sr=SR)
92
+ with pytest.warns(UserWarning, match="polarity-inverted"):
93
+ measure(ref, -noisy(ref, 0.003), sr=SR)