Merge pull request 'Perf/faster analysis' (#2) from perf/faster-analysis into main

Reviewed-on: http://novoyuuparosk.org:1551/mikkeli/uj-mastering-master/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
2026-06-13 16:57:21 +00:00
5 changed files with 329 additions and 111 deletions
+28 -13
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,18 @@ 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`BS.1770 short-term (3 s) + integrated + LRA, via pyloudnorm - `LUFSMetric`true (ungated) EBU R128 short-term (3 s) via a single
- `CrestFactorMetric` — 20·log10(peak/RMS) per 1 s window K-weighting pass (`_kweight`, cached) + a vectorised sliding mean-square.
- `PSRMetric` — sample-peak minus short-term LUFS (3 s window) Integrated (`_integrated_lufs`) and LRA (`_loudness_range`) are reimplemented
- `TruePeakMetric` — 4× oversampled dBTP via `scipy.signal.resample_poly` 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
- `PSRMetric` — sample-peak minus short-term LUFS (3 s window); reuses
`LUFSMetric`'s short-term series (memoised on the `AudioFile`), so PSR is
near-free once LUFS is computed
- `TruePeakMetric` — 4× oversampled dBTP; the whole signal is oversampled once
(`scipy.signal.resample_poly`) then an O(N) running max over windows
- `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time - `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time
bins at ~4000, `N_FFT=4096`. Log/linear frequency is a view toggle bins at ~4000, `N_FFT=4096`. Log/linear frequency is a view toggle
- Drop in new ones (DR, spectral balance) by appending an instance to `METRICS`; - Drop in new ones (DR, spectral balance) by appending an instance to `METRICS`;
@@ -96,7 +110,9 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
renderer, applied uniformly to every metric — not per-metric renderer, applied uniformly to every metric — not per-metric
#### `master_core.py` #### `master_core.py`
- Defines the `AudioFile` class: librosa loading, rolling RMS power, BPM detection - Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection
was **removed**`librosa.beat.beat_track` cost ~3.7 s on every load for a
number no better than tapping by hand
- Loads at **native sample rate** (`librosa.load(..., sr=None)`) so the full - Loads at **native sample rate** (`librosa.load(..., sr=None)`) so the full
band is preserved — analysis runs ~2× heavier on 44.1/48 kHz files than the band is preserved — analysis runs ~2× heavier on 44.1/48 kHz files than the
old 22050 Hz default, by design old 22050 Hz default, by design
@@ -108,11 +124,9 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- **Adaptive colour mapping**: Automatically adjusts scale based on detected headroom - **Adaptive colour mapping**: Automatically adjusts scale based on detected headroom
- High dynamic range: 0-0.6 scale for loud masters - High dynamic range: 0-0.6 scale for loud masters
- Conservative mastering: 0-0.3 scale for quiet masters - Conservative mastering: 0-0.3 scale for quiet masters
- **Loudness metrics**: LUFS (short-term + integrated + LRA), PSR, Crest Factor - **Loudness metrics**: LUFS (ungated short-term + gated integrated + LRA), PSR, Crest Factor
- **Peak analysis**: True Peak (4× oversampled dBTP) - **Peak analysis**: True Peak (4× oversampled dBTP)
- **Spectral view**: log-frequency spectrogram heatmap over time - **Spectral view**: log-frequency spectrogram heatmap over time
- **Readable axes**: exact min/max of every axis is always labelled, even on log scale
- **BPM detection**: Automatic tempo analysis
- **Metadata display**: Artist and title from audio tags - **Metadata display**: Artist and title from audio tags
- **Real-time visualization**: Embedded matplotlib plots with font-aware rendering - **Real-time visualization**: Embedded matplotlib plots with font-aware rendering
@@ -203,9 +217,10 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
### Dependencies ### Dependencies
- librosa: Audio analysis and feature extraction - librosa: Audio analysis and feature extraction
- numpy: Numerical computations - numpy: Numerical computations
- scipy: Signal processing (true-peak polyphase oversampling, spectrogram - scipy: Signal processing (true-peak polyphase oversampling, K-weighting
log-frequency resample) 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 -16
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
@@ -20,7 +21,6 @@ class AnalysisResult:
file_path: str file_path: str
audio_file: AudioFile audio_file: AudioFile
song_name: str song_name: str
bpm: float
max_amplitude: float max_amplitude: float
avg_amplitude: float avg_amplitude: float
metric_data: dict[str, Any] = field(default_factory=dict) metric_data: dict[str, Any] = field(default_factory=dict)
@@ -30,7 +30,6 @@ class AnalysisResult:
def metadata_text(self) -> str: def metadata_text(self) -> str:
return ( return (
f"Track: {safe_title(self.song_name)}\n" f"Track: {safe_title(self.song_name)}\n"
f"BPM: {self.bpm:.1f}\n"
f"Max Amplitude: {self.max_amplitude:.3f}\n" f"Max Amplitude: {self.max_amplitude:.3f}\n"
f"Avg Amplitude: {self.avg_amplitude:.3f}" f"Avg Amplitude: {self.avg_amplitude:.3f}"
) )
@@ -51,32 +50,38 @@ 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, detecting tempo...", 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,
audio_file=audio_file, audio_file=audio_file,
song_name=audio_file.song_name, song_name=audio_file.song_name,
bpm=audio_file.get_bpm(),
max_amplitude=audio_file.max_amplitude, max_amplitude=audio_file.max_amplitude,
avg_amplitude=audio_file.avg_amplitude, avg_amplitude=audio_file.avg_amplitude,
metric_data=metric_data, metric_data=metric_data,
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)} (BPM: {result.bpm:.1f})" 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:
@@ -88,8 +93,8 @@ 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):
super().__init__() super().__init__()
@@ -103,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."""
@@ -124,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):
@@ -152,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})"
@@ -166,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.
@@ -203,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)
@@ -246,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():
+2 -7
View File
@@ -34,13 +34,8 @@ class AudioFile:
self.y_mono = librosa.to_mono(self.y) self.y_mono = librosa.to_mono(self.y)
self.max_amplitude = np.max(np.abs(self.y_mono)) self.max_amplitude = np.max(np.abs(self.y_mono))
self.avg_amplitude = np.mean(np.abs(self.y_mono)) self.avg_amplitude = np.mean(np.abs(self.y_mono))
self.bpm, _ = librosa.beat.beat_track(y=self.y_mono, sr=self.sr) # BPM intentionally not computed: librosa.beat.beat_track cost ~3.7s on a
# 4-min track for a number that's no better than tapping it by hand.
def get_bpm(self):
# librosa.beat.beat_track returns numpy array - extract scalar value
if isinstance(self.bpm, np.ndarray):
return float(self.bpm[0]) if len(self.bpm) > 0 else 0.0
return float(self.bpm)
def get_energy_levels_over_time(self, window=10, hop=2): def get_energy_levels_over_time(self, window=10, hop=2):
"""Compute rolling RMS power. """Compute rolling RMS power.
+171 -73
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
@@ -25,6 +24,7 @@ import numpy as np
import librosa import librosa
import pyloudnorm as pyln import pyloudnorm as pyln
from scipy import signal as scipy_signal from scipy import signal as scipy_signal
from scipy.ndimage import maximum_filter1d
from master_core import AudioFile from master_core import AudioFile
from plotspec import ( from plotspec import (
@@ -41,6 +41,142 @@ def _to_dbfs(linear: np.ndarray | float) -> np.ndarray | float:
return 20.0 * np.log10(np.maximum(linear, _EPS)) return 20.0 * np.log10(np.maximum(linear, _EPS))
def _window_starts(n: int, window_n: int, hop_n: int) -> np.ndarray:
"""Start indices of every full sliding window of length `window_n` over `n`."""
n_windows = 1 + (n - window_n) // hop_n
return np.arange(n_windows) * hop_n
def _window_peaks(abs_signal: np.ndarray, starts: np.ndarray, window_n: int) -> np.ndarray:
"""Max of `abs_signal` over each window [start, start+window_n), vectorised.
Uses an O(N) running-max (scipy maximum_filter1d) sampled at window centres,
replacing the per-window Python `np.max` loops. `maximum_filter1d` centres a
size-`window_n` window on each index, so the centre of [start, start+window_n)
is `start + window_n//2` — the two line up exactly for even windows.
"""
running = maximum_filter1d(abs_signal, size=window_n)
centers = np.minimum(starts + window_n // 2, len(abs_signal) - 1)
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):
"""True (ungated) EBU R128 short-term loudness series + window-centre times.
A vectorised sliding mean-square over the cached K-weighted signal — ~8x faster
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).
Memoised on the AudioFile so LUFS and PSR (same 3 s / 0.5 s window) share it.
"""
key = (round(window_s, 6), round(hop_s, 6))
cache = getattr(audio_file, "_st_lufs_cache", None)
if cache is None:
cache = audio_file._st_lufs_cache = {}
if key in cache:
return cache[key]
yk = _kweight(audio_file)
sr = audio_file.sr
n = len(yk)
window_n = max(int(window_s * sr), 1)
hop_n = max(int(hop_s * sr), 1)
if n < window_n:
ms = float(np.mean(yk * yk)) if n else 0.0
times = np.array([n / (2.0 * sr)])
lufs = np.array([_LUFS_OFFSET + 10.0 * np.log10(max(ms, _EPS))])
else:
csq = np.concatenate(([0.0], np.cumsum(yk * yk)))
starts = _window_starts(n, window_n, hop_n)
ms = (csq[starts + window_n] - csq[starts]) / window_n
lufs = _LUFS_OFFSET + 10.0 * np.log10(np.maximum(ms, _EPS))
times = (starts + window_n / 2.0) / sr
cache[key] = (times, lufs)
return cache[key]
class Metric(ABC): class Metric(ABC):
"""A pluggable analysis metric.""" """A pluggable analysis metric."""
@@ -144,35 +280,17 @@ 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
meter = pyln.Meter(sr)
with warnings.catch_warnings(): # Short-term series: fast, ungated, shared with PSR.
warnings.simplefilter("ignore") times, lufs = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S)
integrated = self._safe_integrated(meter, y) lufs = np.clip(np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR),
self.SILENCE_FLOOR, 0.0)
window_n = int(self.WINDOW_S * sr) # Integrated + LRA from the same cached K-weighting (gating matches pyloudnorm).
hop_n = int(self.HOP_S * sr) yk = _kweight(audio_file)
integrated = _integrated_lufs(yk, sr)
if len(y) < window_n: lra = _loudness_range(yk, sr) if len(yk) >= int(self.WINDOW_S * sr) else float("nan")
times = np.array([len(y) / (2.0 * sr)])
lufs = np.array([integrated if np.isfinite(integrated) else self.SILENCE_FLOOR])
lra = float("nan")
else:
n_windows = 1 + (len(y) - window_n) // hop_n
lufs = np.empty(n_windows)
for i in range(n_windows):
start = i * hop_n
lufs[i] = self._safe_integrated(meter, y[start:start + window_n])
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
try:
lra = float(meter.loudness_range(y))
except (ValueError, FloatingPointError):
lra = float("nan")
lufs = np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR)
lufs = np.clip(lufs, self.SILENCE_FLOOR, 0.0)
return { return {
"times": times, "times": times,
@@ -181,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"]
@@ -238,19 +349,14 @@ class CrestFactorMetric(Metric):
crest = 20.0 * np.log10(max(peak, _EPS) / max(rms, _EPS)) crest = 20.0 * np.log10(max(peak, _EPS) / max(rms, _EPS))
return {"times": times, "crest_db": np.array([crest])} return {"times": times, "crest_db": np.array([crest])}
# RMS via cumulative-sum-of-squares (O(N)); peaks via sliding window view. # RMS via cumulative-sum-of-squares (O(N)); peaks via O(N) running max.
y2 = y * y y2 = y * y
cumsum = np.concatenate(([0.0], np.cumsum(y2))) cumsum = np.concatenate(([0.0], np.cumsum(y2)))
n_windows = 1 + (len(y) - window_n) // hop_n starts = _window_starts(len(y), window_n, hop_n)
starts = np.arange(n_windows) * hop_n mean_sq = (cumsum[starts + window_n] - cumsum[starts]) / window_n
ends = starts + window_n
mean_sq = (cumsum[ends] - cumsum[starts]) / window_n
rms = np.sqrt(np.maximum(mean_sq, _EPS)) rms = np.sqrt(np.maximum(mean_sq, _EPS))
abs_y = np.abs(y) peaks = _window_peaks(np.abs(y), starts, window_n)
peaks = np.empty(n_windows)
for i in range(n_windows):
peaks[i] = np.max(abs_y[starts[i]:ends[i]])
crest_db = 20.0 * np.log10(np.maximum(peaks, _EPS) / rms) crest_db = 20.0 * np.log10(np.maximum(peaks, _EPS) / rms)
times = (starts + window_n / 2.0) / sr times = (starts + window_n / 2.0) / sr
@@ -285,30 +391,18 @@ class PSRMetric(Metric):
def compute(self, audio_file: AudioFile): def compute(self, audio_file: AudioFile):
y = audio_file.y_mono.astype(np.float64, copy=False) y = audio_file.y_mono.astype(np.float64, copy=False)
sr = audio_file.sr sr = audio_file.sr
meter = pyln.Meter(sr) window_n = max(int(self.WINDOW_S * sr), 1)
hop_n = max(int(self.HOP_S * sr), 1)
window_n = int(self.WINDOW_S * sr) # Short-term loudness series, shared (cache hit) with LUFSMetric.
hop_n = int(self.HOP_S * sr) times, lufs_series = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S)
abs_y = np.abs(y)
with warnings.catch_warnings(): if len(y) < window_n:
warnings.simplefilter("ignore") peaks_db = np.array([_to_dbfs(np.max(abs_y)) if len(y) else self.SILENCE_FLOOR])
if len(y) < window_n: else:
times = np.array([len(y) / (2.0 * sr)]) starts = _window_starts(len(y), window_n, hop_n)
peak_db = _to_dbfs(np.max(np.abs(y))) if len(y) else self.SILENCE_FLOOR peaks_db = _to_dbfs(_window_peaks(abs_y, starts, window_n))
lufs = LUFSMetric._safe_integrated(meter, y)
psr = peak_db - lufs if np.isfinite(lufs) else 0.0
return {"times": times, "psr": np.array([psr])}
n_windows = 1 + (len(y) - window_n) // hop_n
abs_y = np.abs(y)
lufs_series = np.empty(n_windows)
peaks_db = np.empty(n_windows)
for i in range(n_windows):
start = i * hop_n
end = start + window_n
peaks_db[i] = _to_dbfs(np.max(abs_y[start:end]))
lufs_series[i] = LUFSMetric._safe_integrated(meter, y[start:end])
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
# PSR is meaningless where the loudness reading is below the absolute gate. # PSR is meaningless where the loudness reading is below the absolute gate.
valid = np.isfinite(lufs_series) & (lufs_series > self.SILENCE_FLOOR) valid = np.isfinite(lufs_series) & (lufs_series > self.SILENCE_FLOOR)
@@ -356,14 +450,18 @@ class TruePeakMetric(Metric):
"integrated_tp_db": float(peak_db), "integrated_tp_db": float(peak_db),
} }
n_windows = 1 + (len(y) - window_n) // hop_n # Oversample the whole signal once (not per window), then take an O(N)
tp_db = np.empty(n_windows) # running max over the oversampled windows — replaces thousands of tiny
for i in range(n_windows): # resample_poly calls with one big one.
start = i * hop_n os_factor = self.OVERSAMPLE
w = y[start:start + window_n] abs_up = np.abs(scipy_signal.resample_poly(y, os_factor, 1).astype(np.float32))
w_up = scipy_signal.resample_poly(w, self.OVERSAMPLE, 1) win_up = window_n * os_factor
tp_db[i] = _to_dbfs(np.max(np.abs(w_up))) running = maximum_filter1d(abs_up, size=win_up)
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
starts = _window_starts(len(y), window_n, hop_n)
centers_up = np.minimum(starts * os_factor + win_up // 2, len(abs_up) - 1)
tp_db = _to_dbfs(running[centers_up])
times = (starts + window_n / 2.0) / sr
integrated_tp_db = float(np.max(tp_db)) integrated_tp_db = float(np.max(tp_db))
return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db} return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db}