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.
@@ -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
+ ![A fixed-frequency slice of the stacked RIR spectrograms forms a space–time power matrix, which is factored into per-position amplitudes and exponential decays shared by every position](https://raw.githubusercontent.com/jeremybbq/common_slope_nmf/main/assets/nmf-overview.png)
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
+ ![A fixed-frequency slice of the stacked RIR spectrograms forms a space–time power matrix, which is factored into per-position amplitudes and exponential decays shared by every position](https://raw.githubusercontent.com/jeremybbq/common_slope_nmf/main/assets/nmf-overview.png)
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]