From e0682fff336a3ed3ad7ed3576f7783fb4badac92 Mon Sep 17 00:00:00 2001 From: Mathieu Luyten Date: Thu, 23 Apr 2026 01:09:29 +0200 Subject: [PATCH] Add UX audio safety profiles and listening guidance --- src/optimaster/gui.py | 74 ++++++++++++++++++++++++++++------ src/optimaster/service.py | 47 ++++++++++++++++++++- tests/test_service_profiles.py | 17 ++++++++ 3 files changed, 123 insertions(+), 15 deletions(-) create mode 100644 tests/test_service_profiles.py diff --git a/src/optimaster/gui.py b/src/optimaster/gui.py index 10e87f6..27a8e3c 100644 --- a/src/optimaster/gui.py +++ b/src/optimaster/gui.py @@ -10,6 +10,7 @@ from PySide6.QtWidgets import ( QApplication, QAbstractItemView, + QCheckBox, QComboBox, QFileDialog, QFormLayout, @@ -52,6 +53,8 @@ class WorkerRequest: output_dir: str mode: OptimizationMode config_path: str | None + destination_profile: str + strict_true_peak: bool class DropFrame(QFrame): @@ -107,6 +110,8 @@ def run(self) -> None: input_file=self.request.input_file, output_dir=self.request.output_dir, mode=self.request.mode, + destination_profile=self.request.destination_profile, + strict_true_peak=self.request.strict_true_peak, progress_callback=self._emit_progress, ) self.finished.emit(result) @@ -128,6 +133,11 @@ def __init__(self) -> None: self.current_analysis: SourceAnalysis | None = None self.current_session: OptimizationSession | None = None self.current_output_dir: Path | None = None + self.destination_profiles = { + "Streaming prudent": "streaming_prudent", + "Club / Loud": "club_loud", + "Archive safe": "archive_safe", + } self._thread: QThread | None = None self._worker: EngineWorker | None = None @@ -194,6 +204,11 @@ def _build_controls(self) -> QGroupBox: for mode in OptimizationMode: self.mode_combo.addItem(mode.value.title(), mode) self.mode_combo.setCurrentIndex(1) + self.destination_combo = QComboBox() + for label, value in self.destination_profiles.items(): + self.destination_combo.addItem(label, value) + self.strict_tp_checkbox = QCheckBox("True peak strict (safer after encoding)") + self.strict_tp_checkbox.setChecked(True) output_button = QPushButton("Choose output") output_button.clicked.connect(self._browse_output_dir) @@ -209,22 +224,25 @@ def _build_controls(self) -> QGroupBox: layout.addWidget(QLabel("Optimization mode"), 0, 0) layout.addWidget(self.mode_combo, 0, 1) - layout.addWidget(QLabel("Output folder"), 1, 0) - layout.addWidget(self.output_edit, 1, 1) - layout.addWidget(output_button, 1, 2) - layout.addWidget(QLabel("Config file"), 2, 0) - layout.addWidget(self.config_edit, 2, 1) - layout.addWidget(config_button, 2, 2) - layout.addWidget(self.analyze_button, 3, 0) - layout.addWidget(self.optimize_button, 3, 1) - layout.addWidget(self.export_button, 3, 2) + layout.addWidget(QLabel("Destination profile"), 1, 0) + layout.addWidget(self.destination_combo, 1, 1) + layout.addWidget(self.strict_tp_checkbox, 1, 2) + layout.addWidget(QLabel("Output folder"), 2, 0) + layout.addWidget(self.output_edit, 2, 1) + layout.addWidget(output_button, 2, 2) + layout.addWidget(QLabel("Config file"), 3, 0) + layout.addWidget(self.config_edit, 3, 1) + layout.addWidget(config_button, 3, 2) + layout.addWidget(self.analyze_button, 4, 0) + layout.addWidget(self.optimize_button, 4, 1) + layout.addWidget(self.export_button, 4, 2) self.status_label = QLabel("Ready for analysis.") self.progress_bar = QProgressBar() self.progress_bar.setRange(0, 100) self.progress_bar.setValue(0) - layout.addWidget(self.status_label, 4, 0, 1, 2) - layout.addWidget(self.progress_bar, 4, 2) + layout.addWidget(self.status_label, 5, 0, 1, 2) + layout.addWidget(self.progress_bar, 5, 2) return box def _build_summary(self) -> QHBoxLayout: @@ -239,13 +257,18 @@ def _build_summary(self) -> QHBoxLayout: "true_peak": QLabel("--"), "lra": QLabel("--"), "diagnostics": QLabel("Run an analysis to inspect the source profile."), + "acoustic_note": QLabel( + "Meters are technical indicators. Final validation depends on monitoring level and room acoustics." + ), } self.metric_labels["diagnostics"].setWordWrap(True) + self.metric_labels["acoustic_note"].setWordWrap(True) source_layout.addRow("Profile", self.metric_labels["profile"]) source_layout.addRow("Integrated LUFS", self.metric_labels["integrated"]) source_layout.addRow("True Peak", self.metric_labels["true_peak"]) source_layout.addRow("LRA", self.metric_labels["lra"]) source_layout.addRow("Diagnostics", self.metric_labels["diagnostics"]) + source_layout.addRow("Engineering note", self.metric_labels["acoustic_note"]) self.best_box = QGroupBox("Recommended candidate") best_layout = QFormLayout(self.best_box) @@ -442,6 +465,8 @@ def _build_request(self, kind: str) -> WorkerRequest | None: output_dir=output_dir, mode=mode, config_path=config_path, + destination_profile=self.destination_combo.currentData(), + strict_true_peak=self.strict_tp_checkbox.isChecked(), ) def _start_worker(self, request: WorkerRequest) -> None: @@ -512,7 +537,10 @@ def _populate_analysis(self, analysis: SourceAnalysis) -> None: self.metric_labels["integrated"].setText(format_metric(metrics.integrated_lufs, "LUFS")) self.metric_labels["true_peak"].setText(format_metric(metrics.true_peak_dbtp, "dBTP")) self.metric_labels["lra"].setText(format_metric(metrics.lra_lu, "LU")) - self.metric_labels["diagnostics"].setText(" | ".join(analysis.diagnostics)) + diagnostics = list(analysis.diagnostics) + if analysis.profile.value in {"very_hot", "almost_ready"}: + diagnostics.append("Source already hot: prioritize transparent and minimal moves.") + self.metric_labels["diagnostics"].setText(" | ".join(diagnostics)) def _populate_session(self, session: OptimizationSession) -> None: self.results_table.setRowCount(len(session.candidates)) @@ -556,7 +584,10 @@ def _populate_best_candidate(self, candidate: CandidateResult | None) -> None: ] ) ) - self.best_labels["reasons"].setText(" | ".join(candidate.reasons)) + top_reasons = candidate.reasons[:3] + if len(candidate.reasons) > 3: + top_reasons.append("Further details available in candidate panel.") + self.best_labels["reasons"].setText(" | ".join(top_reasons)) self.best_labels["path"].setText(str(candidate.output_path)) def _update_selected_candidate_details(self) -> None: @@ -576,10 +607,25 @@ def _update_selected_candidate_details(self) -> None: f"TP {selected.output_metrics.true_peak_dbtp:.1f}, " f"LRA {selected.output_metrics.lra_lu:.1f}" ), + ( + "Delta vs source: " + f"LUFS {selected.output_metrics.integrated_lufs - selected.source_metrics.integrated_lufs:+.1f}, " + f"LRA {selected.output_metrics.lra_lu - selected.source_metrics.lra_lu:+.1f}" + ), "", "Reasons:", ] lines.extend(f"- {reason}" for reason in selected.reasons) + lines.extend( + [ + "", + "Listening checklist:", + "- Compare at matched loudness when possible.", + "- Check transients (kick/snare attack) for pumping or flattening.", + "- Check vocal harshness/sibilance after limiting.", + "- Validate low-end translation on a second system or headphones.", + ] + ) self.details_panel.setPlainText("\n".join(lines)) if self.current_session and self.current_session.best_candidate is selected: self._populate_best_candidate(selected) @@ -628,6 +674,8 @@ def _set_busy(self, busy: bool) -> None: self.optimize_button.setDisabled(busy) self.export_button.setDisabled(busy or self._selected_candidate() is None) self.mode_combo.setDisabled(busy) + self.destination_combo.setDisabled(busy) + self.strict_tp_checkbox.setDisabled(busy) self.input_edit.setDisabled(busy) self.output_edit.setDisabled(busy) self.config_edit.setDisabled(busy) diff --git a/src/optimaster/service.py b/src/optimaster/service.py index 15d06bc..9fd94d5 100644 --- a/src/optimaster/service.py +++ b/src/optimaster/service.py @@ -1,7 +1,7 @@ from __future__ import annotations import json -from dataclasses import dataclass +from dataclasses import dataclass, replace from datetime import UTC, datetime from pathlib import Path from typing import Callable @@ -30,6 +30,31 @@ ProgressCallback = Callable[[str, int], None] +DESTINATION_SCORING_OVERRIDES = { + "streaming_prudent": { + "target_lufs_min": -12.0, + "target_lufs_max": -10.0, + "ideal_true_peak_max": -1.1, + "hard_true_peak_max": -0.8, + }, + "club_loud": { + "target_lufs_min": -10.0, + "target_lufs_max": -8.0, + "ideal_true_peak_max": -1.0, + "hard_true_peak_max": -0.5, + "max_lufs_delta_from_source": 2.5, + }, + "archive_safe": { + "target_lufs_min": -13.0, + "target_lufs_max": -11.0, + "ideal_true_peak_max": -1.2, + "hard_true_peak_max": -0.9, + "min_lra": 5.5, + "preferred_lra_min": 6.5, + }, +} + + @dataclass(slots=True) class EngineService: config: AppConfig @@ -60,9 +85,12 @@ def optimize( input_file: str | Path, output_dir: str | Path, mode: OptimizationMode | None = None, + destination_profile: str = "streaming_prudent", + strict_true_peak: bool = False, progress_callback: ProgressCallback | None = None, ) -> OptimizationSession: selected_mode = mode or self.config.default_mode + scoring_cfg = self._runtime_scoring_config(destination_profile, strict_true_peak) out_dir = Path(output_dir) out_dir.mkdir(parents=True, exist_ok=True) @@ -94,7 +122,7 @@ def optimize( self._notify(progress_callback, f"Scoring {preset.name}", score_progress) score, reasons = score_candidate( metrics=output_metrics, - cfg=self.config.scoring, + cfg=scoring_cfg, source_metrics=analysis.metrics, mode=selected_mode, ) @@ -121,6 +149,21 @@ def optimize( self._notify(progress_callback, "Optimization complete", 100) return session + def _runtime_scoring_config(self, destination_profile: str, strict_true_peak: bool): + scoring_cfg = self.config.scoring + overrides = DESTINATION_SCORING_OVERRIDES.get(destination_profile, {}) + if overrides: + scoring_cfg = replace(scoring_cfg, **overrides) + if strict_true_peak: + strict_ideal = min(scoring_cfg.ideal_true_peak_max, -1.2) + strict_hard = min(scoring_cfg.hard_true_peak_max, -1.0) + scoring_cfg = replace( + scoring_cfg, + ideal_true_peak_max=strict_ideal, + hard_true_peak_max=strict_hard, + ) + return scoring_cfg + def _write_exports(self, session: OptimizationSession, output_dir: Path) -> None: (output_dir / "analysis.json").write_text( json.dumps( diff --git a/tests/test_service_profiles.py b/tests/test_service_profiles.py new file mode 100644 index 0000000..26641a2 --- /dev/null +++ b/tests/test_service_profiles.py @@ -0,0 +1,17 @@ +from optimaster.config import AppConfig, ScoringConfig +from optimaster.service import EngineService + + +def test_runtime_scoring_profile_applies_destination_overrides(): + service = EngineService(config=AppConfig(scoring=ScoringConfig())) + cfg = service._runtime_scoring_config("archive_safe", strict_true_peak=False) + assert cfg.target_lufs_min == -13.0 + assert cfg.target_lufs_max == -11.0 + assert cfg.hard_true_peak_max == -0.9 + + +def test_runtime_scoring_profile_applies_strict_true_peak_cap(): + service = EngineService(config=AppConfig(scoring=ScoringConfig())) + cfg = service._runtime_scoring_config("club_loud", strict_true_peak=True) + assert cfg.ideal_true_peak_max <= -1.2 + assert cfg.hard_true_peak_max <= -1.0