diff --git a/CLAUDE.md b/CLAUDE.md index a2923c9..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,18 @@ 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` — BS.1770 short-term (3 s) + integrated + LRA, via pyloudnorm - - `CrestFactorMetric` — 20·log10(peak/RMS) per 1 s window - - `PSRMetric` — sample-peak minus short-term LUFS (3 s window) - - `TruePeakMetric` — 4× oversampled dBTP via `scipy.signal.resample_poly` + - `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 + 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 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`; @@ -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 #### `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 band is preserved — analysis runs ~2× heavier on 44.1/48 kHz files than the 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 - High dynamic range: 0-0.6 scale for loud 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) - **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 - **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 - librosa: Audio analysis and feature extraction - numpy: Numerical computations -- scipy: Signal processing (true-peak polyphase oversampling, spectrogram - log-frequency resample) -- pyloudnorm: BS.1770 loudness (LUFS, LRA) +- scipy: Signal processing (true-peak polyphase oversampling, K-weighting + filters, spectrogram log-frequency resample, O(N) running-max via ndimage) +- 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 04ae1ba..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 @@ -20,7 +21,6 @@ class AnalysisResult: file_path: str audio_file: AudioFile song_name: str - bpm: float max_amplitude: float avg_amplitude: float metric_data: dict[str, Any] = field(default_factory=dict) @@ -30,7 +30,6 @@ class AnalysisResult: def metadata_text(self) -> str: return ( f"Track: {safe_title(self.song_name)}\n" - f"BPM: {self.bpm:.1f}\n" f"Max Amplitude: {self.max_amplitude:.3f}\n" f"Avg Amplitude: {self.avg_amplitude:.3f}" ) @@ -51,32 +50,38 @@ 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, 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)} - - self.progressUpdate.emit("Finalizing analysis...", 90) + metric_s = time.perf_counter() - t1 result = AnalysisResult( file_path=self.file_path, audio_file=audio_file, song_name=audio_file.song_name, - bpm=audio_file.get_bpm(), max_amplitude=audio_file.max_amplitude, avg_amplitude=audio_file.avg_amplitude, metric_data=metric_data, analysis_successful=True, ) - self.progressUpdate.emit("Analysis complete!", 100) 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) except Exception as e: @@ -88,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__() @@ -103,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.""" @@ -124,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): @@ -152,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})" @@ -166,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. @@ -203,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) @@ -246,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/master_core.py b/master_core.py index 84884c7..c745792 100644 --- a/master_core.py +++ b/master_core.py @@ -34,13 +34,8 @@ class AudioFile: self.y_mono = librosa.to_mono(self.y) self.max_amplitude = np.max(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) - - 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) + # 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_energy_levels_over_time(self, window=10, hop=2): """Compute rolling RMS power. diff --git a/metrics.py b/metrics.py index 1f53482..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 @@ -25,6 +24,7 @@ import numpy as np import librosa import pyloudnorm as pyln from scipy import signal as scipy_signal +from scipy.ndimage import maximum_filter1d from master_core import AudioFile 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)) +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): """A pluggable analysis metric.""" @@ -144,35 +280,17 @@ 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 - meter = pyln.Meter(sr) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - integrated = self._safe_integrated(meter, y) + # Short-term series: fast, ungated, shared with PSR. + times, lufs = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S) + lufs = np.clip(np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR), + self.SILENCE_FLOOR, 0.0) - window_n = int(self.WINDOW_S * sr) - hop_n = int(self.HOP_S * sr) - - if len(y) < window_n: - 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) + # 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, @@ -181,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"] @@ -238,19 +349,14 @@ class CrestFactorMetric(Metric): crest = 20.0 * np.log10(max(peak, _EPS) / max(rms, _EPS)) 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 cumsum = np.concatenate(([0.0], np.cumsum(y2))) - n_windows = 1 + (len(y) - window_n) // hop_n - starts = np.arange(n_windows) * hop_n - ends = starts + window_n - mean_sq = (cumsum[ends] - cumsum[starts]) / window_n + starts = _window_starts(len(y), window_n, hop_n) + mean_sq = (cumsum[starts + window_n] - cumsum[starts]) / window_n rms = np.sqrt(np.maximum(mean_sq, _EPS)) - abs_y = np.abs(y) - peaks = np.empty(n_windows) - for i in range(n_windows): - peaks[i] = np.max(abs_y[starts[i]:ends[i]]) + peaks = _window_peaks(np.abs(y), starts, window_n) crest_db = 20.0 * np.log10(np.maximum(peaks, _EPS) / rms) times = (starts + window_n / 2.0) / sr @@ -285,30 +391,18 @@ class PSRMetric(Metric): def compute(self, audio_file: AudioFile): y = audio_file.y_mono.astype(np.float64, copy=False) 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) - hop_n = int(self.HOP_S * sr) + # Short-term loudness series, shared (cache hit) with LUFSMetric. + times, lufs_series = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S) + abs_y = np.abs(y) - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - if len(y) < window_n: - times = np.array([len(y) / (2.0 * sr)]) - peak_db = _to_dbfs(np.max(np.abs(y))) if len(y) else self.SILENCE_FLOOR - 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 + if len(y) < window_n: + peaks_db = np.array([_to_dbfs(np.max(abs_y)) if len(y) else self.SILENCE_FLOOR]) + else: + starts = _window_starts(len(y), window_n, hop_n) + peaks_db = _to_dbfs(_window_peaks(abs_y, starts, window_n)) # PSR is meaningless where the loudness reading is below the absolute gate. valid = np.isfinite(lufs_series) & (lufs_series > self.SILENCE_FLOOR) @@ -356,14 +450,18 @@ class TruePeakMetric(Metric): "integrated_tp_db": float(peak_db), } - n_windows = 1 + (len(y) - window_n) // hop_n - tp_db = np.empty(n_windows) - for i in range(n_windows): - start = i * hop_n - w = y[start:start + window_n] - w_up = scipy_signal.resample_poly(w, self.OVERSAMPLE, 1) - tp_db[i] = _to_dbfs(np.max(np.abs(w_up))) - times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr + # Oversample the whole signal once (not per window), then take an O(N) + # running max over the oversampled windows — replaces thousands of tiny + # resample_poly calls with one big one. + os_factor = self.OVERSAMPLE + abs_up = np.abs(scipy_signal.resample_poly(y, os_factor, 1).astype(np.float32)) + win_up = window_n * os_factor + running = maximum_filter1d(abs_up, size=win_up) + + 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)) return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db}