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 CHANGED
@@ -13,7 +13,7 @@ Usage:
13
13
  print(f"MOS-LQO: {result.moslqo}")
14
14
  """
15
15
 
16
- __version__: str = "3.3.6"
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", "-r", required=True,
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", "-d", required=True,
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", action="store_true",
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", default=None,
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", type=int, default=60,
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", action="store_true",
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", action="store_true",
59
+ "--no_alignment",
60
+ action="store_true",
51
61
  help="Disable global alignment",
52
62
  )
53
63
  parser.add_argument(
54
- "--no_realignment", action="store_true",
64
+ "--no_realignment",
65
+ action="store_true",
55
66
  help="Disable fine realignment",
56
67
  )
57
68
  parser.add_argument(
58
- "--verbose", "-v", action="store_true",
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, p.similarity,
109
- p.ref_patch_start_time, p.ref_patch_end_time,
110
- p.deg_patch_start_time, p.deg_patch_end_time,
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 upper_envelope, find_best_lag
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
- ) -> Tuple[AudioSignal, AudioSignal, float]:
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 = int(round(sample_rate * window_duration))
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, List, Optional, Sequence, Tuple, Union
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: Optional[str] = None,
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[Tuple[str, str]],
174
+ file_pairs: Sequence[tuple[str, str]],
186
175
  *,
187
- progress_callback: Optional[ProgressCallback] = None,
188
- ) -> List[Union[SimilarityResult, Exception]]:
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 sequentially.
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
- results: List[Union[SimilarityResult, Exception]] = []
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", idx + 1, total, exc,
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
- "VisqolApi must be created (call .create()) before measuring."
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) -> Tuple[NDArray[np.float64], int]:
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 ** 2)))
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(