visqol-python 3.3.4__tar.gz → 3.3.6__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/CHANGELOG.md +55 -0
- visqol_python-3.3.6/CONTRIBUTING.md +77 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/MANIFEST.in +2 -1
- {visqol_python-3.3.4/visqol_python.egg-info → visqol_python-3.3.6}/PKG-INFO +32 -1
- {visqol_python-3.3.4 → visqol_python-3.3.6}/README.md +27 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/pyproject.toml +35 -1
- visqol_python-3.3.6/tests/test_quick.py +261 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/__init__.py +12 -3
- visqol_python-3.3.6/visqol/__main__.py +115 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/alignment.py +22 -12
- visqol_python-3.3.6/visqol/analysis_window.py +76 -0
- visqol_python-3.3.6/visqol/api.py +231 -0
- visqol_python-3.3.6/visqol/audio_utils.py +138 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/gammatone.py +143 -138
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/nsim.py +58 -44
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/patch_creator.py +95 -63
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/patch_selector.py +101 -72
- visqol_python-3.3.6/visqol/py.typed +0 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/quality_mapper.py +56 -45
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/signal_utils.py +26 -18
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/visqol_core.py +84 -52
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/visqol_manager.py +94 -64
- {visqol_python-3.3.4 → visqol_python-3.3.6/visqol_python.egg-info}/PKG-INFO +32 -1
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol_python.egg-info/SOURCES.txt +3 -1
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol_python.egg-info/requires.txt +5 -0
- visqol_python-3.3.4/requirements.txt +0 -4
- visqol_python-3.3.4/tests/test_quick.py +0 -89
- visqol_python-3.3.4/visqol/__main__.py +0 -92
- visqol_python-3.3.4/visqol/analysis_window.py +0 -52
- visqol_python-3.3.4/visqol/api.py +0 -110
- visqol_python-3.3.4/visqol/audio_utils.py +0 -90
- {visqol_python-3.3.4 → visqol_python-3.3.6}/LICENSE +0 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/setup.cfg +0 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/tests/test_conformance.py +0 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol/model/libsvm_nu_svr_model.txt +0 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol_python.egg-info/dependency_links.txt +0 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol_python.egg-info/entry_points.txt +0 -0
- {visqol_python-3.3.4 → visqol_python-3.3.6}/visqol_python.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
|
+
|
|
7
|
+
## [3.3.5] - 2026-03-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Type hints** on all public and internal APIs (`from __future__ import annotations`)
|
|
11
|
+
- **`py.typed`** marker (PEP 561) — mypy / pyright can now type-check dependents
|
|
12
|
+
- **CONTRIBUTING.md** with development setup, code style, and PR guidelines
|
|
13
|
+
- Exported `SimilarityResult` and `AudioSignal` from top-level `visqol` package
|
|
14
|
+
- `mypy` configuration in `pyproject.toml`
|
|
15
|
+
|
|
16
|
+
### Improved
|
|
17
|
+
- **Error handling**: friendly `ValueError` / `FileNotFoundError` / `TypeError` throughout:
|
|
18
|
+
- `VisqolApi.create()` now validates mode, search_window, and model_path
|
|
19
|
+
- `VisqolApi.measure()` checks file existence before processing
|
|
20
|
+
- `VisqolApi.measure_from_arrays()` validates array types, emptiness, and sample rate
|
|
21
|
+
- `AudioSignal` validates sample rate on construction
|
|
22
|
+
- `AnalysisWindow` validates sample_rate and overlap range
|
|
23
|
+
- CLI now catches exceptions and prints user-friendly error messages
|
|
24
|
+
- `AnalysisWindow.apply_hann_window()` uses `ValueError` instead of bare `assert`
|
|
25
|
+
|
|
26
|
+
## [3.3.4] - 2026-03-23
|
|
27
|
+
|
|
28
|
+
### Improved
|
|
29
|
+
- Tests rewritten in **pytest** format with `parametrize` and fixtures
|
|
30
|
+
- Added **CI workflow** (GitHub Actions): auto-test on Python 3.9–3.13 for every push/PR
|
|
31
|
+
- Added **smoke tests** (`test_quick.py`) that run without external testdata
|
|
32
|
+
- Version number now managed in a single place (`visqol/__init__.py`)
|
|
33
|
+
- Removed redundant `setup.py` — `pyproject.toml` is the single source of truth
|
|
34
|
+
- Added this CHANGELOG
|
|
35
|
+
- README: added PyPI / CI / License badges
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
- `requires-python` updated from `>=3.8` to `>=3.9` (numpy/scipy dropped 3.8 support)
|
|
39
|
+
|
|
40
|
+
## [3.3.3] - 2026-03-23
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- Initial PyPI release as `visqol-python`
|
|
44
|
+
- Pure Python port of [Google's ViSQOL v3.3.3](https://github.com/google/visqol)
|
|
45
|
+
- **Audio mode** (48 kHz, SVR quality mapping) — 10/10 conformance tests pass
|
|
46
|
+
- **Speech mode** (16 kHz, exponential polynomial mapping) — 1/1 conformance test passes
|
|
47
|
+
- Python API: `VisqolApi.measure()` and `VisqolApi.measure_from_arrays()`
|
|
48
|
+
- CLI: `python -m visqol` / `visqol` command
|
|
49
|
+
- Bundled SVR model (`libsvm_nu_svr_model.txt`)
|
|
50
|
+
- GitHub Actions workflow for auto-publish to PyPI via Trusted Publisher
|
|
51
|
+
|
|
52
|
+
[3.3.6]: https://github.com/talker93/visqol-python/compare/v3.3.5...v3.3.6
|
|
53
|
+
[3.3.5]: https://github.com/talker93/visqol-python/compare/v3.3.4...v3.3.5
|
|
54
|
+
[3.3.4]: https://github.com/talker93/visqol-python/compare/v3.3.3...v3.3.4
|
|
55
|
+
[3.3.3]: https://github.com/talker93/visqol-python/releases/tag/v3.3.3
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Contributing to ViSQOL (Python)
|
|
2
|
+
|
|
3
|
+
Thank you for your interest in contributing! This document provides guidelines and instructions for contributing to this project.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
1. **Fork** the repository on GitHub
|
|
8
|
+
2. **Clone** your fork locally:
|
|
9
|
+
```bash
|
|
10
|
+
git clone https://github.com/<your-username>/visqol-python.git
|
|
11
|
+
cd visqol-python
|
|
12
|
+
```
|
|
13
|
+
3. **Create a branch** for your changes:
|
|
14
|
+
```bash
|
|
15
|
+
git checkout -b feature/my-improvement
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Development Setup
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
# Create a virtual environment
|
|
22
|
+
python -m venv venv
|
|
23
|
+
source venv/bin/activate # macOS/Linux
|
|
24
|
+
# venv\Scripts\activate # Windows
|
|
25
|
+
|
|
26
|
+
# Install in development mode with test dependencies
|
|
27
|
+
pip install -e ".[test]"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Running Tests
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Quick smoke tests (no external data needed)
|
|
34
|
+
pytest tests/test_quick.py -v
|
|
35
|
+
|
|
36
|
+
# Full conformance tests (requires testdata directory)
|
|
37
|
+
pytest tests/test_conformance.py -v --testdata /path/to/visqol/testdata
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Code Style
|
|
41
|
+
|
|
42
|
+
- **Type hints**: All public functions and methods must include type annotations.
|
|
43
|
+
- **Docstrings**: Use Google-style docstrings for all public APIs.
|
|
44
|
+
- **Imports**: Use `from __future__ import annotations` at the top of every module.
|
|
45
|
+
- Keep line length ≤ 99 characters where practical.
|
|
46
|
+
|
|
47
|
+
## Making Changes
|
|
48
|
+
|
|
49
|
+
1. Write clean, well-documented code with type hints.
|
|
50
|
+
2. Add or update tests for any new functionality.
|
|
51
|
+
3. Ensure all existing tests still pass.
|
|
52
|
+
4. Update `CHANGELOG.md` under an `[Unreleased]` section.
|
|
53
|
+
|
|
54
|
+
## Pull Request Process
|
|
55
|
+
|
|
56
|
+
1. Update the `CHANGELOG.md` with details of your changes.
|
|
57
|
+
2. Ensure all tests pass locally.
|
|
58
|
+
3. Submit a pull request with a clear description of the changes.
|
|
59
|
+
4. Link any relevant issues.
|
|
60
|
+
|
|
61
|
+
## Reporting Bugs
|
|
62
|
+
|
|
63
|
+
Please open an [issue](https://github.com/talker93/visqol-python/issues) with:
|
|
64
|
+
|
|
65
|
+
- A clear, descriptive title
|
|
66
|
+
- Steps to reproduce the problem
|
|
67
|
+
- Expected vs. actual behavior
|
|
68
|
+
- Python version and OS
|
|
69
|
+
- Relevant audio file details (sample rate, duration, format)
|
|
70
|
+
|
|
71
|
+
## Versioning
|
|
72
|
+
|
|
73
|
+
This project follows [Semantic Versioning](https://semver.org/). The single source of truth for the version number is `visqol/__init__.py`.
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
By contributing, you agree that your contributions will be licensed under the [Apache License 2.0](LICENSE).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: visqol-python
|
|
3
|
-
Version: 3.3.
|
|
3
|
+
Version: 3.3.6
|
|
4
4
|
Summary: ViSQOL - Virtual Speech Quality Objective Listener (Pure Python)
|
|
5
5
|
Author: Shan Jiang
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -30,6 +30,10 @@ Requires-Dist: soundfile>=0.10
|
|
|
30
30
|
Requires-Dist: libsvm-official>=3.25
|
|
31
31
|
Provides-Extra: test
|
|
32
32
|
Requires-Dist: pytest>=7.0; extra == "test"
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
35
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.8; extra == "dev"
|
|
33
37
|
Dynamic: license-file
|
|
34
38
|
|
|
35
39
|
# ViSQOL (Python)
|
|
@@ -103,6 +107,33 @@ result = api.measure_from_arrays(ref, deg, sample_rate=sr)
|
|
|
103
107
|
print(f"MOS-LQO: {result.moslqo:.4f}")
|
|
104
108
|
```
|
|
105
109
|
|
|
110
|
+
### Batch Evaluation
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from visqol import VisqolApi
|
|
114
|
+
|
|
115
|
+
api = VisqolApi()
|
|
116
|
+
api.create(mode="audio")
|
|
117
|
+
|
|
118
|
+
file_pairs = [
|
|
119
|
+
("ref1.wav", "deg1.wav"),
|
|
120
|
+
("ref2.wav", "deg2.wav"),
|
|
121
|
+
("ref3.wav", "deg3.wav"),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
# Optional progress callback
|
|
125
|
+
results = api.measure_batch(
|
|
126
|
+
file_pairs,
|
|
127
|
+
progress_callback=lambda done, total: print(f"{done}/{total}"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
for pair, result in zip(file_pairs, results):
|
|
131
|
+
if isinstance(result, Exception):
|
|
132
|
+
print(f"{pair}: FAILED — {result}")
|
|
133
|
+
else:
|
|
134
|
+
print(f"{pair}: MOS-LQO = {result.moslqo:.4f}")
|
|
135
|
+
```
|
|
136
|
+
|
|
106
137
|
### Command Line
|
|
107
138
|
|
|
108
139
|
```bash
|
|
@@ -69,6 +69,33 @@ result = api.measure_from_arrays(ref, deg, sample_rate=sr)
|
|
|
69
69
|
print(f"MOS-LQO: {result.moslqo:.4f}")
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
+
### Batch Evaluation
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from visqol import VisqolApi
|
|
76
|
+
|
|
77
|
+
api = VisqolApi()
|
|
78
|
+
api.create(mode="audio")
|
|
79
|
+
|
|
80
|
+
file_pairs = [
|
|
81
|
+
("ref1.wav", "deg1.wav"),
|
|
82
|
+
("ref2.wav", "deg2.wav"),
|
|
83
|
+
("ref3.wav", "deg3.wav"),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
# Optional progress callback
|
|
87
|
+
results = api.measure_batch(
|
|
88
|
+
file_pairs,
|
|
89
|
+
progress_callback=lambda done, total: print(f"{done}/{total}"),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
for pair, result in zip(file_pairs, results):
|
|
93
|
+
if isinstance(result, Exception):
|
|
94
|
+
print(f"{pair}: FAILED — {result}")
|
|
95
|
+
else:
|
|
96
|
+
print(f"{pair}: MOS-LQO = {result.moslqo:.4f}")
|
|
97
|
+
```
|
|
98
|
+
|
|
72
99
|
### Command Line
|
|
73
100
|
|
|
74
101
|
```bash
|
|
@@ -38,6 +38,11 @@ dependencies = [
|
|
|
38
38
|
|
|
39
39
|
[project.optional-dependencies]
|
|
40
40
|
test = ["pytest>=7.0"]
|
|
41
|
+
dev = [
|
|
42
|
+
"pytest>=7.0",
|
|
43
|
+
"ruff>=0.4",
|
|
44
|
+
"mypy>=1.8",
|
|
45
|
+
]
|
|
41
46
|
|
|
42
47
|
[project.urls]
|
|
43
48
|
Homepage = "https://github.com/talker93/visqol-python"
|
|
@@ -56,7 +61,36 @@ version = {attr = "visqol.__version__"}
|
|
|
56
61
|
exclude = ["tests*"]
|
|
57
62
|
|
|
58
63
|
[tool.setuptools.package-data]
|
|
59
|
-
visqol = ["model/*.txt"]
|
|
64
|
+
visqol = ["model/*.txt", "py.typed"]
|
|
60
65
|
|
|
61
66
|
[tool.pytest.ini_options]
|
|
62
67
|
testpaths = ["tests"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff]
|
|
70
|
+
target-version = "py39"
|
|
71
|
+
line-length = 95
|
|
72
|
+
|
|
73
|
+
[tool.ruff.lint]
|
|
74
|
+
select = [
|
|
75
|
+
"E", # pycodestyle errors
|
|
76
|
+
"W", # pycodestyle warnings
|
|
77
|
+
"F", # pyflakes
|
|
78
|
+
"I", # isort
|
|
79
|
+
"UP", # pyupgrade
|
|
80
|
+
"B", # flake8-bugbear
|
|
81
|
+
"SIM", # flake8-simplify
|
|
82
|
+
"RUF", # ruff-specific rules
|
|
83
|
+
]
|
|
84
|
+
ignore = ["E501"] # line length handled by formatter
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint.isort]
|
|
87
|
+
known-first-party = ["visqol"]
|
|
88
|
+
|
|
89
|
+
[tool.mypy]
|
|
90
|
+
strict = true
|
|
91
|
+
warn_return_any = true
|
|
92
|
+
warn_unused_configs = true
|
|
93
|
+
|
|
94
|
+
[[tool.mypy.overrides]]
|
|
95
|
+
module = ["svmutil.*", "libsvm.*", "soundfile.*"]
|
|
96
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Quick smoke tests for ViSQOL Python.
|
|
3
|
+
|
|
4
|
+
These tests verify basic API functionality without requiring external testdata.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from visqol import (
|
|
11
|
+
AudioSignal,
|
|
12
|
+
PatchSimilarityResult,
|
|
13
|
+
ProgressCallback,
|
|
14
|
+
SimilarityResult,
|
|
15
|
+
VisqolApi,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ── API creation ──
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestApiCreation:
|
|
23
|
+
"""Test that VisqolApi can be created in different modes."""
|
|
24
|
+
|
|
25
|
+
def test_create_audio_mode(self):
|
|
26
|
+
api = VisqolApi()
|
|
27
|
+
api.create(mode="audio")
|
|
28
|
+
|
|
29
|
+
def test_create_speech_mode(self):
|
|
30
|
+
api = VisqolApi()
|
|
31
|
+
api.create(mode="speech")
|
|
32
|
+
|
|
33
|
+
def test_create_default_mode(self):
|
|
34
|
+
"""Default mode (no argument) should work as audio mode."""
|
|
35
|
+
api = VisqolApi()
|
|
36
|
+
api.create()
|
|
37
|
+
|
|
38
|
+
def test_create_case_insensitive(self):
|
|
39
|
+
api = VisqolApi()
|
|
40
|
+
api.create(mode="SPEECH")
|
|
41
|
+
|
|
42
|
+
def test_create_invalid_mode_raises(self):
|
|
43
|
+
api = VisqolApi()
|
|
44
|
+
with pytest.raises(ValueError, match="Invalid mode"):
|
|
45
|
+
api.create(mode="invalid")
|
|
46
|
+
|
|
47
|
+
def test_create_negative_search_window_raises(self):
|
|
48
|
+
api = VisqolApi()
|
|
49
|
+
with pytest.raises(ValueError, match="search_window"):
|
|
50
|
+
api.create(search_window=-1)
|
|
51
|
+
|
|
52
|
+
def test_create_missing_model_raises(self):
|
|
53
|
+
api = VisqolApi()
|
|
54
|
+
with pytest.raises(FileNotFoundError):
|
|
55
|
+
api.create(mode="audio", model_path="/nonexistent/model.txt")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Measure guards ──
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class TestMeasureGuards:
|
|
62
|
+
"""Test that measure() raises helpful errors for bad inputs."""
|
|
63
|
+
|
|
64
|
+
def test_measure_before_create_raises(self):
|
|
65
|
+
api = VisqolApi()
|
|
66
|
+
with pytest.raises(RuntimeError, match="create"):
|
|
67
|
+
api.measure("a.wav", "b.wav")
|
|
68
|
+
|
|
69
|
+
def test_measure_nonexistent_ref_raises(self):
|
|
70
|
+
api = VisqolApi()
|
|
71
|
+
api.create(mode="speech")
|
|
72
|
+
with pytest.raises(FileNotFoundError, match="Reference"):
|
|
73
|
+
api.measure("/nonexistent/ref.wav", "/nonexistent/deg.wav")
|
|
74
|
+
|
|
75
|
+
def test_measure_from_arrays_before_create_raises(self):
|
|
76
|
+
api = VisqolApi()
|
|
77
|
+
with pytest.raises(RuntimeError, match="create"):
|
|
78
|
+
api.measure_from_arrays(np.zeros(100), np.zeros(100), 16000)
|
|
79
|
+
|
|
80
|
+
def test_measure_from_arrays_bad_type_raises(self):
|
|
81
|
+
api = VisqolApi()
|
|
82
|
+
api.create(mode="speech")
|
|
83
|
+
with pytest.raises(TypeError, match="numpy array"):
|
|
84
|
+
api.measure_from_arrays([1, 2, 3], np.zeros(100), 16000) # type: ignore[arg-type]
|
|
85
|
+
|
|
86
|
+
def test_measure_from_arrays_empty_raises(self):
|
|
87
|
+
api = VisqolApi()
|
|
88
|
+
api.create(mode="speech")
|
|
89
|
+
with pytest.raises(ValueError, match="empty"):
|
|
90
|
+
api.measure_from_arrays(np.array([]), np.zeros(100), 16000)
|
|
91
|
+
|
|
92
|
+
def test_measure_from_arrays_bad_sr_raises(self):
|
|
93
|
+
api = VisqolApi()
|
|
94
|
+
api.create(mode="speech")
|
|
95
|
+
with pytest.raises(ValueError, match="sample_rate"):
|
|
96
|
+
api.measure_from_arrays(np.zeros(100), np.zeros(100), 0)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── measure_from_arrays ──
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestMeasureFromArrays:
|
|
103
|
+
"""Test measure_from_arrays with synthetic signals."""
|
|
104
|
+
|
|
105
|
+
def test_identical_signal_high_score(self):
|
|
106
|
+
"""Identical signals should produce a high MOS score."""
|
|
107
|
+
api = VisqolApi()
|
|
108
|
+
api.create(mode="speech")
|
|
109
|
+
sr = 16000
|
|
110
|
+
duration = 3.0
|
|
111
|
+
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
|
|
112
|
+
signal = 0.5 * np.sin(2 * np.pi * 440 * t)
|
|
113
|
+
result = api.measure_from_arrays(signal, signal, sample_rate=sr)
|
|
114
|
+
assert result.moslqo >= 4.0, (
|
|
115
|
+
f"Identical signal should give MOS >= 4.0, got {result.moslqo:.4f}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def test_degraded_signal_lower_score(self):
|
|
119
|
+
"""Adding noise to a signal should produce a lower MOS score."""
|
|
120
|
+
api = VisqolApi()
|
|
121
|
+
api.create(mode="speech")
|
|
122
|
+
sr = 16000
|
|
123
|
+
duration = 3.0
|
|
124
|
+
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
|
|
125
|
+
ref = 0.5 * np.sin(2 * np.pi * 440 * t)
|
|
126
|
+
rng = np.random.default_rng(42)
|
|
127
|
+
deg = ref + 0.3 * rng.standard_normal(len(ref))
|
|
128
|
+
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
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── Result fields ──
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TestResultFields:
|
|
138
|
+
"""Test that SimilarityResult has all expected fields."""
|
|
139
|
+
|
|
140
|
+
def test_result_has_expected_fields(self):
|
|
141
|
+
api = VisqolApi()
|
|
142
|
+
api.create(mode="speech")
|
|
143
|
+
sr = 16000
|
|
144
|
+
duration = 3.0
|
|
145
|
+
t = np.linspace(0, duration, int(sr * duration), endpoint=False)
|
|
146
|
+
signal = 0.5 * np.sin(2 * np.pi * 440 * t)
|
|
147
|
+
result = api.measure_from_arrays(signal, signal, sample_rate=sr)
|
|
148
|
+
assert hasattr(result, "moslqo")
|
|
149
|
+
assert hasattr(result, "vnsim")
|
|
150
|
+
assert hasattr(result, "fvnsim")
|
|
151
|
+
assert hasattr(result, "fstdnsim")
|
|
152
|
+
assert hasattr(result, "fvdegenergy")
|
|
153
|
+
assert hasattr(result, "patch_sims")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ── __repr__ / __str__ ──
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class TestReprStr:
|
|
160
|
+
"""Test readable string representations."""
|
|
161
|
+
|
|
162
|
+
def test_audio_signal_repr(self):
|
|
163
|
+
sig = AudioSignal(np.zeros(16000), 16000)
|
|
164
|
+
r = repr(sig)
|
|
165
|
+
assert "AudioSignal" in r
|
|
166
|
+
assert "16000" in r
|
|
167
|
+
assert "1.000" in r
|
|
168
|
+
|
|
169
|
+
def test_audio_signal_str(self):
|
|
170
|
+
sig = AudioSignal(np.zeros(48000), 48000)
|
|
171
|
+
s = str(sig)
|
|
172
|
+
assert "1.000s" in s
|
|
173
|
+
assert "48000" in s
|
|
174
|
+
|
|
175
|
+
def test_similarity_result_str(self):
|
|
176
|
+
res = SimilarityResult(moslqo=4.5, vnsim=0.95)
|
|
177
|
+
s = str(res)
|
|
178
|
+
assert "4.5" in s
|
|
179
|
+
assert "0.95" in s
|
|
180
|
+
|
|
181
|
+
def test_similarity_result_repr(self):
|
|
182
|
+
res = SimilarityResult(moslqo=4.5, vnsim=0.95)
|
|
183
|
+
r = repr(res)
|
|
184
|
+
assert "SimilarityResult" in r
|
|
185
|
+
assert "moslqo" in r
|
|
186
|
+
|
|
187
|
+
def test_patch_similarity_result_str(self):
|
|
188
|
+
p = PatchSimilarityResult(similarity=0.85)
|
|
189
|
+
s = str(p)
|
|
190
|
+
assert "0.85" in s
|
|
191
|
+
|
|
192
|
+
def test_patch_similarity_result_repr(self):
|
|
193
|
+
p = PatchSimilarityResult(similarity=0.85)
|
|
194
|
+
r = repr(p)
|
|
195
|
+
assert "PatchSimilarityResult" in r
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ── measure_batch ──
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class TestMeasureBatch:
|
|
202
|
+
"""Test batch evaluation API."""
|
|
203
|
+
|
|
204
|
+
def test_batch_before_create_raises(self):
|
|
205
|
+
api = VisqolApi()
|
|
206
|
+
with pytest.raises(RuntimeError, match="create"):
|
|
207
|
+
api.measure_batch([("/a.wav", "/b.wav")])
|
|
208
|
+
|
|
209
|
+
def test_batch_nonexistent_files_returns_exceptions(self):
|
|
210
|
+
api = VisqolApi()
|
|
211
|
+
api.create(mode="speech")
|
|
212
|
+
results = api.measure_batch([
|
|
213
|
+
("/nonexistent/a.wav", "/nonexistent/b.wav"),
|
|
214
|
+
("/nonexistent/c.wav", "/nonexistent/d.wav"),
|
|
215
|
+
])
|
|
216
|
+
assert len(results) == 2
|
|
217
|
+
assert all(isinstance(r, Exception) for r in results)
|
|
218
|
+
|
|
219
|
+
def test_batch_progress_callback(self):
|
|
220
|
+
api = VisqolApi()
|
|
221
|
+
api.create(mode="speech")
|
|
222
|
+
progress_log: list[tuple[int, int]] = []
|
|
223
|
+
|
|
224
|
+
def cb(done: int, total: int) -> None:
|
|
225
|
+
progress_log.append((done, total))
|
|
226
|
+
|
|
227
|
+
results = api.measure_batch(
|
|
228
|
+
[("/nonexistent/a.wav", "/nonexistent/b.wav")],
|
|
229
|
+
progress_callback=cb,
|
|
230
|
+
)
|
|
231
|
+
assert len(results) == 1
|
|
232
|
+
assert progress_log == [(1, 1)]
|
|
233
|
+
|
|
234
|
+
def test_batch_empty(self):
|
|
235
|
+
api = VisqolApi()
|
|
236
|
+
api.create(mode="speech")
|
|
237
|
+
results = api.measure_batch([])
|
|
238
|
+
assert results == []
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# ── Package metadata ──
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class TestVersion:
|
|
245
|
+
"""Test package version is accessible."""
|
|
246
|
+
|
|
247
|
+
def test_version_string(self):
|
|
248
|
+
import visqol
|
|
249
|
+
assert hasattr(visqol, "__version__")
|
|
250
|
+
assert isinstance(visqol.__version__, str)
|
|
251
|
+
parts = visqol.__version__.split(".")
|
|
252
|
+
assert len(parts) >= 2, "Version should have at least major.minor"
|
|
253
|
+
|
|
254
|
+
def test_public_exports(self):
|
|
255
|
+
"""Package should export key classes."""
|
|
256
|
+
import visqol
|
|
257
|
+
assert hasattr(visqol, "VisqolApi")
|
|
258
|
+
assert hasattr(visqol, "SimilarityResult")
|
|
259
|
+
assert hasattr(visqol, "AudioSignal")
|
|
260
|
+
assert hasattr(visqol, "PatchSimilarityResult")
|
|
261
|
+
assert hasattr(visqol, "ProgressCallback")
|
|
@@ -13,8 +13,17 @@ Usage:
|
|
|
13
13
|
print(f"MOS-LQO: {result.moslqo}")
|
|
14
14
|
"""
|
|
15
15
|
|
|
16
|
-
__version__ = "3.3.
|
|
16
|
+
__version__: str = "3.3.6"
|
|
17
17
|
|
|
18
|
-
from visqol.api import VisqolApi
|
|
18
|
+
from visqol.api import ProgressCallback, VisqolApi
|
|
19
|
+
from visqol.audio_utils import AudioSignal
|
|
20
|
+
from visqol.nsim import PatchSimilarityResult
|
|
21
|
+
from visqol.visqol_core import SimilarityResult
|
|
19
22
|
|
|
20
|
-
__all__ = [
|
|
23
|
+
__all__: list[str] = [
|
|
24
|
+
"VisqolApi",
|
|
25
|
+
"SimilarityResult",
|
|
26
|
+
"AudioSignal",
|
|
27
|
+
"PatchSimilarityResult",
|
|
28
|
+
"ProgressCallback",
|
|
29
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ViSQOL command-line interface.
|
|
3
|
+
|
|
4
|
+
Usage::
|
|
5
|
+
|
|
6
|
+
python -m visqol --reference ref.wav --degraded deg.wav [--speech_mode]
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
from visqol.api import VisqolApi
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("visqol")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> None:
|
|
21
|
+
"""Entry point for the ``visqol`` CLI."""
|
|
22
|
+
parser = argparse.ArgumentParser(
|
|
23
|
+
description="ViSQOL - Virtual Speech Quality Objective Listener (Python)",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--reference", "-r", required=True,
|
|
27
|
+
help="Path to reference audio file (WAV)",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument(
|
|
30
|
+
"--degraded", "-d", required=True,
|
|
31
|
+
help="Path to degraded audio file (WAV)",
|
|
32
|
+
)
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--speech_mode", action="store_true",
|
|
35
|
+
help="Use speech mode (16 kHz, exponential mapping)",
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument(
|
|
38
|
+
"--model", default=None,
|
|
39
|
+
help="Path to SVR model file (Audio mode only)",
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--search_window", type=int, default=60,
|
|
43
|
+
help="Search window radius (default: 60)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--unscaled_speech", action="store_true",
|
|
47
|
+
help="Don't scale speech MOS to max 5.0",
|
|
48
|
+
)
|
|
49
|
+
parser.add_argument(
|
|
50
|
+
"--no_alignment", action="store_true",
|
|
51
|
+
help="Disable global alignment",
|
|
52
|
+
)
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--no_realignment", action="store_true",
|
|
55
|
+
help="Disable fine realignment",
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--verbose", "-v", action="store_true",
|
|
59
|
+
help="Enable verbose output",
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
args = parser.parse_args()
|
|
63
|
+
|
|
64
|
+
# Setup logging
|
|
65
|
+
level = logging.DEBUG if args.verbose else logging.WARNING
|
|
66
|
+
logging.basicConfig(
|
|
67
|
+
level=level,
|
|
68
|
+
format="%(levelname)s: %(message)s",
|
|
69
|
+
stream=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Run ViSQOL
|
|
73
|
+
mode: str = "speech" if args.speech_mode else "audio"
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
api = VisqolApi()
|
|
77
|
+
api.create(
|
|
78
|
+
mode=mode,
|
|
79
|
+
model_path=args.model,
|
|
80
|
+
search_window=args.search_window,
|
|
81
|
+
use_unscaled_speech=args.unscaled_speech,
|
|
82
|
+
disable_global_alignment=args.no_alignment,
|
|
83
|
+
disable_realignment=args.no_realignment,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
result = api.measure(args.reference, args.degraded)
|
|
87
|
+
|
|
88
|
+
except (FileNotFoundError, ValueError, RuntimeError) as exc:
|
|
89
|
+
logger.error("%s", exc)
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
logger.error("Unexpected error: %s", exc)
|
|
93
|
+
sys.exit(2)
|
|
94
|
+
|
|
95
|
+
# Output results
|
|
96
|
+
print(f"MOS-LQO: {result.moslqo:.6f}")
|
|
97
|
+
print(f"VNSIM: {result.vnsim:.6f}")
|
|
98
|
+
|
|
99
|
+
if args.verbose:
|
|
100
|
+
logger.info("FVNSIM: %s", result.fvnsim)
|
|
101
|
+
logger.info("FVNSIM10: %s", result.fvnsim10)
|
|
102
|
+
logger.info("FSTDNSIM: %s", result.fstdnsim)
|
|
103
|
+
logger.info("FVDEGENERGY: %s", result.fvdegenergy)
|
|
104
|
+
logger.info("Patches: %d", len(result.patch_sims))
|
|
105
|
+
for i, p in enumerate(result.patch_sims):
|
|
106
|
+
logger.info(
|
|
107
|
+
" Patch %d: sim=%.4f ref=[%.3f-%.3f] deg=[%.3f-%.3f]",
|
|
108
|
+
i, p.similarity,
|
|
109
|
+
p.ref_patch_start_time, p.ref_patch_end_time,
|
|
110
|
+
p.deg_patch_start_time, p.deg_patch_end_time,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|