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 120 additions and 82 deletions
Showing only changes of commit d7782bb9d9 - Show all commits
+16 -10
View File
@@ -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
+2 -7
View File
@@ -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:
+2 -7
View File
@@ -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.
+100 -58
View File
@@ -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}