Perf/faster analysis #2

Merged
mikkeli merged 2 commits from perf/faster-analysis into main 2026-06-13 16:57:21 +00:00
4 changed files with 241 additions and 61 deletions
Showing only changes of commit 507af2f676 - Show all commits
+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
#### `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
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`
- 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:
- `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale
- `WaveformMetric` — min/max envelope, fixed ±1.1 y-range
- `LUFSMetric` — true (ungated) EBU R128 short-term (3 s) computed via a single
K-weighting pass (`_short_term_lufs`, reusing pyloudnorm's filter
coefficients) + a vectorised sliding mean-square; integrated + LRA still come
from pyloudnorm (one gated call each). ~2× faster than the old per-window loop
- `LUFSMetric` — true (ungated) EBU R128 short-term (3 s) via a single
K-weighting pass (`_kweight`, cached) + a vectorised sliding mean-square.
Integrated (`_integrated_lufs`) and LRA (`_loudness_range`) are reimplemented
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
@@ -211,7 +219,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- numpy: Numerical computations
- scipy: Signal processing (true-peak polyphase oversampling, K-weighting
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)
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
- mutagen: Audio metadata extraction
+107 -12
View File
@@ -7,6 +7,7 @@ from PyQt5.QtCore import QObject, pyqtSignal, QThread
from dataclasses import dataclass, field
from typing import Any, Optional
import os
import time
import logging
from master_core import AudioFile
@@ -49,16 +50,21 @@ class AudioAnalysisWorker(QThread):
def run(self):
try:
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
self.progressUpdate.emit("Loading audio file...", 10)
base = os.path.basename(self.file_path)
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)
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)}
self.progressUpdate.emit("Finalizing analysis...", 90)
metric_s = time.perf_counter() - t1
result = AnalysisResult(
file_path=self.file_path,
@@ -70,8 +76,12 @@ class AudioAnalysisWorker(QThread):
analysis_successful=True,
)
self.progressUpdate.emit("Analysis complete!", 100)
self.logger.info(f"Analysis completed: {os.path.basename(self.file_path)}")
self.logger.info(
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)
except Exception as e:
@@ -83,8 +93,8 @@ class AudioAnalysisWorker(QThread):
class MetricComputeWorker(QThread):
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
completed = pyqtSignal(str, str, object) # file_path, metric_id, data
failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message
completed = pyqtSignal(str, str, object, float) # file_path, metric_id, data, seconds
failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message
def __init__(self, file_path: str, audio_file: AudioFile, metric: Metric):
super().__init__()
@@ -98,14 +108,53 @@ class MetricComputeWorker(QThread):
self.logger.info(
f"Computing {self.metric.display_name} for {os.path.basename(self.file_path)}"
)
t0 = time.perf_counter()
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:
msg = f"{self.metric.display_name} compute failed: {e}"
self.logger.error(msg)
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):
"""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
metricReady = pyqtSignal(str, str) # file_path, metric_id
metricComputeError = pyqtSignal(str, str, str) # file_path, metric_id, error
metricTiming = pyqtSignal(str, str, float) # file_path, metric_id, seconds
def __init__(self):
super().__init__()
self.results_cache: dict[str, AnalysisResult] = {}
self.current_worker: Optional[AudioAnalysisWorker] = None
self.metric_workers: dict[tuple[str, str], MetricComputeWorker] = {}
self.prefetch_worker: Optional[PrefetchWorker] = None
self.logger = logging.getLogger(__name__)
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.wait()
# A new foreground load supersedes background prefetch of the previous file.
self._stop_prefetch()
self.analysisStarted.emit(file_path)
self.logger.info(
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):
self.results_cache[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:
"""Ensure the metric's data exists for the file; emit metricReady when ready.
@@ -198,12 +281,13 @@ class AnalysisResultsManager(QObject):
worker.start()
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)
if result is not None:
result.metric_data[metric_id] = data
self.metric_workers.pop((file_path, metric_id), None)
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):
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:
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.metricReady.connect(self.on_metric_ready)
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)
def closeEvent(self, event):
"""Stop background analysis/prefetch threads before the window closes."""
self.analysis_manager.shutdown()
super().closeEvent(event)
def dragEnterEvent(self, event):
"""Handle drag enter event for file drops."""
if event.mimeData().hasUrls():
@@ -199,9 +205,13 @@ class MainWindow(QMainWindow):
self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}")
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.visualization_widget.set_status(f"{message} ({percentage}%)")
self.visualization_widget.set_status(message)
def on_file_selected(self, item):
"""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
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):
self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}")
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
import warnings
from abc import ABC, abstractmethod
from typing import Any
@@ -61,18 +60,95 @@ def _window_peaks(abs_signal: np.ndarray, starts: np.ndarray, window_n: int) ->
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.
K-weights the whole signal *once* with pyloudnorm's own BS.1770 biquad
coefficients, then takes a vectorised sliding mean-square. This is ~8x faster
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. The
integrated number and LRA (which *are* gated) still come from pyloudnorm.
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 one
computation. Depends on pyloudnorm's `Meter._filters` internals; the dev-time
validation against pyloudnorm guards against a coefficient change.
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)
@@ -81,24 +157,20 @@ def _short_term_lufs(audio_file: AudioFile, window_s: float, hop_s: float):
if key in cache:
return cache[key]
y = audio_file.y_mono.astype(np.float64, copy=False)
yk = _kweight(audio_file)
sr = audio_file.sr
meter = pyln.Meter(sr)
yk = y
for filt in meter._filters.values():
yk = scipy_signal.lfilter(filt.b, filt.a, yk) * filt.passband_gain
n = len(yk)
window_n = max(int(window_s * sr), 1)
hop_n = max(int(hop_s * sr), 1)
if len(y) < window_n:
ms = float(np.mean(yk * yk)) if len(yk) else 0.0
times = np.array([len(y) / (2.0 * sr)])
lufs = np.array([-0.691 + 10.0 * np.log10(max(ms, _EPS))])
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(len(y), window_n, hop_n)
starts = _window_starts(n, window_n, hop_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
cache[key] = (times, lufs)
@@ -208,7 +280,6 @@ class LUFSMetric(Metric):
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
def compute(self, audio_file: AudioFile):
y = audio_file.y_mono.astype(np.float64, copy=False)
sr = audio_file.sr
# 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),
self.SILENCE_FLOOR, 0.0)
# Integrated loudness + LRA keep pyloudnorm's exact gating (one call each).
meter = pyln.Meter(sr)
with warnings.catch_warnings():
warnings.simplefilter("ignore")
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")
# Integrated + LRA from the same cached K-weighting (gating matches pyloudnorm).
yk = _kweight(audio_file)
integrated = _integrated_lufs(yk, sr)
lra = _loudness_range(yk, sr) if len(yk) >= int(self.WINDOW_S * sr) else float("nan")
return {
"times": times,
@@ -236,13 +299,6 @@ class LUFSMetric(Metric):
"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:
times = data["times"]
lufs = data["lufs"]