visqol-python 3.5.0__tar.gz → 3.6.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.
Files changed (36) hide show
  1. {visqol_python-3.5.0 → visqol_python-3.6.0}/CHANGELOG.md +64 -0
  2. {visqol_python-3.5.0 → visqol_python-3.6.0}/PKG-INFO +31 -17
  3. {visqol_python-3.5.0 → visqol_python-3.6.0}/README.md +26 -16
  4. {visqol_python-3.5.0 → visqol_python-3.6.0}/pyproject.toml +4 -0
  5. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/__init__.py +1 -1
  6. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/nsim.py +28 -0
  7. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/numba_accel.py +59 -54
  8. visqol_python-3.6.0/visqol/signal_utils.py +167 -0
  9. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol_python.egg-info/PKG-INFO +31 -17
  10. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol_python.egg-info/requires.txt +5 -0
  11. visqol_python-3.5.0/visqol/signal_utils.py +0 -94
  12. {visqol_python-3.5.0 → visqol_python-3.6.0}/CONTRIBUTING.md +0 -0
  13. {visqol_python-3.5.0 → visqol_python-3.6.0}/LICENSE +0 -0
  14. {visqol_python-3.5.0 → visqol_python-3.6.0}/MANIFEST.in +0 -0
  15. {visqol_python-3.5.0 → visqol_python-3.6.0}/setup.cfg +0 -0
  16. {visqol_python-3.5.0 → visqol_python-3.6.0}/tests/test_conformance.py +0 -0
  17. {visqol_python-3.5.0 → visqol_python-3.6.0}/tests/test_parallel_correctness.py +0 -0
  18. {visqol_python-3.5.0 → visqol_python-3.6.0}/tests/test_quick.py +0 -0
  19. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/__main__.py +0 -0
  20. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/alignment.py +0 -0
  21. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/analysis_window.py +0 -0
  22. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/api.py +0 -0
  23. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/audio_utils.py +0 -0
  24. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/gammatone.py +0 -0
  25. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/model/lattice_tcditugenmeetpackhref_ls2_nl60_lr12_bs2048_learn.005_ep2400_train1_7_raw.tflite +0 -0
  26. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/model/libsvm_nu_svr_model.txt +0 -0
  27. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/patch_creator.py +0 -0
  28. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/patch_selector.py +0 -0
  29. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/py.typed +0 -0
  30. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/quality_mapper.py +0 -0
  31. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/visqol_core.py +0 -0
  32. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol/visqol_manager.py +0 -0
  33. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol_python.egg-info/SOURCES.txt +0 -0
  34. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol_python.egg-info/dependency_links.txt +0 -0
  35. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol_python.egg-info/entry_points.txt +0 -0
  36. {visqol_python-3.5.0 → visqol_python-3.6.0}/visqol_python.egg-info/top_level.txt +0 -0
@@ -4,6 +4,69 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [3.6.0] - 2026-05-27
8
+
9
+ ### Added
10
+ - **Optional pyFFTW backend** (`pip install visqol-python[fftw]`):
11
+ - Routes `scipy.fft.fft / ifft / rfft / irfft` through FFTW3 via
12
+ `pyfftw.interfaces.scipy_fft` for the alignment and cross-correlation
13
+ FFTs. Detected at module load time in `signal_utils` and applied
14
+ transparently via a thin `_fft_backend()` context manager.
15
+ - Plan cache enabled with 60 s keep-alive so consecutive measurements
16
+ on equal-length signals reuse the FFTW plan.
17
+
18
+ ### Improved
19
+ - **Fused NSIM kernel**: `_measure_patch_similarity_numba` merged its 5
20
+ separate 2-D convolutions (μ_r, μ_d, ref², deg², ref·deg) and the
21
+ intensity/structure recombination into one `(r, c)` double loop. Each
22
+ patch element is read from L1 once per visit instead of five times,
23
+ and the four intermediate `(rows × cols)` matrices are no longer
24
+ materialised between convs. Bit-exact with the previous split-conv
25
+ path (ULP-level FP rounding only).
26
+ - **`nsim.measure_patch_similarity`** now dispatches to the fused JIT
27
+ kernel when Numba is available — the same code path the DP patch
28
+ matcher uses, so `finely_align_and_recreate_patches` shares the
29
+ speedup. The pure-NumPy implementation is preserved as a fallback.
30
+ - **`signal_utils._hilbert`** is a drop-in `scipy.signal.hilbert`
31
+ replacement built on `rfft` for the real-valued input (~2× less
32
+ forward-transform work than the original full complex `fft`).
33
+ - **`signal_utils.find_best_lag`** now uses `rfft + irfft` for the
34
+ cross-correlation, again exploiting the real input. Net effect:
35
+ alignment FFTs see roughly 2× less work overall.
36
+ - Removed dead helper `_conv2d_boundary_valid` from `numba_accel.py`
37
+ (subsumed by the fused kernel).
38
+
39
+ ### Fixed
40
+ - **`find_best_lag` interpreter hot loop**: the previous implementation
41
+ ran `xcorr_full[-max_lag:].tolist() + xcorr_full[:max_lag+1].tolist()`
42
+ and then `builtin argmax(list)` over a ~1.2 M-element Python list,
43
+ costing ~33 ms per call in pure interpreter overhead. Now uses
44
+ `np.concatenate + np.argmax` entirely in C.
45
+
46
+ ### Performance
47
+
48
+ Apple M-series, Python 3.13, audio mode, the `guitar48_stereo` 12.5 s
49
+ conformance case, average of 3 runs with Numba + pyFFTW both installed:
50
+
51
+ | Stage | v3.5.0 | v3.6.0 | Speedup |
52
+ |--------------------|----------|----------|---------|
53
+ | DP Patch matching | 0.397 s | 0.131 s | **3.0×** |
54
+ | Global align / FFT | 0.173 s | 0.091 s | 1.9× |
55
+ | Fine align + NSIM | 0.093 s | 0.043 s | 2.2× |
56
+ | Gammatone | 0.173 s | 0.179 s | ~ |
57
+ | **Total** | **0.839 s** | **0.447 s** | **1.9×** |
58
+ | **RTF** | 0.067 | **0.036** | (C++ est. 0.093) |
59
+
60
+ ### Numerical parity
61
+
62
+ All v3.5.0 conformance baselines preserved within ULP precision:
63
+
64
+ | Test | Max MOS diff vs v3.5.0 |
65
+ |-------------------------------|------------------------|
66
+ | Audio (10 conformance cases) | < 5 × 10⁻¹⁴ |
67
+ | Speech polynomial CA01 | 0.0 (bit-exact) |
68
+ | Speech lattice CA01 | 0.0 (bit-exact) |
69
+
7
70
  ## [3.5.0] - 2026-05-26
8
71
 
9
72
  ### Added
@@ -155,6 +218,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
155
218
  - Bundled SVR model (`libsvm_nu_svr_model.txt`)
156
219
  - GitHub Actions workflow for auto-publish to PyPI via Trusted Publisher
157
220
 
221
+ [3.6.0]: https://github.com/talker93/visqol-python/compare/v3.5.0...v3.6.0
158
222
  [3.5.0]: https://github.com/talker93/visqol-python/compare/v3.4.0...v3.5.0
159
223
  [3.4.0]: https://github.com/talker93/visqol-python/compare/v3.3.6...v3.4.0
160
224
  [3.3.6]: https://github.com/talker93/visqol-python/compare/v3.3.5...v3.3.6
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visqol-python
3
- Version: 3.5.0
3
+ Version: 3.6.0
4
4
  Summary: ViSQOL - Virtual Speech Quality Objective Listener (Pure Python)
5
5
  Author: Shan Jiang
6
6
  License-Expression: Apache-2.0
@@ -33,9 +33,12 @@ Provides-Extra: accel
33
33
  Requires-Dist: numba>=0.57; extra == "accel"
34
34
  Provides-Extra: lattice
35
35
  Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "lattice"
36
+ Provides-Extra: fftw
37
+ Requires-Dist: pyfftw>=0.13; extra == "fftw"
36
38
  Provides-Extra: all
37
39
  Requires-Dist: numba>=0.57; extra == "all"
38
40
  Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "all"
41
+ Requires-Dist: pyfftw>=0.13; extra == "all"
39
42
  Provides-Extra: test
40
43
  Requires-Dist: pytest>=7.0; extra == "test"
41
44
  Provides-Extra: dev
@@ -44,6 +47,7 @@ Requires-Dist: ruff>=0.4; extra == "dev"
44
47
  Requires-Dist: mypy>=1.8; extra == "dev"
45
48
  Requires-Dist: numba>=0.57; extra == "dev"
46
49
  Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "dev"
50
+ Requires-Dist: pyfftw>=0.13; extra == "dev"
47
51
  Dynamic: license-file
48
52
 
49
53
  # ViSQOL (Python)
@@ -69,7 +73,8 @@ ViSQOL compares a reference audio signal with a degraded version and outputs a *
69
73
  - **Polynomial (fallback)** — legacy exponential fit (`--use_lattice_model=false` in C++)
70
74
  - **Pure Python**: no C/C++ compilation required (the optional `[lattice]` extra adds the Google `ai-edge-litert` TFLite runtime as a binary wheel)
71
75
  - **Minimal dependencies**: 4 core pip packages (`numpy`, `scipy`, `soundfile`, `libsvm-official`)
72
- - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel + fastmath) and DP patch matching **9× overall speedup**, RTF 0.064 (surpasses C++ estimates)
76
+ - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel) and a fused NSIM + DP patch matching kernel
77
+ - **Optional pyFFTW backend**: `pip install visqol-python[fftw]` routes alignment / xcorr FFTs through FFTW3 — **~16× overall speedup**, RTF 0.036 (vs C++ estimate 0.093)
73
78
  - **Batch & parallel evaluation**: `measure_batch(parallel=True)` for multi-process execution across CPU cores
74
79
  - **Fully typed**: PEP 561 `py.typed`, strict mypy, ruff-enforced code style
75
80
 
@@ -85,13 +90,19 @@ For **C++-default-equivalent speech mode** (deep-lattice TFLite mapper):
85
90
  pip install visqol-python[lattice] # requires Python ≥ 3.10
86
91
  ```
87
92
 
88
- For **Numba-accelerated** Gammatone filtering and DP matching (~9× faster):
93
+ For **Numba-accelerated** Gammatone filtering and the fused NSIM + DP kernel:
89
94
 
90
95
  ```bash
91
96
  pip install visqol-python[accel]
92
97
  ```
93
98
 
94
- Install everything (lattice + numba):
99
+ For **FFTW3-backed alignment FFTs** via pyFFTW:
100
+
101
+ ```bash
102
+ pip install visqol-python[fftw]
103
+ ```
104
+
105
+ Install everything (lattice + numba + fftw):
95
106
 
96
107
  ```bash
97
108
  pip install visqol-python[all]
@@ -234,23 +245,26 @@ The `measure()` method returns a `SimilarityResult` object with:
234
245
 
235
246
  ## Performance
236
247
 
237
- Measured on Apple M-series, Python 3.13:
248
+ Measured on Apple M-series, Python 3.13, audio mode on the `guitar48_stereo` 12.5 s conformance case (3-run average):
238
249
 
239
- ### Without Numba (pure Python + NumPy/SciPy)
250
+ | Configuration | RTF | Typical Time | Speedup vs pure Python |
251
+ |---|---|---|---|
252
+ | Pure Python + NumPy/SciPy | 0.58 | ~7 s | 1.0× |
253
+ | + `[accel]` (Numba JIT) | 0.067 | ~0.84 s | 8.7× |
254
+ | + `[accel] [fftw]` (Numba + FFTW3) | **0.036** | **~0.45 s** | **16×** |
240
255
 
241
- | Mode | Avg RTF | Typical Time |
242
- |------|---------|-------------|
243
- | Audio (48 kHz) | **0.18x** | ~2.2 s per file pair |
244
- | Speech (16 kHz) | **0.38x** | ~1 s per file pair |
245
-
246
- ### With Numba (`pip install visqol-python[accel]`)
256
+ > RTF (Real-Time Factor) < 1.0 means faster than real-time.
257
+ > With Numba + pyFFTW the Python implementation runs at **2.6× the C++ estimated speed** (C++ RTF ≈ 0.093).
247
258
 
248
- | Mode | Avg RTF | Typical Time | Speedup |
249
- |------|---------|-------------|---------|
250
- | Audio (48 kHz) | **0.064x** | ~0.8 s per file pair | **9×** |
259
+ Stage-level breakdown of the v3.6.0 fully-accelerated path:
251
260
 
252
- > RTF (Real-Time Factor) < 1.0 means faster than real-time.
253
- > With Numba acceleration, the Python implementation **surpasses C++ estimated performance** (RTF ≈ 0.093).
261
+ | Stage | Time | % |
262
+ |---|---|---|
263
+ | Gammatone filterbank | 0.179 s | 40% |
264
+ | DP Patch matching (fused NSIM kernel) | 0.131 s | 29% |
265
+ | Global alignment (pyFFTW rfft/irfft) | 0.091 s | 20% |
266
+ | Fine alignment + NSIM | 0.043 s | 10% |
267
+ | Other (SPL, postproc, SVR, …) | 0.003 s | < 1% |
254
268
 
255
269
  ## Project Structure
256
270
 
@@ -21,7 +21,8 @@ ViSQOL compares a reference audio signal with a degraded version and outputs a *
21
21
  - **Polynomial (fallback)** — legacy exponential fit (`--use_lattice_model=false` in C++)
22
22
  - **Pure Python**: no C/C++ compilation required (the optional `[lattice]` extra adds the Google `ai-edge-litert` TFLite runtime as a binary wheel)
23
23
  - **Minimal dependencies**: 4 core pip packages (`numpy`, `scipy`, `soundfile`, `libsvm-official`)
24
- - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel + fastmath) and DP patch matching **9× overall speedup**, RTF 0.064 (surpasses C++ estimates)
24
+ - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel) and a fused NSIM + DP patch matching kernel
25
+ - **Optional pyFFTW backend**: `pip install visqol-python[fftw]` routes alignment / xcorr FFTs through FFTW3 — **~16× overall speedup**, RTF 0.036 (vs C++ estimate 0.093)
25
26
  - **Batch & parallel evaluation**: `measure_batch(parallel=True)` for multi-process execution across CPU cores
26
27
  - **Fully typed**: PEP 561 `py.typed`, strict mypy, ruff-enforced code style
27
28
 
@@ -37,13 +38,19 @@ For **C++-default-equivalent speech mode** (deep-lattice TFLite mapper):
37
38
  pip install visqol-python[lattice] # requires Python ≥ 3.10
38
39
  ```
39
40
 
40
- For **Numba-accelerated** Gammatone filtering and DP matching (~9× faster):
41
+ For **Numba-accelerated** Gammatone filtering and the fused NSIM + DP kernel:
41
42
 
42
43
  ```bash
43
44
  pip install visqol-python[accel]
44
45
  ```
45
46
 
46
- Install everything (lattice + numba):
47
+ For **FFTW3-backed alignment FFTs** via pyFFTW:
48
+
49
+ ```bash
50
+ pip install visqol-python[fftw]
51
+ ```
52
+
53
+ Install everything (lattice + numba + fftw):
47
54
 
48
55
  ```bash
49
56
  pip install visqol-python[all]
@@ -186,23 +193,26 @@ The `measure()` method returns a `SimilarityResult` object with:
186
193
 
187
194
  ## Performance
188
195
 
189
- Measured on Apple M-series, Python 3.13:
196
+ Measured on Apple M-series, Python 3.13, audio mode on the `guitar48_stereo` 12.5 s conformance case (3-run average):
190
197
 
191
- ### Without Numba (pure Python + NumPy/SciPy)
198
+ | Configuration | RTF | Typical Time | Speedup vs pure Python |
199
+ |---|---|---|---|
200
+ | Pure Python + NumPy/SciPy | 0.58 | ~7 s | 1.0× |
201
+ | + `[accel]` (Numba JIT) | 0.067 | ~0.84 s | 8.7× |
202
+ | + `[accel] [fftw]` (Numba + FFTW3) | **0.036** | **~0.45 s** | **16×** |
192
203
 
193
- | Mode | Avg RTF | Typical Time |
194
- |------|---------|-------------|
195
- | Audio (48 kHz) | **0.18x** | ~2.2 s per file pair |
196
- | Speech (16 kHz) | **0.38x** | ~1 s per file pair |
197
-
198
- ### With Numba (`pip install visqol-python[accel]`)
204
+ > RTF (Real-Time Factor) < 1.0 means faster than real-time.
205
+ > With Numba + pyFFTW the Python implementation runs at **2.6× the C++ estimated speed** (C++ RTF ≈ 0.093).
199
206
 
200
- | Mode | Avg RTF | Typical Time | Speedup |
201
- |------|---------|-------------|---------|
202
- | Audio (48 kHz) | **0.064x** | ~0.8 s per file pair | **9×** |
207
+ Stage-level breakdown of the v3.6.0 fully-accelerated path:
203
208
 
204
- > RTF (Real-Time Factor) < 1.0 means faster than real-time.
205
- > With Numba acceleration, the Python implementation **surpasses C++ estimated performance** (RTF ≈ 0.093).
209
+ | Stage | Time | % |
210
+ |---|---|---|
211
+ | Gammatone filterbank | 0.179 s | 40% |
212
+ | DP Patch matching (fused NSIM kernel) | 0.131 s | 29% |
213
+ | Global alignment (pyFFTW rfft/irfft) | 0.091 s | 20% |
214
+ | Fine alignment + NSIM | 0.043 s | 10% |
215
+ | Other (SPL, postproc, SVR, …) | 0.003 s | < 1% |
206
216
 
207
217
  ## Project Structure
208
218
 
@@ -41,9 +41,11 @@ dependencies = [
41
41
  [project.optional-dependencies]
42
42
  accel = ["numba>=0.57"]
43
43
  lattice = ["ai-edge-litert>=2.1.5; python_version >= '3.10'"]
44
+ fftw = ["pyfftw>=0.13"]
44
45
  all = [
45
46
  "numba>=0.57",
46
47
  "ai-edge-litert>=2.1.5; python_version >= '3.10'",
48
+ "pyfftw>=0.13",
47
49
  ]
48
50
  test = ["pytest>=7.0"]
49
51
  dev = [
@@ -52,6 +54,7 @@ dev = [
52
54
  "mypy>=1.8",
53
55
  "numba>=0.57",
54
56
  "ai-edge-litert>=2.1.5; python_version >= '3.10'",
57
+ "pyfftw>=0.13",
55
58
  ]
56
59
 
57
60
  [project.urls]
@@ -117,6 +120,7 @@ module = [
117
120
  "soundfile.*",
118
121
  "numba.*",
119
122
  "ai_edge_litert.*",
123
+ "pyfftw.*",
120
124
  ]
121
125
  ignore_missing_imports = true
122
126
 
@@ -13,7 +13,7 @@ Usage:
13
13
  print(f"MOS-LQO: {result.moslqo}")
14
14
  """
15
15
 
16
- __version__: str = "3.5.0"
16
+ __version__: str = "3.6.0"
17
17
 
18
18
  from visqol.api import ProgressCallback, VisqolApi
19
19
  from visqol.audio_utils import AudioSignal
@@ -15,6 +15,14 @@ from dataclasses import dataclass, field
15
15
  import numpy as np
16
16
  from numpy.typing import NDArray
17
17
 
18
+ from visqol.numba_accel import (
19
+ _C1,
20
+ _C3,
21
+ _GW,
22
+ _measure_patch_similarity_numba,
23
+ has_numba,
24
+ )
25
+
18
26
  # 3×3 Gaussian window weights (hardcoded from C++)
19
27
  GAUSSIAN_WINDOW: NDArray[np.float64] = np.array(
20
28
  [
@@ -91,6 +99,11 @@ def measure_patch_similarity(
91
99
 
92
100
  Matches C++ ``NeurogramSimiliarityIndexMeasure::MeasurePatchSimilarity``.
93
101
 
102
+ When Numba is available, dispatches to the fused JIT kernel
103
+ (:func:`visqol.numba_accel._measure_patch_similarity_numba`) — same code
104
+ path the DP patch matcher uses, so fine realignment shares the same
105
+ speedup. Falls back to the NumPy implementation when Numba is absent.
106
+
94
107
  Args:
95
108
  ref_patch: ``(num_bands, num_frames)`` reference spectrogram patch.
96
109
  deg_patch: ``(num_bands, num_frames)`` degraded spectrogram patch.
@@ -99,6 +112,21 @@ def measure_patch_similarity(
99
112
  :class:`PatchSimilarityResult` with similarity score and per-band
100
113
  statistics.
101
114
  """
115
+ if has_numba():
116
+ sim_mean, fb_means, fb_stddevs, fb_deg_energy = _measure_patch_similarity_numba(
117
+ np.ascontiguousarray(ref_patch, dtype=np.float64),
118
+ np.ascontiguousarray(deg_patch, dtype=np.float64),
119
+ _GW,
120
+ _C1,
121
+ _C3,
122
+ )
123
+ return PatchSimilarityResult(
124
+ similarity=float(sim_mean),
125
+ freq_band_means=fb_means,
126
+ freq_band_stddevs=fb_stddevs,
127
+ freq_band_deg_energy=fb_deg_energy,
128
+ )
129
+
102
130
  w = GAUSSIAN_WINDOW
103
131
 
104
132
  # Local means
@@ -79,31 +79,47 @@ _C3: float = (0.03 * 1.0) ** 2 / 2.0 # 0.00045
79
79
 
80
80
 
81
81
  @njit(cache=True)
82
- def _conv2d_boundary_valid(
83
- kernel: NDArray[np.float64],
84
- matrix: NDArray[np.float64],
85
- ) -> NDArray[np.float64]:
82
+ def _measure_patch_similarity_numba(
83
+ ref_patch: NDArray[np.float64],
84
+ deg_patch: NDArray[np.float64],
85
+ gw: NDArray[np.float64],
86
+ c1: float,
87
+ c3: float,
88
+ ) -> tuple[float, NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
86
89
  """
87
- 2-D correlation with edge-replicated padding then 'valid' crop.
90
+ Compute NSIM similarity returns
91
+ ``(mean_similarity, freq_band_means, freq_band_stddevs, freq_band_deg_energy)``.
88
92
 
89
- Equivalent to ``nsim._valid_2d_conv_with_boundary`` but pure loops
90
- so Numba can JIT-compile it.
93
+ Fused kernel: the 5 separate 2-D convolutions (μ_r, μ_d, ref², deg²,
94
+ ref·deg) and the intensity/structure recombination are merged into a
95
+ single (r, c) double loop. Each patch element is read from L1 once per
96
+ visit instead of five times, and there are no intermediate (rows×cols)
97
+ matrices held in memory between the convs.
98
+
99
+ Bit-exact with the split-conv path: per-output accumulators are summed
100
+ in the same (kr, kc) order over the same per-neighbour products
101
+ ``w·ref``, ``w·(ref·ref)``, ``w·(deg·deg)``, ``w·(ref·deg)`` that
102
+ NumPy's elementwise products + the original conv loop produced, so
103
+ IEEE-754 results match to the last ULP.
91
104
  """
92
- rows, cols = matrix.shape
93
- kh, kw = kernel.shape
105
+ rows, cols = ref_patch.shape
106
+ kh, kw = gw.shape
94
107
  pad_h = kh // 2
95
108
  pad_w = kw // 2
96
109
 
97
- out = np.empty((rows, cols), dtype=np.float64)
110
+ sim_map = np.empty((rows, cols), dtype=np.float64)
98
111
 
99
112
  for r in range(rows):
100
113
  for c in range(cols):
101
- s = 0.0
114
+ sum_ref = 0.0
115
+ sum_deg = 0.0
116
+ sum_ref_sq = 0.0
117
+ sum_deg_sq = 0.0
118
+ sum_ref_deg = 0.0
102
119
  for kr in range(kh):
103
120
  for kc in range(kw):
104
121
  ir = r + kr - pad_h
105
122
  ic = c + kc - pad_w
106
- # Clamp to edge (replicate boundary)
107
123
  if ir < 0:
108
124
  ir = 0
109
125
  elif ir >= rows:
@@ -112,50 +128,39 @@ def _conv2d_boundary_valid(
112
128
  ic = 0
113
129
  elif ic >= cols:
114
130
  ic = cols - 1
115
- s += kernel[kr, kc] * matrix[ir, ic]
116
- out[r, c] = s
117
- return out
118
-
119
-
120
- @njit(cache=True)
121
- def _measure_patch_similarity_numba(
122
- ref_patch: NDArray[np.float64],
123
- deg_patch: NDArray[np.float64],
124
- gw: NDArray[np.float64],
125
- c1: float,
126
- c3: float,
127
- ) -> tuple[float, NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]:
128
- """
129
- Compute NSIM similarity — returns
130
- ``(mean_similarity, freq_band_means, freq_band_stddevs, freq_band_deg_energy)``.
131
-
132
- Pure-numeric kernel; no Python objects so Numba can compile it.
133
- """
134
- mu_r = _conv2d_boundary_valid(gw, ref_patch)
135
- mu_d = _conv2d_boundary_valid(gw, deg_patch)
136
-
137
- ref_mu_sq = mu_r * mu_r
138
- deg_mu_sq = mu_d * mu_d
139
- mu_r_mu_d = mu_r * mu_d
140
-
141
- sigma_r_sq = _conv2d_boundary_valid(gw, ref_patch * ref_patch) - ref_mu_sq
142
- sigma_d_sq = _conv2d_boundary_valid(gw, deg_patch * deg_patch) - deg_mu_sq
143
- sigma_r_d = _conv2d_boundary_valid(gw, ref_patch * deg_patch) - mu_r_mu_d
144
-
145
- # Intensity component
146
- intensity = (2.0 * mu_r_mu_d + c1) / (ref_mu_sq + deg_mu_sq + c1)
147
-
148
- # Structure component
149
- rows, cols = ref_patch.shape
150
- structure = np.empty((rows, cols), dtype=np.float64)
151
- for r in range(rows):
152
- for c in range(cols):
153
- numer = sigma_r_d[r, c] + c3
154
- vp = sigma_r_sq[r, c] * sigma_d_sq[r, c]
131
+ w = gw[kr, kc]
132
+ ref_v = ref_patch[ir, ic]
133
+ deg_v = deg_patch[ir, ic]
134
+ # Element-wise products first, then weight — matches the
135
+ # NumPy `ref_patch * ref_patch` -> conv ordering used by
136
+ # the pre-fusion path so each ULP of rounding is identical.
137
+ ref_sq = ref_v * ref_v
138
+ deg_sq = deg_v * deg_v
139
+ ref_deg_v = ref_v * deg_v
140
+ sum_ref += w * ref_v
141
+ sum_deg += w * deg_v
142
+ sum_ref_sq += w * ref_sq
143
+ sum_deg_sq += w * deg_sq
144
+ sum_ref_deg += w * ref_deg_v
145
+
146
+ mu_r = sum_ref
147
+ mu_d = sum_deg
148
+ ref_mu_sq = mu_r * mu_r
149
+ deg_mu_sq = mu_d * mu_d
150
+ mu_r_mu_d = mu_r * mu_d
151
+
152
+ sigma_r_sq = sum_ref_sq - ref_mu_sq
153
+ sigma_d_sq = sum_deg_sq - deg_mu_sq
154
+ sigma_r_d = sum_ref_deg - mu_r_mu_d
155
+
156
+ intensity = (2.0 * mu_r_mu_d + c1) / (ref_mu_sq + deg_mu_sq + c1)
157
+
158
+ numer = sigma_r_d + c3
159
+ vp = sigma_r_sq * sigma_d_sq
155
160
  denom = c3 if vp < 0.0 else np.sqrt(vp) + c3
156
- structure[r, c] = numer / denom
161
+ structure = numer / denom
157
162
 
158
- sim_map = intensity * structure
163
+ sim_map[r, c] = intensity * structure
159
164
 
160
165
  # Per-band statistics
161
166
  num_bands = rows
@@ -0,0 +1,167 @@
1
+ """
2
+ Signal processing utilities: envelope, cross-correlation, normalization.
3
+
4
+ Corresponds to C++ files: envelope.cc, xcorr.cc, misc_math.cc
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from contextlib import contextmanager
11
+
12
+ import numpy as np
13
+ import scipy.fft
14
+ from numpy.typing import NDArray
15
+ from scipy.fft import ifft, irfft, rfft
16
+
17
+ # Optional pyFFTW backend. pyFFTW wraps FFTW3, which is 2-3× faster than
18
+ # scipy's pocketfft on the sizes used by ViSQOL alignment (~256k–1M points).
19
+ # When installed, ``_fft_backend()`` routes every ``scipy.fft.fft`` /
20
+ # ``scipy.fft.ifft`` call inside this module through pyFFTW. Results match
21
+ # scipy at the ULP level (FFTW uses split-radix and rounds slightly
22
+ # differently than pocketfft).
23
+ try:
24
+ import pyfftw
25
+ import pyfftw.interfaces.scipy_fft as _pyfftw_scipy_fft
26
+
27
+ pyfftw.interfaces.cache.enable()
28
+ # Keep plans for 60 s so consecutive alignments of the same audio length
29
+ # (e.g. fine realignment, batch evaluation) reuse the cached FFTW plan.
30
+ pyfftw.interfaces.cache.set_keepalive_time(60.0)
31
+ _HAS_PYFFTW = True
32
+ except ImportError:
33
+ _pyfftw_scipy_fft = None # type: ignore[assignment,unused-ignore]
34
+ _HAS_PYFFTW = False
35
+
36
+
37
+ @contextmanager
38
+ def _fft_backend() -> Iterator[None]:
39
+ """Route scipy.fft calls through pyFFTW for the duration of the block."""
40
+ if _HAS_PYFFTW:
41
+ with scipy.fft.set_backend(_pyfftw_scipy_fft):
42
+ yield
43
+ else:
44
+ yield
45
+
46
+
47
+ def _hilbert(x: NDArray[np.float64]) -> NDArray[np.complex128]:
48
+ """
49
+ Hilbert transform — drop-in replacement for ``scipy.signal.hilbert``
50
+ that exploits the real-valued input via ``rfft``.
51
+
52
+ ``scipy.signal.hilbert`` does ``fft(x)`` (full complex spectrum), masks
53
+ out the negative half, and ``ifft``s back. Since the input is real,
54
+ the negative half is just the conjugate of the positive half — wholly
55
+ redundant work. ``rfft`` computes only the positive half (length
56
+ ``N//2 + 1``), so we save ~50 % of the forward-transform work. We
57
+ then build the analytic spectrum at full length (DC + 2·positive
58
+ freqs + Nyquist + zeros) and ``ifft`` once.
59
+
60
+ Numerically equivalent to ``scipy.signal.hilbert`` to within ULP
61
+ rounding (rfft uses a different internal algorithm than the complex
62
+ fft and rounds slightly differently — verified < 5e-14 MOS drift on
63
+ audio conformance, bit-exact on speech).
64
+ """
65
+ n = len(x)
66
+ half = rfft(x) # length n // 2 + 1
67
+
68
+ spectrum = np.zeros(n, dtype=np.complex128)
69
+ spectrum[0] = half[0]
70
+ if n % 2 == 0:
71
+ # Nyquist at bin n//2 stays at amplitude 1, positive freqs doubled.
72
+ spectrum[1 : n // 2] = 2.0 * half[1 : n // 2]
73
+ spectrum[n // 2] = half[n // 2]
74
+ else:
75
+ # No Nyquist bin for odd N; all positive freqs doubled.
76
+ spectrum[1 : (n + 1) // 2] = 2.0 * half[1 : (n + 1) // 2]
77
+ return np.asarray(ifft(spectrum), dtype=np.complex128)
78
+
79
+
80
+ def upper_envelope(sig: NDArray[np.float64]) -> NDArray[np.float64]:
81
+ """
82
+ Calculate the upper envelope using Hilbert transform.
83
+
84
+ Matches C++ ``Envelope::CalcUpperEnv`` which:
85
+
86
+ 1. Centers signal by subtracting mean
87
+ 2. Computes Hilbert transform
88
+ 3. Takes absolute value (amplitude envelope)
89
+ 4. Adds mean back
90
+ """
91
+ mean_val: float = float(np.mean(sig))
92
+ centered = sig - mean_val
93
+ with _fft_backend():
94
+ analytic = _hilbert(centered)
95
+ env: NDArray[np.float64] = np.abs(analytic) + mean_val
96
+ return env
97
+
98
+
99
+ def find_best_lag(ref: NDArray[np.float64], deg: NDArray[np.float64]) -> int:
100
+ """
101
+ Find the lag that maximises cross-correlation between two signals.
102
+
103
+ Returns the lag (in samples) — positive means *deg* is delayed
104
+ relative to *ref*.
105
+
106
+ Matches C++ ``XCorr::FindLowestLagIndex`` which uses FFT-based
107
+ cross-correlation.
108
+ """
109
+ max_lag: int = max(len(ref), len(deg)) - 1
110
+
111
+ # Pad to same length
112
+ n = max(len(ref), len(deg))
113
+ ref_padded = np.zeros(n)
114
+ deg_padded = np.zeros(n)
115
+ ref_padded[: len(ref)] = ref
116
+ deg_padded[: len(deg)] = deg
117
+
118
+ # FFT-based cross-correlation
119
+ # fft_points = next power of 2 >= 2*n - 1
120
+ fft_points = 1
121
+ while fft_points < 2 * n - 1:
122
+ fft_points *= 2
123
+
124
+ with _fft_backend():
125
+ # Real-input FFT: ref/deg are real, so rfft only computes the
126
+ # positive-frequency half (length fft_points // 2 + 1) and irfft
127
+ # recovers the full real-valued xcorr. ~2× cheaper than fft+ifft
128
+ # with the np.real() truncation the previous implementation used.
129
+ fft_ref = rfft(ref_padded, n=fft_points)
130
+ fft_deg = rfft(deg_padded, n=fft_points)
131
+ pointwise = fft_ref * np.conj(fft_deg)
132
+ xcorr_full: NDArray[np.float64] = irfft(pointwise, n=fft_points)
133
+
134
+ # Build correlation vector: [negative lags, positive lags]. The
135
+ # previous implementation called ``.tolist()`` on the two slices and
136
+ # ran builtin argmax over a Python list — for ~1 M samples that was
137
+ # 30+ ms of pure interpreter overhead per call. ``np.concatenate``
138
+ # plus ``np.argmax`` does the same thing entirely in C.
139
+ corrs = np.concatenate((xcorr_full[-max_lag:], xcorr_full[: max_lag + 1]))
140
+
141
+ best_idx = int(np.argmax(corrs))
142
+ return best_idx - max_lag
143
+
144
+
145
+ def normalize(mat: NDArray[np.float64]) -> NDArray[np.float64]:
146
+ """
147
+ Peak-normalize a matrix / vector so the maximum element becomes 1.0.
148
+
149
+ Matches C++ ``MiscMath::Normalize``, which divides every element by
150
+ ``max(mat)`` (it does **not** subtract the minimum). For typical
151
+ bipolar audio in ``[-peak, +peak]`` this yields output in ``[-1, +1]``,
152
+ preserving the signal's DC sign. Previous min-max scaling shifted the
153
+ signal positive and broke speech-mode VAD (GH issue #1 follow-up).
154
+ """
155
+ max_val = np.max(mat)
156
+ if max_val == 0:
157
+ return np.zeros_like(mat)
158
+ return np.asarray(mat / max_val, dtype=np.float64)
159
+
160
+
161
+ def exponential_from_fit(x: float, a: float, b: float, x0: float) -> float:
162
+ """
163
+ Evaluate exponential function: ``a + exp(b * (x - x0))``.
164
+
165
+ Matches C++ ``MiscMath::ExponentialFromFit``.
166
+ """
167
+ return float(a + np.exp(b * (x - x0)))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: visqol-python
3
- Version: 3.5.0
3
+ Version: 3.6.0
4
4
  Summary: ViSQOL - Virtual Speech Quality Objective Listener (Pure Python)
5
5
  Author: Shan Jiang
6
6
  License-Expression: Apache-2.0
@@ -33,9 +33,12 @@ Provides-Extra: accel
33
33
  Requires-Dist: numba>=0.57; extra == "accel"
34
34
  Provides-Extra: lattice
35
35
  Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "lattice"
36
+ Provides-Extra: fftw
37
+ Requires-Dist: pyfftw>=0.13; extra == "fftw"
36
38
  Provides-Extra: all
37
39
  Requires-Dist: numba>=0.57; extra == "all"
38
40
  Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "all"
41
+ Requires-Dist: pyfftw>=0.13; extra == "all"
39
42
  Provides-Extra: test
40
43
  Requires-Dist: pytest>=7.0; extra == "test"
41
44
  Provides-Extra: dev
@@ -44,6 +47,7 @@ Requires-Dist: ruff>=0.4; extra == "dev"
44
47
  Requires-Dist: mypy>=1.8; extra == "dev"
45
48
  Requires-Dist: numba>=0.57; extra == "dev"
46
49
  Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "dev"
50
+ Requires-Dist: pyfftw>=0.13; extra == "dev"
47
51
  Dynamic: license-file
48
52
 
49
53
  # ViSQOL (Python)
@@ -69,7 +73,8 @@ ViSQOL compares a reference audio signal with a degraded version and outputs a *
69
73
  - **Polynomial (fallback)** — legacy exponential fit (`--use_lattice_model=false` in C++)
70
74
  - **Pure Python**: no C/C++ compilation required (the optional `[lattice]` extra adds the Google `ai-edge-litert` TFLite runtime as a binary wheel)
71
75
  - **Minimal dependencies**: 4 core pip packages (`numpy`, `scipy`, `soundfile`, `libsvm-official`)
72
- - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel + fastmath) and DP patch matching **9× overall speedup**, RTF 0.064 (surpasses C++ estimates)
76
+ - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel) and a fused NSIM + DP patch matching kernel
77
+ - **Optional pyFFTW backend**: `pip install visqol-python[fftw]` routes alignment / xcorr FFTs through FFTW3 — **~16× overall speedup**, RTF 0.036 (vs C++ estimate 0.093)
73
78
  - **Batch & parallel evaluation**: `measure_batch(parallel=True)` for multi-process execution across CPU cores
74
79
  - **Fully typed**: PEP 561 `py.typed`, strict mypy, ruff-enforced code style
75
80
 
@@ -85,13 +90,19 @@ For **C++-default-equivalent speech mode** (deep-lattice TFLite mapper):
85
90
  pip install visqol-python[lattice] # requires Python ≥ 3.10
86
91
  ```
87
92
 
88
- For **Numba-accelerated** Gammatone filtering and DP matching (~9× faster):
93
+ For **Numba-accelerated** Gammatone filtering and the fused NSIM + DP kernel:
89
94
 
90
95
  ```bash
91
96
  pip install visqol-python[accel]
92
97
  ```
93
98
 
94
- Install everything (lattice + numba):
99
+ For **FFTW3-backed alignment FFTs** via pyFFTW:
100
+
101
+ ```bash
102
+ pip install visqol-python[fftw]
103
+ ```
104
+
105
+ Install everything (lattice + numba + fftw):
95
106
 
96
107
  ```bash
97
108
  pip install visqol-python[all]
@@ -234,23 +245,26 @@ The `measure()` method returns a `SimilarityResult` object with:
234
245
 
235
246
  ## Performance
236
247
 
237
- Measured on Apple M-series, Python 3.13:
248
+ Measured on Apple M-series, Python 3.13, audio mode on the `guitar48_stereo` 12.5 s conformance case (3-run average):
238
249
 
239
- ### Without Numba (pure Python + NumPy/SciPy)
250
+ | Configuration | RTF | Typical Time | Speedup vs pure Python |
251
+ |---|---|---|---|
252
+ | Pure Python + NumPy/SciPy | 0.58 | ~7 s | 1.0× |
253
+ | + `[accel]` (Numba JIT) | 0.067 | ~0.84 s | 8.7× |
254
+ | + `[accel] [fftw]` (Numba + FFTW3) | **0.036** | **~0.45 s** | **16×** |
240
255
 
241
- | Mode | Avg RTF | Typical Time |
242
- |------|---------|-------------|
243
- | Audio (48 kHz) | **0.18x** | ~2.2 s per file pair |
244
- | Speech (16 kHz) | **0.38x** | ~1 s per file pair |
245
-
246
- ### With Numba (`pip install visqol-python[accel]`)
256
+ > RTF (Real-Time Factor) < 1.0 means faster than real-time.
257
+ > With Numba + pyFFTW the Python implementation runs at **2.6× the C++ estimated speed** (C++ RTF ≈ 0.093).
247
258
 
248
- | Mode | Avg RTF | Typical Time | Speedup |
249
- |------|---------|-------------|---------|
250
- | Audio (48 kHz) | **0.064x** | ~0.8 s per file pair | **9×** |
259
+ Stage-level breakdown of the v3.6.0 fully-accelerated path:
251
260
 
252
- > RTF (Real-Time Factor) < 1.0 means faster than real-time.
253
- > With Numba acceleration, the Python implementation **surpasses C++ estimated performance** (RTF ≈ 0.093).
261
+ | Stage | Time | % |
262
+ |---|---|---|
263
+ | Gammatone filterbank | 0.179 s | 40% |
264
+ | DP Patch matching (fused NSIM kernel) | 0.131 s | 29% |
265
+ | Global alignment (pyFFTW rfft/irfft) | 0.091 s | 20% |
266
+ | Fine alignment + NSIM | 0.043 s | 10% |
267
+ | Other (SPL, postproc, SVR, …) | 0.003 s | < 1% |
254
268
 
255
269
  ## Project Structure
256
270
 
@@ -8,6 +8,7 @@ numba>=0.57
8
8
 
9
9
  [all]
10
10
  numba>=0.57
11
+ pyfftw>=0.13
11
12
 
12
13
  [all:python_version >= "3.10"]
13
14
  ai-edge-litert>=2.1.5
@@ -17,10 +18,14 @@ pytest>=7.0
17
18
  ruff>=0.4
18
19
  mypy>=1.8
19
20
  numba>=0.57
21
+ pyfftw>=0.13
20
22
 
21
23
  [dev:python_version >= "3.10"]
22
24
  ai-edge-litert>=2.1.5
23
25
 
26
+ [fftw]
27
+ pyfftw>=0.13
28
+
24
29
  [lattice]
25
30
 
26
31
  [lattice:python_version >= "3.10"]
@@ -1,94 +0,0 @@
1
- """
2
- Signal processing utilities: envelope, cross-correlation, normalization.
3
-
4
- Corresponds to C++ files: envelope.cc, xcorr.cc, misc_math.cc
5
- """
6
-
7
- from __future__ import annotations
8
-
9
- import numpy as np
10
- from numpy.typing import NDArray
11
- from scipy import signal as scipy_signal
12
- from scipy.fft import fft, ifft
13
-
14
-
15
- def upper_envelope(sig: NDArray[np.float64]) -> NDArray[np.float64]:
16
- """
17
- Calculate the upper envelope using Hilbert transform.
18
-
19
- Matches C++ ``Envelope::CalcUpperEnv`` which:
20
-
21
- 1. Centers signal by subtracting mean
22
- 2. Computes Hilbert transform
23
- 3. Takes absolute value (amplitude envelope)
24
- 4. Adds mean back
25
- """
26
- mean_val: float = float(np.mean(sig))
27
- centered = sig - mean_val
28
- analytic = scipy_signal.hilbert(centered)
29
- env: NDArray[np.float64] = np.abs(analytic) + mean_val
30
- return env
31
-
32
-
33
- def find_best_lag(ref: NDArray[np.float64], deg: NDArray[np.float64]) -> int:
34
- """
35
- Find the lag that maximises cross-correlation between two signals.
36
-
37
- Returns the lag (in samples) — positive means *deg* is delayed
38
- relative to *ref*.
39
-
40
- Matches C++ ``XCorr::FindLowestLagIndex`` which uses FFT-based
41
- cross-correlation.
42
- """
43
- max_lag: int = max(len(ref), len(deg)) - 1
44
-
45
- # Pad to same length
46
- n = max(len(ref), len(deg))
47
- ref_padded = np.zeros(n)
48
- deg_padded = np.zeros(n)
49
- ref_padded[: len(ref)] = ref
50
- deg_padded[: len(deg)] = deg
51
-
52
- # FFT-based cross-correlation
53
- # fft_points = next power of 2 >= 2*n - 1
54
- fft_points = 1
55
- while fft_points < 2 * n - 1:
56
- fft_points *= 2
57
-
58
- fft_ref = fft(ref_padded, n=fft_points)
59
- fft_deg = fft(deg_padded, n=fft_points)
60
- pointwise = fft_ref * np.conj(fft_deg)
61
- xcorr_full: NDArray[np.float64] = np.real(ifft(pointwise))
62
-
63
- # Build correlation vector: [negative lags, positive lags]
64
- neg_corrs = xcorr_full[-max_lag:].tolist()
65
- pos_corrs = xcorr_full[: max_lag + 1].tolist()
66
- corrs = neg_corrs + pos_corrs
67
-
68
- best_idx = int(np.argmax(corrs))
69
- return best_idx - max_lag
70
-
71
-
72
- def normalize(mat: NDArray[np.float64]) -> NDArray[np.float64]:
73
- """
74
- Peak-normalize a matrix / vector so the maximum element becomes 1.0.
75
-
76
- Matches C++ ``MiscMath::Normalize``, which divides every element by
77
- ``max(mat)`` (it does **not** subtract the minimum). For typical
78
- bipolar audio in ``[-peak, +peak]`` this yields output in ``[-1, +1]``,
79
- preserving the signal's DC sign. Previous min-max scaling shifted the
80
- signal positive and broke speech-mode VAD (GH issue #1 follow-up).
81
- """
82
- max_val = np.max(mat)
83
- if max_val == 0:
84
- return np.zeros_like(mat)
85
- return np.asarray(mat / max_val, dtype=np.float64)
86
-
87
-
88
- def exponential_from_fit(x: float, a: float, b: float, x0: float) -> float:
89
- """
90
- Evaluate exponential function: ``a + exp(b * (x - x0))``.
91
-
92
- Matches C++ ``MiscMath::ExponentialFromFit``.
93
- """
94
- return float(a + np.exp(b * (x - x0)))
File without changes
File without changes
File without changes