Native integrated/LRA, background prefetch, timing-rich status

LUFS integrated + LRA:
- Reimplement BS.1770 two-stage gating (integrated) and EBU 3342 (LRA) directly
  from the cached K-weighted signal, dropping pyloudnorm's two whole-signal
  re-filters. Validated bit-equal to pyloudnorm across steady/dynamic/quiet
  signals (0.0000 diff). LUFS compute ~1.9s -> ~0.6s (orig 3.8s). pyloudnorm now
  only supplies the filter coefficients.

Prefetch on load:
- After a file loads, PrefetchWorker computes the remaining metrics in the
  background (sequential, cooperatively cancellable, skips on-demand hits), so
  the first switch to any metric is instant. Superseded when a new file loads.

Status slip:
- Workers measure compute time; metricTiming + phase/duration progress messages
  drive the slip ("Loaded in Ns - computing X...", "X computed in Ys"). The old
  fake percentage (10% then done) is logged but no longer shown.
- shutdown() stops background threads on window close.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Mikkeli Matlock
2026-06-14 01:49:50 +09:00
parent d7782bb9d9
commit 507af2f676
4 changed files with 241 additions and 61 deletions
+107 -12
View File
@@ -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
@@ -49,16 +50,21 @@ 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...", 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,
@@ -70,8 +76,12 @@ class AudioAnalysisWorker(QThread):
analysis_successful=True,
)
self.progressUpdate.emit("Analysis complete!", 100)
self.logger.info(f"Analysis completed: {os.path.basename(self.file_path)}")
self.logger.info(
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:
@@ -83,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__()
@@ -98,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."""
@@ -119,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):
@@ -147,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})"
@@ -161,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.
@@ -198,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)
@@ -241,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()