visqol-python 3.3.5__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,10 +13,17 @@ Usage:
13
13
  print(f"MOS-LQO: {result.moslqo}")
14
14
  """
15
15
 
16
- __version__: str = "3.3.5"
16
+ __version__: str = "3.4.0"
17
17
 
18
- from visqol.api import VisqolApi
19
- from visqol.visqol_core import SimilarityResult
18
+ from visqol.api import ProgressCallback, VisqolApi
20
19
  from visqol.audio_utils import AudioSignal
20
+ from visqol.nsim import PatchSimilarityResult
21
+ from visqol.visqol_core import SimilarityResult
21
22
 
22
- __all__: list[str] = ["VisqolApi", "SimilarityResult", "AudioSignal"]
23
+ __all__: list[str] = [
24
+ "AudioSignal",
25
+ "PatchSimilarityResult",
26
+ "ProgressCallback",
27
+ "SimilarityResult",
28
+ "VisqolApi",
29
+ ]
visqol/__main__.py CHANGED
@@ -9,11 +9,13 @@ Usage::
9
9
  from __future__ import annotations
10
10
 
11
11
  import argparse
12
- import sys
13
12
  import logging
13
+ import sys
14
14
 
15
15
  from visqol.api import VisqolApi
16
16
 
17
+ logger = logging.getLogger("visqol")
18
+
17
19
 
18
20
  def main() -> None:
19
21
  """Entry point for the ``visqol`` CLI."""
@@ -21,39 +23,52 @@ def main() -> None:
21
23
  description="ViSQOL - Virtual Speech Quality Objective Listener (Python)",
22
24
  )
23
25
  parser.add_argument(
24
- "--reference", "-r", required=True,
26
+ "--reference",
27
+ "-r",
28
+ required=True,
25
29
  help="Path to reference audio file (WAV)",
26
30
  )
27
31
  parser.add_argument(
28
- "--degraded", "-d", required=True,
32
+ "--degraded",
33
+ "-d",
34
+ required=True,
29
35
  help="Path to degraded audio file (WAV)",
30
36
  )
31
37
  parser.add_argument(
32
- "--speech_mode", action="store_true",
38
+ "--speech_mode",
39
+ action="store_true",
33
40
  help="Use speech mode (16 kHz, exponential mapping)",
34
41
  )
35
42
  parser.add_argument(
36
- "--model", default=None,
43
+ "--model",
44
+ default=None,
37
45
  help="Path to SVR model file (Audio mode only)",
38
46
  )
39
47
  parser.add_argument(
40
- "--search_window", type=int, default=60,
48
+ "--search_window",
49
+ type=int,
50
+ default=60,
41
51
  help="Search window radius (default: 60)",
42
52
  )
43
53
  parser.add_argument(
44
- "--unscaled_speech", action="store_true",
54
+ "--unscaled_speech",
55
+ action="store_true",
45
56
  help="Don't scale speech MOS to max 5.0",
46
57
  )
47
58
  parser.add_argument(
48
- "--no_alignment", action="store_true",
59
+ "--no_alignment",
60
+ action="store_true",
49
61
  help="Disable global alignment",
50
62
  )
51
63
  parser.add_argument(
52
- "--no_realignment", action="store_true",
64
+ "--no_realignment",
65
+ action="store_true",
53
66
  help="Disable fine realignment",
54
67
  )
55
68
  parser.add_argument(
56
- "--verbose", "-v", action="store_true",
69
+ "--verbose",
70
+ "-v",
71
+ action="store_true",
57
72
  help="Enable verbose output",
58
73
  )
59
74
 
@@ -61,7 +76,11 @@ def main() -> None:
61
76
 
62
77
  # Setup logging
63
78
  level = logging.DEBUG if args.verbose else logging.WARNING
64
- logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
79
+ logging.basicConfig(
80
+ level=level,
81
+ format="%(levelname)s: %(message)s",
82
+ stream=sys.stderr,
83
+ )
65
84
 
66
85
  # Run ViSQOL
67
86
  mode: str = "speech" if args.speech_mode else "audio"
@@ -79,30 +98,32 @@ def main() -> None:
79
98
 
80
99
  result = api.measure(args.reference, args.degraded)
81
100
 
82
- except FileNotFoundError as exc:
83
- print(f"Error: {exc}", file=sys.stderr)
84
- sys.exit(1)
85
- except ValueError as exc:
86
- print(f"Error: {exc}", file=sys.stderr)
87
- sys.exit(1)
88
- except RuntimeError as exc:
89
- print(f"Error: {exc}", file=sys.stderr)
101
+ except (FileNotFoundError, ValueError, RuntimeError) as exc:
102
+ logger.error("%s", exc)
90
103
  sys.exit(1)
104
+ except Exception as exc:
105
+ logger.error("Unexpected error: %s", exc)
106
+ sys.exit(2)
91
107
 
92
108
  # Output results
93
109
  print(f"MOS-LQO: {result.moslqo:.6f}")
94
110
  print(f"VNSIM: {result.vnsim:.6f}")
111
+
95
112
  if args.verbose:
96
- print(f"FVNSIM: {result.fvnsim}")
97
- print(f"FVNSIM10: {result.fvnsim10}")
98
- print(f"FSTDNSIM: {result.fstdnsim}")
99
- print(f"FVDEGENERGY: {result.fvdegenergy}")
100
- print(f"Patches: {len(result.patch_sims)}")
113
+ logger.info("FVNSIM: %s", result.fvnsim)
114
+ logger.info("FVNSIM10: %s", result.fvnsim10)
115
+ logger.info("FSTDNSIM: %s", result.fstdnsim)
116
+ logger.info("FVDEGENERGY: %s", result.fvdegenergy)
117
+ logger.info("Patches: %d", len(result.patch_sims))
101
118
  for i, p in enumerate(result.patch_sims):
102
- print(
103
- f" Patch {i}: sim={p.similarity:.4f} "
104
- f"ref=[{p.ref_patch_start_time:.3f}-{p.ref_patch_end_time:.3f}] "
105
- f"deg=[{p.deg_patch_start_time:.3f}-{p.deg_patch_end_time:.3f}]"
119
+ logger.info(
120
+ " Patch %d: sim=%.4f ref=[%.3f-%.3f] deg=[%.3f-%.3f]",
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,
106
127
  )
107
128
 
108
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
@@ -8,15 +8,20 @@ Corresponds to C++ file: visqol_api.cc
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import logging
11
12
  import os
12
- from typing import Optional
13
+ from collections.abc import Sequence
14
+ from concurrent.futures import ProcessPoolExecutor, as_completed
15
+ from typing import Callable
13
16
 
14
17
  import numpy as np
15
18
  from numpy.typing import NDArray
16
19
 
17
20
  from visqol.audio_utils import AudioSignal
18
- from visqol.visqol_manager import VisqolManager
19
21
  from visqol.visqol_core import SimilarityResult
22
+ from visqol.visqol_manager import VisqolManager
23
+
24
+ logger = logging.getLogger(__name__)
20
25
 
21
26
  # Valid mode names
22
27
  _VALID_MODES = frozenset({"audio", "speech"})
@@ -25,6 +30,9 @@ _VALID_MODES = frozenset({"audio", "speech"})
25
30
  _DEFAULT_MODEL_DIR: str = os.path.join(os.path.dirname(__file__), "model")
26
31
  _DEFAULT_SVR_MODEL: str = os.path.join(_DEFAULT_MODEL_DIR, "libsvm_nu_svr_model.txt")
27
32
 
33
+ # Type alias for progress callback: (completed_count, total_count) -> None
34
+ ProgressCallback = Callable[[int, int], None]
35
+
28
36
 
29
37
  class VisqolApi:
30
38
  """
@@ -41,11 +49,12 @@ class VisqolApi:
41
49
  def __init__(self) -> None:
42
50
  self._manager: VisqolManager = VisqolManager()
43
51
  self._is_created: bool = False
52
+ self._create_kwargs: dict[str, object] = {}
44
53
 
45
54
  def create(
46
55
  self,
47
56
  mode: str = "audio",
48
- model_path: Optional[str] = None,
57
+ model_path: str | None = None,
49
58
  search_window: int = 60,
50
59
  use_unscaled_speech: bool = False,
51
60
  disable_global_alignment: bool = False,
@@ -71,14 +80,10 @@ class VisqolApi:
71
80
  """
72
81
  mode_lower = mode.lower()
73
82
  if mode_lower not in _VALID_MODES:
74
- raise ValueError(
75
- f"Invalid mode {mode!r}. Must be one of {sorted(_VALID_MODES)}."
76
- )
83
+ raise ValueError(f"Invalid mode {mode!r}. Must be one of {sorted(_VALID_MODES)}.")
77
84
 
78
85
  if search_window <= 0:
79
- raise ValueError(
80
- f"search_window must be a positive integer, got {search_window}."
81
- )
86
+ raise ValueError(f"search_window must be a positive integer, got {search_window}.")
82
87
 
83
88
  use_speech_mode = mode_lower == "speech"
84
89
 
@@ -86,18 +91,19 @@ class VisqolApi:
86
91
  model_path = _DEFAULT_SVR_MODEL
87
92
 
88
93
  if model_path is not None and not os.path.isfile(model_path):
89
- raise FileNotFoundError(
90
- f"SVR model file not found: {model_path}"
91
- )
92
-
93
- self._manager.init(
94
- model_path=model_path or "",
95
- use_speech_mode=use_speech_mode,
96
- use_unscaled_speech=use_unscaled_speech,
97
- search_window=search_window,
98
- disable_global_alignment=disable_global_alignment,
99
- disable_realignment=disable_realignment,
100
- )
94
+ raise FileNotFoundError(f"SVR model file not found: {model_path}")
95
+
96
+ # Store kwargs for batch mode (subprocess recreation)
97
+ self._create_kwargs = {
98
+ "model_path": model_path or "",
99
+ "use_speech_mode": use_speech_mode,
100
+ "use_unscaled_speech": use_unscaled_speech,
101
+ "search_window": search_window,
102
+ "disable_global_alignment": disable_global_alignment,
103
+ "disable_realignment": disable_realignment,
104
+ }
105
+
106
+ self._manager.init(**self._create_kwargs) # type: ignore[arg-type]
101
107
  self._is_created = True
102
108
 
103
109
  def measure(self, ref_path: str, deg_path: str) -> SimilarityResult:
@@ -149,26 +155,147 @@ class VisqolApi:
149
155
  self._ensure_created()
150
156
 
151
157
  if not isinstance(ref_array, np.ndarray):
152
- raise TypeError(
153
- f"ref_array must be a numpy array, got {type(ref_array).__name__}"
154
- )
158
+ raise TypeError(f"ref_array must be a numpy array, got {type(ref_array).__name__}")
155
159
  if not isinstance(deg_array, np.ndarray):
156
- raise TypeError(
157
- f"deg_array must be a numpy array, got {type(deg_array).__name__}"
158
- )
160
+ raise TypeError(f"deg_array must be a numpy array, got {type(deg_array).__name__}")
159
161
  if ref_array.size == 0:
160
162
  raise ValueError("ref_array must not be empty.")
161
163
  if deg_array.size == 0:
162
164
  raise ValueError("deg_array must not be empty.")
163
165
  if sample_rate <= 0:
164
- raise ValueError(
165
- f"sample_rate must be a positive integer, got {sample_rate}."
166
- )
166
+ raise ValueError(f"sample_rate must be a positive integer, got {sample_rate}.")
167
167
 
168
168
  ref_signal = AudioSignal(ref_array, sample_rate)
169
169
  deg_signal = AudioSignal(deg_array, sample_rate)
170
170
  return self._manager.run_from_signals(ref_signal, deg_signal)
171
171
 
172
+ def measure_batch(
173
+ self,
174
+ file_pairs: Sequence[tuple[str, str]],
175
+ *,
176
+ max_workers: int | None = None,
177
+ parallel: bool = True,
178
+ progress_callback: ProgressCallback | None = None,
179
+ ) -> list[SimilarityResult | Exception]:
180
+ """
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.
187
+
188
+ Args:
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).
195
+ progress_callback: Optional ``(completed, total) -> None`` callback
196
+ invoked after each pair is processed.
197
+
198
+ Returns:
199
+ List of :class:`SimilarityResult` (on success) or :class:`Exception`
200
+ (on failure) for each pair, **in the same order** as *file_pairs*.
201
+
202
+ Raises:
203
+ RuntimeError: If :meth:`create` has not been called.
204
+ """
205
+ self._ensure_created()
206
+ total = len(file_pairs)
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] = []
229
+
230
+ for idx, (ref_path, deg_path) in enumerate(file_pairs):
231
+ try:
232
+ result = self.measure(ref_path, deg_path)
233
+ results.append(result)
234
+ except Exception as exc:
235
+ results.append(exc)
236
+ logger.warning(
237
+ "Pair %d/%d failed: %s",
238
+ idx + 1,
239
+ total,
240
+ exc,
241
+ )
242
+ if progress_callback is not None:
243
+ progress_callback(idx + 1, total)
244
+
245
+ return results
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
+
172
299
  # ------------------------------------------------------------------
173
300
  # Private helpers
174
301
  # ------------------------------------------------------------------
@@ -176,6 +303,44 @@ class VisqolApi:
176
303
  def _ensure_created(self) -> None:
177
304
  """Raise if :meth:`create` has not been called."""
178
305
  if not self._is_created:
179
- raise RuntimeError(
180
- "VisqolApi must be created (call .create()) before measuring."
181
- )
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:
@@ -53,8 +50,18 @@ class AudioSignal:
53
50
  def __len__(self) -> int:
54
51
  return len(self.data)
55
52
 
53
+ def __repr__(self) -> str:
54
+ return (
55
+ f"AudioSignal(samples={self.num_samples}, "
56
+ f"sample_rate={self.sample_rate}, "
57
+ f"duration={self.duration:.3f}s)"
58
+ )
56
59
 
57
- def load_audio(path: str) -> Tuple[NDArray[np.float64], int]:
60
+ def __str__(self) -> str:
61
+ return f"AudioSignal({self.duration:.3f}s @ {self.sample_rate} Hz)"
62
+
63
+
64
+ def load_audio(path: str) -> tuple[NDArray[np.float64], int]:
58
65
  """
59
66
  Load a WAV file and return ``(data, sample_rate)``.
60
67
 
@@ -72,16 +79,14 @@ def load_audio(path: str) -> Tuple[NDArray[np.float64], int]:
72
79
  try:
73
80
  data, sr = sf.read(path, dtype="float64", always_2d=True)
74
81
  except Exception as exc:
75
- raise RuntimeError(
76
- f"Failed to read audio file '{path}': {exc}"
77
- ) from exc
82
+ raise RuntimeError(f"Failed to read audio file '{path}': {exc}") from exc
78
83
  return data, int(sr)
79
84
 
80
85
 
81
86
  def to_mono(data: NDArray[np.float64]) -> NDArray[np.float64]:
82
87
  """Convert multi-channel audio to mono by averaging channels."""
83
88
  if data.ndim == 2 and data.shape[1] > 1:
84
- return np.mean(data, axis=1)
89
+ return np.asarray(np.mean(data, axis=1), dtype=np.float64)
85
90
  elif data.ndim == 2:
86
91
  return data[:, 0]
87
92
  return data
@@ -106,10 +111,10 @@ def calc_sound_pressure_level(signal: AudioSignal) -> float:
106
111
  ``SPL = 20 * log10(rms / reference_point)``
107
112
  """
108
113
  data = signal.data
109
- rms: float = float(np.sqrt(np.mean(data ** 2)))
114
+ rms: float = float(np.sqrt(np.mean(data**2)))
110
115
  if rms == 0:
111
116
  return float(-np.inf)
112
- return 20.0 * np.log10(rms / SPL_REFERENCE_POINT)
117
+ return float(20.0 * np.log10(rms / SPL_REFERENCE_POINT))
113
118
 
114
119
 
115
120
  def scale_to_match_sound_pressure_level(