visqol-python 3.3.6__tar.gz → 3.4.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.3.6 → visqol_python-3.4.0}/CHANGELOG.md +45 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/PKG-INFO +4 -1
- {visqol_python-3.3.6 → visqol_python-3.4.0}/pyproject.toml +21 -2
- {visqol_python-3.3.6 → visqol_python-3.4.0}/tests/test_conformance.py +44 -23
- visqol_python-3.4.0/tests/test_parallel_correctness.py +96 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/tests/test_quick.py +9 -9
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/__init__.py +3 -3
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/__main__.py +28 -12
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/alignment.py +7 -11
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/analysis_window.py +3 -6
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/api.py +146 -31
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/audio_utils.py +6 -11
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/gammatone.py +142 -42
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/nsim.py +13 -17
- visqol_python-3.4.0/visqol/numba_accel.py +619 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/patch_creator.py +25 -28
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/patch_selector.py +224 -61
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/quality_mapper.py +13 -14
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/signal_utils.py +3 -3
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/visqol_core.py +38 -31
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/visqol_manager.py +16 -14
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol_python.egg-info/PKG-INFO +4 -1
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol_python.egg-info/SOURCES.txt +2 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol_python.egg-info/requires.txt +4 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/CONTRIBUTING.md +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/LICENSE +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/MANIFEST.in +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/README.md +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/setup.cfg +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/model/libsvm_nu_svr_model.txt +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol/py.typed +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol_python.egg-info/dependency_links.txt +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol_python.egg-info/entry_points.txt +0 -0
- {visqol_python-3.3.6 → visqol_python-3.4.0}/visqol_python.egg-info/top_level.txt +0 -0
|
@@ -4,6 +4,50 @@ 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.4.0] - 2026-03-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Numba JIT acceleration** (`pip install visqol-python[accel]`):
|
|
11
|
+
- DP patch matching inner loops compiled to machine code via `@njit`
|
|
12
|
+
- Gammatone IIR filterbank compiled with `parallel=True` + `fastmath=True` — frames processed in parallel across all CPU cores
|
|
13
|
+
- NSIM similarity kernel JIT-compiled
|
|
14
|
+
- Automatic `NUMBA_THREADING_LAYER=workqueue` setup for macOS compatibility
|
|
15
|
+
- Zero-loss parallel accuracy (each frame's IIR state is independent)
|
|
16
|
+
- **Batch evaluation API**: `VisqolApi.measure_batch()` with optional `parallel=True` and `max_workers` for multi-process execution
|
|
17
|
+
- Exported `PatchSimilarityResult` and `ProgressCallback` from top-level package
|
|
18
|
+
|
|
19
|
+
### Performance
|
|
20
|
+
- **12x Gammatone speedup** via parallel + fastmath (1.53s → 0.13s per signal pair)
|
|
21
|
+
- **8.7x DP patch matching speedup** via Numba JIT (3.5s → 0.40s)
|
|
22
|
+
- **Overall 9x speedup**: RTF 0.58 → 0.064 (surpasses C++ estimate of 0.093)
|
|
23
|
+
- Fine alignment skip optimization: 29x speedup when lag == 0
|
|
24
|
+
|
|
25
|
+
### Improved
|
|
26
|
+
- `__repr__` / `__str__` for `SimilarityResult`, `AudioSignal`, `PatchSimilarityResult`, `Spectrogram`
|
|
27
|
+
- Logging replaces print statements in CLI verbose output
|
|
28
|
+
- Development tooling: ruff lint/format + mypy strict type checking in CI
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **CI failures**: resolved all ruff lint (308 errors), ruff format (24 files), and mypy (24 errors) issues
|
|
32
|
+
- Added `per-file-ignores` for benchmark test scripts (E402, E702)
|
|
33
|
+
- Added mypy override for `numba_accel.py` (untyped `@njit` decorators)
|
|
34
|
+
- Fixed `no-any-return` errors across `audio_utils.py`, `gammatone.py`, `visqol_core.py`, `api.py`
|
|
35
|
+
- Added `TYPE_CHECKING` imports for `ImagePatchCreator` / `VadPatchCreator` in `visqol_core.py`
|
|
36
|
+
|
|
37
|
+
## [3.3.6] - 2026-03-23
|
|
38
|
+
|
|
39
|
+
### Added
|
|
40
|
+
- **Batch evaluation API**: `VisqolApi.measure_batch()` with `progress_callback` support
|
|
41
|
+
- **Numba optional acceleration**: `visqol/numba_accel.py` with JIT-compiled DP forward pass and NSIM kernel
|
|
42
|
+
- `[accel]` optional dependency group: `pip install visqol-python[accel]`
|
|
43
|
+
|
|
44
|
+
### Improved
|
|
45
|
+
- `GammatoneFilterBank.apply_filter()` pre-builds coefficient arrays (avoids per-channel allocation)
|
|
46
|
+
- `prepare_spectrograms_for_comparison()` vectorized per-frame noise floor
|
|
47
|
+
- Ruff lint/format configuration added to `pyproject.toml`
|
|
48
|
+
- CI enhanced with lint and type-check jobs
|
|
49
|
+
- Development dependencies: `[project.optional-dependencies] dev`
|
|
50
|
+
|
|
7
51
|
## [3.3.5] - 2026-03-23
|
|
8
52
|
|
|
9
53
|
### Added
|
|
@@ -49,6 +93,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
|
|
49
93
|
- Bundled SVR model (`libsvm_nu_svr_model.txt`)
|
|
50
94
|
- GitHub Actions workflow for auto-publish to PyPI via Trusted Publisher
|
|
51
95
|
|
|
96
|
+
[3.4.0]: https://github.com/talker93/visqol-python/compare/v3.3.6...v3.4.0
|
|
52
97
|
[3.3.6]: https://github.com/talker93/visqol-python/compare/v3.3.5...v3.3.6
|
|
53
98
|
[3.3.5]: https://github.com/talker93/visqol-python/compare/v3.3.4...v3.3.5
|
|
54
99
|
[3.3.4]: https://github.com/talker93/visqol-python/compare/v3.3.3...v3.3.4
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: visqol-python
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.4.0
|
|
4
4
|
Summary: ViSQOL - Virtual Speech Quality Objective Listener (Pure Python)
|
|
5
5
|
Author: Shan Jiang
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -28,12 +28,15 @@ Requires-Dist: numpy>=1.20
|
|
|
28
28
|
Requires-Dist: scipy>=1.7
|
|
29
29
|
Requires-Dist: soundfile>=0.10
|
|
30
30
|
Requires-Dist: libsvm-official>=3.25
|
|
31
|
+
Provides-Extra: accel
|
|
32
|
+
Requires-Dist: numba>=0.57; extra == "accel"
|
|
31
33
|
Provides-Extra: test
|
|
32
34
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
33
35
|
Provides-Extra: dev
|
|
34
36
|
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
35
37
|
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
36
38
|
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
39
|
+
Requires-Dist: numba>=0.57; extra == "dev"
|
|
37
40
|
Dynamic: license-file
|
|
38
41
|
|
|
39
42
|
# ViSQOL (Python)
|
|
@@ -37,11 +37,13 @@ dependencies = [
|
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
[project.optional-dependencies]
|
|
40
|
+
accel = ["numba>=0.57"]
|
|
40
41
|
test = ["pytest>=7.0"]
|
|
41
42
|
dev = [
|
|
42
43
|
"pytest>=7.0",
|
|
43
44
|
"ruff>=0.4",
|
|
44
45
|
"mypy>=1.8",
|
|
46
|
+
"numba>=0.57",
|
|
45
47
|
]
|
|
46
48
|
|
|
47
49
|
[project.urls]
|
|
@@ -81,7 +83,16 @@ select = [
|
|
|
81
83
|
"SIM", # flake8-simplify
|
|
82
84
|
"RUF", # ruff-specific rules
|
|
83
85
|
]
|
|
84
|
-
ignore = [
|
|
86
|
+
ignore = [
|
|
87
|
+
"E501", # line length handled by formatter
|
|
88
|
+
"RUF001", # ambiguous unicode in string (intentional mathematical symbols)
|
|
89
|
+
"RUF002", # ambiguous unicode in docstring (intentional: ×, −)
|
|
90
|
+
"RUF003", # ambiguous unicode in comment (intentional: ×, →)
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
[tool.ruff.lint.per-file-ignores]
|
|
94
|
+
"tests/bench_*.py" = ["E402", "E702"] # bench scripts use sys.path manipulation
|
|
95
|
+
"tests/test_parallel_correctness.py" = ["E402"]
|
|
85
96
|
|
|
86
97
|
[tool.ruff.lint.isort]
|
|
87
98
|
known-first-party = ["visqol"]
|
|
@@ -92,5 +103,13 @@ warn_return_any = true
|
|
|
92
103
|
warn_unused_configs = true
|
|
93
104
|
|
|
94
105
|
[[tool.mypy.overrides]]
|
|
95
|
-
module = ["svmutil.*", "libsvm.*", "soundfile.*"]
|
|
106
|
+
module = ["svmutil.*", "libsvm.*", "soundfile.*", "numba.*"]
|
|
96
107
|
ignore_missing_imports = true
|
|
108
|
+
|
|
109
|
+
[[tool.mypy.overrides]]
|
|
110
|
+
module = ["visqol.numba_accel"]
|
|
111
|
+
# numba @njit decorators are untyped; strict mode is not feasible here
|
|
112
|
+
strict = false
|
|
113
|
+
disallow_untyped_defs = false
|
|
114
|
+
disallow_untyped_decorators = false
|
|
115
|
+
warn_unused_ignores = false
|
|
@@ -18,9 +18,9 @@ import pytest
|
|
|
18
18
|
|
|
19
19
|
from visqol import VisqolApi
|
|
20
20
|
|
|
21
|
-
|
|
22
21
|
# ── Fixtures ──
|
|
23
22
|
|
|
23
|
+
|
|
24
24
|
@pytest.fixture(scope="session")
|
|
25
25
|
def testdata_dir(request):
|
|
26
26
|
"""Resolve testdata directory from --testdata or auto-detect."""
|
|
@@ -63,36 +63,56 @@ def speech_api():
|
|
|
63
63
|
TOLERANCE = 0.05
|
|
64
64
|
|
|
65
65
|
AUDIO_CASES = [
|
|
66
|
-
("strauss48_stereo.wav", "strauss48_stereo_lp35.wav",
|
|
67
|
-
|
|
68
|
-
("
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
66
|
+
("strauss48_stereo.wav", "strauss48_stereo_lp35.wav", 1.3888791489130758, "strauss_lp35"),
|
|
67
|
+
("steely48_stereo.wav", "steely48_stereo_lp7.wav", 2.2501683734385183, "steely_lp7"),
|
|
68
|
+
("sopr48_stereo.wav", "sopr48_stereo_256kbps_aac.wav", 4.68228969737946, "sopr_256aac"),
|
|
69
|
+
(
|
|
70
|
+
"ravel48_stereo.wav",
|
|
71
|
+
"ravel48_stereo_128kbps_opus.wav",
|
|
72
|
+
4.465141897255348,
|
|
73
|
+
"ravel_128opus",
|
|
74
|
+
),
|
|
75
|
+
(
|
|
76
|
+
"moonlight48_stereo.wav",
|
|
77
|
+
"moonlight48_stereo_128kbps_aac.wav",
|
|
78
|
+
4.684292801646114,
|
|
79
|
+
"moonlight_128aac",
|
|
80
|
+
),
|
|
81
|
+
(
|
|
82
|
+
"harpsichord48_stereo.wav",
|
|
83
|
+
"harpsichord48_stereo_96kbps_mp3.wav",
|
|
84
|
+
4.22374532766003,
|
|
85
|
+
"harpsichord_96mp3",
|
|
86
|
+
),
|
|
87
|
+
(
|
|
88
|
+
"guitar48_stereo.wav",
|
|
89
|
+
"guitar48_stereo_64kbps_aac.wav",
|
|
90
|
+
4.349722308064298,
|
|
91
|
+
"guitar_64aac",
|
|
92
|
+
),
|
|
93
|
+
("glock48_stereo.wav", "glock48_stereo_48kbps_aac.wav", 4.332452943882108, "glock_48aac"),
|
|
94
|
+
(
|
|
95
|
+
"contrabassoon48_stereo.wav",
|
|
96
|
+
"contrabassoon48_stereo_24kbps_aac.wav",
|
|
97
|
+
2.346868205375293,
|
|
98
|
+
"contrabassoon_24aac",
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
"castanets48_stereo.wav",
|
|
102
|
+
"castanets48_stereo.wav",
|
|
103
|
+
4.732101253042348,
|
|
104
|
+
"castanets_identity",
|
|
105
|
+
),
|
|
86
106
|
]
|
|
87
107
|
|
|
88
108
|
SPEECH_CASES = [
|
|
89
|
-
("CA01_01.wav", "transcoded_CA01_01.wav",
|
|
90
|
-
3.374505555111911, "CA01_transcoded"),
|
|
109
|
+
("CA01_01.wav", "transcoded_CA01_01.wav", 3.374505555111911, "CA01_transcoded"),
|
|
91
110
|
]
|
|
92
111
|
|
|
93
112
|
|
|
94
113
|
# ── Audio mode tests ──
|
|
95
114
|
|
|
115
|
+
|
|
96
116
|
@pytest.mark.parametrize(
|
|
97
117
|
"ref_name, deg_name, expected_mos, test_id",
|
|
98
118
|
AUDIO_CASES,
|
|
@@ -110,6 +130,7 @@ def test_audio_conformance(audio_api, conf_dir, ref_name, deg_name, expected_mos
|
|
|
110
130
|
|
|
111
131
|
# ── Speech mode tests ──
|
|
112
132
|
|
|
133
|
+
|
|
113
134
|
@pytest.mark.parametrize(
|
|
114
135
|
"ref_name, deg_name, expected_mos, test_id",
|
|
115
136
|
SPEECH_CASES,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Correctness test: verify that the parallel+fastmath Gammatone spectrogram
|
|
3
|
+
produces results consistent with the original serial version.
|
|
4
|
+
|
|
5
|
+
Approach: run the full ViSQOL pipeline on a known test file and check
|
|
6
|
+
that the MOS score is within expected tolerance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
VISQOL_ROOT = os.path.dirname(ROOT)
|
|
15
|
+
sys.path.insert(0, ROOT)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- Quick sanity: does the module load without errors? ---
|
|
19
|
+
print("=" * 60)
|
|
20
|
+
print("1) Import & warmup")
|
|
21
|
+
print("=" * 60)
|
|
22
|
+
from visqol import numba_accel
|
|
23
|
+
|
|
24
|
+
print(f" _HAS_NUMBA = {numba_accel._HAS_NUMBA}")
|
|
25
|
+
print(f" _HAS_NUMBA_PARALLEL = {numba_accel._HAS_NUMBA_PARALLEL}")
|
|
26
|
+
print(f" NUMBA_THREADING_LAYER = {os.environ.get('NUMBA_THREADING_LAYER', '(not set)')}")
|
|
27
|
+
|
|
28
|
+
t0 = time.perf_counter()
|
|
29
|
+
numba_accel.warmup()
|
|
30
|
+
t1 = time.perf_counter()
|
|
31
|
+
print(f" warmup completed in {t1 - t0:.2f}s")
|
|
32
|
+
|
|
33
|
+
# --- Full pipeline correctness test ---
|
|
34
|
+
print()
|
|
35
|
+
print("=" * 60)
|
|
36
|
+
print("2) Full pipeline MOS score test")
|
|
37
|
+
print("=" * 60)
|
|
38
|
+
|
|
39
|
+
from visqol.api import VisqolApi
|
|
40
|
+
|
|
41
|
+
TESTDATA = os.path.join(VISQOL_ROOT, "testdata", "conformance_testdata_subset")
|
|
42
|
+
ref_file = os.path.join(TESTDATA, "guitar48_stereo.wav")
|
|
43
|
+
deg_file = os.path.join(TESTDATA, "guitar48_stereo_64kbps_aac.wav")
|
|
44
|
+
|
|
45
|
+
if not os.path.exists(ref_file):
|
|
46
|
+
import glob
|
|
47
|
+
|
|
48
|
+
testdata_root = os.path.join(VISQOL_ROOT, "testdata")
|
|
49
|
+
wavs = sorted(glob.glob(os.path.join(testdata_root, "**", "*.wav"), recursive=True))
|
|
50
|
+
if len(wavs) >= 2:
|
|
51
|
+
ref_file = wavs[0]
|
|
52
|
+
deg_file = wavs[1]
|
|
53
|
+
else:
|
|
54
|
+
print(" ERROR: No test WAV files found")
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
print(f" ref: {os.path.basename(ref_file)}")
|
|
58
|
+
print(f" deg: {os.path.basename(deg_file)}")
|
|
59
|
+
|
|
60
|
+
v = VisqolApi()
|
|
61
|
+
v.create(mode="audio")
|
|
62
|
+
|
|
63
|
+
# Run 1: get score (also triggers JIT compilation if not warmed up)
|
|
64
|
+
t0 = time.perf_counter()
|
|
65
|
+
result1 = v.measure(ref_file, deg_file)
|
|
66
|
+
t1 = time.perf_counter()
|
|
67
|
+
print(f" Run 1: MOS = {result1.moslqo:.6f} ({t1 - t0:.3f}s)")
|
|
68
|
+
|
|
69
|
+
# Run 2: steady-state (JIT cached)
|
|
70
|
+
t0 = time.perf_counter()
|
|
71
|
+
result2 = v.measure(ref_file, deg_file)
|
|
72
|
+
t1 = time.perf_counter()
|
|
73
|
+
print(f" Run 2: MOS = {result2.moslqo:.6f} ({t1 - t0:.3f}s)")
|
|
74
|
+
|
|
75
|
+
# Check reproducibility
|
|
76
|
+
diff = abs(result1.moslqo - result2.moslqo)
|
|
77
|
+
print(f" Run1 vs Run2 diff: {diff:.2e}")
|
|
78
|
+
assert diff < 1e-10, f"Non-reproducible results: diff={diff}"
|
|
79
|
+
print(" ✅ Results are reproducible across runs")
|
|
80
|
+
|
|
81
|
+
# Check MOS is in a reasonable range
|
|
82
|
+
print(f" MOS value: {result1.moslqo:.6f}")
|
|
83
|
+
assert 1.0 <= result1.moslqo <= 5.0, f"MOS out of range: {result1.moslqo}"
|
|
84
|
+
print(" ✅ MOS in valid range [1, 5]")
|
|
85
|
+
|
|
86
|
+
# Run 3: timing
|
|
87
|
+
t0 = time.perf_counter()
|
|
88
|
+
result3 = v.measure(ref_file, deg_file)
|
|
89
|
+
t1 = time.perf_counter()
|
|
90
|
+
print(f" Run 3: MOS = {result3.moslqo:.6f} ({t1 - t0:.3f}s)")
|
|
91
|
+
|
|
92
|
+
print()
|
|
93
|
+
print("=" * 60)
|
|
94
|
+
print("ALL CORRECTNESS CHECKS PASSED ✅")
|
|
95
|
+
print(f"Steady-state latency: {t1 - t0:.3f}s")
|
|
96
|
+
print("=" * 60)
|
|
@@ -10,12 +10,10 @@ import pytest
|
|
|
10
10
|
from visqol import (
|
|
11
11
|
AudioSignal,
|
|
12
12
|
PatchSimilarityResult,
|
|
13
|
-
ProgressCallback,
|
|
14
13
|
SimilarityResult,
|
|
15
14
|
VisqolApi,
|
|
16
15
|
)
|
|
17
16
|
|
|
18
|
-
|
|
19
17
|
# ── API creation ──
|
|
20
18
|
|
|
21
19
|
|
|
@@ -126,9 +124,7 @@ class TestMeasureFromArrays:
|
|
|
126
124
|
rng = np.random.default_rng(42)
|
|
127
125
|
deg = ref + 0.3 * rng.standard_normal(len(ref))
|
|
128
126
|
result = api.measure_from_arrays(ref, deg, sample_rate=sr)
|
|
129
|
-
assert 1.0 <= result.moslqo <= 5.0,
|
|
130
|
-
f"MOS should be in [1, 5], got {result.moslqo:.4f}"
|
|
131
|
-
)
|
|
127
|
+
assert 1.0 <= result.moslqo <= 5.0, f"MOS should be in [1, 5], got {result.moslqo:.4f}"
|
|
132
128
|
|
|
133
129
|
|
|
134
130
|
# ── Result fields ──
|
|
@@ -209,10 +205,12 @@ class TestMeasureBatch:
|
|
|
209
205
|
def test_batch_nonexistent_files_returns_exceptions(self):
|
|
210
206
|
api = VisqolApi()
|
|
211
207
|
api.create(mode="speech")
|
|
212
|
-
results = api.measure_batch(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
208
|
+
results = api.measure_batch(
|
|
209
|
+
[
|
|
210
|
+
("/nonexistent/a.wav", "/nonexistent/b.wav"),
|
|
211
|
+
("/nonexistent/c.wav", "/nonexistent/d.wav"),
|
|
212
|
+
]
|
|
213
|
+
)
|
|
216
214
|
assert len(results) == 2
|
|
217
215
|
assert all(isinstance(r, Exception) for r in results)
|
|
218
216
|
|
|
@@ -246,6 +244,7 @@ class TestVersion:
|
|
|
246
244
|
|
|
247
245
|
def test_version_string(self):
|
|
248
246
|
import visqol
|
|
247
|
+
|
|
249
248
|
assert hasattr(visqol, "__version__")
|
|
250
249
|
assert isinstance(visqol.__version__, str)
|
|
251
250
|
parts = visqol.__version__.split(".")
|
|
@@ -254,6 +253,7 @@ class TestVersion:
|
|
|
254
253
|
def test_public_exports(self):
|
|
255
254
|
"""Package should export key classes."""
|
|
256
255
|
import visqol
|
|
256
|
+
|
|
257
257
|
assert hasattr(visqol, "VisqolApi")
|
|
258
258
|
assert hasattr(visqol, "SimilarityResult")
|
|
259
259
|
assert hasattr(visqol, "AudioSignal")
|
|
@@ -13,7 +13,7 @@ Usage:
|
|
|
13
13
|
print(f"MOS-LQO: {result.moslqo}")
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
__version__: str = "3.
|
|
16
|
+
__version__: str = "3.4.0"
|
|
17
17
|
|
|
18
18
|
from visqol.api import ProgressCallback, VisqolApi
|
|
19
19
|
from visqol.audio_utils import AudioSignal
|
|
@@ -21,9 +21,9 @@ from visqol.nsim import PatchSimilarityResult
|
|
|
21
21
|
from visqol.visqol_core import SimilarityResult
|
|
22
22
|
|
|
23
23
|
__all__: list[str] = [
|
|
24
|
-
"VisqolApi",
|
|
25
|
-
"SimilarityResult",
|
|
26
24
|
"AudioSignal",
|
|
27
25
|
"PatchSimilarityResult",
|
|
28
26
|
"ProgressCallback",
|
|
27
|
+
"SimilarityResult",
|
|
28
|
+
"VisqolApi",
|
|
29
29
|
]
|
|
@@ -23,39 +23,52 @@ def main() -> None:
|
|
|
23
23
|
description="ViSQOL - Virtual Speech Quality Objective Listener (Python)",
|
|
24
24
|
)
|
|
25
25
|
parser.add_argument(
|
|
26
|
-
"--reference",
|
|
26
|
+
"--reference",
|
|
27
|
+
"-r",
|
|
28
|
+
required=True,
|
|
27
29
|
help="Path to reference audio file (WAV)",
|
|
28
30
|
)
|
|
29
31
|
parser.add_argument(
|
|
30
|
-
"--degraded",
|
|
32
|
+
"--degraded",
|
|
33
|
+
"-d",
|
|
34
|
+
required=True,
|
|
31
35
|
help="Path to degraded audio file (WAV)",
|
|
32
36
|
)
|
|
33
37
|
parser.add_argument(
|
|
34
|
-
"--speech_mode",
|
|
38
|
+
"--speech_mode",
|
|
39
|
+
action="store_true",
|
|
35
40
|
help="Use speech mode (16 kHz, exponential mapping)",
|
|
36
41
|
)
|
|
37
42
|
parser.add_argument(
|
|
38
|
-
"--model",
|
|
43
|
+
"--model",
|
|
44
|
+
default=None,
|
|
39
45
|
help="Path to SVR model file (Audio mode only)",
|
|
40
46
|
)
|
|
41
47
|
parser.add_argument(
|
|
42
|
-
"--search_window",
|
|
48
|
+
"--search_window",
|
|
49
|
+
type=int,
|
|
50
|
+
default=60,
|
|
43
51
|
help="Search window radius (default: 60)",
|
|
44
52
|
)
|
|
45
53
|
parser.add_argument(
|
|
46
|
-
"--unscaled_speech",
|
|
54
|
+
"--unscaled_speech",
|
|
55
|
+
action="store_true",
|
|
47
56
|
help="Don't scale speech MOS to max 5.0",
|
|
48
57
|
)
|
|
49
58
|
parser.add_argument(
|
|
50
|
-
"--no_alignment",
|
|
59
|
+
"--no_alignment",
|
|
60
|
+
action="store_true",
|
|
51
61
|
help="Disable global alignment",
|
|
52
62
|
)
|
|
53
63
|
parser.add_argument(
|
|
54
|
-
"--no_realignment",
|
|
64
|
+
"--no_realignment",
|
|
65
|
+
action="store_true",
|
|
55
66
|
help="Disable fine realignment",
|
|
56
67
|
)
|
|
57
68
|
parser.add_argument(
|
|
58
|
-
"--verbose",
|
|
69
|
+
"--verbose",
|
|
70
|
+
"-v",
|
|
71
|
+
action="store_true",
|
|
59
72
|
help="Enable verbose output",
|
|
60
73
|
)
|
|
61
74
|
|
|
@@ -105,9 +118,12 @@ def main() -> None:
|
|
|
105
118
|
for i, p in enumerate(result.patch_sims):
|
|
106
119
|
logger.info(
|
|
107
120
|
" Patch %d: sim=%.4f ref=[%.3f-%.3f] deg=[%.3f-%.3f]",
|
|
108
|
-
i,
|
|
109
|
-
p.
|
|
110
|
-
p.
|
|
121
|
+
i,
|
|
122
|
+
p.similarity,
|
|
123
|
+
p.ref_patch_start_time,
|
|
124
|
+
p.ref_patch_end_time,
|
|
125
|
+
p.deg_patch_start_time,
|
|
126
|
+
p.deg_patch_end_time,
|
|
111
127
|
)
|
|
112
128
|
|
|
113
129
|
|
|
@@ -6,17 +6,13 @@ Corresponds to C++ file: alignment.cc
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
from typing import Tuple
|
|
10
|
-
|
|
11
9
|
import numpy as np
|
|
12
10
|
|
|
13
11
|
from visqol.audio_utils import AudioSignal
|
|
14
|
-
from visqol.signal_utils import
|
|
12
|
+
from visqol.signal_utils import find_best_lag, upper_envelope
|
|
15
13
|
|
|
16
14
|
|
|
17
|
-
def globally_align(
|
|
18
|
-
reference: AudioSignal, degraded: AudioSignal
|
|
19
|
-
) -> Tuple[AudioSignal, float]:
|
|
15
|
+
def globally_align(reference: AudioSignal, degraded: AudioSignal) -> tuple[AudioSignal, float]:
|
|
20
16
|
"""
|
|
21
17
|
Globally align degraded signal to reference signal.
|
|
22
18
|
|
|
@@ -43,7 +39,7 @@ def globally_align(
|
|
|
43
39
|
|
|
44
40
|
if best_lag < 0:
|
|
45
41
|
# Degraded comes before reference: truncate front of degraded
|
|
46
|
-
new_data = degraded.data[abs(best_lag):]
|
|
42
|
+
new_data = degraded.data[abs(best_lag) :]
|
|
47
43
|
else:
|
|
48
44
|
# Reference comes before degraded: prepend zeros to degraded
|
|
49
45
|
new_data = np.concatenate([np.zeros(best_lag), degraded.data])
|
|
@@ -55,7 +51,7 @@ def globally_align(
|
|
|
55
51
|
|
|
56
52
|
def align_and_truncate(
|
|
57
53
|
reference: AudioSignal, degraded: AudioSignal
|
|
58
|
-
) ->
|
|
54
|
+
) -> tuple[AudioSignal, AudioSignal, float]:
|
|
59
55
|
"""
|
|
60
56
|
Align and truncate signals to the same length.
|
|
61
57
|
|
|
@@ -70,15 +66,15 @@ def align_and_truncate(
|
|
|
70
66
|
deg_data = aligned_deg.data
|
|
71
67
|
|
|
72
68
|
if len(ref_data) > len(deg_data):
|
|
73
|
-
ref_data = ref_data[:len(deg_data)]
|
|
69
|
+
ref_data = ref_data[: len(deg_data)]
|
|
74
70
|
elif len(ref_data) < len(deg_data):
|
|
75
71
|
# For positive lag, the beginning of ref aligns with zeros
|
|
76
72
|
lag_samples = int(lag_seconds * reference.sample_rate)
|
|
77
73
|
if lag_samples > 0:
|
|
78
74
|
ref_data = ref_data[lag_samples:]
|
|
79
|
-
deg_data = deg_data[lag_samples:lag_samples + len(ref_data)]
|
|
75
|
+
deg_data = deg_data[lag_samples : lag_samples + len(ref_data)]
|
|
80
76
|
else:
|
|
81
|
-
deg_data = deg_data[:len(ref_data)]
|
|
77
|
+
deg_data = deg_data[: len(ref_data)]
|
|
82
78
|
|
|
83
79
|
# Ensure same length
|
|
84
80
|
min_len = min(len(ref_data), len(deg_data))
|
|
@@ -43,7 +43,7 @@ class AnalysisWindow:
|
|
|
43
43
|
|
|
44
44
|
self.window_duration: float = window_duration
|
|
45
45
|
self.overlap: float = overlap
|
|
46
|
-
self.size: int =
|
|
46
|
+
self.size: int = round(sample_rate * window_duration)
|
|
47
47
|
self._hann_window: NDArray[np.float64] | None = None
|
|
48
48
|
|
|
49
49
|
@property
|
|
@@ -57,9 +57,7 @@ class AnalysisWindow:
|
|
|
57
57
|
if self._hann_window is None:
|
|
58
58
|
# Match C++ exactly: 0.5 − 0.5 * cos(2π i / (size − 1))
|
|
59
59
|
n = self.size
|
|
60
|
-
self._hann_window = 0.5 - 0.5 * np.cos(
|
|
61
|
-
2.0 * np.pi * np.arange(n) / (n - 1)
|
|
62
|
-
)
|
|
60
|
+
self._hann_window = 0.5 - 0.5 * np.cos(2.0 * np.pi * np.arange(n) / (n - 1))
|
|
63
61
|
return self._hann_window
|
|
64
62
|
|
|
65
63
|
def apply_hann_window(self, frame: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
@@ -70,7 +68,6 @@ class AnalysisWindow:
|
|
|
70
68
|
"""
|
|
71
69
|
if len(frame) != self.size:
|
|
72
70
|
raise ValueError(
|
|
73
|
-
f"Frame length ({len(frame)}) does not match "
|
|
74
|
-
f"window size ({self.size})."
|
|
71
|
+
f"Frame length ({len(frame)}) does not match window size ({self.size})."
|
|
75
72
|
)
|
|
76
73
|
return frame * self.hann_window
|