common-slope-nmf 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.
- common_slope_nmf-0.1.0/LICENSE +21 -0
- common_slope_nmf-0.1.0/PKG-INFO +139 -0
- common_slope_nmf-0.1.0/README.md +103 -0
- common_slope_nmf-0.1.0/THIRD_PARTY_NOTICES.md +19 -0
- common_slope_nmf-0.1.0/common_slope_nmf/__init__.py +67 -0
- common_slope_nmf-0.1.0/common_slope_nmf/baseline.py +135 -0
- common_slope_nmf-0.1.0/common_slope_nmf/loss.py +64 -0
- common_slope_nmf-0.1.0/common_slope_nmf/model.py +129 -0
- common_slope_nmf-0.1.0/common_slope_nmf/preprocess.py +182 -0
- common_slope_nmf-0.1.0/common_slope_nmf/rir.py +189 -0
- common_slope_nmf-0.1.0/common_slope_nmf/sage/__init__.py +15 -0
- common_slope_nmf-0.1.0/common_slope_nmf/sage/_util.py +68 -0
- common_slope_nmf-0.1.0/common_slope_nmf/sage/amplitudes.py +133 -0
- common_slope_nmf-0.1.0/common_slope_nmf/sage/decay.py +249 -0
- common_slope_nmf-0.1.0/common_slope_nmf/sage/init.py +70 -0
- common_slope_nmf-0.1.0/common_slope_nmf/sage/rates.py +233 -0
- common_slope_nmf-0.1.0/common_slope_nmf/synth.py +342 -0
- common_slope_nmf-0.1.0/common_slope_nmf/util.py +37 -0
- common_slope_nmf-0.1.0/common_slope_nmf.egg-info/PKG-INFO +139 -0
- common_slope_nmf-0.1.0/common_slope_nmf.egg-info/SOURCES.txt +23 -0
- common_slope_nmf-0.1.0/common_slope_nmf.egg-info/dependency_links.txt +1 -0
- common_slope_nmf-0.1.0/common_slope_nmf.egg-info/requires.txt +14 -0
- common_slope_nmf-0.1.0/common_slope_nmf.egg-info/top_level.txt +1 -0
- common_slope_nmf-0.1.0/pyproject.toml +52 -0
- common_slope_nmf-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeremy Bai
|
|
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,139 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: common-slope-nmf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Physically constrained IS-NMF/SAGE for common-slope room-acoustic decay estimation.
|
|
5
|
+
Author: Jeremy Bai
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/jeremybbq/common_slope_nmf
|
|
8
|
+
Project-URL: Repository, https://github.com/jeremybbq/common_slope_nmf
|
|
9
|
+
Project-URL: Issues, https://github.com/jeremybbq/common_slope_nmf/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/jeremybbq/common_slope_nmf/releases
|
|
11
|
+
Keywords: acoustics,decay,nmf,reverberation,room-impulse-response,sage
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
License-File: THIRD_PARTY_NOTICES.md
|
|
24
|
+
Requires-Dist: numpy>=1.23
|
|
25
|
+
Requires-Dist: scipy>=1.10
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
28
|
+
Provides-Extra: experiments
|
|
29
|
+
Requires-Dist: h5py>=3.7; extra == "experiments"
|
|
30
|
+
Requires-Dist: matplotlib>=3.7; extra == "experiments"
|
|
31
|
+
Provides-Extra: decayfitnet
|
|
32
|
+
Requires-Dist: onnx>=1.15; extra == "decayfitnet"
|
|
33
|
+
Requires-Dist: onnxruntime>=1.16; extra == "decayfitnet"
|
|
34
|
+
Requires-Dist: torch>=2.0; extra == "decayfitnet"
|
|
35
|
+
Dynamic: license-file
|
|
36
|
+
|
|
37
|
+
# Common-slope joint estimation of decay and amplitude using parameterized nonnegative matrix factorization
|
|
38
|
+
|
|
39
|
+
Estimate shared frequency dependent decay rates (or reverberation time, RT), and the amplitudes of the decays in each recording of room impulse responses (RIRs).
|
|
40
|
+
|
|
41
|
+

|
|
42
|
+
|
|
43
|
+
At one frequency, the powers of all RIRs form a space–time matrix (blue frame). It is factored into per-position amplitudes and a few exponential decays shared by every position; the right panel shows one RIR (green frame) with its two fitted decays.
|
|
44
|
+
|
|
45
|
+
The exponential decay fit uses an Itakura–Saito divergence loss (the corresponding negative log-likelihood from the complex-Gaussian variance model) and a SAGE optimizer.
|
|
46
|
+
|
|
47
|
+
For RIRs indexed with $r$, frequency $f$, and time frame $n$ at elapsed time $\tau_n$, the model variance is
|
|
48
|
+
|
|
49
|
+
```math
|
|
50
|
+
V_{rfn} = b_{rf} + \sum_{k=1}^{K} a_{rfk} \, e^{-\lambda_{fk} \tau_n}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The rates $\lambda_{fk}$ are shared. The amplitudes $a_{rfk}$ and floors $b_{rf}$ stay non-negative and can differ by recording.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
Python 3.10 or newer.
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install common-slope-nmf
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The experiments from the paper are not in the standalone PyPI package. To run them, clone the repository and set it up with [uv](https://docs.astral.sh/uv/):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
uv sync --extra experiments --extra test
|
|
67
|
+
uv run pytest
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
`uv sync` creates `.venv` and installs the package in editable mode.
|
|
71
|
+
|
|
72
|
+
NumPy and SciPy are required.
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
The fit takes STFT power of shape `(R, F, N)` (RIRs, frequencies, frames) and the elapsed time of each frame in seconds. Frame zero is the decay origin, so the RIRs must already start at a common onset.
|
|
77
|
+
|
|
78
|
+
From waveforms, with `rirs` a real array of shape `(R, L)`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from common_slope_nmf import rir_stft_power
|
|
82
|
+
|
|
83
|
+
stft = rir_stft_power(
|
|
84
|
+
rirs,
|
|
85
|
+
sample_rate_hz,
|
|
86
|
+
frame_size_samples=512,
|
|
87
|
+
hop_size_samples=256,
|
|
88
|
+
fft_size_samples=512,
|
|
89
|
+
minimum_frequency_hz=125.0,
|
|
90
|
+
maximum_frequency_hz=8_000.0,
|
|
91
|
+
)
|
|
92
|
+
observed_power, frame_time_s = stft.observed_power, stft.frame_time_s
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The values shown are the defaults. The window is Hann, sizes are in samples, and `stft.frequencies_hz` holds the bins kept between the two frequency limits.
|
|
96
|
+
|
|
97
|
+
Then estimate `n_components` shared decays per frequency:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
import numpy as np
|
|
101
|
+
from common_slope_nmf import fit_decay, init_decay, rate_to_t60
|
|
102
|
+
|
|
103
|
+
n_components = 2
|
|
104
|
+
|
|
105
|
+
# One pooled decay rate per frequency, a few-frame estimate of the total amplitude, and the noise floor.
|
|
106
|
+
rate, amplitude, floor = init_decay(observed_power, frame_time_s)
|
|
107
|
+
result = fit_decay(
|
|
108
|
+
observed_power,
|
|
109
|
+
frame_time_s,
|
|
110
|
+
initial_rates_per_s=np.repeat(rate[:, None], n_components, axis=1),
|
|
111
|
+
initial_amplitudes=np.repeat(amplitude[:, :, None] / n_components, n_components, axis=2),
|
|
112
|
+
initial_noise_floor=floor,
|
|
113
|
+
)
|
|
114
|
+
t60_s = rate_to_t60(result.rates_per_s)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`result.rates_per_s` is of size `(F, K)`, `result.amplitudes` is `(R, F, K)`, and `result.noise_floor` is `(R, F)`.
|
|
118
|
+
|
|
119
|
+
## Experiments
|
|
120
|
+
|
|
121
|
+
Run all experiments from the repository root. The `plot_*` scripts draw figures from those saved results. For example,
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv run python -m experiments.synthetic.convergence
|
|
125
|
+
uv run python -m experiments.synthetic.plot_convergence --results output/[PATH]/loss_convergence_results.npz
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Measured coupled-room RIRs ([Meeting Room to Hallway, v1.3](https://doi.org/10.5281/zenodo.6375520)), read from `--dataset-dir`:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
uv run python -m experiments.room_to_hallway.sage_fit --dataset-dir PATH
|
|
132
|
+
uv run python -m experiments.room_to_hallway.plot_sage_fit --results output/RUN
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`experiments.room_to_hallway.decayfitnet_benchmarks` compares these fits with DecayFitNet and CommonSlopeAnalysis. It needs `uv sync --extra decayfitnet` and an external DecayFitNet checkout; see [THIRD_PARTY_NOTICES.md](https://github.com/jeremybbq/common_slope_nmf/blob/main/THIRD_PARTY_NOTICES.md).
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT. Notices for adapted third-party code are in [THIRD_PARTY_NOTICES.md](https://github.com/jeremybbq/common_slope_nmf/blob/main/THIRD_PARTY_NOTICES.md).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Common-slope joint estimation of decay and amplitude using parameterized nonnegative matrix factorization
|
|
2
|
+
|
|
3
|
+
Estimate shared frequency dependent decay rates (or reverberation time, RT), and the amplitudes of the decays in each recording of room impulse responses (RIRs).
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
At one frequency, the powers of all RIRs form a space–time matrix (blue frame). It is factored into per-position amplitudes and a few exponential decays shared by every position; the right panel shows one RIR (green frame) with its two fitted decays.
|
|
8
|
+
|
|
9
|
+
The exponential decay fit uses an Itakura–Saito divergence loss (the corresponding negative log-likelihood from the complex-Gaussian variance model) and a SAGE optimizer.
|
|
10
|
+
|
|
11
|
+
For RIRs indexed with $r$, frequency $f$, and time frame $n$ at elapsed time $\tau_n$, the model variance is
|
|
12
|
+
|
|
13
|
+
```math
|
|
14
|
+
V_{rfn} = b_{rf} + \sum_{k=1}^{K} a_{rfk} \, e^{-\lambda_{fk} \tau_n}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The rates $\lambda_{fk}$ are shared. The amplitudes $a_{rfk}$ and floors $b_{rf}$ stay non-negative and can differ by recording.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
Python 3.10 or newer.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install common-slope-nmf
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The experiments from the paper are not in the standalone PyPI package. To run them, clone the repository and set it up with [uv](https://docs.astral.sh/uv/):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv sync --extra experiments --extra test
|
|
31
|
+
uv run pytest
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`uv sync` creates `.venv` and installs the package in editable mode.
|
|
35
|
+
|
|
36
|
+
NumPy and SciPy are required.
|
|
37
|
+
|
|
38
|
+
## Quick start
|
|
39
|
+
|
|
40
|
+
The fit takes STFT power of shape `(R, F, N)` (RIRs, frequencies, frames) and the elapsed time of each frame in seconds. Frame zero is the decay origin, so the RIRs must already start at a common onset.
|
|
41
|
+
|
|
42
|
+
From waveforms, with `rirs` a real array of shape `(R, L)`:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from common_slope_nmf import rir_stft_power
|
|
46
|
+
|
|
47
|
+
stft = rir_stft_power(
|
|
48
|
+
rirs,
|
|
49
|
+
sample_rate_hz,
|
|
50
|
+
frame_size_samples=512,
|
|
51
|
+
hop_size_samples=256,
|
|
52
|
+
fft_size_samples=512,
|
|
53
|
+
minimum_frequency_hz=125.0,
|
|
54
|
+
maximum_frequency_hz=8_000.0,
|
|
55
|
+
)
|
|
56
|
+
observed_power, frame_time_s = stft.observed_power, stft.frame_time_s
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The values shown are the defaults. The window is Hann, sizes are in samples, and `stft.frequencies_hz` holds the bins kept between the two frequency limits.
|
|
60
|
+
|
|
61
|
+
Then estimate `n_components` shared decays per frequency:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
import numpy as np
|
|
65
|
+
from common_slope_nmf import fit_decay, init_decay, rate_to_t60
|
|
66
|
+
|
|
67
|
+
n_components = 2
|
|
68
|
+
|
|
69
|
+
# One pooled decay rate per frequency, a few-frame estimate of the total amplitude, and the noise floor.
|
|
70
|
+
rate, amplitude, floor = init_decay(observed_power, frame_time_s)
|
|
71
|
+
result = fit_decay(
|
|
72
|
+
observed_power,
|
|
73
|
+
frame_time_s,
|
|
74
|
+
initial_rates_per_s=np.repeat(rate[:, None], n_components, axis=1),
|
|
75
|
+
initial_amplitudes=np.repeat(amplitude[:, :, None] / n_components, n_components, axis=2),
|
|
76
|
+
initial_noise_floor=floor,
|
|
77
|
+
)
|
|
78
|
+
t60_s = rate_to_t60(result.rates_per_s)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`result.rates_per_s` is of size `(F, K)`, `result.amplitudes` is `(R, F, K)`, and `result.noise_floor` is `(R, F)`.
|
|
82
|
+
|
|
83
|
+
## Experiments
|
|
84
|
+
|
|
85
|
+
Run all experiments from the repository root. The `plot_*` scripts draw figures from those saved results. For example,
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
uv run python -m experiments.synthetic.convergence
|
|
89
|
+
uv run python -m experiments.synthetic.plot_convergence --results output/[PATH]/loss_convergence_results.npz
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Measured coupled-room RIRs ([Meeting Room to Hallway, v1.3](https://doi.org/10.5281/zenodo.6375520)), read from `--dataset-dir`:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
uv run python -m experiments.room_to_hallway.sage_fit --dataset-dir PATH
|
|
96
|
+
uv run python -m experiments.room_to_hallway.plot_sage_fit --results output/RUN
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`experiments.room_to_hallway.decayfitnet_benchmarks` compares these fits with DecayFitNet and CommonSlopeAnalysis. It needs `uv sync --extra decayfitnet` and an external DecayFitNet checkout; see [THIRD_PARTY_NOTICES.md](https://github.com/jeremybbq/common_slope_nmf/blob/main/THIRD_PARTY_NOTICES.md).
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT. Notices for adapted third-party code are in [THIRD_PARTY_NOTICES.md](https://github.com/jeremybbq/common_slope_nmf/blob/main/THIRD_PARTY_NOTICES.md).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Third-party notices
|
|
2
|
+
|
|
3
|
+
`common_slope_nmf/baseline.py` adapts the common-decay clustering procedure from Georg Götz's [CommonSlopeAnalysis](https://github.com/georg-goetz/CommonSlopeAnalysis). The room-to-hallway benchmark calls filtering, Schroeder integration, and network inference from an external [DecayFitNet](https://github.com/georg-goetz/DecayFitNet) checkout. The ONNX weights and input transforms are not included in this repository.
|
|
4
|
+
|
|
5
|
+
## DecayFitNet
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2021 Georg Götz
|
|
8
|
+
|
|
9
|
+
## CommonSlopeAnalysis
|
|
10
|
+
|
|
11
|
+
Copyright (c) 2023 Georg Götz
|
|
12
|
+
|
|
13
|
+
Both projects are provided under the MIT License:
|
|
14
|
+
|
|
15
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Research package for constrained multi-slope IS-NMF decay estimation."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("common-slope-nmf")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
from .loss import is_divergence
|
|
11
|
+
from .model import (
|
|
12
|
+
exponential_features,
|
|
13
|
+
exponential_variance,
|
|
14
|
+
rate_to_t60,
|
|
15
|
+
t60_to_rate,
|
|
16
|
+
)
|
|
17
|
+
from .preprocess import fit_linear_decay, head_power, tail_power
|
|
18
|
+
from .rir import (
|
|
19
|
+
STFTPower,
|
|
20
|
+
global_energy_onset,
|
|
21
|
+
rir_stft_power,
|
|
22
|
+
select_stft_frames,
|
|
23
|
+
)
|
|
24
|
+
from .sage import (
|
|
25
|
+
AmplitudeFit,
|
|
26
|
+
DecayFit,
|
|
27
|
+
fit_amplitudes,
|
|
28
|
+
fit_decay,
|
|
29
|
+
init_decay,
|
|
30
|
+
update_rates_newton,
|
|
31
|
+
)
|
|
32
|
+
from .synth import (
|
|
33
|
+
SynthData,
|
|
34
|
+
sample_complex_gaussian,
|
|
35
|
+
sample_stft_power,
|
|
36
|
+
sample_power,
|
|
37
|
+
sample_simplex_amplitudes,
|
|
38
|
+
sample_t60,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"__version__",
|
|
43
|
+
"AmplitudeFit",
|
|
44
|
+
"SynthData",
|
|
45
|
+
"DecayFit",
|
|
46
|
+
"STFTPower",
|
|
47
|
+
"exponential_features",
|
|
48
|
+
"exponential_variance",
|
|
49
|
+
"fit_amplitudes",
|
|
50
|
+
"fit_decay",
|
|
51
|
+
"fit_linear_decay",
|
|
52
|
+
"global_energy_onset",
|
|
53
|
+
"head_power",
|
|
54
|
+
"init_decay",
|
|
55
|
+
"is_divergence",
|
|
56
|
+
"rate_to_t60",
|
|
57
|
+
"rir_stft_power",
|
|
58
|
+
"select_stft_frames",
|
|
59
|
+
"sample_complex_gaussian",
|
|
60
|
+
"sample_stft_power",
|
|
61
|
+
"sample_power",
|
|
62
|
+
"sample_simplex_amplitudes",
|
|
63
|
+
"sample_t60",
|
|
64
|
+
"tail_power",
|
|
65
|
+
"t60_to_rate",
|
|
66
|
+
"update_rates_newton",
|
|
67
|
+
]
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Common-slope clustering of DecayFitNet energy decay times.
|
|
2
|
+
|
|
3
|
+
DecayFitNet filtering, Schroeder integration, resampling, and network inference stay in an external DecayFitNet checkout. This module keeps the common-decay-time clustering from Georg Götz's MATLAB analysis that the Python toolbox does not provide.
|
|
4
|
+
|
|
5
|
+
References
|
|
6
|
+
----------
|
|
7
|
+
[1] G. Götz, S. J. Schlecht, and V. Pulkki, "Common-slope modeling of late reverberation," IEEE/ACM Trans. Audio, Speech, Lang. Process., vol. 31, pp. 3945-3957, 2023. https://doi.org/10.1109/TASLP.2023.3317572
|
|
8
|
+
[2] G. Götz, R. Falcón Pérez, S. J. Schlecht, and V. Pulkki, "Neural network for multi-exponential sound energy decay analysis," J. Acoust. Soc. Am., vol. 152, no. 2, pp. 942-953, 2022. https://doi.org/10.1121/10.0013416
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import ArrayLike, NDArray
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _histogram_edges(
|
|
18
|
+
minimum: float, maximum: float, resolution: float
|
|
19
|
+
) -> NDArray[np.float64]:
|
|
20
|
+
lower = np.floor(minimum / resolution) * resolution
|
|
21
|
+
upper = np.ceil(maximum / resolution) * resolution
|
|
22
|
+
return np.arange(lower, upper + resolution / 2.0, resolution)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _kmedians_1d(
|
|
26
|
+
values: NDArray[np.float64],
|
|
27
|
+
n_clusters: int,
|
|
28
|
+
*,
|
|
29
|
+
seed: int,
|
|
30
|
+
n_init: int = 5,
|
|
31
|
+
max_iter: int = 10_000,
|
|
32
|
+
) -> tuple[NDArray[np.int64], NDArray[np.float64]]:
|
|
33
|
+
generator = np.random.default_rng(seed)
|
|
34
|
+
unique = np.unique(values)
|
|
35
|
+
if unique.size < n_clusters:
|
|
36
|
+
raise ValueError("fewer distinct positive values than requested clusters.")
|
|
37
|
+
best_cost = np.inf
|
|
38
|
+
best_labels = np.empty(values.size, dtype=np.int64)
|
|
39
|
+
best_centers = np.empty(n_clusters, dtype=np.float64)
|
|
40
|
+
for _ in range(n_init):
|
|
41
|
+
centers = generator.choice(unique, size=n_clusters, replace=False).astype(float)
|
|
42
|
+
labels = np.full(values.size, -1, dtype=np.int64)
|
|
43
|
+
for _ in range(max_iter):
|
|
44
|
+
updated_labels = np.argmin(
|
|
45
|
+
np.abs(values[:, np.newaxis] - centers[np.newaxis, :]), axis=1
|
|
46
|
+
)
|
|
47
|
+
if np.array_equal(updated_labels, labels):
|
|
48
|
+
break
|
|
49
|
+
labels = updated_labels
|
|
50
|
+
for cluster in range(n_clusters):
|
|
51
|
+
members = values[labels == cluster]
|
|
52
|
+
if members.size:
|
|
53
|
+
centers[cluster] = np.median(members)
|
|
54
|
+
cost = float(np.sum(np.abs(values - centers[labels])))
|
|
55
|
+
if cost < best_cost:
|
|
56
|
+
best_cost = cost
|
|
57
|
+
best_labels = labels.copy()
|
|
58
|
+
best_centers = centers.copy()
|
|
59
|
+
return best_labels, best_centers
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def determine_common_decay_times(
|
|
63
|
+
t60_s: ArrayLike,
|
|
64
|
+
n_common_slopes: int,
|
|
65
|
+
*,
|
|
66
|
+
histogram_resolution_s: float = 0.05,
|
|
67
|
+
seed: int = 42,
|
|
68
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64]]:
|
|
69
|
+
"""Cluster per-RIR energy decay times into Georg-style common slopes.
|
|
70
|
+
|
|
71
|
+
Positive entries of ``t60_s`` are clustered by one-dimensional L1 k-medians. Each common time is the center of the most populated histogram bin within its sorted cluster.
|
|
72
|
+
|
|
73
|
+
Returns
|
|
74
|
+
-------
|
|
75
|
+
common_t60_s, cluster_sizes
|
|
76
|
+
Sorted shared energy decay times in seconds, shape ``(K,)``, and the number of positive DecayFitNet samples assigned to each cluster, shape ``(K,)``.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
values = np.asarray(t60_s, dtype=np.float64)
|
|
80
|
+
positive = values[np.isfinite(values) & (values > 0.0)].ravel()
|
|
81
|
+
if n_common_slopes <= 0:
|
|
82
|
+
raise ValueError("n_common_slopes must be positive.")
|
|
83
|
+
if positive.size < n_common_slopes:
|
|
84
|
+
raise ValueError("too few positive decay times for requested clusters.")
|
|
85
|
+
if not np.isfinite(histogram_resolution_s) or histogram_resolution_s <= 0.0:
|
|
86
|
+
raise ValueError("histogram_resolution_s must be finite and positive.")
|
|
87
|
+
labels, centers = _kmedians_1d(
|
|
88
|
+
positive, n_common_slopes, seed=seed
|
|
89
|
+
)
|
|
90
|
+
cluster_order = np.argsort(centers)
|
|
91
|
+
edges = _histogram_edges(
|
|
92
|
+
float(np.min(positive)),
|
|
93
|
+
float(np.max(positive)),
|
|
94
|
+
histogram_resolution_s,
|
|
95
|
+
)
|
|
96
|
+
common = np.empty(n_common_slopes, dtype=np.float64)
|
|
97
|
+
cluster_sizes = np.empty(n_common_slopes, dtype=np.int64)
|
|
98
|
+
for output_index, cluster_index in enumerate(cluster_order):
|
|
99
|
+
members = positive[labels == cluster_index]
|
|
100
|
+
if members.size == 0:
|
|
101
|
+
raise RuntimeError("k-medians produced an empty common-slope cluster.")
|
|
102
|
+
counts, _ = np.histogram(members, bins=edges)
|
|
103
|
+
peak = int(np.argmax(counts))
|
|
104
|
+
common[output_index] = np.mean(edges[peak : peak + 2])
|
|
105
|
+
cluster_sizes[output_index] = members.size
|
|
106
|
+
return common, cluster_sizes
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def edc_to_equivalent_rir_power_amplitudes(
|
|
110
|
+
amplitudes_edc: ArrayLike,
|
|
111
|
+
t60_s: ArrayLike,
|
|
112
|
+
sample_rate_hz: float,
|
|
113
|
+
) -> NDArray[np.float64]:
|
|
114
|
+
"""Convert EDC coefficients to discrete-time RIR power amplitudes.
|
|
115
|
+
|
|
116
|
+
``amplitudes_edc`` and ``t60_s`` must broadcast on their final slope axis.
|
|
117
|
+
The returned power amplitudes use the same absolute energy units multiplied
|
|
118
|
+
by ``1 - exp(-6 log(10)/(fs*T60))``. They remain band-filter dependent and
|
|
119
|
+
are not numerically identical to STFT-bin variance amplitudes.
|
|
120
|
+
|
|
121
|
+
Returns
|
|
122
|
+
-------
|
|
123
|
+
ndarray
|
|
124
|
+
Equivalent unit-origin per-sample RIR power amplitudes, with the
|
|
125
|
+
broadcast shape of ``amplitudes_edc`` and ``t60_s``.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
amplitudes = np.asarray(amplitudes_edc, dtype=np.float64)
|
|
129
|
+
t60 = np.asarray(t60_s, dtype=np.float64)
|
|
130
|
+
if np.any(amplitudes < 0.0) or not np.all(np.isfinite(amplitudes)):
|
|
131
|
+
raise ValueError("amplitudes_edc must contain finite non-negative values.")
|
|
132
|
+
if np.any(t60 <= 0.0) or not np.all(np.isfinite(t60)):
|
|
133
|
+
raise ValueError("t60_s must contain finite positive values.")
|
|
134
|
+
factor = -np.expm1(-6.0 * np.log(10.0) / (sample_rate_hz * t60))
|
|
135
|
+
return amplitudes * factor
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Itakura--Saito criterion for complex-Gaussian variance models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Literal
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import ArrayLike, NDArray
|
|
9
|
+
|
|
10
|
+
from .util import _positive_array
|
|
11
|
+
|
|
12
|
+
Reduction = Literal["none", "sum", "mean"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _matching_power_and_variance(
|
|
16
|
+
observed_power: ArrayLike,
|
|
17
|
+
model_variance: ArrayLike,
|
|
18
|
+
) -> tuple[NDArray[np.float64], NDArray[np.float64]]:
|
|
19
|
+
power = _positive_array("observed_power", observed_power)
|
|
20
|
+
variance = _positive_array("model_variance", model_variance)
|
|
21
|
+
if power.shape != variance.shape:
|
|
22
|
+
raise ValueError("observed_power and model_variance must have the same shape.")
|
|
23
|
+
return power, variance
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _reduce(
|
|
27
|
+
values: NDArray[np.float64], reduction: Reduction
|
|
28
|
+
) -> float | NDArray[np.float64]:
|
|
29
|
+
if reduction == "none":
|
|
30
|
+
return values
|
|
31
|
+
if reduction == "sum":
|
|
32
|
+
return float(np.sum(values))
|
|
33
|
+
if reduction == "mean":
|
|
34
|
+
return float(np.mean(values))
|
|
35
|
+
raise ValueError("reduction must be 'none', 'sum', or 'mean'.")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_divergence(
|
|
39
|
+
observed_power: ArrayLike,
|
|
40
|
+
model_variance: ArrayLike,
|
|
41
|
+
*,
|
|
42
|
+
reduction: Reduction = "sum",
|
|
43
|
+
) -> float | NDArray[np.float64]:
|
|
44
|
+
"""Evaluate the Itakura--Saito divergence ``d_IS(Y | V)``.
|
|
45
|
+
|
|
46
|
+
Parameters
|
|
47
|
+
----------
|
|
48
|
+
observed_power
|
|
49
|
+
Strictly positive observed energy/power ``Y``.
|
|
50
|
+
model_variance
|
|
51
|
+
Strictly positive model variance ``V`` with the same shape and energy/power units as ``observed_power``.
|
|
52
|
+
reduction
|
|
53
|
+
``"sum"`` or ``"mean"`` returns a scalar; ``"none"`` returns the elementwise divergence with the same shape as ``Y`` and ``V``.
|
|
54
|
+
|
|
55
|
+
Returns
|
|
56
|
+
-------
|
|
57
|
+
float or ndarray
|
|
58
|
+
``Y / V - log(Y / V) - 1`` after the requested reduction. The divergence is dimensionless and invariant to a common positive scale applied to ``Y`` and ``V``.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
power, variance = _matching_power_and_variance(observed_power, model_variance)
|
|
62
|
+
log_ratio = np.log(power) - np.log(variance)
|
|
63
|
+
values = np.expm1(log_ratio) - log_ratio
|
|
64
|
+
return _reduce(values, reduction)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Physical parameter transforms and exponential variance construction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from numpy.typing import ArrayLike, NDArray
|
|
7
|
+
|
|
8
|
+
from .util import _nonnegative_array, _positive_array
|
|
9
|
+
|
|
10
|
+
_T60_RATE_PRODUCT = 6.0 * np.log(10.0)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def t60_to_rate(t60_s: ArrayLike) -> float | NDArray[np.float64]:
|
|
14
|
+
"""Convert energy-decay ``T60`` values in seconds to rates in ``s^-1``.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
t60_s
|
|
19
|
+
Positive scalar or array of energy/power ``T60`` values in seconds.
|
|
20
|
+
|
|
21
|
+
Returns
|
|
22
|
+
-------
|
|
23
|
+
float or ndarray
|
|
24
|
+
Decay rates with the same shape as ``t60_s``. A scalar input returns a scalar. The convention is ``rate = 6 log(10) / T60``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
t60 = _positive_array("t60_s", t60_s)
|
|
28
|
+
rate = _T60_RATE_PRODUCT / t60
|
|
29
|
+
return float(rate) if rate.ndim == 0 else rate
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def rate_to_t60(rate_per_s: ArrayLike) -> float | NDArray[np.float64]:
|
|
33
|
+
"""Convert energy-decay rates in ``s^-1`` to ``T60`` values in seconds.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
rate_per_s
|
|
38
|
+
Positive scalar or array of exponential energy-decay rates in inverse seconds.
|
|
39
|
+
|
|
40
|
+
Returns
|
|
41
|
+
-------
|
|
42
|
+
float or ndarray
|
|
43
|
+
Energy/power ``T60`` values with the same shape as ``rate_per_s``. A scalar input returns a scalar.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
rate = _positive_array("rate_per_s", rate_per_s)
|
|
47
|
+
t60 = _T60_RATE_PRODUCT / rate
|
|
48
|
+
return float(t60) if t60.ndim == 0 else t60
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def exponential_features(
|
|
52
|
+
frame_time_s: ArrayLike, rate_per_s: ArrayLike
|
|
53
|
+
) -> NDArray[np.float64]:
|
|
54
|
+
"""Construct unit-origin exponential energy-decay features.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
frame_time_s
|
|
59
|
+
One-dimensional time vector of STFT frame elapsed time in seconds, shape ``(N,)``. Values must be finite and non-negative. Frame zero is the decay origin.
|
|
60
|
+
rate_per_s
|
|
61
|
+
Positive decay rates in inverse seconds, shape ``(..., K)``. A scalar is interpreted as one component.
|
|
62
|
+
|
|
63
|
+
Returns
|
|
64
|
+
-------
|
|
65
|
+
ndarray
|
|
66
|
+
Exponential features ``exp(-rate * time)``, shape ``(..., K, N)``. Every feature equals one at frame time zero.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
frame_time_s = _nonnegative_array("frame_time_s", frame_time_s)
|
|
70
|
+
if frame_time_s.ndim != 1:
|
|
71
|
+
raise ValueError("frame_time_s must be one-dimensional.")
|
|
72
|
+
|
|
73
|
+
rates = np.atleast_1d(_positive_array("rate_per_s", rate_per_s))
|
|
74
|
+
return np.exp(-rates[..., np.newaxis] * frame_time_s)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def exponential_variance(
|
|
78
|
+
frame_time_s: ArrayLike,
|
|
79
|
+
rate_per_s: ArrayLike,
|
|
80
|
+
amplitudes: ArrayLike,
|
|
81
|
+
noise_floor: ArrayLike = 0.0,
|
|
82
|
+
) -> NDArray[np.float64]:
|
|
83
|
+
"""Evaluate a non-negative sum of exponential variance components.
|
|
84
|
+
|
|
85
|
+
Parameters
|
|
86
|
+
----------
|
|
87
|
+
frame_time_s
|
|
88
|
+
Time vector of STFT frame elapsed time in seconds, shape ``(N,)``. Frame zero is the decay origin.
|
|
89
|
+
rate_per_s
|
|
90
|
+
Positive shared rates in inverse seconds, shape ``(..., K)``. For the full model this is commonly ``(F, K)``.
|
|
91
|
+
amplitudes
|
|
92
|
+
Non-negative energy/variance amplitudes at ``frame_time_s == 0``, with a shape broadcastable with ``rate_per_s``. This is commonly ``(R, F, K)``.
|
|
93
|
+
noise_floor
|
|
94
|
+
Non-negative time-invariant variance, broadcastable to the parameter batch shape after the component axis is removed. This is commonly ``(R, F)``.
|
|
95
|
+
|
|
96
|
+
Returns
|
|
97
|
+
-------
|
|
98
|
+
ndarray
|
|
99
|
+
Total variance, shape ``broadcast(rate_per_s, amplitudes)[:-1] + (N,)``. A scalar rate and scalar amplitude produce shape ``(N,)``.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
frame_time_s = _nonnegative_array("frame_time_s", frame_time_s)
|
|
103
|
+
if frame_time_s.ndim != 1:
|
|
104
|
+
raise ValueError("frame_time_s must be one-dimensional.")
|
|
105
|
+
|
|
106
|
+
rates = np.atleast_1d(_positive_array("rate_per_s", rate_per_s))
|
|
107
|
+
weights = np.atleast_1d(_nonnegative_array("amplitudes", amplitudes))
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
rates, weights = np.broadcast_arrays(rates, weights)
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"rate_per_s and amplitudes must have broadcast-compatible shapes."
|
|
114
|
+
) from exc
|
|
115
|
+
|
|
116
|
+
components = weights[..., np.newaxis] * np.exp(
|
|
117
|
+
-rates[..., np.newaxis] * frame_time_s
|
|
118
|
+
)
|
|
119
|
+
variance = np.sum(components, axis=-2)
|
|
120
|
+
|
|
121
|
+
floor = _nonnegative_array("noise_floor", noise_floor)
|
|
122
|
+
try:
|
|
123
|
+
floor = np.broadcast_to(floor, variance.shape[:-1])
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise ValueError(
|
|
126
|
+
"noise_floor must broadcast to the parameter batch shape."
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
return variance + floor[..., np.newaxis]
|