Speed up analysis: drop BPM, vectorise loudness/true-peak, share short-term
Profiled hot spots on a 4-min track and cut the worst offenders: - Remove BPM: librosa.beat.beat_track ran on every load (~3.7s) for a number no better than tapping by hand. Dropped from AudioFile + the metadata panel. - LUFS short-term: replace 474 per-window pyloudnorm.integrated_loudness calls with one K-weighting pass (reusing pyloudnorm's own filter coefficients) + a vectorised sliding mean-square. This is true *ungated* EBU R128 short-term (the old loop wrongly gated each 3s window). Integrated + LRA still use pyloudnorm's gated calls. ~3.8s -> ~1.9s. - PSR: reuse LUFS's short-term series (memoised on the AudioFile) + vectorised sample-peak. ~3.0s -> ~0.2s. - True Peak: oversample the whole signal once, then an O(N) running max over windows instead of per-window resample_poly. Bit-identical to the old loop (max|diff| 0.0000 dB). ~2.1s -> ~1.1s. - Crest Factor: peaks via the same O(N) running max (last per-window loop gone). lufs+psr+true_peak: ~9.2s -> ~3.2s, plus ~3.7s of BPM removed from every load. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
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
|
||||
# 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)
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user