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.
- deepmark_anni-0.1.0/LICENSE +21 -0
- deepmark_anni-0.1.0/PKG-INFO +150 -0
- deepmark_anni-0.1.0/README.md +128 -0
- deepmark_anni-0.1.0/pyproject.toml +38 -0
- deepmark_anni-0.1.0/setup.cfg +4 -0
- deepmark_anni-0.1.0/src/anni/__init__.py +5 -0
- deepmark_anni-0.1.0/src/anni/_auditory_model.py +111 -0
- deepmark_anni-0.1.0/src/anni/_core.py +287 -0
- deepmark_anni-0.1.0/src/anni/measure.py +103 -0
- deepmark_anni-0.1.0/src/deepmark_anni.egg-info/PKG-INFO +150 -0
- deepmark_anni-0.1.0/src/deepmark_anni.egg-info/SOURCES.txt +13 -0
- deepmark_anni-0.1.0/src/deepmark_anni.egg-info/dependency_links.txt +1 -0
- deepmark_anni-0.1.0/src/deepmark_anni.egg-info/requires.txt +6 -0
- deepmark_anni-0.1.0/src/deepmark_anni.egg-info/top_level.txt +1 -0
- deepmark_anni-0.1.0/tests/test_anni.py +93 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
anni
|
|
@@ -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)
|