Merge pull request 'Perf/faster analysis' (#2) from perf/faster-analysis into main
Reviewed-on: http://novoyuuparosk.org:1551/mikkeli/uj-mastering-master/pulls/2
This commit was merged in pull request #2.
This commit is contained in:
@@ -30,10 +30,16 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
|
|||||||
- Real-time analysis display and file management
|
- Real-time analysis display and file management
|
||||||
|
|
||||||
#### `analysis_results_manager.py`
|
#### `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
|
- Caches both the loaded `AudioFile` and per-metric `compute()` output, so
|
||||||
metric/font switches re-render from cache without reloading librosa
|
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`
|
#### `audio_visualization_widget.py`
|
||||||
- Persistent pyqtgraph plot — the PlotItem is reused across renders, never torn
|
- 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:
|
- Current registry:
|
||||||
- `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale
|
- `RMSPowerMetric` — 10 s rolling RMS with adaptive colour scale
|
||||||
- `WaveformMetric` — min/max envelope, fixed ±1.1 y-range
|
- `WaveformMetric` — min/max envelope, fixed ±1.1 y-range
|
||||||
- `LUFSMetric` — BS.1770 short-term (3 s) + integrated + LRA, via pyloudnorm
|
- `LUFSMetric` — true (ungated) EBU R128 short-term (3 s) via a single
|
||||||
- `CrestFactorMetric` — 20·log10(peak/RMS) per 1 s window
|
K-weighting pass (`_kweight`, cached) + a vectorised sliding mean-square.
|
||||||
- `PSRMetric` — sample-peak minus short-term LUFS (3 s window)
|
Integrated (`_integrated_lufs`) and LRA (`_loudness_range`) are reimplemented
|
||||||
- `TruePeakMetric` — 4× oversampled dBTP via `scipy.signal.resample_poly`
|
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
|
- `SpectrogramMetric` — log-frequency STFT heatmap; adaptive hop caps time
|
||||||
bins at ~4000, `N_FFT=4096`. Log/linear frequency is a view toggle
|
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`;
|
- 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
|
renderer, applied uniformly to every metric — not per-metric
|
||||||
|
|
||||||
#### `master_core.py`
|
#### `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
|
- 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
|
band is preserved — analysis runs ~2× heavier on 44.1/48 kHz files than the
|
||||||
old 22050 Hz default, by design
|
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
|
- **Adaptive colour mapping**: Automatically adjusts scale based on detected headroom
|
||||||
- High dynamic range: 0-0.6 scale for loud masters
|
- High dynamic range: 0-0.6 scale for loud masters
|
||||||
- Conservative mastering: 0-0.3 scale for quiet 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)
|
- **Peak analysis**: True Peak (4× oversampled dBTP)
|
||||||
- **Spectral view**: log-frequency spectrogram heatmap over time
|
- **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
|
- **Metadata display**: Artist and title from audio tags
|
||||||
- **Real-time visualization**: Embedded matplotlib plots with font-aware rendering
|
- **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
|
### Dependencies
|
||||||
- librosa: Audio analysis and feature extraction
|
- librosa: Audio analysis and feature extraction
|
||||||
- numpy: Numerical computations
|
- numpy: Numerical computations
|
||||||
- scipy: Signal processing (true-peak polyphase oversampling, spectrogram
|
- scipy: Signal processing (true-peak polyphase oversampling, K-weighting
|
||||||
log-frequency resample)
|
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)
|
- pyqtgraph: Interactive plotting (zoom/pan, overlay, lin/log)
|
||||||
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
|
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
|
||||||
- mutagen: Audio metadata extraction
|
- mutagen: Audio metadata extraction
|
||||||
|
|||||||
+106
-16
@@ -7,6 +7,7 @@ from PyQt5.QtCore import QObject, pyqtSignal, QThread
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
import os
|
import os
|
||||||
|
import time
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from master_core import AudioFile
|
from master_core import AudioFile
|
||||||
@@ -20,7 +21,6 @@ class AnalysisResult:
|
|||||||
file_path: str
|
file_path: str
|
||||||
audio_file: AudioFile
|
audio_file: AudioFile
|
||||||
song_name: str
|
song_name: str
|
||||||
bpm: float
|
|
||||||
max_amplitude: float
|
max_amplitude: float
|
||||||
avg_amplitude: float
|
avg_amplitude: float
|
||||||
metric_data: dict[str, Any] = field(default_factory=dict)
|
metric_data: dict[str, Any] = field(default_factory=dict)
|
||||||
@@ -30,7 +30,6 @@ class AnalysisResult:
|
|||||||
def metadata_text(self) -> str:
|
def metadata_text(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"Track: {safe_title(self.song_name)}\n"
|
f"Track: {safe_title(self.song_name)}\n"
|
||||||
f"BPM: {self.bpm:.1f}\n"
|
|
||||||
f"Max Amplitude: {self.max_amplitude:.3f}\n"
|
f"Max Amplitude: {self.max_amplitude:.3f}\n"
|
||||||
f"Avg Amplitude: {self.avg_amplitude:.3f}"
|
f"Avg Amplitude: {self.avg_amplitude:.3f}"
|
||||||
)
|
)
|
||||||
@@ -51,32 +50,38 @@ class AudioAnalysisWorker(QThread):
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
try:
|
try:
|
||||||
self.logger.info(f"Starting analysis of: {os.path.basename(self.file_path)}")
|
base = os.path.basename(self.file_path)
|
||||||
self.progressUpdate.emit("Loading audio file...", 10)
|
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)
|
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)}
|
metric_data = {self.metric.id: self.metric.compute(audio_file)}
|
||||||
|
metric_s = time.perf_counter() - t1
|
||||||
self.progressUpdate.emit("Finalizing analysis...", 90)
|
|
||||||
|
|
||||||
result = AnalysisResult(
|
result = AnalysisResult(
|
||||||
file_path=self.file_path,
|
file_path=self.file_path,
|
||||||
audio_file=audio_file,
|
audio_file=audio_file,
|
||||||
song_name=audio_file.song_name,
|
song_name=audio_file.song_name,
|
||||||
bpm=audio_file.get_bpm(),
|
|
||||||
max_amplitude=audio_file.max_amplitude,
|
max_amplitude=audio_file.max_amplitude,
|
||||||
avg_amplitude=audio_file.avg_amplitude,
|
avg_amplitude=audio_file.avg_amplitude,
|
||||||
metric_data=metric_data,
|
metric_data=metric_data,
|
||||||
analysis_successful=True,
|
analysis_successful=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.progressUpdate.emit("Analysis complete!", 100)
|
|
||||||
self.logger.info(
|
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)
|
self.analysisCompleted.emit(self.file_path, result)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -88,8 +93,8 @@ class AudioAnalysisWorker(QThread):
|
|||||||
class MetricComputeWorker(QThread):
|
class MetricComputeWorker(QThread):
|
||||||
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
|
"""Worker thread that computes a single metric against an already-loaded AudioFile."""
|
||||||
|
|
||||||
completed = pyqtSignal(str, str, object) # file_path, metric_id, data
|
completed = pyqtSignal(str, str, object, float) # file_path, metric_id, data, seconds
|
||||||
failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message
|
failed = pyqtSignal(str, str, str) # file_path, metric_id, error_message
|
||||||
|
|
||||||
def __init__(self, file_path: str, audio_file: AudioFile, metric: Metric):
|
def __init__(self, file_path: str, audio_file: AudioFile, metric: Metric):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
@@ -103,14 +108,53 @@ class MetricComputeWorker(QThread):
|
|||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Computing {self.metric.display_name} for {os.path.basename(self.file_path)}"
|
f"Computing {self.metric.display_name} for {os.path.basename(self.file_path)}"
|
||||||
)
|
)
|
||||||
|
t0 = time.perf_counter()
|
||||||
data = self.metric.compute(self.audio_file)
|
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:
|
except Exception as e:
|
||||||
msg = f"{self.metric.display_name} compute failed: {e}"
|
msg = f"{self.metric.display_name} compute failed: {e}"
|
||||||
self.logger.error(msg)
|
self.logger.error(msg)
|
||||||
self.failed.emit(self.file_path, self.metric.id, str(e))
|
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):
|
class AnalysisResultsManager(QObject):
|
||||||
"""Manages audio file analysis and coordinates between processing and GUI."""
|
"""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
|
metricComputeStarted = pyqtSignal(str, str) # file_path, metric_id
|
||||||
metricReady = 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
|
metricComputeError = pyqtSignal(str, str, str) # file_path, metric_id, error
|
||||||
|
metricTiming = pyqtSignal(str, str, float) # file_path, metric_id, seconds
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.results_cache: dict[str, AnalysisResult] = {}
|
self.results_cache: dict[str, AnalysisResult] = {}
|
||||||
self.current_worker: Optional[AudioAnalysisWorker] = None
|
self.current_worker: Optional[AudioAnalysisWorker] = None
|
||||||
self.metric_workers: dict[tuple[str, str], MetricComputeWorker] = {}
|
self.metric_workers: dict[tuple[str, str], MetricComputeWorker] = {}
|
||||||
|
self.prefetch_worker: Optional[PrefetchWorker] = None
|
||||||
self.logger = logging.getLogger(__name__)
|
self.logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
def analyze_file(self, file_path: str, metric_id: str = DEFAULT_METRIC_ID):
|
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.quit()
|
||||||
self.current_worker.wait()
|
self.current_worker.wait()
|
||||||
|
|
||||||
|
# A new foreground load supersedes background prefetch of the previous file.
|
||||||
|
self._stop_prefetch()
|
||||||
|
|
||||||
self.analysisStarted.emit(file_path)
|
self.analysisStarted.emit(file_path)
|
||||||
self.logger.info(
|
self.logger.info(
|
||||||
f"Queuing analysis: {os.path.basename(file_path)} ({metric.display_name})"
|
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):
|
def _on_worker_completed(self, file_path: str, result: AnalysisResult):
|
||||||
self.results_cache[file_path] = result
|
self.results_cache[file_path] = result
|
||||||
self.analysisCompleted.emit(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:
|
def request_metric(self, file_path: str, metric_id: str) -> bool:
|
||||||
"""Ensure the metric's data exists for the file; emit metricReady when ready.
|
"""Ensure the metric's data exists for the file; emit metricReady when ready.
|
||||||
@@ -203,12 +281,13 @@ class AnalysisResultsManager(QObject):
|
|||||||
worker.start()
|
worker.start()
|
||||||
return True
|
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)
|
result = self.results_cache.get(file_path)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
result.metric_data[metric_id] = data
|
result.metric_data[metric_id] = data
|
||||||
self.metric_workers.pop((file_path, metric_id), None)
|
self.metric_workers.pop((file_path, metric_id), None)
|
||||||
self.metricReady.emit(file_path, metric_id)
|
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):
|
def _on_metric_failed(self, file_path: str, metric_id: str, error_message: str):
|
||||||
self.metric_workers.pop((file_path, metric_id), None)
|
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:
|
def is_file_analyzed(self, file_path: str) -> bool:
|
||||||
return file_path in self.results_cache
|
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()
|
||||||
|
|||||||
@@ -121,8 +121,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
|
self.analysis_manager.metricComputeStarted.connect(self.on_metric_compute_started)
|
||||||
self.analysis_manager.metricReady.connect(self.on_metric_ready)
|
self.analysis_manager.metricReady.connect(self.on_metric_ready)
|
||||||
self.analysis_manager.metricComputeError.connect(self.on_metric_compute_error)
|
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)
|
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):
|
def dragEnterEvent(self, event):
|
||||||
"""Handle drag enter event for file drops."""
|
"""Handle drag enter event for file drops."""
|
||||||
if event.mimeData().hasUrls():
|
if event.mimeData().hasUrls():
|
||||||
@@ -199,9 +205,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}")
|
self.visualization_widget.set_status(f"Error analyzing {filename}: {error_message}")
|
||||||
|
|
||||||
def on_progress_update(self, message, percentage):
|
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.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):
|
def on_file_selected(self, item):
|
||||||
"""Called when a file is highlighted (drives the metadata panel only)."""
|
"""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
|
return # no longer part of the overlay set
|
||||||
self._refresh_view()
|
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):
|
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}")
|
self.logger.error(f"Metric compute failed ({metric_id} / {os.path.basename(file_path)}): {error_message}")
|
||||||
if file_path in self._overlay_paths():
|
if file_path in self._overlay_paths():
|
||||||
|
|||||||
+2
-7
@@ -34,13 +34,8 @@ class AudioFile:
|
|||||||
self.y_mono = librosa.to_mono(self.y)
|
self.y_mono = librosa.to_mono(self.y)
|
||||||
self.max_amplitude = np.max(np.abs(self.y_mono))
|
self.max_amplitude = np.max(np.abs(self.y_mono))
|
||||||
self.avg_amplitude = np.mean(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)
|
# 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_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)
|
|
||||||
|
|
||||||
def get_energy_levels_over_time(self, window=10, hop=2):
|
def get_energy_levels_over_time(self, window=10, hop=2):
|
||||||
"""Compute rolling RMS power.
|
"""Compute rolling RMS power.
|
||||||
|
|||||||
+171
-73
@@ -17,7 +17,6 @@ the renderer, applied uniformly to every metric.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import warnings
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -25,6 +24,7 @@ import numpy as np
|
|||||||
import librosa
|
import librosa
|
||||||
import pyloudnorm as pyln
|
import pyloudnorm as pyln
|
||||||
from scipy import signal as scipy_signal
|
from scipy import signal as scipy_signal
|
||||||
|
from scipy.ndimage import maximum_filter1d
|
||||||
|
|
||||||
from master_core import AudioFile
|
from master_core import AudioFile
|
||||||
from plotspec import (
|
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))
|
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):
|
class Metric(ABC):
|
||||||
"""A pluggable analysis metric."""
|
"""A pluggable analysis metric."""
|
||||||
|
|
||||||
@@ -144,35 +280,17 @@ class LUFSMetric(Metric):
|
|||||||
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
|
SILENCE_FLOOR = -70.0 # BS.1770 absolute gate
|
||||||
|
|
||||||
def compute(self, audio_file: AudioFile):
|
def compute(self, audio_file: AudioFile):
|
||||||
y = audio_file.y_mono.astype(np.float64, copy=False)
|
|
||||||
sr = audio_file.sr
|
sr = audio_file.sr
|
||||||
meter = pyln.Meter(sr)
|
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
# Short-term series: fast, ungated, shared with PSR.
|
||||||
warnings.simplefilter("ignore")
|
times, lufs = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S)
|
||||||
integrated = self._safe_integrated(meter, y)
|
lufs = np.clip(np.where(np.isfinite(lufs), lufs, self.SILENCE_FLOOR),
|
||||||
|
self.SILENCE_FLOOR, 0.0)
|
||||||
|
|
||||||
window_n = int(self.WINDOW_S * sr)
|
# Integrated + LRA from the same cached K-weighting (gating matches pyloudnorm).
|
||||||
hop_n = int(self.HOP_S * sr)
|
yk = _kweight(audio_file)
|
||||||
|
integrated = _integrated_lufs(yk, sr)
|
||||||
if len(y) < window_n:
|
lra = _loudness_range(yk, sr) if len(yk) >= int(self.WINDOW_S * sr) else float("nan")
|
||||||
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)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"times": times,
|
"times": times,
|
||||||
@@ -181,13 +299,6 @@ class LUFSMetric(Metric):
|
|||||||
"lra": lra,
|
"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:
|
def build_spec(self, data, view=DEFAULT_VIEW) -> PlotSpec:
|
||||||
times = data["times"]
|
times = data["times"]
|
||||||
lufs = data["lufs"]
|
lufs = data["lufs"]
|
||||||
@@ -238,19 +349,14 @@ class CrestFactorMetric(Metric):
|
|||||||
crest = 20.0 * np.log10(max(peak, _EPS) / max(rms, _EPS))
|
crest = 20.0 * np.log10(max(peak, _EPS) / max(rms, _EPS))
|
||||||
return {"times": times, "crest_db": np.array([crest])}
|
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
|
y2 = y * y
|
||||||
cumsum = np.concatenate(([0.0], np.cumsum(y2)))
|
cumsum = np.concatenate(([0.0], np.cumsum(y2)))
|
||||||
n_windows = 1 + (len(y) - window_n) // hop_n
|
starts = _window_starts(len(y), window_n, hop_n)
|
||||||
starts = np.arange(n_windows) * hop_n
|
mean_sq = (cumsum[starts + window_n] - cumsum[starts]) / window_n
|
||||||
ends = starts + window_n
|
|
||||||
mean_sq = (cumsum[ends] - cumsum[starts]) / window_n
|
|
||||||
rms = np.sqrt(np.maximum(mean_sq, _EPS))
|
rms = np.sqrt(np.maximum(mean_sq, _EPS))
|
||||||
|
|
||||||
abs_y = np.abs(y)
|
peaks = _window_peaks(np.abs(y), starts, window_n)
|
||||||
peaks = np.empty(n_windows)
|
|
||||||
for i in range(n_windows):
|
|
||||||
peaks[i] = np.max(abs_y[starts[i]:ends[i]])
|
|
||||||
|
|
||||||
crest_db = 20.0 * np.log10(np.maximum(peaks, _EPS) / rms)
|
crest_db = 20.0 * np.log10(np.maximum(peaks, _EPS) / rms)
|
||||||
times = (starts + window_n / 2.0) / sr
|
times = (starts + window_n / 2.0) / sr
|
||||||
@@ -285,30 +391,18 @@ class PSRMetric(Metric):
|
|||||||
def compute(self, audio_file: AudioFile):
|
def compute(self, audio_file: AudioFile):
|
||||||
y = audio_file.y_mono.astype(np.float64, copy=False)
|
y = audio_file.y_mono.astype(np.float64, copy=False)
|
||||||
sr = audio_file.sr
|
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)
|
# Short-term loudness series, shared (cache hit) with LUFSMetric.
|
||||||
hop_n = int(self.HOP_S * sr)
|
times, lufs_series = _short_term_lufs(audio_file, self.WINDOW_S, self.HOP_S)
|
||||||
|
abs_y = np.abs(y)
|
||||||
|
|
||||||
with warnings.catch_warnings():
|
if len(y) < window_n:
|
||||||
warnings.simplefilter("ignore")
|
peaks_db = np.array([_to_dbfs(np.max(abs_y)) if len(y) else self.SILENCE_FLOOR])
|
||||||
if len(y) < window_n:
|
else:
|
||||||
times = np.array([len(y) / (2.0 * sr)])
|
starts = _window_starts(len(y), window_n, hop_n)
|
||||||
peak_db = _to_dbfs(np.max(np.abs(y))) if len(y) else self.SILENCE_FLOOR
|
peaks_db = _to_dbfs(_window_peaks(abs_y, starts, window_n))
|
||||||
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
|
|
||||||
|
|
||||||
# PSR is meaningless where the loudness reading is below the absolute gate.
|
# PSR is meaningless where the loudness reading is below the absolute gate.
|
||||||
valid = np.isfinite(lufs_series) & (lufs_series > self.SILENCE_FLOOR)
|
valid = np.isfinite(lufs_series) & (lufs_series > self.SILENCE_FLOOR)
|
||||||
@@ -356,14 +450,18 @@ class TruePeakMetric(Metric):
|
|||||||
"integrated_tp_db": float(peak_db),
|
"integrated_tp_db": float(peak_db),
|
||||||
}
|
}
|
||||||
|
|
||||||
n_windows = 1 + (len(y) - window_n) // hop_n
|
# Oversample the whole signal once (not per window), then take an O(N)
|
||||||
tp_db = np.empty(n_windows)
|
# running max over the oversampled windows — replaces thousands of tiny
|
||||||
for i in range(n_windows):
|
# resample_poly calls with one big one.
|
||||||
start = i * hop_n
|
os_factor = self.OVERSAMPLE
|
||||||
w = y[start:start + window_n]
|
abs_up = np.abs(scipy_signal.resample_poly(y, os_factor, 1).astype(np.float32))
|
||||||
w_up = scipy_signal.resample_poly(w, self.OVERSAMPLE, 1)
|
win_up = window_n * os_factor
|
||||||
tp_db[i] = _to_dbfs(np.max(np.abs(w_up)))
|
running = maximum_filter1d(abs_up, size=win_up)
|
||||||
times = (np.arange(n_windows) * hop_n + window_n / 2.0) / sr
|
|
||||||
|
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))
|
integrated_tp_db = float(np.max(tp_db))
|
||||||
return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db}
|
return {"times": times, "tp_db": tp_db, "integrated_tp_db": integrated_tp_db}
|
||||||
|
|||||||
Reference in New Issue
Block a user