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.
- {visqol_python-3.5.0 → visqol_python-3.7.0}/CHANGELOG.md +104 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/PKG-INFO +35 -22
- {visqol_python-3.5.0 → visqol_python-3.7.0}/README.md +26 -16
- {visqol_python-3.5.0 → visqol_python-3.7.0}/pyproject.toml +9 -6
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/__init__.py +1 -1
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/api.py +1 -2
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/nsim.py +28 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/numba_accel.py +59 -54
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/patch_selector.py +16 -15
- visqol_python-3.7.0/visqol/signal_utils.py +167 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/PKG-INFO +35 -22
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/requires.txt +5 -6
- visqol_python-3.5.0/visqol/signal_utils.py +0 -94
- {visqol_python-3.5.0 → visqol_python-3.7.0}/CONTRIBUTING.md +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/LICENSE +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/MANIFEST.in +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/setup.cfg +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/tests/test_conformance.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/tests/test_parallel_correctness.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/tests/test_quick.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/__main__.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/alignment.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/analysis_window.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/audio_utils.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/gammatone.py +0 -0
- {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
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/model/libsvm_nu_svr_model.txt +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/patch_creator.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/py.typed +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/quality_mapper.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/visqol_core.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol/visqol_manager.py +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/SOURCES.txt +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/dependency_links.txt +0 -0
- {visqol_python-3.5.0 → visqol_python-3.7.0}/visqol_python.egg-info/entry_points.txt +0 -0
- {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.
|
|
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.
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 = "
|
|
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
|
|
|
@@ -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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
90
|
+
Compute NSIM similarity — returns
|
|
91
|
+
``(mean_similarity, freq_band_means, freq_band_stddevs, freq_band_deg_energy)``.
|
|
88
92
|
|
|
89
|
-
|
|
90
|
-
|
|
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 =
|
|
93
|
-
kh, kw =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
161
|
+
structure = numer / denom
|
|
157
162
|
|
|
158
|
-
|
|
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.
|
|
473
|
-
4.
|
|
474
|
-
5.
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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 (
|
|
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.
|
|
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.
|
|
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;
|
|
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;
|
|
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;
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
[
|
|
22
|
+
[fftw]
|
|
23
|
+
pyfftw>=0.13
|
|
25
24
|
|
|
26
|
-
[lattice
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|