diff --git a/CLAUDE.md b/CLAUDE.md index a2923c9..c752491 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,10 +82,16 @@ 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) 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 + - `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 +102,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 +116,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,8 +209,8 @@ 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) +- 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) - pyqtgraph: Interactive plotting (zoom/pan, overlay, lin/log) - matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency diff --git a/analysis_results_manager.py b/analysis_results_manager.py index 04ae1ba..b0154f5 100644 --- a/analysis_results_manager.py +++ b/analysis_results_manager.py @@ -20,7 +20,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 +29,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}" ) @@ -55,7 +53,7 @@ class AudioAnalysisWorker(QThread): self.progressUpdate.emit("Loading audio file...", 10) audio_file = AudioFile(self.file_path) - self.progressUpdate.emit("Audio loaded, detecting tempo...", 30) + self.progressUpdate.emit("Audio loaded...", 30) self.progressUpdate.emit(f"Computing {self.metric.display_name}...", 60) metric_data = {self.metric.id: self.metric.compute(audio_file)} @@ -66,7 +64,6 @@ class AudioAnalysisWorker(QThread): 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, @@ -74,9 +71,7 @@ class AudioAnalysisWorker(QThread): ) self.progressUpdate.emit("Analysis complete!", 100) - self.logger.info( - f"Analysis completed: {os.path.basename(self.file_path)} (BPM: {result.bpm:.1f})" - ) + self.logger.info(f"Analysis completed: {os.path.basename(self.file_path)}") self.analysisCompleted.emit(self.file_path, result) except Exception as e: 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..cc92102 100644 --- a/metrics.py +++ b/metrics.py @@ -25,6 +25,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 +42,69 @@ 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] + + +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 + 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. + + 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. + """ + 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] + + y = audio_file.y_mono.astype(np.float64, copy=False) + 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 + + 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))]) + else: + csq = np.concatenate(([0.0], np.cumsum(yk * yk))) + starts = _window_starts(len(y), window_n, hop_n) + ms = (csq[starts + window_n] - csq[starts]) / window_n + lufs = -0.691 + 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.""" @@ -146,33 +210,24 @@ class LUFSMetric(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) + # 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) + + # 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) - - 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 + if len(y) >= int(self.WINDOW_S * 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) + else: + lra = float("nan") return { "times": times, @@ -238,19 +293,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 +335,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 +394,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}