visqol-python 3.5.0__tar.gz → 3.7.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.7.0}/CHANGELOG.md +104 -0
  2. {visqol_python-3.5.0 → visqol_python-3.7.0}/PKG-INFO +35 -22
  3. {visqol_python-3.5.0 → visqol_python-3.7.0}/README.md +26 -16
  4. {visqol_python-3.5.0 → visqol_python-3.7.0}/pyproject.toml +9 -6
  5. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/__init__.py +1 -1
  6. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/api.py +1 -2
  7. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/nsim.py +28 -0
  8. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/numba_accel.py +59 -54
  9. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/patch_selector.py +16 -15
  10. visqol_python-3.7.0/visqol/signal_utils.py +167 -0
  11. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/PKG-INFO +35 -22
  12. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/requires.txt +5 -6
  13. visqol_python-3.5.0/visqol/signal_utils.py +0 -94
  14. {visqol_python-3.5.0 → visqol_python-3.7.0}/CONTRIBUTING.md +0 -0
  15. {visqol_python-3.5.0 → visqol_python-3.7.0}/LICENSE +0 -0
  16. {visqol_python-3.5.0 → visqol_python-3.7.0}/MANIFEST.in +0 -0
  17. {visqol_python-3.5.0 → visqol_python-3.7.0}/setup.cfg +0 -0
  18. {visqol_python-3.5.0 → visqol_python-3.7.0}/tests/test_conformance.py +0 -0
  19. {visqol_python-3.5.0 → visqol_python-3.7.0}/tests/test_parallel_correctness.py +0 -0
  20. {visqol_python-3.5.0 → visqol_python-3.7.0}/tests/test_quick.py +0 -0
  21. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/__main__.py +0 -0
  22. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/alignment.py +0 -0
  23. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/analysis_window.py +0 -0
  24. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/audio_utils.py +0 -0
  25. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/gammatone.py +0 -0
  26. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/model/lattice_tcditugenmeetpackhref_ls2_nl60_lr12_bs2048_learn.005_ep2400_train1_7_raw.tflite +0 -0
  27. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/model/libsvm_nu_svr_model.txt +0 -0
  28. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/patch_creator.py +0 -0
  29. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/py.typed +0 -0
  30. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/quality_mapper.py +0 -0
  31. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/visqol_core.py +0 -0
  32. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/visqol_manager.py +0 -0
  33. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/SOURCES.txt +0 -0
  34. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/dependency_links.txt +0 -0
  35. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/entry_points.txt +0 -0
  36. {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/top_level.txt +0 -0
@@ -4,6 +4,108 @@ 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.7.0] - 2026-05-30
8
+
9
+ ### Fixed
10
+ - **Audio-mode C++ parity in fine realignment** (`finely_align_and_recreate_patches`):
11
+ the previous "B2 optimisation" skipped the per-patch spectrogram rebuild
12
+ whenever the fine-alignment lag was zero, on the assumption that the rebuilt
13
+ spectrogram would be identical to the original patch. That assumption is
14
+ wrong. The original patch is sliced out of the **full-signal** Gammatone
15
+ spectrogram (warm IIR filter state, carrying history), whereas the rebuild
16
+ runs Gammatone over the **sliced** audio from a cold filter state — so the
17
+ leading frames differ even when no shift is applied. C++ ViSQOL always
18
+ rebuilds and keeps whichever scores higher, so skipping it silently dropped
19
+ that improvement. The skip is removed; every matched patch is now rebuilt and
20
+ compared, exactly as C++ does.
21
+ - **Impact:** audio MOS-LQO now matches the C++ binary to ULP. Max diff over
22
+ the 10 conformance cases dropped from **2.41 × 10⁻² → 1.17 × 10⁻⁴**; 9 of 10
23
+ cases are bit-exact (~10⁻¹⁴). The lone residual (`contrabassoon_24aac`,
24
+ 1.17 × 10⁻⁴) is an unrelated sub-sample boundary-patch alignment rounding,
25
+ now smaller than the speech-mode parity gap.
26
+ - Existing audio scores shift by up to ~0.024 MOS where they were previously
27
+ too low; this is a move **toward** the C++ ground truth, not away from it.
28
+ - Speech polynomial and lattice scores are unaffected (bit-identical before
29
+ and after).
30
+
31
+ ### Performance
32
+ - Fine realignment now rebuilds the spectrogram for every patch (including
33
+ `lag == 0`), so it no longer benefits from the zero-lag skip. Net effect is
34
+ roughly **1.4–1.5× slower realignment** (e.g. `guitar48_stereo` 0.44 s → 0.65 s
35
+ end-to-end with Numba + pyFFTW). This is the cost of exact C++ parity and is
36
+ consistent with the project's precision-first policy. Audio-mode RTF rises
37
+ from ~0.036 (3.6.0) to ~0.054 — still ~1.7× faster than the C++ binary
38
+ (~0.093) and faster than 3.5.0 (~0.067).
39
+
40
+ ### Changed
41
+ - **Dropped Python 3.9 support** (`requires-python = ">=3.10"`). Downstream
42
+ projects still on 3.9 should pin `visqol-python<3.7`.
43
+ - CI: bumped GitHub Actions to Node-24-compatible major versions
44
+ (checkout v5, setup-python v6, upload/download-artifact v5).
45
+
46
+ ## [3.6.0] - 2026-05-27
47
+
48
+ ### Added
49
+ - **Optional pyFFTW backend** (`pip install visqol-python[fftw]`):
50
+ - Routes `scipy.fft.fft / ifft / rfft / irfft` through FFTW3 via
51
+ `pyfftw.interfaces.scipy_fft` for the alignment and cross-correlation
52
+ FFTs. Detected at module load time in `signal_utils` and applied
53
+ transparently via a thin `_fft_backend()` context manager.
54
+ - Plan cache enabled with 60 s keep-alive so consecutive measurements
55
+ on equal-length signals reuse the FFTW plan.
56
+
57
+ ### Improved
58
+ - **Fused NSIM kernel**: `_measure_patch_similarity_numba` merged its 5
59
+ separate 2-D convolutions (μ_r, μ_d, ref², deg², ref·deg) and the
60
+ intensity/structure recombination into one `(r, c)` double loop. Each
61
+ patch element is read from L1 once per visit instead of five times,
62
+ and the four intermediate `(rows × cols)` matrices are no longer
63
+ materialised between convs. Bit-exact with the previous split-conv
64
+ path (ULP-level FP rounding only).
65
+ - **`nsim.measure_patch_similarity`** now dispatches to the fused JIT
66
+ kernel when Numba is available — the same code path the DP patch
67
+ matcher uses, so `finely_align_and_recreate_patches` shares the
68
+ speedup. The pure-NumPy implementation is preserved as a fallback.
69
+ - **`signal_utils._hilbert`** is a drop-in `scipy.signal.hilbert`
70
+ replacement built on `rfft` for the real-valued input (~2× less
71
+ forward-transform work than the original full complex `fft`).
72
+ - **`signal_utils.find_best_lag`** now uses `rfft + irfft` for the
73
+ cross-correlation, again exploiting the real input. Net effect:
74
+ alignment FFTs see roughly 2× less work overall.
75
+ - Removed dead helper `_conv2d_boundary_valid` from `numba_accel.py`
76
+ (subsumed by the fused kernel).
77
+
78
+ ### Fixed
79
+ - **`find_best_lag` interpreter hot loop**: the previous implementation
80
+ ran `xcorr_full[-max_lag:].tolist() + xcorr_full[:max_lag+1].tolist()`
81
+ and then `builtin argmax(list)` over a ~1.2 M-element Python list,
82
+ costing ~33 ms per call in pure interpreter overhead. Now uses
83
+ `np.concatenate + np.argmax` entirely in C.
84
+
85
+ ### Performance
86
+
87
+ Apple M-series, Python 3.13, audio mode, the `guitar48_stereo` 12.5 s
88
+ conformance case, average of 3 runs with Numba + pyFFTW both installed:
89
+
90
+ | Stage | v3.5.0 | v3.6.0 | Speedup |
91
+ |--------------------|----------|----------|---------|
92
+ | DP Patch matching | 0.397 s | 0.131 s | **3.0×** |
93
+ | Global align / FFT | 0.173 s | 0.091 s | 1.9× |
94
+ | Fine align + NSIM | 0.093 s | 0.043 s | 2.2× |
95
+ | Gammatone | 0.173 s | 0.179 s | ~ |
96
+ | **Total** | **0.839 s** | **0.447 s** | **1.9×** |
97
+ | **RTF** | 0.067 | **0.036** | (C++ est. 0.093) |
98
+
99
+ ### Numerical parity
100
+
101
+ All v3.5.0 conformance baselines preserved within ULP precision:
102
+
103
+ | Test | Max MOS diff vs v3.5.0 |
104
+ |-------------------------------|------------------------|
105
+ | Audio (10 conformance cases) | < 5 × 10⁻¹⁴ |
106
+ | Speech polynomial CA01 | 0.0 (bit-exact) |
107
+ | Speech lattice CA01 | 0.0 (bit-exact) |
108
+
7
109
  ## [3.5.0] - 2026-05-26
8
110
 
9
111
  ### Added
@@ -155,6 +257,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
155
257
  - Bundled SVR model (`libsvm_nu_svr_model.txt`)
156
258
  - GitHub Actions workflow for auto-publish to PyPI via Trusted Publisher
157
259
 
260
+ [3.7.0]: https://github.com/talker93/visqol-python/compare/v3.6.0...v3.7.0
261
+ [3.6.0]: https://github.com/talker93/visqol-python/compare/v3.5.0...v3.6.0
158
262
  [3.5.0]: https://github.com/talker93/visqol-python/compare/v3.4.0...v3.5.0
159
263
  [3.4.0]: https://github.com/talker93/visqol-python/compare/v3.3.6...v3.4.0
160
264
  [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.7.0
4
4
  Summary: ViSQOL - Virtual Speech Quality Objective Listener (Pure Python)
5
5
  Author: Shan Jiang
6
6
  License-Expression: Apache-2.0
@@ -14,7 +14,6 @@ Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Intended Audience :: Science/Research
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.9
18
17
  Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
20
19
  Classifier: Programming Language :: Python :: 3.12
@@ -22,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
22
21
  Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
23
22
  Classifier: Topic :: Scientific/Engineering
24
23
  Classifier: Typing :: Typed
25
- Requires-Python: >=3.9
24
+ Requires-Python: >=3.10
26
25
  Description-Content-Type: text/markdown
27
26
  License-File: LICENSE
28
27
  Requires-Dist: numpy>=1.20
@@ -32,10 +31,13 @@ Requires-Dist: libsvm-official>=3.25
32
31
  Provides-Extra: accel
33
32
  Requires-Dist: numba>=0.57; extra == "accel"
34
33
  Provides-Extra: lattice
35
- Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "lattice"
34
+ Requires-Dist: ai-edge-litert>=2.1.5; extra == "lattice"
35
+ Provides-Extra: fftw
36
+ Requires-Dist: pyfftw>=0.13; extra == "fftw"
36
37
  Provides-Extra: all
37
38
  Requires-Dist: numba>=0.57; extra == "all"
38
- Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "all"
39
+ Requires-Dist: ai-edge-litert>=2.1.5; extra == "all"
40
+ Requires-Dist: pyfftw>=0.13; extra == "all"
39
41
  Provides-Extra: test
40
42
  Requires-Dist: pytest>=7.0; extra == "test"
41
43
  Provides-Extra: dev
@@ -43,7 +45,8 @@ Requires-Dist: pytest>=7.0; extra == "dev"
43
45
  Requires-Dist: ruff>=0.4; extra == "dev"
44
46
  Requires-Dist: mypy>=1.8; extra == "dev"
45
47
  Requires-Dist: numba>=0.57; extra == "dev"
46
- Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "dev"
48
+ Requires-Dist: ai-edge-litert>=2.1.5; extra == "dev"
49
+ Requires-Dist: pyfftw>=0.13; extra == "dev"
47
50
  Dynamic: license-file
48
51
 
49
52
  # ViSQOL (Python)
@@ -69,7 +72,8 @@ ViSQOL compares a reference audio signal with a degraded version and outputs a *
69
72
  - **Polynomial (fallback)** — legacy exponential fit (`--use_lattice_model=false` in C++)
70
73
  - **Pure Python**: no C/C++ compilation required (the optional `[lattice]` extra adds the Google `ai-edge-litert` TFLite runtime as a binary wheel)
71
74
  - **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)
75
+ - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel) and a fused NSIM + DP patch matching kernel
76
+ - **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
77
  - **Batch & parallel evaluation**: `measure_batch(parallel=True)` for multi-process execution across CPU cores
74
78
  - **Fully typed**: PEP 561 `py.typed`, strict mypy, ruff-enforced code style
75
79
 
@@ -85,13 +89,19 @@ For **C++-default-equivalent speech mode** (deep-lattice TFLite mapper):
85
89
  pip install visqol-python[lattice] # requires Python ≥ 3.10
86
90
  ```
87
91
 
88
- For **Numba-accelerated** Gammatone filtering and DP matching (~9× faster):
92
+ For **Numba-accelerated** Gammatone filtering and the fused NSIM + DP kernel:
89
93
 
90
94
  ```bash
91
95
  pip install visqol-python[accel]
92
96
  ```
93
97
 
94
- Install everything (lattice + numba):
98
+ For **FFTW3-backed alignment FFTs** via pyFFTW:
99
+
100
+ ```bash
101
+ pip install visqol-python[fftw]
102
+ ```
103
+
104
+ Install everything (lattice + numba + fftw):
95
105
 
96
106
  ```bash
97
107
  pip install visqol-python[all]
@@ -234,23 +244,26 @@ The `measure()` method returns a `SimilarityResult` object with:
234
244
 
235
245
  ## Performance
236
246
 
237
- Measured on Apple M-series, Python 3.13:
247
+ Measured on Apple M-series, Python 3.13, audio mode on the `guitar48_stereo` 12.5 s conformance case (3-run average):
238
248
 
239
- ### Without Numba (pure Python + NumPy/SciPy)
249
+ | Configuration | RTF | Typical Time | Speedup vs pure Python |
250
+ |---|---|---|---|
251
+ | Pure Python + NumPy/SciPy | 0.58 | ~7 s | 1.0× |
252
+ | + `[accel]` (Numba JIT) | 0.067 | ~0.84 s | 8.7× |
253
+ | + `[accel] [fftw]` (Numba + FFTW3) | **0.036** | **~0.45 s** | **16×** |
240
254
 
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]`)
255
+ > RTF (Real-Time Factor) < 1.0 means faster than real-time.
256
+ > With Numba + pyFFTW the Python implementation runs at **2.6× the C++ estimated speed** (C++ RTF ≈ 0.093).
247
257
 
248
- | Mode | Avg RTF | Typical Time | Speedup |
249
- |------|---------|-------------|---------|
250
- | Audio (48 kHz) | **0.064x** | ~0.8 s per file pair | **9×** |
258
+ Stage-level breakdown of the v3.6.0 fully-accelerated path:
251
259
 
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).
260
+ | Stage | Time | % |
261
+ |---|---|---|
262
+ | Gammatone filterbank | 0.179 s | 40% |
263
+ | DP Patch matching (fused NSIM kernel) | 0.131 s | 29% |
264
+ | Global alignment (pyFFTW rfft/irfft) | 0.091 s | 20% |
265
+ | Fine alignment + NSIM | 0.043 s | 10% |
266
+ | Other (SPL, postproc, SVR, …) | 0.003 s | < 1% |
254
267
 
255
268
  ## Project Structure
256
269
 
@@ -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
 
@@ -8,7 +8,7 @@ dynamic = ["version"]
8
8
  description = "ViSQOL - Virtual Speech Quality Objective Listener (Pure Python)"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
11
- requires-python = ">=3.9"
11
+ requires-python = ">=3.10"
12
12
  authors = [
13
13
  {name = "Shan Jiang"},
14
14
  ]
@@ -22,7 +22,6 @@ classifiers = [
22
22
  "Intended Audience :: Developers",
23
23
  "Intended Audience :: Science/Research",
24
24
  "Programming Language :: Python :: 3",
25
- "Programming Language :: Python :: 3.9",
26
25
  "Programming Language :: Python :: 3.10",
27
26
  "Programming Language :: Python :: 3.11",
28
27
  "Programming Language :: Python :: 3.12",
@@ -40,10 +39,12 @@ dependencies = [
40
39
 
41
40
  [project.optional-dependencies]
42
41
  accel = ["numba>=0.57"]
43
- lattice = ["ai-edge-litert>=2.1.5; python_version >= '3.10'"]
42
+ lattice = ["ai-edge-litert>=2.1.5"]
43
+ fftw = ["pyfftw>=0.13"]
44
44
  all = [
45
45
  "numba>=0.57",
46
- "ai-edge-litert>=2.1.5; python_version >= '3.10'",
46
+ "ai-edge-litert>=2.1.5",
47
+ "pyfftw>=0.13",
47
48
  ]
48
49
  test = ["pytest>=7.0"]
49
50
  dev = [
@@ -51,7 +52,8 @@ dev = [
51
52
  "ruff>=0.4",
52
53
  "mypy>=1.8",
53
54
  "numba>=0.57",
54
- "ai-edge-litert>=2.1.5; python_version >= '3.10'",
55
+ "ai-edge-litert>=2.1.5",
56
+ "pyfftw>=0.13",
55
57
  ]
56
58
 
57
59
  [project.urls]
@@ -77,7 +79,7 @@ visqol = ["model/*.txt", "model/*.tflite", "py.typed"]
77
79
  testpaths = ["tests"]
78
80
 
79
81
  [tool.ruff]
80
- target-version = "py39"
82
+ target-version = "py310"
81
83
  line-length = 95
82
84
 
83
85
  [tool.ruff.lint]
@@ -117,6 +119,7 @@ module = [
117
119
  "soundfile.*",
118
120
  "numba.*",
119
121
  "ai_edge_litert.*",
122
+ "pyfftw.*",
120
123
  ]
121
124
  ignore_missing_imports = true
122
125
 
@@ -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.7.0"
17
17
 
18
18
  from visqol.api import ProgressCallback, VisqolApi
19
19
  from visqol.audio_utils import AudioSignal
@@ -10,9 +10,8 @@ from __future__ import annotations
10
10
 
11
11
  import logging
12
12
  import os
13
- from collections.abc import Sequence
13
+ from collections.abc import Callable, Sequence
14
14
  from concurrent.futures import ProcessPoolExecutor, as_completed
15
- from typing import Callable
16
15
 
17
16
  import numpy as np
18
17
  from numpy.typing import NDArray
@@ -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
@@ -469,14 +469,19 @@ def finely_align_and_recreate_patches(
469
469
 
470
470
  1. Extract audio sub-signals
471
471
  2. Re-align at fine granularity
472
- 3. **If lag == 0, skip spectrogram rebuild** (B2 optimisation)
473
- 4. Rebuild spectrograms only when alignment changed
474
- 5. Recompute NSIM
475
- 6. Keep the better result (original or re-aligned)
476
-
477
- The B2 optimisation avoids redundant Gammatone filtering when the
478
- fine alignment produces zero shift the rebuilt spectrograms would
479
- be identical to the originals, so skipping saves **~2-3 s** per file.
472
+ 3. Rebuild spectrograms from the (re)aligned audio slices
473
+ 4. Recompute NSIM
474
+ 5. Keep the better result (original or re-aligned)
475
+
476
+ The rebuild is performed for **every** patch, including those with
477
+ ``lag == 0``. This mirrors C++ exactly: the original patch was sliced
478
+ out of the *full-signal* spectrogram (its Gammatone IIR state is warm,
479
+ carrying history from earlier samples), whereas the rebuilt patch runs
480
+ Gammatone over the *sliced* audio from a cold filter state. The two
481
+ therefore differ in their leading frames even when no time shift is
482
+ applied, and C++ keeps whichever scores higher. Skipping the rebuild
483
+ on ``lag == 0`` (a former "optimisation") silently dropped this
484
+ improvement and broke audio-mode C++ parity by up to ~0.024 MOS.
480
485
  """
481
486
  realigned_results: list[PatchSimilarityResult] = list(sim_results)
482
487
 
@@ -506,17 +511,13 @@ def finely_align_and_recreate_patches(
506
511
  except Exception:
507
512
  continue
508
513
 
509
- # B2 optimisation: if lag is zero, alignment didn't change anything.
510
- # The rebuilt spectrogram would be identical to the original, so the
511
- # recomputed NSIM would also be identical — skip the expensive rebuild.
512
- if lag == 0.0:
513
- continue
514
-
515
514
  # Check we have enough samples
516
515
  if len(ref_aligned.data) <= window.size or len(deg_aligned.data) <= window.size:
517
516
  continue
518
517
 
519
- # 3. Rebuild spectrograms (only when lag != 0)
518
+ # 3. Rebuild spectrograms from the aligned slices (always see docstring:
519
+ # cold vs warm Gammatone state makes this differ from the original
520
+ # patch even when lag == 0, so it is not a redundant recompute).
520
521
  try:
521
522
  ref_spec = spect_builder.build(ref_aligned, window)
522
523
  deg_spec = spect_builder.build(deg_aligned, window)
@@ -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.7.0
4
4
  Summary: ViSQOL - Virtual Speech Quality Objective Listener (Pure Python)
5
5
  Author: Shan Jiang
6
6
  License-Expression: Apache-2.0
@@ -14,7 +14,6 @@ Classifier: Development Status :: 4 - Beta
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: Intended Audience :: Science/Research
16
16
  Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.9
18
17
  Classifier: Programming Language :: Python :: 3.10
19
18
  Classifier: Programming Language :: Python :: 3.11
20
19
  Classifier: Programming Language :: Python :: 3.12
@@ -22,7 +21,7 @@ Classifier: Programming Language :: Python :: 3.13
22
21
  Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
23
22
  Classifier: Topic :: Scientific/Engineering
24
23
  Classifier: Typing :: Typed
25
- Requires-Python: >=3.9
24
+ Requires-Python: >=3.10
26
25
  Description-Content-Type: text/markdown
27
26
  License-File: LICENSE
28
27
  Requires-Dist: numpy>=1.20
@@ -32,10 +31,13 @@ Requires-Dist: libsvm-official>=3.25
32
31
  Provides-Extra: accel
33
32
  Requires-Dist: numba>=0.57; extra == "accel"
34
33
  Provides-Extra: lattice
35
- Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "lattice"
34
+ Requires-Dist: ai-edge-litert>=2.1.5; extra == "lattice"
35
+ Provides-Extra: fftw
36
+ Requires-Dist: pyfftw>=0.13; extra == "fftw"
36
37
  Provides-Extra: all
37
38
  Requires-Dist: numba>=0.57; extra == "all"
38
- Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "all"
39
+ Requires-Dist: ai-edge-litert>=2.1.5; extra == "all"
40
+ Requires-Dist: pyfftw>=0.13; extra == "all"
39
41
  Provides-Extra: test
40
42
  Requires-Dist: pytest>=7.0; extra == "test"
41
43
  Provides-Extra: dev
@@ -43,7 +45,8 @@ Requires-Dist: pytest>=7.0; extra == "dev"
43
45
  Requires-Dist: ruff>=0.4; extra == "dev"
44
46
  Requires-Dist: mypy>=1.8; extra == "dev"
45
47
  Requires-Dist: numba>=0.57; extra == "dev"
46
- Requires-Dist: ai-edge-litert>=2.1.5; python_version >= "3.10" and extra == "dev"
48
+ Requires-Dist: ai-edge-litert>=2.1.5; extra == "dev"
49
+ Requires-Dist: pyfftw>=0.13; extra == "dev"
47
50
  Dynamic: license-file
48
51
 
49
52
  # ViSQOL (Python)
@@ -69,7 +72,8 @@ ViSQOL compares a reference audio signal with a degraded version and outputs a *
69
72
  - **Polynomial (fallback)** — legacy exponential fit (`--use_lattice_model=false` in C++)
70
73
  - **Pure Python**: no C/C++ compilation required (the optional `[lattice]` extra adds the Google `ai-edge-litert` TFLite runtime as a binary wheel)
71
74
  - **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)
75
+ - **Optional Numba acceleration**: `pip install visqol-python[accel]` for JIT-compiled Gammatone filterbank (parallel) and a fused NSIM + DP patch matching kernel
76
+ - **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
77
  - **Batch & parallel evaluation**: `measure_batch(parallel=True)` for multi-process execution across CPU cores
74
78
  - **Fully typed**: PEP 561 `py.typed`, strict mypy, ruff-enforced code style
75
79
 
@@ -85,13 +89,19 @@ For **C++-default-equivalent speech mode** (deep-lattice TFLite mapper):
85
89
  pip install visqol-python[lattice] # requires Python ≥ 3.10
86
90
  ```
87
91
 
88
- For **Numba-accelerated** Gammatone filtering and DP matching (~9× faster):
92
+ For **Numba-accelerated** Gammatone filtering and the fused NSIM + DP kernel:
89
93
 
90
94
  ```bash
91
95
  pip install visqol-python[accel]
92
96
  ```
93
97
 
94
- Install everything (lattice + numba):
98
+ For **FFTW3-backed alignment FFTs** via pyFFTW:
99
+
100
+ ```bash
101
+ pip install visqol-python[fftw]
102
+ ```
103
+
104
+ Install everything (lattice + numba + fftw):
95
105
 
96
106
  ```bash
97
107
  pip install visqol-python[all]
@@ -234,23 +244,26 @@ The `measure()` method returns a `SimilarityResult` object with:
234
244
 
235
245
  ## Performance
236
246
 
237
- Measured on Apple M-series, Python 3.13:
247
+ Measured on Apple M-series, Python 3.13, audio mode on the `guitar48_stereo` 12.5 s conformance case (3-run average):
238
248
 
239
- ### Without Numba (pure Python + NumPy/SciPy)
249
+ | Configuration | RTF | Typical Time | Speedup vs pure Python |
250
+ |---|---|---|---|
251
+ | Pure Python + NumPy/SciPy | 0.58 | ~7 s | 1.0× |
252
+ | + `[accel]` (Numba JIT) | 0.067 | ~0.84 s | 8.7× |
253
+ | + `[accel] [fftw]` (Numba + FFTW3) | **0.036** | **~0.45 s** | **16×** |
240
254
 
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]`)
255
+ > RTF (Real-Time Factor) < 1.0 means faster than real-time.
256
+ > With Numba + pyFFTW the Python implementation runs at **2.6× the C++ estimated speed** (C++ RTF ≈ 0.093).
247
257
 
248
- | Mode | Avg RTF | Typical Time | Speedup |
249
- |------|---------|-------------|---------|
250
- | Audio (48 kHz) | **0.064x** | ~0.8 s per file pair | **9×** |
258
+ Stage-level breakdown of the v3.6.0 fully-accelerated path:
251
259
 
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).
260
+ | Stage | Time | % |
261
+ |---|---|---|
262
+ | Gammatone filterbank | 0.179 s | 40% |
263
+ | DP Patch matching (fused NSIM kernel) | 0.131 s | 29% |
264
+ | Global alignment (pyFFTW rfft/irfft) | 0.091 s | 20% |
265
+ | Fine alignment + NSIM | 0.043 s | 10% |
266
+ | Other (SPL, postproc, SVR, …) | 0.003 s | < 1% |
254
267
 
255
268
  ## Project Structure
256
269
 
@@ -8,22 +8,21 @@ numba>=0.57
8
8
 
9
9
  [all]
10
10
  numba>=0.57
11
-
12
- [all:python_version >= "3.10"]
13
11
  ai-edge-litert>=2.1.5
12
+ pyfftw>=0.13
14
13
 
15
14
  [dev]
16
15
  pytest>=7.0
17
16
  ruff>=0.4
18
17
  mypy>=1.8
19
18
  numba>=0.57
20
-
21
- [dev:python_version >= "3.10"]
22
19
  ai-edge-litert>=2.1.5
20
+ pyfftw>=0.13
23
21
 
24
- [lattice]
22
+ [fftw]
23
+ pyfftw>=0.13
25
24
 
26
- [lattice:python_version >= "3.10"]
25
+ [lattice]
27
26
  ai-edge-litert>=2.1.5
28
27
 
29
28
  [test]
@@ -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