visqol-python 3.3.6__py3-none-any.whl → 3.4.0__py3-none-any.whl
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/__init__.py +3 -3
- visqol/__main__.py +28 -12
- visqol/alignment.py +7 -11
- visqol/analysis_window.py +3 -6
- visqol/api.py +146 -31
- visqol/audio_utils.py +6 -11
- visqol/gammatone.py +142 -42
- visqol/nsim.py +13 -17
- visqol/numba_accel.py +619 -0
- visqol/patch_creator.py +25 -28
- visqol/patch_selector.py +224 -61
- visqol/quality_mapper.py +13 -14
- visqol/signal_utils.py +3 -3
- visqol/visqol_core.py +38 -31
- visqol/visqol_manager.py +16 -14
- {visqol_python-3.3.6.dist-info → visqol_python-3.4.0.dist-info}/METADATA +4 -1
- visqol_python-3.4.0.dist-info/RECORD +23 -0
- visqol_python-3.3.6.dist-info/RECORD +0 -22
- {visqol_python-3.3.6.dist-info → visqol_python-3.4.0.dist-info}/WHEEL +0 -0
- {visqol_python-3.3.6.dist-info → visqol_python-3.4.0.dist-info}/entry_points.txt +0 -0
- {visqol_python-3.3.6.dist-info → visqol_python-3.4.0.dist-info}/licenses/LICENSE +0 -0
- {visqol_python-3.3.6.dist-info → visqol_python-3.4.0.dist-info}/top_level.txt +0 -0
visqol/__init__.py
CHANGED
|
@@ -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
|
]
|
visqol/__main__.py
CHANGED
|
@@ -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
|
|
visqol/alignment.py
CHANGED
|
@@ -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))
|
visqol/analysis_window.py
CHANGED
|
@@ -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
|
visqol/api.py
CHANGED
|
@@ -10,15 +10,16 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import logging
|
|
12
12
|
import os
|
|
13
|
+
from collections.abc import Sequence
|
|
13
14
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
14
|
-
from typing import Callable
|
|
15
|
+
from typing import Callable
|
|
15
16
|
|
|
16
17
|
import numpy as np
|
|
17
18
|
from numpy.typing import NDArray
|
|
18
19
|
|
|
19
20
|
from visqol.audio_utils import AudioSignal
|
|
20
|
-
from visqol.visqol_manager import VisqolManager
|
|
21
21
|
from visqol.visqol_core import SimilarityResult
|
|
22
|
+
from visqol.visqol_manager import VisqolManager
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
24
25
|
|
|
@@ -53,7 +54,7 @@ class VisqolApi:
|
|
|
53
54
|
def create(
|
|
54
55
|
self,
|
|
55
56
|
mode: str = "audio",
|
|
56
|
-
model_path:
|
|
57
|
+
model_path: str | None = None,
|
|
57
58
|
search_window: int = 60,
|
|
58
59
|
use_unscaled_speech: bool = False,
|
|
59
60
|
disable_global_alignment: bool = False,
|
|
@@ -79,14 +80,10 @@ class VisqolApi:
|
|
|
79
80
|
"""
|
|
80
81
|
mode_lower = mode.lower()
|
|
81
82
|
if mode_lower not in _VALID_MODES:
|
|
82
|
-
raise ValueError(
|
|
83
|
-
f"Invalid mode {mode!r}. Must be one of {sorted(_VALID_MODES)}."
|
|
84
|
-
)
|
|
83
|
+
raise ValueError(f"Invalid mode {mode!r}. Must be one of {sorted(_VALID_MODES)}.")
|
|
85
84
|
|
|
86
85
|
if search_window <= 0:
|
|
87
|
-
raise ValueError(
|
|
88
|
-
f"search_window must be a positive integer, got {search_window}."
|
|
89
|
-
)
|
|
86
|
+
raise ValueError(f"search_window must be a positive integer, got {search_window}.")
|
|
90
87
|
|
|
91
88
|
use_speech_mode = mode_lower == "speech"
|
|
92
89
|
|
|
@@ -94,9 +91,7 @@ class VisqolApi:
|
|
|
94
91
|
model_path = _DEFAULT_SVR_MODEL
|
|
95
92
|
|
|
96
93
|
if model_path is not None and not os.path.isfile(model_path):
|
|
97
|
-
raise FileNotFoundError(
|
|
98
|
-
f"SVR model file not found: {model_path}"
|
|
99
|
-
)
|
|
94
|
+
raise FileNotFoundError(f"SVR model file not found: {model_path}")
|
|
100
95
|
|
|
101
96
|
# Store kwargs for batch mode (subprocess recreation)
|
|
102
97
|
self._create_kwargs = {
|
|
@@ -160,21 +155,15 @@ class VisqolApi:
|
|
|
160
155
|
self._ensure_created()
|
|
161
156
|
|
|
162
157
|
if not isinstance(ref_array, np.ndarray):
|
|
163
|
-
raise TypeError(
|
|
164
|
-
f"ref_array must be a numpy array, got {type(ref_array).__name__}"
|
|
165
|
-
)
|
|
158
|
+
raise TypeError(f"ref_array must be a numpy array, got {type(ref_array).__name__}")
|
|
166
159
|
if not isinstance(deg_array, np.ndarray):
|
|
167
|
-
raise TypeError(
|
|
168
|
-
f"deg_array must be a numpy array, got {type(deg_array).__name__}"
|
|
169
|
-
)
|
|
160
|
+
raise TypeError(f"deg_array must be a numpy array, got {type(deg_array).__name__}")
|
|
170
161
|
if ref_array.size == 0:
|
|
171
162
|
raise ValueError("ref_array must not be empty.")
|
|
172
163
|
if deg_array.size == 0:
|
|
173
164
|
raise ValueError("deg_array must not be empty.")
|
|
174
165
|
if sample_rate <= 0:
|
|
175
|
-
raise ValueError(
|
|
176
|
-
f"sample_rate must be a positive integer, got {sample_rate}."
|
|
177
|
-
)
|
|
166
|
+
raise ValueError(f"sample_rate must be a positive integer, got {sample_rate}.")
|
|
178
167
|
|
|
179
168
|
ref_signal = AudioSignal(ref_array, sample_rate)
|
|
180
169
|
deg_signal = AudioSignal(deg_array, sample_rate)
|
|
@@ -182,28 +171,61 @@ class VisqolApi:
|
|
|
182
171
|
|
|
183
172
|
def measure_batch(
|
|
184
173
|
self,
|
|
185
|
-
file_pairs: Sequence[
|
|
174
|
+
file_pairs: Sequence[tuple[str, str]],
|
|
186
175
|
*,
|
|
187
|
-
|
|
188
|
-
|
|
176
|
+
max_workers: int | None = None,
|
|
177
|
+
parallel: bool = True,
|
|
178
|
+
progress_callback: ProgressCallback | None = None,
|
|
179
|
+
) -> list[SimilarityResult | Exception]:
|
|
189
180
|
"""
|
|
190
|
-
Evaluate multiple file pairs
|
|
181
|
+
Evaluate multiple file pairs, optionally in parallel.
|
|
182
|
+
|
|
183
|
+
When *parallel* is ``True`` (the default), evaluation is distributed
|
|
184
|
+
across multiple **processes** via :class:`ProcessPoolExecutor`. Each
|
|
185
|
+
worker recreates its own ``VisqolManager`` from the same configuration
|
|
186
|
+
so there is **zero** shared state and no GIL contention.
|
|
191
187
|
|
|
192
188
|
Args:
|
|
193
189
|
file_pairs: Sequence of ``(ref_path, deg_path)`` tuples.
|
|
190
|
+
max_workers: Maximum number of worker processes. *None* means
|
|
191
|
+
``min(len(file_pairs), os.cpu_count())``. Ignored when
|
|
192
|
+
*parallel* is ``False``.
|
|
193
|
+
parallel: If ``False``, fall back to sequential evaluation
|
|
194
|
+
(useful for debugging or when multiprocessing is unavailable).
|
|
194
195
|
progress_callback: Optional ``(completed, total) -> None`` callback
|
|
195
196
|
invoked after each pair is processed.
|
|
196
197
|
|
|
197
198
|
Returns:
|
|
198
199
|
List of :class:`SimilarityResult` (on success) or :class:`Exception`
|
|
199
|
-
(on failure) for each pair, in the same order as *file_pairs*.
|
|
200
|
+
(on failure) for each pair, **in the same order** as *file_pairs*.
|
|
200
201
|
|
|
201
202
|
Raises:
|
|
202
203
|
RuntimeError: If :meth:`create` has not been called.
|
|
203
204
|
"""
|
|
204
205
|
self._ensure_created()
|
|
205
206
|
total = len(file_pairs)
|
|
206
|
-
|
|
207
|
+
|
|
208
|
+
if not parallel or total <= 1:
|
|
209
|
+
return self._measure_batch_sequential(file_pairs, progress_callback)
|
|
210
|
+
|
|
211
|
+
return self._measure_batch_parallel(
|
|
212
|
+
file_pairs,
|
|
213
|
+
max_workers,
|
|
214
|
+
progress_callback,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# ------------------------------------------------------------------
|
|
218
|
+
# Batch helpers
|
|
219
|
+
# ------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def _measure_batch_sequential(
|
|
222
|
+
self,
|
|
223
|
+
file_pairs: Sequence[tuple[str, str]],
|
|
224
|
+
progress_callback: ProgressCallback | None = None,
|
|
225
|
+
) -> list[SimilarityResult | Exception]:
|
|
226
|
+
"""Evaluate file pairs one by one (original behaviour)."""
|
|
227
|
+
total = len(file_pairs)
|
|
228
|
+
results: list[SimilarityResult | Exception] = []
|
|
207
229
|
|
|
208
230
|
for idx, (ref_path, deg_path) in enumerate(file_pairs):
|
|
209
231
|
try:
|
|
@@ -212,13 +234,68 @@ class VisqolApi:
|
|
|
212
234
|
except Exception as exc:
|
|
213
235
|
results.append(exc)
|
|
214
236
|
logger.warning(
|
|
215
|
-
"Pair %d/%d failed: %s",
|
|
237
|
+
"Pair %d/%d failed: %s",
|
|
238
|
+
idx + 1,
|
|
239
|
+
total,
|
|
240
|
+
exc,
|
|
216
241
|
)
|
|
217
242
|
if progress_callback is not None:
|
|
218
243
|
progress_callback(idx + 1, total)
|
|
219
244
|
|
|
220
245
|
return results
|
|
221
246
|
|
|
247
|
+
def _measure_batch_parallel(
|
|
248
|
+
self,
|
|
249
|
+
file_pairs: Sequence[tuple[str, str]],
|
|
250
|
+
max_workers: int | None,
|
|
251
|
+
progress_callback: ProgressCallback | None = None,
|
|
252
|
+
) -> list[SimilarityResult | Exception]:
|
|
253
|
+
"""Evaluate file pairs in parallel using ProcessPoolExecutor."""
|
|
254
|
+
total = len(file_pairs)
|
|
255
|
+
|
|
256
|
+
if max_workers is None:
|
|
257
|
+
cpu_count = os.cpu_count() or 1
|
|
258
|
+
max_workers = min(total, cpu_count)
|
|
259
|
+
|
|
260
|
+
# We pickle the create_kwargs so each worker can build its own manager.
|
|
261
|
+
create_kwargs = self._create_kwargs
|
|
262
|
+
|
|
263
|
+
results: list[SimilarityResult | Exception] = [
|
|
264
|
+
RuntimeError("not yet evaluated")
|
|
265
|
+
] * total
|
|
266
|
+
|
|
267
|
+
completed = 0
|
|
268
|
+
|
|
269
|
+
with ProcessPoolExecutor(max_workers=max_workers) as pool:
|
|
270
|
+
# Submit all futures and track their original index.
|
|
271
|
+
future_to_idx = {}
|
|
272
|
+
for idx, (ref_path, deg_path) in enumerate(file_pairs):
|
|
273
|
+
future = pool.submit(
|
|
274
|
+
_worker_measure,
|
|
275
|
+
create_kwargs,
|
|
276
|
+
ref_path,
|
|
277
|
+
deg_path,
|
|
278
|
+
)
|
|
279
|
+
future_to_idx[future] = idx
|
|
280
|
+
|
|
281
|
+
for future in as_completed(future_to_idx):
|
|
282
|
+
idx = future_to_idx[future]
|
|
283
|
+
try:
|
|
284
|
+
results[idx] = future.result()
|
|
285
|
+
except Exception as exc:
|
|
286
|
+
results[idx] = exc
|
|
287
|
+
logger.warning(
|
|
288
|
+
"Pair %d/%d failed: %s",
|
|
289
|
+
idx + 1,
|
|
290
|
+
total,
|
|
291
|
+
exc,
|
|
292
|
+
)
|
|
293
|
+
completed += 1
|
|
294
|
+
if progress_callback is not None:
|
|
295
|
+
progress_callback(completed, total)
|
|
296
|
+
|
|
297
|
+
return results
|
|
298
|
+
|
|
222
299
|
# ------------------------------------------------------------------
|
|
223
300
|
# Private helpers
|
|
224
301
|
# ------------------------------------------------------------------
|
|
@@ -226,6 +303,44 @@ class VisqolApi:
|
|
|
226
303
|
def _ensure_created(self) -> None:
|
|
227
304
|
"""Raise if :meth:`create` has not been called."""
|
|
228
305
|
if not self._is_created:
|
|
229
|
-
raise RuntimeError(
|
|
230
|
-
|
|
231
|
-
|
|
306
|
+
raise RuntimeError("VisqolApi must be created (call .create()) before measuring.")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# =====================================================================
|
|
310
|
+
# Module-level worker function for multiprocessing (must be picklable)
|
|
311
|
+
# =====================================================================
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _worker_measure(
|
|
315
|
+
create_kwargs: dict[str, object],
|
|
316
|
+
ref_path: str,
|
|
317
|
+
deg_path: str,
|
|
318
|
+
) -> SimilarityResult:
|
|
319
|
+
"""
|
|
320
|
+
Evaluate a single file pair in a worker process.
|
|
321
|
+
|
|
322
|
+
Each worker lazily creates its own ``VisqolManager`` on first call
|
|
323
|
+
(stored in a global to reuse across tasks in the same process).
|
|
324
|
+
"""
|
|
325
|
+
global _worker_manager
|
|
326
|
+
|
|
327
|
+
try:
|
|
328
|
+
mgr = _worker_manager
|
|
329
|
+
except NameError:
|
|
330
|
+
mgr = None
|
|
331
|
+
|
|
332
|
+
if mgr is None:
|
|
333
|
+
mgr = VisqolManager()
|
|
334
|
+
mgr.init(**create_kwargs) # type: ignore[arg-type]
|
|
335
|
+
_worker_manager = mgr
|
|
336
|
+
|
|
337
|
+
ref_signal = __import__("visqol.audio_utils", fromlist=["load_as_mono"]).load_as_mono(
|
|
338
|
+
ref_path
|
|
339
|
+
)
|
|
340
|
+
deg_signal = __import__("visqol.audio_utils", fromlist=["load_as_mono"]).load_as_mono(
|
|
341
|
+
deg_path
|
|
342
|
+
)
|
|
343
|
+
return mgr.run_from_signals(ref_signal, deg_signal)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
_worker_manager: VisqolManager | None = None
|
visqol/audio_utils.py
CHANGED
|
@@ -8,7 +8,6 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import logging
|
|
10
10
|
import os
|
|
11
|
-
from typing import Tuple
|
|
12
11
|
|
|
13
12
|
import numpy as np
|
|
14
13
|
from numpy.typing import NDArray
|
|
@@ -37,9 +36,7 @@ class AudioSignal:
|
|
|
37
36
|
self.sample_rate: int = int(sample_rate)
|
|
38
37
|
|
|
39
38
|
if self.sample_rate <= 0:
|
|
40
|
-
raise ValueError(
|
|
41
|
-
f"sample_rate must be positive, got {self.sample_rate}"
|
|
42
|
-
)
|
|
39
|
+
raise ValueError(f"sample_rate must be positive, got {self.sample_rate}")
|
|
43
40
|
|
|
44
41
|
@property
|
|
45
42
|
def duration(self) -> float:
|
|
@@ -64,7 +61,7 @@ class AudioSignal:
|
|
|
64
61
|
return f"AudioSignal({self.duration:.3f}s @ {self.sample_rate} Hz)"
|
|
65
62
|
|
|
66
63
|
|
|
67
|
-
def load_audio(path: str) ->
|
|
64
|
+
def load_audio(path: str) -> tuple[NDArray[np.float64], int]:
|
|
68
65
|
"""
|
|
69
66
|
Load a WAV file and return ``(data, sample_rate)``.
|
|
70
67
|
|
|
@@ -82,16 +79,14 @@ def load_audio(path: str) -> Tuple[NDArray[np.float64], int]:
|
|
|
82
79
|
try:
|
|
83
80
|
data, sr = sf.read(path, dtype="float64", always_2d=True)
|
|
84
81
|
except Exception as exc:
|
|
85
|
-
raise RuntimeError(
|
|
86
|
-
f"Failed to read audio file '{path}': {exc}"
|
|
87
|
-
) from exc
|
|
82
|
+
raise RuntimeError(f"Failed to read audio file '{path}': {exc}") from exc
|
|
88
83
|
return data, int(sr)
|
|
89
84
|
|
|
90
85
|
|
|
91
86
|
def to_mono(data: NDArray[np.float64]) -> NDArray[np.float64]:
|
|
92
87
|
"""Convert multi-channel audio to mono by averaging channels."""
|
|
93
88
|
if data.ndim == 2 and data.shape[1] > 1:
|
|
94
|
-
return np.mean(data, axis=1)
|
|
89
|
+
return np.asarray(np.mean(data, axis=1), dtype=np.float64)
|
|
95
90
|
elif data.ndim == 2:
|
|
96
91
|
return data[:, 0]
|
|
97
92
|
return data
|
|
@@ -116,10 +111,10 @@ def calc_sound_pressure_level(signal: AudioSignal) -> float:
|
|
|
116
111
|
``SPL = 20 * log10(rms / reference_point)``
|
|
117
112
|
"""
|
|
118
113
|
data = signal.data
|
|
119
|
-
rms: float = float(np.sqrt(np.mean(data
|
|
114
|
+
rms: float = float(np.sqrt(np.mean(data**2)))
|
|
120
115
|
if rms == 0:
|
|
121
116
|
return float(-np.inf)
|
|
122
|
-
return 20.0 * np.log10(rms / SPL_REFERENCE_POINT)
|
|
117
|
+
return float(20.0 * np.log10(rms / SPL_REFERENCE_POINT))
|
|
123
118
|
|
|
124
119
|
|
|
125
120
|
def scale_to_match_sound_pressure_level(
|