Native integrated/LRA, background prefetch, timing-rich status

LUFS integrated + LRA:
- Reimplement BS.1770 two-stage gating (integrated) and EBU 3342 (LRA) directly
  from the cached K-weighted signal, dropping pyloudnorm's two whole-signal
  re-filters. Validated bit-equal to pyloudnorm across steady/dynamic/quiet
  signals (0.0000 diff). LUFS compute ~1.9s -> ~0.6s (orig 3.8s). pyloudnorm now
  only supplies the filter coefficients.

Prefetch on load:
- After a file loads, PrefetchWorker computes the remaining metrics in the
  background (sequential, cooperatively cancellable, skips on-demand hits), so
  the first switch to any metric is instant. Superseded when a new file loads.

Status slip:
- Workers measure compute time; metricTiming + phase/duration progress messages
  drive the slip ("Loaded in Ns - computing X...", "X computed in Ys"). The old
  fake percentage (10% then done) is logged but no longer shown.
- shutdown() stops background threads on window close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-14 01:49:50 +09:00
parent d7782bb9d9
commit 507af2f676
4 changed files with 241 additions and 61 deletions
+16 -7
View File
@@ -30,10 +30,16 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- Real-time analysis display and file management - Real-time analysis display and file management
#### `analysis_results_manager.py` #### `analysis_results_manager.py`
- Background threading for audio analysis - Background threading for audio analysis (`AudioAnalysisWorker` = load + first
metric; `MetricComputeWorker` = one metric on an already-loaded file)
- Caches both the loaded `AudioFile` and per-metric `compute()` output, so - Caches both the loaded `AudioFile` and per-metric `compute()` output, so
metric/font switches re-render from cache without reloading librosa metric/font switches re-render from cache without reloading librosa
- Progress tracking and error handling - **Prefetch** (`PrefetchWorker`): after a file loads, the remaining metrics are
computed in the background (one at a time, cooperatively cancellable) so the
first switch to any metric is instant too. Superseded when a new file loads
- Timing: workers measure compute time; `metricTiming` + phase/duration progress
messages drive the status slip ("X computed in Ys", "Loaded in Ns — computing…")
- `shutdown()` stops all threads on window close (`MainWindow.closeEvent`)
#### `audio_visualization_widget.py` #### `audio_visualization_widget.py`
- Persistent pyqtgraph plot — the PlotItem is reused across renders, never torn - Persistent pyqtgraph plot — the PlotItem is reused across renders, never torn
@@ -82,10 +88,12 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- Current registry: - Current registry:
- `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale - `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale
- `WaveformMetric` — min/max envelope, fixed ±1.1 y-range - `WaveformMetric` — min/max envelope, fixed ±1.1 y-range
- `LUFSMetric` — true (ungated) EBU R128 short-term (3 s) computed via a single - `LUFSMetric` — true (ungated) EBU R128 short-term (3 s) via a single
K-weighting pass (`_short_term_lufs`, reusing pyloudnorm's filter K-weighting pass (`_kweight`, cached) + a vectorised sliding mean-square.
coefficients) + a vectorised sliding mean-square; integrated + LRA still come Integrated (`_integrated_lufs`) and LRA (`_loudness_range`) are reimplemented
from pyloudnorm (one gated call each). ~2× faster than the old per-window loop from the same cached K-weighted signal — validated **bit-equal** to
pyloudnorm — so nothing re-filters the signal. ~3.8 s → ~0.6 s. pyloudnorm is
now used only to source the BS.1770 filter coefficients
- `CrestFactorMetric` — 20·log10(peak/RMS) per 1 s window; peaks via O(N) running max - `CrestFactorMetric` — 20·log10(peak/RMS) per 1 s window; peaks via O(N) running max
- `PSRMetric` — sample-peak minus short-term LUFS (3 s window); reuses - `PSRMetric` — sample-peak minus short-term LUFS (3 s window); reuses
`LUFSMetric`'s short-term series (memoised on the `AudioFile`), so PSR is `LUFSMetric`'s short-term series (memoised on the `AudioFile`), so PSR is
@@ -211,7 +219,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- numpy: Numerical computations - numpy: Numerical computations
- scipy: Signal processing (true-peak polyphase oversampling, K-weighting - scipy: Signal processing (true-peak polyphase oversampling, K-weighting
filters, spectrogram log-frequency resample, O(N) running-max via ndimage) filters, spectrogram log-frequency resample, O(N) running-max via ndimage)
- pyloudnorm: BS.1770 loudness (LUFS, LRA) - pyloudnorm: source of the BS.1770 K-weighting filter coefficients (the LUFS
short-term / integrated / LRA math is now computed directly, validated against it)
- pyqtgraph: Interactive plotting (zoom/pan, overlay, lin/log) - pyqtgraph: Interactive plotting (zoom/pan, overlay, lin/log)
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency - matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
- mutagen: Audio metadata extraction - mutagen: Audio metadata extraction
+106 -11
View File
@@ -7,6 +7,7 @@ from PyQt5.QtCore import QObject, pyqtSignal, QThread
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Optional from typing import Any, Optional
import os import os
import time
import logging import logging
from master_core import AudioFile from master_core import AudioFile
@@ -49,16 +50,21 @@ class AudioAnalysisWorker(QThread):
def run(self): def run(self):
try: try:
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}") base = os.path.basename(self.file_path)
self.progressUpdate.emit("Loading audio file...", 10) self.logger.info(f"Starting analysis of: {base}")
# Decode is a black box (no progress callback), so report it as a phase
# with its measured duration rather than a fake percentage.
self.progressUpdate.emit(f"Loading {base}", 0)
t0 = time.perf_counter()
audio_file = AudioFile(self.file_path) audio_file = AudioFile(self.file_path)
self.progressUpdate.emit("Audio loaded...", 30) load_s = time.perf_counter() - t0
self.progressUpdate.emit(f"Computing {self.metric.display_name}...", 60) self.progressUpdate.emit(
f"Loaded in {load_s:.1f}s — computing {self.metric.display_name}", 50)
t1 = time.perf_counter()
metric_data = {self.metric.id: self.metric.compute(audio_file)} metric_data = {self.metric.id: self.metric.compute(audio_file)}
metric_s = time.perf_counter() - t1
self.progressUpdate.emit("Finalizing analysis...", 90)
result = AnalysisResult( result = AnalysisResult(
file_path=self.file_path, file_path=self.file_path,
@@ -70,8 +76,12 @@ class AudioAnalysisWorker(QThread):
analysis_successful=True, analysis_successful=True,
) )
self.progressUpdate.emit("Analysis complete!", 100) self.logger.info(
self.logger.info(f"Analysis completed: {os.path.basename(self.file_path)}") f"Analysis completed: {base} (load {load_s:.2f}s, "
f"{self.metric.id} {metric_s:.2f}s)")
self.progressUpdate.emit(
f"{self.metric.display_name} ready in {metric_s:.1f}s "
f"(loaded in {load_s:.1f}s)", 100)
self.analysisCompleted.emit(self.file_path, result) self.analysisCompleted.emit(self.file_path, result)
except Exception as e: except Exception as e:
@@ -83,7 +93,7 @@ class AudioAnalysisWorker(QThread):
class MetricComputeWorker(QThread): class MetricComputeWorker(QThread):
"""Worker thread that computes a single metric against an already-loaded AudioFile.""" """Worker thread that computes a single metric against an already-loaded AudioFile."""
completed = pyqtSignal(str, str, object) # file_path, metric_id, data completed = pyqtSignal(str, str, object, float) # file_path, metric_id, data, seconds
failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message
def __init__(self, file_path: str, audio_file: AudioFile, metric: Metric): def __init__(self, file_path: str, audio_file: AudioFile, metric: Metric):
@@ -98,14 +108,53 @@ class MetricComputeWorker(QThread):
self.logger.info( self.logger.info(
f"Computing {self.metric.display_name} for {os.path.basename(self.file_path)}" f"Computing {self.metric.display_name} for {os.path.basename(self.file_path)}"
) )
t0 = time.perf_counter()
data = self.metric.compute(self.audio_file) data = self.metric.compute(self.audio_file)
self.completed.emit(self.file_path, self.metric.id, data) elapsed = time.perf_counter() - t0
self.completed.emit(self.file_path, self.metric.id, data, elapsed)
except Exception as e: except Exception as e:
msg = f"{self.metric.display_name} compute failed: {e}" msg = f"{self.metric.display_name} compute failed: {e}"
self.logger.error(msg) self.logger.error(msg)
self.failed.emit(self.file_path, self.metric.id, str(e)) self.failed.emit(self.file_path, self.metric.id, str(e))
class PrefetchWorker(QThread):
"""Background worker that warms the cache by computing the remaining metrics.
Runs the given metrics sequentially on an already-loaded AudioFile so that
switching to any metric is instant the first time too. Cooperative: `stop()`
lets it bail between metrics (e.g. when a new file supersedes it). Skips any
metric that got computed on-demand in the meantime.
"""
computedOne = pyqtSignal(str, str, object) # file_path, metric_id, data
def __init__(self, file_path: str, result: "AnalysisResult", metrics: list):
super().__init__()
self.file_path = file_path
self.result = result
self.metrics = metrics
self._stop = False
self.logger = logging.getLogger(__name__)
def stop(self):
self._stop = True
def run(self):
for metric in self.metrics:
if self._stop:
return
if metric.id in self.result.metric_data:
continue # already computed on-demand while we were working
try:
data = metric.compute(self.result.audio_file)
if self._stop:
return
self.computedOne.emit(self.file_path, metric.id, data)
except Exception as e:
self.logger.warning(f"Prefetch of {metric.id} failed: {e}")
class AnalysisResultsManager(QObject): class AnalysisResultsManager(QObject):
"""Manages audio file analysis and coordinates between processing and GUI.""" """Manages audio file analysis and coordinates between processing and GUI."""
@@ -119,12 +168,14 @@ class AnalysisResultsManager(QObject):
metricComputeStarted = pyqtSignal(str, str) # file_path, metric_id metricComputeStarted = pyqtSignal(str, str) # file_path, metric_id
metricReady = pyqtSignal(str, str) # file_path, metric_id metricReady = pyqtSignal(str, str) # file_path, metric_id
metricComputeError = pyqtSignal(str, str, str) # file_path, metric_id, error metricComputeError = pyqtSignal(str, str, str) # file_path, metric_id, error
metricTiming = pyqtSignal(str, str, float) # file_path, metric_id, seconds
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.results_cache: dict[str, AnalysisResult] = {} self.results_cache: dict[str, AnalysisResult] = {}
self.current_worker: Optional[AudioAnalysisWorker] = None self.current_worker: Optional[AudioAnalysisWorker] = None
self.metric_workers: dict[tuple[str, str], MetricComputeWorker] = {} self.metric_workers: dict[tuple[str, str], MetricComputeWorker] = {}
self.prefetch_worker: Optional[PrefetchWorker] = None
self.logger = logging.getLogger(__name__) self.logger = logging.getLogger(__name__)
def analyze_file(self, file_path: str, metric_id: str = DEFAULT_METRIC_ID): def analyze_file(self, file_path: str, metric_id: str = DEFAULT_METRIC_ID):
@@ -147,6 +198,9 @@ class AnalysisResultsManager(QObject):
self.current_worker.quit() self.current_worker.quit()
self.current_worker.wait() self.current_worker.wait()
# A new foreground load supersedes background prefetch of the previous file.
self._stop_prefetch()
self.analysisStarted.emit(file_path) self.analysisStarted.emit(file_path)
self.logger.info( self.logger.info(
f"Queuing analysis: {os.path.basename(file_path)} ({metric.display_name})" f"Queuing analysis: {os.path.basename(file_path)} ({metric.display_name})"
@@ -161,6 +215,35 @@ class AnalysisResultsManager(QObject):
def _on_worker_completed(self, file_path: str, result: AnalysisResult): def _on_worker_completed(self, file_path: str, result: AnalysisResult):
self.results_cache[file_path] = result self.results_cache[file_path] = result
self.analysisCompleted.emit(file_path, result) self.analysisCompleted.emit(file_path, result)
# Warm the cache for the rest of the metrics so switching is instant.
self._start_prefetch(file_path, result)
def _start_prefetch(self, file_path: str, result: AnalysisResult):
"""Compute the not-yet-cached metrics in the background, one at a time."""
self._stop_prefetch()
pending = [m for m in METRICS.values() if m.id not in result.metric_data]
if not pending:
return
self.logger.info(
f"Prefetching {len(pending)} metric(s) for {os.path.basename(file_path)}")
self.prefetch_worker = PrefetchWorker(file_path, result, pending)
self.prefetch_worker.computedOne.connect(self._on_prefetch_one)
self.prefetch_worker.start()
def _stop_prefetch(self):
worker = self.prefetch_worker
if worker is not None and worker.isRunning():
worker.stop()
worker.wait()
self.prefetch_worker = None
def _on_prefetch_one(self, file_path: str, metric_id: str, data: object):
result = self.results_cache.get(file_path)
if result is not None and metric_id not in result.metric_data:
result.metric_data[metric_id] = data
# metricReady (not metricTiming): warms any waiting view without spamming the
# status bar with background completions.
self.metricReady.emit(file_path, metric_id)
def request_metric(self, file_path: str, metric_id: str) -> bool: def request_metric(self, file_path: str, metric_id: str) -> bool:
"""Ensure the metric's data exists for the file; emit metricReady when ready. """Ensure the metric's data exists for the file; emit metricReady when ready.
@@ -198,12 +281,13 @@ class AnalysisResultsManager(QObject):
worker.start() worker.start()
return True return True
def _on_metric_completed(self, file_path: str, metric_id: str, data: object): def _on_metric_completed(self, file_path: str, metric_id: str, data: object, seconds: float):
result = self.results_cache.get(file_path) result = self.results_cache.get(file_path)
if result is not None: if result is not None:
result.metric_data[metric_id] = data result.metric_data[metric_id] = data
self.metric_workers.pop((file_path, metric_id), None) self.metric_workers.pop((file_path, metric_id), None)
self.metricReady.emit(file_path, metric_id) self.metricReady.emit(file_path, metric_id)
self.metricTiming.emit(file_path, metric_id, seconds)
def _on_metric_failed(self, file_path: str, metric_id: str, error_message: str): def _on_metric_failed(self, file_path: str, metric_id: str, error_message: str):
self.metric_workers.pop((file_path, metric_id), None) self.metric_workers.pop((file_path, metric_id), None)
@@ -241,3 +325,14 @@ class AnalysisResultsManager(QObject):
def is_file_analyzed(self, file_path: str) -> bool: def is_file_analyzed(self, file_path: str) -> bool:
return file_path in self.results_cache return file_path in self.results_cache
def shutdown(self):
"""Stop all background threads cleanly (call on app close)."""
self._stop_prefetch()
if self.current_worker and self.current_worker.isRunning():
self.current_worker.quit()
self.current_worker.wait()
for worker in list(self.metric_workers.values()):
if worker.isRunning():
worker.wait()
self.metric_workers.clear()
+22 -2
View File
@@ -121,8 +121,14 @@ class MainWindow(QMainWindow):
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started) self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
self.analysis_manager.metricReady.connect(self.on_metric_ready) self.analysis_manager.metricReady.connect(self.on_metric_ready)
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error) self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
self.analysis_manager.metricTiming.connect(self.on_metric_timing)
self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved) self.visualization_widget.referenceLineMoved.connect(self.on_reference_line_moved)
def closeEvent(self, event):
"""Stop background analysis/prefetch threads before the window closes."""
self.analysis_manager.shutdown()
super().closeEvent(event)
def dragEnterEvent(self, event): def dragEnterEvent(self, event):
"""Handle drag enter event for file drops.""" """Handle drag enter event for file drops."""
if event.mimeData().hasUrls(): if event.mimeData().hasUrls():
@@ -199,9 +205,13 @@ class MainWindow(QMainWindow):
self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}") self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}")
def on_progress_update(self, message, percentage): def on_progress_update(self, message, percentage):
"""Called when analysis progress updates.""" """Called when analysis progress updates.
The messages already carry phase + timing; the percentage was a coarse
fake (load jumped 10->done), so it's logged but not shown in the slip.
"""
self.logger.debug(f"Progress: {message} ({percentage}%)") self.logger.debug(f"Progress: {message} ({percentage}%)")
self.visualization_widget.set_status(f"{message} ({percentage}%)") self.visualization_widget.set_status(message)
def on_file_selected(self, item): def on_file_selected(self, item):
"""Called when a file is highlighted (drives the metadata panel only).""" """Called when a file is highlighted (drives the metadata panel only)."""
@@ -296,6 +306,16 @@ class MainWindow(QMainWindow):
return # no longer part of the overlay set return # no longer part of the overlay set
self._refresh_view() self._refresh_view()
def on_metric_timing(self, file_path: str, metric_id: str, seconds: float):
"""An on-demand metric compute finished — report how long it took."""
if file_path not in self._overlay_paths():
return
if metric_id != self.plot_control.current_metric_id():
return
metric = METRICS.get(metric_id)
display = metric.display_name if metric else metric_id
self.visualization_widget.set_status(f"{display} computed in {seconds:.1f}s")
def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str): def on_metric_compute_error(self, file_path: str, metric_id: str, error_message: str):
self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}") self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}")
if file_path in self._overlay_paths(): if file_path in self._overlay_paths():
+96 -40
View File
@@ -17,7 +17,6 @@ the renderer, applied uniformly to every metric.
from __future__ import annotations from __future__ import annotations
import warnings
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
@@ -61,18 +60,95 @@ def _window_peaks(abs_signal: np.ndarray, starts: np.ndarray, window_n: int) ->
return running[centers] return running[centers]
# BS.1770 loudness offset and absolute gate, shared by the routines below.
_LUFS_OFFSET = -0.691
_ABS_GATE = -70.0
def _kweight(audio_file: AudioFile) -> np.ndarray:
"""K-weighted mono signal (float64), filtered once and cached on the AudioFile.
Uses pyloudnorm's own BS.1770 biquad coefficients and filtering (passband_gain
* lfilter, exactly as `IIRfilter.apply_filter`), so every loudness quantity
derived from it matches pyloudnorm. Depends on `Meter._filters` internals; the
dev-time validation guards against a coefficient change.
"""
cached = getattr(audio_file, "_yk", None)
if cached is not None:
return cached
yk = audio_file.y_mono.astype(np.float64, copy=False)
for filt in pyln.Meter(audio_file.sr)._filters.values():
yk = filt.passband_gain * scipy_signal.lfilter(filt.b, filt.a, yk)
audio_file._yk = yk
return yk
def _block_loudness(yk: np.ndarray, sr: int, block_s: float, step_pct: float):
"""Per-block mean-square energy `z` and block loudness `l`, matching pyloudnorm.
Blocks are `block_s` long, stepped by `block_s * step_pct`; energy is divided
by the *nominal* block length (not the rounded sample count), exactly as
BS.1770 / pyloudnorm define it.
"""
T = len(yk) / sr
n_blocks = int(np.round((T - block_s) / (block_s * step_pct)) + 1)
if n_blocks < 1:
return np.array([]), np.array([])
j = np.arange(n_blocks)
lo = (block_s * (j * step_pct) * sr).astype(int)
up = np.minimum((block_s * (j * step_pct + 1) * sr).astype(int), len(yk))
csq = np.concatenate(([0.0], np.cumsum(yk * yk)))
z = (csq[up] - csq[lo]) / (block_s * sr)
with np.errstate(divide="ignore"):
l = _LUFS_OFFSET + 10.0 * np.log10(z)
return z, l
def _integrated_lufs(yk: np.ndarray, sr: int) -> float:
"""ITU-R BS.1770 integrated (two-stage gated) loudness from the K-weighted signal.
Reimplements pyloudnorm's gating on 400 ms / 75%-overlap blocks — validated
bit-equal to `Meter.integrated_loudness` — so the whole-signal re-filter that
pyloudnorm would do is avoided (the K-weighting is already cached).
"""
z, l = _block_loudness(yk, sr, block_s=0.4, step_pct=0.25)
abs_gated = l >= _ABS_GATE
if not abs_gated.any():
return float("-inf")
gamma_r = _LUFS_OFFSET + 10.0 * np.log10(np.mean(z[abs_gated])) - 10.0
gated = (l > gamma_r) & (l > _ABS_GATE)
if not gated.any():
return float("-inf")
return float(_LUFS_OFFSET + 10.0 * np.log10(np.mean(z[gated])))
def _loudness_range(yk: np.ndarray, sr: int) -> float:
"""EBU Tech 3342 loudness range (LU) from the K-weighted signal.
3 s blocks at ~10 Hz with 1.5 s of trailing silence, absolute + relative
gating, then the 95th-minus-10th percentile spread — matching pyloudnorm's
`loudness_range` (validated bit-equal).
"""
yk_padded = np.concatenate((yk, np.zeros(int(1.5 * sr))))
_, l = _block_loudness(yk_padded, sr, block_s=3.0, step_pct=0.03)
abs_gated = l[l >= _ABS_GATE]
if len(abs_gated) == 0:
return float("nan")
stl_integrated = 10.0 * np.log10(np.mean(np.power(10.0, abs_gated / 10.0)))
rel_gated = abs_gated[abs_gated >= stl_integrated - 20.0]
if len(rel_gated) == 0:
return float("nan")
return float(np.percentile(rel_gated, 95) - np.percentile(rel_gated, 10))
def _short_term_lufs(audio_file: AudioFile, window_s: float, hop_s: float): def _short_term_lufs(audio_file: AudioFile, window_s: float, hop_s: float):
"""True (ungated) EBU R128 short-term loudness series + window-centre times. """True (ungated) EBU R128 short-term loudness series + window-centre times.
K-weights the whole signal *once* with pyloudnorm's own BS.1770 biquad A vectorised sliding mean-square over the cached K-weighted signal — ~8x faster
coefficients, then takes a vectorised sliding mean-square. This is ~8x faster
than the old loop of per-window `integrated_loudness` calls, which also wrongly than the old loop of per-window `integrated_loudness` calls, which also wrongly
gated each 3 s window short-term loudness is ungated by definition. The gated each 3 s window (short-term loudness is ungated by definition).
integrated number and LRA (which *are* gated) still come from pyloudnorm.
Memoised on the AudioFile so LUFS and PSR (same 3 s / 0.5 s window) share one Memoised on the AudioFile so LUFS and PSR (same 3 s / 0.5 s window) share it.
computation. Depends on pyloudnorm's `Meter._filters` internals; the dev-time
validation against pyloudnorm guards against a coefficient change.
""" """
key = (round(window_s, 6), round(hop_s, 6)) key = (round(window_s, 6), round(hop_s, 6))
cache = getattr(audio_file, "_st_lufs_cache", None) cache = getattr(audio_file, "_st_lufs_cache", None)
@@ -81,24 +157,20 @@ def _short_term_lufs(audio_file: AudioFile, window_s: float, hop_s: float):
if key in cache: if key in cache:
return cache[key] return cache[key]
y = audio_file.y_mono.astype(np.float64, copy=False) yk = _kweight(audio_file)
sr = audio_file.sr sr = audio_file.sr
meter = pyln.Meter(sr) n = len(yk)
yk = y
for filt in meter._filters.values():
yk = scipy_signal.lfilter(filt.b, filt.a, yk) * filt.passband_gain
window_n = max(int(window_s * sr), 1) window_n = max(int(window_s * sr), 1)
hop_n = max(int(hop_s * sr), 1) hop_n = max(int(hop_s * sr), 1)
if len(y) < window_n: if n < window_n:
ms = float(np.mean(yk * yk)) if len(yk) else 0.0 ms = float(np.mean(yk * yk)) if n else 0.0
times = np.array([len(y) / (2.0 * sr)]) times = np.array([n / (2.0 * sr)])
lufs = np.array([-0.691 + 10.0 * np.log10(max(ms, _EPS))]) lufs = np.array([_LUFS_OFFSET + 10.0 * np.log10(max(ms, _EPS))])
else: else:
csq = np.concatenate(([0.0], np.cumsum(yk * yk))) csq = np.concatenate(([0.0], np.cumsum(yk * yk)))
starts = _window_starts(len(y), window_n, hop_n) starts = _window_starts(n, window_n, hop_n)
ms = (csq[starts + window_n] - csq[starts]) / window_n ms = (csq[starts + window_n] - csq[starts]) / window_n
lufs = -0.691 + 10.0 * np.log10(np.maximum(ms, _EPS)) lufs = _LUFS_OFFSET + 10.0 * np.log10(np.maximum(ms, _EPS))
times = (starts + window_n / 2.0) / sr times = (starts + window_n / 2.0) / sr
cache[key] = (times, lufs) cache[key] = (times, lufs)
@@ -208,7 +280,6 @@ class LUFSMetric(Metric):
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
def compute(self, audio_file: AudioFile): def compute(self, audio_file: AudioFile):
y = audio_file.y_mono.astype(np.float64, copy=False)
sr = audio_file.sr sr = audio_file.sr
# Short-term series: fast, ungated, shared with PSR. # Short-term series: fast, ungated, shared with PSR.
@@ -216,18 +287,10 @@ class LUFSMetric(Metric):
lufs = np.clip(np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR), lufs = np.clip(np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR),
self.SILENCE_FLOOR, 0.0) self.SILENCE_FLOOR, 0.0)
# Integrated loudness + LRA keep pyloudnorm's exact gating (one call each). # Integrated + LRA from the same cached K-weighting (gating matches pyloudnorm).
meter = pyln.Meter(sr) yk = _kweight(audio_file)
with warnings.catch_warnings(): integrated = _integrated_lufs(yk, sr)
warnings.simplefilter("ignore") lra = _loudness_range(yk, sr) if len(yk) >= int(self.WINDOW_S * sr) else float("nan")
integrated = self._safe_integrated(meter, y)
if len(y) >= int(self.WINDOW_S * sr):
try:
lra = float(meter.loudness_range(y))
except (ValueError, FloatingPointError):
lra = float("nan")
else:
lra = float("nan")
return { return {
"times": times, "times": times,
@@ -236,13 +299,6 @@ class LUFSMetric(Metric):
"lra": lra, "lra": lra,
} }
@staticmethod
def _safe_integrated(meter: "pyln.Meter", segment: np.ndarray) -> float:
try:
return float(meter.integrated_loudness(segment))
except (ValueError, FloatingPointError):
return float("-inf")
def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec: def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
times = data["times"] times = data["times"]
lufs = data["lufs"] lufs = data["lufs"]