diff --git a/CLAUDE.md b/CLAUDE.md index c752491..6e49ad9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/analysis_results_manager.py b/analysis_results_manager.py index b0154f5..76084e1 100644 --- a/analysis_results_manager.py +++ b/analysis_results_manager.py @@ -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() diff --git a/main.py b/main.py index 08d1afb..c8bb5d3 100644 --- a/main.py +++ b/main.py @@ -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(): diff --git a/metrics.py b/metrics.py index cc92102..c26088c 100644 --- a/metrics.py +++ b/metrics.py @@ -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"]