From a4482c2fab30037a81959d6412cb7315e8e6bc59 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Tue, 17 Feb 2026 19:11:17 +0100 Subject: [PATCH 01/30] Generalization of the DAW processors. Each DAW processor can be instantiated multiple times. --- .gitignore | 1 + sessionprepgui/mainwindow.py | 110 ++++++++------- sessionprepgui/param_widgets.py | 148 ++++++++++++++++++++ sessionprepgui/preferences.py | 21 ++- sessionpreplib/config.py | 5 + sessionpreplib/daw_processors/__init__.py | 28 +++- sessionpreplib/daw_processors/dawproject.py | 82 ++++++++++- sessionpreplib/daw_processors/protools.py | 6 +- 8 files changed, 346 insertions(+), 55 deletions(-) diff --git a/.gitignore b/.gitignore index 341e354..93a82ec 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ nuitka-crash-report.xml sessionpreplib/daw_processors/ptsl/index.md sessionpreplib/daw_processors/ptsl/PTSL_2025.10.md sessionpreplib/daw_processors/ptsl/PTSL_2025.10.proto +sessionpreplib/daw_processors/ptsl/DAWProject-Reference.html diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index badb06f..5b27098 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -50,7 +50,10 @@ ANALYSIS_PARAMS, PRESENTATION_PARAMS, default_config, flatten_structured_config, ) -from sessionpreplib.daw_processors import default_daw_processors +from sessionpreplib.daw_processors import ( + default_daw_processors, + create_runtime_daw_processors, +) from sessionpreplib.detector import TrackDetector from sessionpreplib.detectors import default_detectors, detector_help_map from sessionpreplib.processors import default_processors @@ -245,7 +248,7 @@ class _FolderDropTree(QTreeWidget): drag-and-drop to reorder tracks within / across folders. """ - # (filenames, folder_pt_id, insert_index) -1 = append + # (filenames, folder_id, insert_index) -1 = append tracks_dropped = Signal(list, str, int) tracks_unassigned = Signal(list) # [filenames] @@ -289,7 +292,7 @@ def _is_valid_mime(self, mimeData) -> bool: return False def _resolve_drop(self, pos): - """Return (folder_pt_id, insert_index) for a drop at *pos*. + """Return (folder_id, insert_index) for a drop at *pos*. Uses the item geometry to decide above / on / below placement. Returns (None, -1) if the drop target is invalid. @@ -424,7 +427,7 @@ def __init__(self): # Instantiate and configure DAW processors t0 = time.perf_counter() - self._daw_processors = default_daw_processors() + self._daw_processors: list = [] self._active_daw_processor = None self._configure_daw_processors() dbg(f"daw_processors: {(time.perf_counter() - t0) * 1000:.1f} ms") @@ -782,10 +785,13 @@ def _build_setup_page(self) -> QWidget: # ── DAW processor helpers ───────────────────────────────────────────── def _configure_daw_processors(self): - """Configure all DAW processors from the current flat config.""" + """Rebuild DAW processor list from the current flat config. + + Uses the runtime factory so DAWProject templates are expanded + into individual processor instances. + """ flat = self._flat_config() - for dp in self._daw_processors: - dp.configure(flat) + self._daw_processors = create_runtime_daw_processors(flat) def _populate_daw_combo(self): """Fill the DAW dropdown with enabled processors.""" @@ -804,12 +810,13 @@ def _update_daw_lifecycle_buttons(self): """Enable/disable Fetch/Transfer/Sync based on active processor state.""" has_processor = self._active_daw_processor is not None self._fetch_action.setEnabled(has_processor) - pt_state = ( - self._session.daw_state.get("protools", {}) - if self._session else {} + dp_id = self._active_daw_processor.id if has_processor else None + dp_state = ( + self._session.daw_state.get(dp_id, {}) + if self._session and dp_id else {} ) - has_folders = bool(pt_state.get("folders")) - has_assignments = bool(pt_state.get("assignments")) + has_folders = bool(dp_state.get("folders")) + has_assignments = bool(dp_state.get("assignments")) self._auto_assign_action.setEnabled(has_folders) self._transfer_action.setEnabled(has_processor and has_assignments) self._sync_action.setEnabled(False) @@ -955,16 +962,16 @@ def _on_daw_transfer_result(self, ok: bool, message: str, results): self._status_bar.showMessage(f"Transfer failed: {message}") def _populate_folder_tree(self): - """Build the folder tree from daw_state['protools']['folders'].""" + """Build the folder tree from the active DAW processor's daw_state.""" self._folder_tree.clear() - if not self._session: + if not self._session or not self._active_daw_processor: return - pt_state = self._session.daw_state.get("protools", {}) - folders = pt_state.get("folders", []) - assignments = pt_state.get("assignments", {}) + dp_state = self._session.daw_state.get(self._active_daw_processor.id, {}) + folders = dp_state.get("folders", []) + assignments = dp_state.get("assignments", {}) - # Build lookup: pt_id -> folder dict - folder_map = {f["pt_id"]: f for f in folders} + # Build lookup: id -> folder dict + folder_map = {f["id"]: f for f in folders} # Build children map: parent_id -> [child folders] children_map: dict[str | None, list] = {} for f in folders: @@ -977,7 +984,7 @@ def _populate_folder_tree(self): # Build inverse assignments: folder_id -> [filenames] # Use track_order for stable ordering, fall back to sorted - track_order = pt_state.get("track_order", {}) + track_order = dp_state.get("track_order", {}) folder_tracks: dict[str, list[str]] = {} for fname, fid in assignments.items(): folder_tracks.setdefault(fid, []).append(fname) @@ -1011,7 +1018,7 @@ def _folder_icon(color_hex: str) -> QIcon: def add_folder(parent_widget, folder): item = QTreeWidgetItem(parent_widget) item.setText(0, folder["name"]) - item.setData(0, Qt.UserRole, folder["pt_id"]) + item.setData(0, Qt.UserRole, folder["id"]) item.setData(0, Qt.UserRole + 1, "folder") if folder["folder_type"] == "routing": item.setIcon(0, routing_icon) @@ -1022,7 +1029,7 @@ def add_folder(parent_widget, folder): & ~Qt.ItemIsDragEnabled) # Add assigned tracks as children - for fname in folder_tracks.get(folder["pt_id"], []): + for fname in folder_tracks.get(folder["id"], []): track_item = QTreeWidgetItem(item) track_item.setText(0, fname) track_item.setData(0, Qt.UserRole, fname) @@ -1038,7 +1045,7 @@ def add_folder(parent_widget, folder): track_item.setBackground(0, tint) # Recurse into child folders - for child in children_map.get(folder["pt_id"], []): + for child in children_map.get(folder["id"], []): add_folder(item, child) item.setExpanded(True) @@ -1051,13 +1058,13 @@ def add_folder(parent_widget, folder): @Slot(list, str, int) def _assign_tracks_to_folder(self, filenames: list[str], - folder_pt_id: str, insert_index: int = -1): - """Assign session tracks to a PT folder in the local data model.""" - if not self._session: + folder_id: str, insert_index: int = -1): + """Assign session tracks to a DAW folder in the local data model.""" + if not self._session or not self._active_daw_processor: return - pt_state = self._session.daw_state.setdefault("protools", {}) - assignments = pt_state.setdefault("assignments", {}) - track_order = pt_state.setdefault("track_order", {}) + dp_state = self._session.daw_state.setdefault(self._active_daw_processor.id, {}) + assignments = dp_state.setdefault("assignments", {}) + track_order = dp_state.setdefault("track_order", {}) # Remove tracks from their previous folder order lists for fname in filenames: @@ -1070,10 +1077,10 @@ def _assign_tracks_to_folder(self, filenames: list[str], # Update assignment mapping for fname in filenames: - assignments[fname] = folder_pt_id + assignments[fname] = folder_id # Insert into track_order for the target folder - order = track_order.setdefault(folder_pt_id, []) + order = track_order.setdefault(folder_id, []) # Remove duplicates already in the list for fname in filenames: try: @@ -1093,13 +1100,13 @@ def _assign_tracks_to_folder(self, filenames: list[str], @Slot(list) def _unassign_tracks(self, filenames: list[str]): """Remove track-to-folder assignments and refresh UI.""" - if not self._session: + if not self._session or not self._active_daw_processor: return - pt_state = self._session.daw_state.get("protools") - if not pt_state: + dp_state = self._session.daw_state.get(self._active_daw_processor.id) + if not dp_state: return - assignments = pt_state.get("assignments", {}) - track_order = pt_state.get("track_order", {}) + assignments = dp_state.get("assignments", {}) + track_order = dp_state.get("track_order", {}) for fname in filenames: fid = assignments.pop(fname, None) if fid and fid in track_order: @@ -1114,20 +1121,21 @@ def _unassign_tracks(self, filenames: list[str]): @Slot() def _on_auto_assign(self): """Auto-assign unassigned tracks to folders based on group DAW targets.""" - if not self._session: + if not self._session or not self._active_daw_processor: return - pt_state = self._session.daw_state.get("protools", {}) - folders = pt_state.get("folders", []) - assignments = pt_state.get("assignments", {}) + dp_id = self._active_daw_processor.id + dp_state = self._session.daw_state.get(dp_id, {}) + folders = dp_state.get("folders", []) + assignments = dp_state.get("assignments", {}) if not folders: return - # Build folder name lookup: lowered+trimmed name → pt_id + # Build folder name lookup: lowered+trimmed name → folder id folder_by_name: dict[str, str] = {} for f in folders: key = f["name"].strip().lower() if key and key not in folder_by_name: - folder_by_name[key] = f["pt_id"] + folder_by_name[key] = f["id"] # Build group → daw_target lookup from session groups group_target: dict[str, str] = {} @@ -1141,10 +1149,10 @@ def _on_auto_assign(self): self, "Auto-Assign", "No DAW targets are configured.\n\n" "Open the Groups tab and set a DAW Target for each " - "group that should be mapped to a Pro Tools folder.") + "group that should be mapped to a DAW folder.") return - # Collect assignments: folder_pt_id → [filenames] + # Collect assignments: folder_id → [filenames] batch: dict[str, list[str]] = {} no_group = 0 no_target = 0 @@ -1937,6 +1945,7 @@ def _read_session_config(self) -> dict[str, Any]: # DAW Processors daw_procs: dict[str, dict] = {} + global_dp = self._active_preset().get("daw_processors", {}) for dp in default_daw_processors(): wkey = f"daw_processors.{dp.id}" if wkey not in self._session_widgets: @@ -1944,6 +1953,10 @@ def _read_session_config(self) -> dict[str, Any]: section = {} for key, widget in self._session_widgets[wkey]: section[key] = _read_widget(widget) + # Carry forward non-widget keys (e.g. dawproject_templates) + for gk, gv in global_dp.get(dp.id, {}).items(): + if gk not in section: + section[gk] = gv daw_procs[dp.id] = section cfg["daw_processors"] = daw_procs @@ -3344,11 +3357,12 @@ def _populate_setup_table(self): gcm_rank = self._group_rank_map() glm = self._gain_linked_map() - # Determine which tracks are assigned to a PT folder + # Determine which tracks are assigned to a DAW folder assignments = {} - if self._session.daw_state: - pt_state = self._session.daw_state.get("protools", {}) - assignments = pt_state.get("assignments", {}) + if self._session.daw_state and self._active_daw_processor: + dp_state = self._session.daw_state.get( + self._active_daw_processor.id, {}) + assignments = dp_state.get("assignments", {}) for row, track in enumerate(ok_tracks): pr = ( diff --git a/sessionprepgui/param_widgets.py b/sessionprepgui/param_widgets.py index d7162cd..7d0fa1a 100644 --- a/sessionprepgui/param_widgets.py +++ b/sessionprepgui/param_widgets.py @@ -17,6 +17,7 @@ QCheckBox, QComboBox, QDoubleSpinBox, + QFileDialog, QHBoxLayout, QHeaderView, QLabel, @@ -653,3 +654,150 @@ def _on_sort_az(self): entry.get("match_pattern", "")) self._table.blockSignals(False) self.groups_changed.emit() + + +# --------------------------------------------------------------------------- +# DawProjectTemplatesWidget — template list for DAWProject processor +# --------------------------------------------------------------------------- + +class DawProjectTemplatesWidget(QWidget): + """Editable table of DAWProject mix templates. + + Each row has a *Name*, a *Template Path* (with Browse button), + and a *Fader Ceiling (dB)* spinbox. The widget stores its data as + ``[{name, template_path, fader_ceiling_db}, ...]``. + """ + + templates_changed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._init_ui() + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + lbl = QLabel("Mix Templates") + layout.addWidget(lbl) + + self._table = QTableWidget() + self._table.setColumnCount(3) + self._table.setHorizontalHeaderLabels( + ["Name", "Template Path", "Fader Ceiling (dB)"]) + gh = self._table.horizontalHeader() + gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + gh.setSectionResizeMode(0, QHeaderView.Interactive) + gh.resizeSection(0, 180) + gh.setSectionResizeMode(1, QHeaderView.Stretch) + gh.setSectionResizeMode(2, QHeaderView.Fixed) + gh.resizeSection(2, 120) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + self._table.setSelectionMode(QTableWidget.SingleSelection) + layout.addWidget(self._table, 1) + + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(6) + add_btn = QPushButton("Add") + add_btn.clicked.connect(self._on_add) + btn_row.addWidget(add_btn) + remove_btn = QPushButton("Remove") + remove_btn.clicked.connect(self._on_remove) + btn_row.addWidget(remove_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + # ── Public API ──────────────────────────────────────────────────── + + def set_templates(self, templates: list[dict]): + """Populate the table from a list of template dicts.""" + self._table.blockSignals(True) + self._table.setRowCount(0) + self._table.setRowCount(len(templates)) + for row, tpl in enumerate(templates): + self._set_row( + row, + tpl.get("name", ""), + tpl.get("template_path", ""), + float(tpl.get("fader_ceiling_db", 24.0)), + ) + self._table.blockSignals(False) + + def get_templates(self) -> list[dict]: + """Read the table back into a list of template dicts.""" + templates: list[dict] = [] + for row in range(self._table.rowCount()): + name_item = self._table.item(row, 0) + name = name_item.text().strip() if name_item else "" + # Path is inside a container widget (QLineEdit + browse btn) + path = "" + path_container = self._table.cellWidget(row, 1) + if path_container: + le = path_container.findChild(QLineEdit) + if le: + path = le.text().strip() + ceiling_widget = self._table.cellWidget(row, 2) + ceiling = ceiling_widget.value() if ceiling_widget else 24.0 + if name or path: + templates.append({ + "name": name, + "template_path": path, + "fader_ceiling_db": ceiling, + }) + return templates + + # ── Row helpers ─────────────────────────────────────────────────── + + def _set_row(self, row: int, name: str, template_path: str, + fader_ceiling_db: float = 24.0): + self._table.setItem(row, 0, QTableWidgetItem(name)) + + # Path cell: read-only text item + browse button via cell widget + path_container = QWidget() + path_layout = QHBoxLayout(path_container) + path_layout.setContentsMargins(2, 0, 2, 0) + path_layout.setSpacing(4) + path_edit = QLineEdit(template_path) + path_edit.setPlaceholderText("Path to .dawproject file") + path_layout.addWidget(path_edit, 1) + browse_btn = QPushButton("Browse\u2026") + browse_btn.setFixedWidth(80) + browse_btn.setToolTip("Browse for .dawproject template") + browse_btn.clicked.connect( + lambda _checked=False, le=path_edit: self._browse_template(le)) + path_layout.addWidget(browse_btn) + self._table.setCellWidget(row, 1, path_container) + + # Fader ceiling spinbox + ceiling_spin = QDoubleSpinBox() + ceiling_spin.setRange(0.0, 48.0) + ceiling_spin.setDecimals(1) + ceiling_spin.setSuffix(" dB") + ceiling_spin.setValue(fader_ceiling_db) + self._table.setCellWidget(row, 2, ceiling_spin) + + def _browse_template(self, line_edit: QLineEdit): + path, _ = QFileDialog.getOpenFileName( + self, "Select DAWProject Template", + line_edit.text(), + "DAWProject Files (*.dawproject);;All Files (*)") + if path: + line_edit.setText(path) + self.templates_changed.emit() + + # ── Button handlers ────────────────────────────────────────────── + + def _on_add(self): + row = self._table.rowCount() + self._table.setRowCount(row + 1) + self._set_row(row, "", "", 24.0) + self.templates_changed.emit() + + def _on_remove(self): + row = self._table.currentRow() + if row < 0: + return + self._table.removeRow(row) + self.templates_changed.emit() diff --git a/sessionprepgui/preferences.py b/sessionprepgui/preferences.py index 6ac732c..4ebfac9 100644 --- a/sessionprepgui/preferences.py +++ b/sessionprepgui/preferences.py @@ -45,6 +45,7 @@ _build_tooltip, _read_widget, _set_widget_value, + DawProjectTemplatesWidget, GroupsTableWidget, sanitize_output_folder, ) @@ -68,7 +69,7 @@ class PreferencesDialog(QDialog): def __init__(self, config: dict[str, Any], parent=None): super().__init__(parent) self.setWindowTitle("Preferences") - self.resize(1150, 550) + self.resize(1150, 700) self._config = copy.deepcopy(config) self._widgets: dict[str, list[tuple[str, QWidget]]] = {} self._general_widgets: list[tuple[str, QWidget]] = [] @@ -455,6 +456,16 @@ def _build_daw_processor_pages(self): values = dp_sections.get(dp.id, {}) page, widgets = _build_param_page(params, values) self._widgets[f"daw_processors.{dp.id}"] = widgets + + # DAWProject: append templates widget below the param widgets + if dp.id == "dawproject": + tpl_widget = DawProjectTemplatesWidget() + templates = values.get("dawproject_templates", []) + tpl_widget.set_templates(templates) + self._dawproject_templates_widget = tpl_widget + page.layout().insertWidget( + page.layout().count() - 1, tpl_widget) + self._add_preset_page(child, page) # ── Colors page ──────────────────────────────────────────────────── @@ -863,6 +874,10 @@ def _save_cfg_preset_widgets(self): section = daw_procs.setdefault(dp.id, {}) for key, widget in self._widgets[wkey]: section[key] = _read_widget(widget) + # DAWProject: persist templates list + if dp.id == "dawproject" and hasattr(self, "_dawproject_templates_widget"): + section["dawproject_templates"] = ( + self._dawproject_templates_widget.get_templates()) # Presentation presentation = preset.setdefault("presentation", {}) @@ -911,6 +926,10 @@ def _load_cfg_preset_widgets(self, preset_name: str): for key, widget in self._widgets[wkey]: if key in values: _set_widget_value(widget, values[key]) + # DAWProject: restore templates list + if dp.id == "dawproject" and hasattr(self, "_dawproject_templates_widget"): + self._dawproject_templates_widget.set_templates( + values.get("dawproject_templates", [])) # Presentation pres = preset.get("presentation", {}) diff --git a/sessionpreplib/config.py b/sessionpreplib/config.py index 6265aa2..07455dd 100644 --- a/sessionpreplib/config.py +++ b/sessionpreplib/config.py @@ -444,6 +444,11 @@ def build_structured_defaults() -> dict[str, Any]: params = dp.config_params() if params: structured["daw_processors"][dp.id] = {p.key: p.default for p in params} + # DAWProject: include templates list default + if dp.id == "dawproject": + structured["daw_processors"].setdefault(dp.id, {}) + structured["daw_processors"][dp.id].setdefault( + "dawproject_templates", []) return structured diff --git a/sessionpreplib/daw_processors/__init__.py b/sessionpreplib/daw_processors/__init__.py index c6466b6..6c489f9 100644 --- a/sessionpreplib/daw_processors/__init__.py +++ b/sessionpreplib/daw_processors/__init__.py @@ -1,13 +1,39 @@ from __future__ import annotations +from typing import Any + from ..daw_processor import DawProcessor from .protools import ProToolsDawProcessor from .dawproject import DawProjectDawProcessor def default_daw_processors() -> list[DawProcessor]: - """Returns all built-in DAW processors.""" + """Returns all built-in DAW processors (for config schema / preferences).""" return [ ProToolsDawProcessor(), DawProjectDawProcessor(), ] + + +def create_runtime_daw_processors( + flat_config: dict[str, Any], +) -> list[DawProcessor]: + """Create configured processor instances for runtime use. + + ProTools always yields a single instance. DAWProject expands + into one instance per configured template. Processors that are + disabled via their ``*_enabled`` config key are excluded. + """ + processors: list[DawProcessor] = [] + + pt = ProToolsDawProcessor() + pt.configure(flat_config) + if pt.enabled: + processors.append(pt) + + for inst in DawProjectDawProcessor.create_instances(flat_config): + inst.configure(flat_config) + if inst.enabled: + processors.append(inst) + + return processors diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index e5e547c..8fadc9d 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -2,6 +2,8 @@ from __future__ import annotations +import os +import zipfile from typing import Any from ..daw_processor import DawProcessor @@ -14,21 +16,97 @@ class DawProjectDawProcessor(DawProcessor): DAWproject is an open interchange format for DAW sessions. This processor generates .dawproject files from the session state rather than communicating with a running DAW instance. + + Each configured template becomes a separate instance with its own + ``id`` and ``name``, created via :meth:`create_instances`. """ id = "dawproject" name = "DAWproject" + fader_ceiling_db: float = 24.0 + + def __init__( + self, + *, + instance_index: int | None = None, + template_name: str = "", + template_path: str = "", + template_fader_ceiling_db: float = 24.0, + ): + self._instance_index = instance_index + self._template_name = template_name + self._template_path = template_path + if instance_index is not None: + self.id = f"dawproject_{instance_index}" + self.name = f"DAWproject \u2013 {template_name}" + self.fader_ceiling_db = template_fader_ceiling_db + + # ── Factory ──────────────────────────────────────────────────────── + + @classmethod + def create_instances( + cls, flat_config: dict[str, Any], + ) -> list[DawProjectDawProcessor]: + """Create one processor instance per configured template. + + Reads ``dawproject_templates`` from *flat_config*. Each entry + is a dict with keys ``name``, ``template_path``, and optionally + ``fader_ceiling_db``. Returns an empty list when no templates + are configured (the base "DAWproject" entry in the dropdown is + suppressed in that case). + """ + templates = flat_config.get("dawproject_templates", []) + if not isinstance(templates, list): + return [] + instances: list[DawProjectDawProcessor] = [] + for idx, tpl in enumerate(templates): + if not isinstance(tpl, dict): + continue + name = tpl.get("name", "").strip() + path = tpl.get("template_path", "").strip() + ceiling = float(tpl.get("fader_ceiling_db", 24.0)) + if not name or not path: + continue + instances.append(cls( + instance_index=idx, + template_name=name, + template_path=path, + template_fader_ceiling_db=ceiling, + )) + return instances + + # ── Config ───────────────────────────────────────────────────────── def configure(self, config: dict[str, Any]) -> None: + # For template instances the enabled toggle is governed by the + # base dawproject_enabled key. + saved = config.get(f"{self.id}_enabled") + if saved is None: + config[f"{self.id}_enabled"] = config.get("dawproject_enabled", True) super().configure(config) + # ── Lifecycle ────────────────────────────────────────────────────── + def check_connectivity(self) -> tuple[bool, str]: - return False, "DAWproject export not yet implemented." + if not self._template_path: + return False, "No template file configured." + if not os.path.isfile(self._template_path): + return False, f"Template not found: {self._template_path}" + try: + with zipfile.ZipFile(self._template_path, "r") as zf: + if "project.xml" not in zf.namelist(): + return False, "Template ZIP missing project.xml." + except zipfile.BadZipFile: + return False, "Template file is not a valid ZIP archive." + return True, f"Template OK: {os.path.basename(self._template_path)}" def fetch(self, session: SessionContext) -> SessionContext: + # Sprint 2: parse template structure and populate daw_state return session - def transfer(self, session: SessionContext) -> list[DawCommandResult]: + def transfer(self, session: SessionContext, + progress_cb=None) -> list[DawCommandResult]: + # Sprint 2: write populated .dawproject to output folder return [] def sync(self, session: SessionContext) -> list[DawCommandResult]: diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index c0b0948..4ed620f 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -203,7 +203,7 @@ def fetch(self, session: SessionContext) -> SessionContext: else "basic" ) folders.append({ - "pt_id": track.id, + "id": track.id, "name": track.name, "folder_type": folder_type, "index": track.index, @@ -213,7 +213,7 @@ def fetch(self, session: SessionContext) -> SessionContext: # Preserve existing assignments where folder IDs still match pt_state = session.daw_state.get(self.id, {}) old_assignments: dict[str, str] = pt_state.get("assignments", {}) - valid_ids = {f["pt_id"] for f in folders} + valid_ids = {f["id"] for f in folders} assignments = { fname: fid for fname, fid in old_assignments.items() if fid in valid_ids @@ -309,7 +309,7 @@ def transfer(self, session: SessionContext, return [] # Build lookups - folder_map = {f["pt_id"]: f for f in folders} + folder_map = {f["id"]: f for f in folders} track_map = {t.filename: t for t in session.tracks} # Build ordered work list: [(filename, folder_id), ...] From 59b716d09367532b8ba3efbfd2980afe30cbabc1 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Tue, 17 Feb 2026 20:46:24 +0100 Subject: [PATCH 02/30] first version of dawproject support --- .gitignore | 3 + pyproject.toml | 10 +- sessionprepgui/mainwindow.py | 7 +- sessionpreplib/daw_processors/dawproject.py | 318 +++++++++++++++++++- uv.lock | 36 +++ 5 files changed, 369 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 93a82ec..5ac50ea 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ sessionpreplib/daw_processors/ptsl/index.md sessionpreplib/daw_processors/ptsl/PTSL_2025.10.md sessionpreplib/daw_processors/ptsl/PTSL_2025.10.proto sessionpreplib/daw_processors/ptsl/DAWProject-Reference.html + +# Temporary build directories +_dawproject_build/ diff --git a/pyproject.toml b/pyproject.toml index 1cd7183..58b187d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,9 @@ sessionprep = "sessionprep:process_files" requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.metadata] +allow-direct-references = true + [tool.hatch.version] path = "sessionpreplib/_version.py" @@ -33,7 +36,12 @@ pythonpath = ["."] [project.optional-dependencies] cli = ["rich>=13.0"] -gui = ["PySide6>=6.10.2", "sounddevice>=0.5.5", "py-ptsl>=600.2.0"] +gui = [ + "PySide6>=6.10.2", + "sounddevice>=0.5.5", + "py-ptsl>=600.2.0", + "dawproject @ git+https://github.com/roex-audio/dawproject-py.git@e848bd06b00408018ff97dfb54942f3fa303a6a6", +] [dependency-groups] dev = [ diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 5b27098..6a63323 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -925,7 +925,8 @@ def _on_daw_transfer(self): def _do_daw_transfer(self): """Actually start the transfer (called after successful connectivity check).""" - self._status_bar.showMessage("Transferring to Pro Tools\u2026") + dp_name = self._active_daw_processor.name if self._active_daw_processor else "DAW" + self._status_bar.showMessage(f"Transferring to {dp_name}\u2026") self._transfer_progress.start("Preparing\u2026") # Inject GUI config (groups + colors) into session.config so # transfer() can resolve group → color ARGB @@ -933,6 +934,10 @@ def _do_daw_transfer(self): self._session_groups) colors = self._config.get("colors", PT_DEFAULT_COLORS) self._session.config["gui"]["colors"] = colors + # Inject source dir and output folder for file-based processors + self._session.config["_source_dir"] = self._source_dir + self._session.config["_output_folder"] = self._config.get( + "app", {}).get("output_folder", "processed") self._daw_transfer_worker = DawTransferWorker( self._active_daw_processor, self._session) self._daw_transfer_worker.progress.connect(self._on_transfer_progress) diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index 8fadc9d..06378c8 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -2,6 +2,8 @@ from __future__ import annotations +import logging +import math import os import zipfile from typing import Any @@ -9,6 +11,23 @@ from ..daw_processor import DawProcessor from ..models import DawCommand, DawCommandResult, SessionContext +log = logging.getLogger(__name__) + + +def _db_to_linear(db: float) -> float: + """Convert decibels to linear gain (0 dB → 1.0).""" + return math.pow(10.0, db / 20.0) + + +def _argb_to_rgb_hex(argb: str) -> str | None: + """Convert an ARGB hex string (e.g. 'FF3399CC') to '#rrggbb'.""" + argb = argb.lstrip("#") + if len(argb) == 8: + return f"#{argb[2:]}" + if len(argb) == 6: + return f"#{argb}" + return None + class DawProjectDawProcessor(DawProcessor): """DAW processor that writes .dawproject files. @@ -101,13 +120,306 @@ def check_connectivity(self) -> tuple[bool, str]: return True, f"Template OK: {os.path.basename(self._template_path)}" def fetch(self, session: SessionContext) -> SessionContext: - # Sprint 2: parse template structure and populate daw_state + try: + from dawproject import ( # noqa: F401 + ContentType, DawProject, Referenceable, + ) + except ImportError: + raise RuntimeError( + "dawproject package not installed. " + "Install with: pip install dawproject") + + Referenceable.reset_id() + project = DawProject.load_project(self._template_path) + + folders: list[dict[str, Any]] = [] + self._walk_structure( + project.structure, folders, parent_id=None, counter=[0]) + + # Preserve existing assignments where folder IDs still match + dp_state = session.daw_state.get(self.id, {}) + old_assignments: dict[str, str] = dp_state.get("assignments", {}) + valid_ids = {f["id"] for f in folders} + assignments = { + fname: fid for fname, fid in old_assignments.items() + if fid in valid_ids + } + + session.daw_state[self.id] = { + "folders": folders, + "assignments": assignments, + } return session + def _walk_structure( + self, + tracks: list, + folders: list[dict[str, Any]], + parent_id: str | None, + counter: list[int], + ) -> None: + """Recursively collect folder tracks from the project structure.""" + from dawproject import ContentType + + for track in tracks: + ct_values = set() + for ct in getattr(track, "content_type", []): + if isinstance(ct, ContentType): + ct_values.add(ct) + else: + try: + ct_values.add(ContentType(ct)) + except ValueError: + pass + + if ContentType.TRACKS in ct_values: + folder_type = "routing" if track.channel else "basic" + folders.append({ + "id": track.id, + "name": track.name or "(unnamed)", + "folder_type": folder_type, + "index": counter[0], + "parent_id": parent_id, + }) + counter[0] += 1 + # Recurse into nested tracks + self._walk_structure( + track.tracks, folders, parent_id=track.id, + counter=counter) + def transfer(self, session: SessionContext, progress_cb=None) -> list[DawCommandResult]: - # Sprint 2: write populated .dawproject to output folder - return [] + try: + from dawproject import ( + Arrangement, Audio, Channel, Clips, ContentType, + DawProject, FileReference, Lanes, MetaData, + MixerRole, RealParameter, Referenceable, TimeUnit, + Track, Unit, Utility, + ) + except ImportError: + return [DawCommandResult( + command=DawCommand("transfer", "", {}), + success=False, + error="dawproject package not installed", + )] + + dp_state = session.daw_state.get(self.id, {}) + assignments: dict[str, str] = dp_state.get("assignments", {}) + daw_folders = dp_state.get("folders", []) + track_order = dp_state.get("track_order", {}) + + if not assignments: + return [] + + results: list[DawCommandResult] = [] + + # ── Determine output path ───────────────────────────────── + source_dir = session.config.get("_source_dir", "") + output_folder = session.config.get("_output_folder", "processed") + if not source_dir: + return [DawCommandResult( + command=DawCommand("transfer", "", {}), + success=False, error="No source directory set")] + + output_dir = os.path.join(source_dir, output_folder) + os.makedirs(output_dir, exist_ok=True) + + safe_name = self._template_name or "dawproject" + safe_name = "".join( + c if c.isalnum() or c in " _-" else "_" for c in safe_name) + output_path = os.path.join(output_dir, f"{safe_name}.dawproject") + + # ── Load template ───────────────────────────────────────── + Referenceable.reset_id() + try: + project = DawProject.load_project(self._template_path) + except Exception as e: + return [DawCommandResult( + command=DawCommand("load_template", "", {}), + success=False, error=f"Failed to load template: {e}")] + + # Build folder ID → Track object lookup from the loaded project + folder_track_map: dict[str, Any] = {} + self._build_folder_map(project.structure, folder_track_map) + + # Build lookups + folder_dict_map = {f["id"]: f for f in daw_folders} + track_map = {t.filename: t for t in session.tracks} + + # Build ordered work list + work: list[tuple[str, str]] = [] + seen: set[str] = set() + for fid, ordered_names in track_order.items(): + for fname in ordered_names: + if fname in assignments and assignments[fname] == fid: + work.append((fname, fid)) + seen.add(fname) + for fname, fid in sorted(assignments.items()): + if fname not in seen: + work.append((fname, fid)) + + total = len(work) + if progress_cb: + progress_cb(0, total, "Building DAWproject\u2026") + + # ── Ensure arrangement exists ───────────────────────────── + if project.arrangement is None: + project.arrangement = Arrangement( + lanes=Lanes(time_unit=TimeUnit.SECONDS)) + if project.arrangement.lanes is None: + project.arrangement.lanes = Lanes(time_unit=TimeUnit.SECONDS) + + use_processed = session.config.get("_use_processed", False) + + # ── Create tracks and clips ─────────────────────────────── + for step, (fname, fid) in enumerate(work): + folder_track = folder_track_map.get(fid) + folder_dict = folder_dict_map.get(fid) + tc = track_map.get(fname) + + if not folder_track or not tc: + results.append(DawCommandResult( + command=DawCommand("add_track", fname, + {"folder_id": fid}), + success=False, + error=f"Folder or track not found: {fid} / {fname}")) + continue + + # Resolve audio file path + if (use_processed and tc.processed_filepath + and os.path.isfile(tc.processed_filepath)): + audio_path = os.path.abspath(tc.processed_filepath) + else: + audio_path = os.path.abspath(tc.filepath) + + # Compute fader volume (linear) + fader_db = 0.0 + if tc.processor_results: + pr = next(iter(tc.processor_results.values()), None) + if pr and pr.data: + fader_db = pr.data.get("fader_offset", 0.0) + volume_linear = _db_to_linear(fader_db) + + # Resolve group color → #rrggbb + track_color = self._resolve_track_color(tc.group, session) + + # Create the track with channel + track_name = os.path.splitext(fname)[0] + new_track = Utility.create_track( + name=track_name, + content_types={ContentType.AUDIO}, + mixer_role=MixerRole.REGULAR, + volume=volume_linear, + pan=0.5, + ) + + # Set color + if track_color: + new_track.color = track_color + + # Route to folder's channel + if folder_track.channel is not None: + new_track.channel.destination = folder_track.channel + + # Add to folder's children + folder_track.tracks.append(new_track) + + # Also add to project.structure top-level if not already + # (some DAWs expect all tracks at the structure level) + + # Create audio clip in arrangement + audio = Audio( + time_unit=TimeUnit.SECONDS, + file=FileReference( + path=audio_path.replace("\\", "/"), + external=True), + sample_rate=tc.samplerate, + channels=tc.channels, + duration=tc.duration_sec, + ) + clip = Utility.create_clip( + content=audio, time=0.0, duration=tc.duration_sec) + clips = Utility.create_clips(clip) + + # Create a Lanes entry for this track in the arrangement + track_lane = Lanes( + track=new_track, + time_unit=TimeUnit.SECONDS, + lanes=[clips], + ) + project.arrangement.lanes.lanes.append(track_lane) + + results.append(DawCommandResult( + command=DawCommand("add_track", fname, + {"folder": folder_dict.get("name", ""), + "fader_db": fader_db}), + success=True)) + + if progress_cb: + progress_cb(step + 1, total, + f"Added {track_name} ({step + 1}/{total})") + + # ── Save ────────────────────────────────────────────────── + try: + metadata = DawProject.load_metadata(self._template_path) + except Exception: + metadata = MetaData() + + try: + DawProject.save(project, metadata, {}, output_path) + results.append(DawCommandResult( + command=DawCommand("save_project", output_path, {}), + success=True)) + log.info("DAWproject saved to %s", output_path) + except Exception as e: + results.append(DawCommandResult( + command=DawCommand("save_project", output_path, {}), + success=False, error=f"Failed to save: {e}")) + + session.daw_command_log.extend(results) + return results + + def _build_folder_map( + self, tracks: list, folder_map: dict[str, Any], + ) -> None: + """Recursively build a mapping of folder ID → Track object.""" + from dawproject import ContentType + + for track in tracks: + ct_values = set() + for ct in getattr(track, "content_type", []): + if isinstance(ct, ContentType): + ct_values.add(ct) + else: + try: + ct_values.add(ContentType(ct)) + except ValueError: + pass + if ContentType.TRACKS in ct_values: + folder_map[track.id] = track + self._build_folder_map(track.tracks, folder_map) + + def _resolve_track_color( + self, group_name: str | None, session: SessionContext, + ) -> str | None: + """Return ``#rrggbb`` for the track's group color, or ``None``.""" + if not group_name: + return None + groups = session.config.get("gui", {}).get("groups", []) + color_name: str | None = None + for g in groups: + if g.get("name") == group_name: + color_name = g.get("color") + break + if not color_name: + return None + colors = session.config.get("gui", {}).get("colors", []) + for c in colors: + if c.get("name") == color_name: + argb = c.get("argb") + if argb: + return _argb_to_rgb_hex(argb) + return None def sync(self, session: SessionContext) -> list[DawCommandResult]: return [] diff --git a/uv.lock b/uv.lock index 07b5d35..7823518 100644 --- a/uv.lock +++ b/uv.lock @@ -82,6 +82,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, ] +[[package]] +name = "dawproject" +version = "0.1.0" +source = { git = "https://github.com/roex-audio/dawproject-py.git?rev=e848bd06b00408018ff97dfb54942f3fa303a6a6#e848bd06b00408018ff97dfb54942f3fa303a6a6" } +dependencies = [ + { name = "lxml" }, +] + [[package]] name = "flake8" version = "7.3.0" @@ -126,6 +134,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, +] + [[package]] name = "macholib" version = "1.16.4" @@ -530,6 +564,7 @@ cli = [ { name = "rich" }, ] gui = [ + { name = "dawproject" }, { name = "py-ptsl" }, { name = "pyside6" }, { name = "sounddevice" }, @@ -549,6 +584,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "dawproject", marker = "extra == 'gui'", git = "https://github.com/roex-audio/dawproject-py.git?rev=e848bd06b00408018ff97dfb54942f3fa303a6a6" }, { name = "numpy", specifier = ">=1.26" }, { name = "py-ptsl", marker = "extra == 'gui'", specifier = ">=600.2.0" }, { name = "pyside6", marker = "extra == 'gui'", specifier = ">=6.10.2" }, From 4a89e04c33ab9b3ded162d4840bb8a0985f1d333 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Tue, 17 Feb 2026 22:59:04 +0100 Subject: [PATCH 03/30] rebuild parts of config dialog from shared widget, fix for preapre buttons not working, fix for output directory being cleaned too much, attempt at dawproject clip gain (unsuccessful). --- sessionprepgui/mainwindow.py | 226 +++------------ sessionprepgui/param_widgets.py | 287 +++++++++++++++++++- sessionprepgui/preferences.py | 213 ++------------- sessionpreplib/daw_processors/dawproject.py | 77 +++++- sessionpreplib/pipeline.py | 14 +- 5 files changed, 411 insertions(+), 406 deletions(-) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 6a63323..753923a 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -47,13 +47,10 @@ from sessionpreplib.audio import get_window_samples from sessionpreplib.config import ( - ANALYSIS_PARAMS, PRESENTATION_PARAMS, default_config, + default_config, flatten_structured_config, ) -from sessionpreplib.daw_processors import ( - default_daw_processors, - create_runtime_daw_processors, -) +from sessionpreplib.daw_processors import create_runtime_daw_processors from sessionpreplib.detector import TrackDetector from sessionpreplib.detectors import default_detectors, detector_help_map from sessionpreplib.processors import default_processors @@ -75,7 +72,10 @@ ) from .helpers import track_analysis_label, esc, fmt_time from .log import dbg -from .param_widgets import _build_param_page, _read_widget, _set_widget_value +from .param_widgets import ( + _build_param_page, _read_widget, _set_widget_value, + build_config_pages, load_config_widgets, read_config_widgets, +) from .preferences import PreferencesDialog, _argb_to_qcolor from .report import render_summary_html, render_track_detail_html from .widgets import BatchEditTableWidget, BatchComboBox, ProgressPanel @@ -651,6 +651,7 @@ def _build_setup_page(self) -> QWidget: self._daw_check_label = QLabel("") self._daw_check_label.setContentsMargins(6, 0, 0, 0) + self._daw_check_label.setMaximumWidth(260) self._setup_toolbar.addWidget(self._daw_check_label) self._setup_toolbar.addSeparator() @@ -853,9 +854,13 @@ def _on_daw_check_result(self, ok: bool, message: str): if cb: cb() else: - self._daw_check_label.setText(message) + self._daw_check_label.setText("Connection failed") self._daw_check_label.setStyleSheet(f"color: {COLORS['problems']};") self._pending_after_check = None + QMessageBox.warning( + self, "Connection Failed", + f"{self._active_daw_processor.name} connection could " + f"not be established.\n\n{message}") self._update_daw_lifecycle_buttons() # ── DAW Fetch + Folder Tree ─────────────────────────────────────────── @@ -928,6 +933,9 @@ def _do_daw_transfer(self): dp_name = self._active_daw_processor.name if self._active_daw_processor else "DAW" self._status_bar.showMessage(f"Transferring to {dp_name}\u2026") self._transfer_progress.start("Preparing\u2026") + # Refresh pipeline config from current session widgets so that + # processor enabled/disabled changes made after analysis take effect. + self._session.config.update(self._flat_config()) # Inject GUI config (groups + colors) into session.config so # transfer() can resolve group → color ARGB self._session.config.setdefault("gui", {})["groups"] = list( @@ -1752,87 +1760,18 @@ def _build_session_settings_tab(self) -> QWidget: def _build_session_pages(self): """Populate the session config tree + stack from the active preset.""" - preset = self._active_preset() - # Analysis - item = QTreeWidgetItem(self._session_tree, ["Analysis"]) - values = preset.get("analysis", {}) - pg, wdg = _build_param_page(ANALYSIS_PARAMS, values) - self._session_widgets["analysis"] = wdg - idx = self._session_stack.addWidget(pg) - self._session_page_index[id(item)] = idx - - # Detectors (parent shows presentation params) - det_parent = QTreeWidgetItem(self._session_tree, ["Detectors"]) - pres_values = preset.get("presentation", {}) - pg, wdg = _build_param_page(PRESENTATION_PARAMS, pres_values) - self._session_widgets["_presentation"] = wdg - idx = self._session_stack.addWidget(pg) - self._session_page_index[id(det_parent)] = idx - - det_sections = preset.get("detectors", {}) - for det in default_detectors(): - params = det.config_params() - if not params: - continue - child = QTreeWidgetItem(det_parent, [det.name]) - vals = det_sections.get(det.id, {}) - pg, wdg = _build_param_page(params, vals) - self._session_widgets[f"detectors.{det.id}"] = wdg - idx = self._session_stack.addWidget(pg) - self._session_page_index[id(child)] = idx - - # Processors - proc_parent = QTreeWidgetItem(self._session_tree, ["Processors"]) - placeholder = QWidget() - pl = QVBoxLayout(placeholder) - pl.setContentsMargins(12, 12, 12, 12) - pl.addWidget(QLabel("Select a processor from the tree to configure.")) - pl.addStretch() - idx = self._session_stack.addWidget(placeholder) - self._session_page_index[id(proc_parent)] = idx - - proc_sections = preset.get("processors", {}) - for proc in default_processors(): - params = proc.config_params() - if not params: - continue - child = QTreeWidgetItem(proc_parent, [proc.name]) - vals = proc_sections.get(proc.id, {}) - pg, wdg = _build_param_page(params, vals) - self._session_widgets[f"processors.{proc.id}"] = wdg - idx = self._session_stack.addWidget(pg) - self._session_page_index[id(child)] = idx - - # Connect processor enabled toggle to live-update Processing column - enabled_key = f"{proc.id}_enabled" - for key, widget in wdg: - if key == enabled_key and isinstance(widget, QCheckBox): - widget.toggled.connect(self._on_processor_enabled_changed) - break - - # DAW Processors - daw_parent = QTreeWidgetItem(self._session_tree, ["DAW Processors"]) - placeholder2 = QWidget() - pl2 = QVBoxLayout(placeholder2) - pl2.setContentsMargins(12, 12, 12, 12) - pl2.addWidget(QLabel( - "Select a DAW processor from the tree to configure.")) - pl2.addStretch() - idx = self._session_stack.addWidget(placeholder2) - self._session_page_index[id(daw_parent)] = idx - - dp_sections = preset.get("daw_processors", {}) - for dp in default_daw_processors(): - params = dp.config_params() - if not params: - continue - child = QTreeWidgetItem(daw_parent, [dp.name]) - vals = dp_sections.get(dp.id, {}) - pg, wdg = _build_param_page(params, vals) - self._session_widgets[f"daw_processors.{dp.id}"] = wdg - idx = self._session_stack.addWidget(pg) - self._session_page_index[id(child)] = idx + def _register_page(tree_item, page): + idx = self._session_stack.addWidget(page) + self._session_page_index[id(tree_item)] = idx + + self._session_dawproject_templates_widget = build_config_pages( + self._session_tree, + self._active_preset(), + self._session_widgets, + _register_page, + on_processor_enabled=self._on_processor_enabled_changed, + ) def _on_session_tree_selection(self, current, _previous): if current is None: @@ -1863,109 +1802,18 @@ def _load_session_widgets(self, preset: dict[str, Any]): def _load_session_widgets_inner(self, preset: dict[str, Any]): """Inner loader — sets widget values without triggering column refresh.""" - # Analysis - analysis = preset.get("analysis", {}) - for key, widget in self._session_widgets.get("analysis", []): - if key in analysis: - _set_widget_value(widget, analysis[key]) - - # Presentation - pres = preset.get("presentation", {}) - for key, widget in self._session_widgets.get("_presentation", []): - if key in pres: - _set_widget_value(widget, pres[key]) - - # Detectors - det_sections = preset.get("detectors", {}) - for det in default_detectors(): - wkey = f"detectors.{det.id}" - if wkey not in self._session_widgets: - continue - vals = det_sections.get(det.id, {}) - for key, widget in self._session_widgets[wkey]: - if key in vals: - _set_widget_value(widget, vals[key]) - - # Processors - proc_sections = preset.get("processors", {}) - for proc in default_processors(): - wkey = f"processors.{proc.id}" - if wkey not in self._session_widgets: - continue - vals = proc_sections.get(proc.id, {}) - for key, widget in self._session_widgets[wkey]: - if key in vals: - _set_widget_value(widget, vals[key]) - - # DAW Processors - dp_sections = preset.get("daw_processors", {}) - for dp in default_daw_processors(): - wkey = f"daw_processors.{dp.id}" - if wkey not in self._session_widgets: - continue - vals = dp_sections.get(dp.id, {}) - for key, widget in self._session_widgets[wkey]: - if key in vals: - _set_widget_value(widget, vals[key]) + load_config_widgets( + self._session_widgets, preset, + self._session_dawproject_templates_widget) def _read_session_config(self) -> dict[str, Any]: """Read current session widget values into a structured config dict.""" - cfg: dict[str, Any] = {} - - # Analysis - analysis: dict[str, Any] = {} - for key, widget in self._session_widgets.get("analysis", []): - analysis[key] = _read_widget(widget) - cfg["analysis"] = analysis - - # Presentation - presentation: dict[str, Any] = {} - for key, widget in self._session_widgets.get("_presentation", []): - presentation[key] = _read_widget(widget) - cfg["presentation"] = presentation - - # Detectors - detectors: dict[str, dict] = {} - for det in default_detectors(): - wkey = f"detectors.{det.id}" - if wkey not in self._session_widgets: - continue - section: dict[str, Any] = {} - for key, widget in self._session_widgets[wkey]: - section[key] = _read_widget(widget) - detectors[det.id] = section - cfg["detectors"] = detectors - - # Processors - processors: dict[str, dict] = {} - for proc in default_processors(): - wkey = f"processors.{proc.id}" - if wkey not in self._session_widgets: - continue - section = {} - for key, widget in self._session_widgets[wkey]: - section[key] = _read_widget(widget) - processors[proc.id] = section - cfg["processors"] = processors - - # DAW Processors - daw_procs: dict[str, dict] = {} - global_dp = self._active_preset().get("daw_processors", {}) - for dp in default_daw_processors(): - wkey = f"daw_processors.{dp.id}" - if wkey not in self._session_widgets: - continue - section = {} - for key, widget in self._session_widgets[wkey]: - section[key] = _read_widget(widget) - # Carry forward non-widget keys (e.g. dawproject_templates) - for gk, gv in global_dp.get(dp.id, {}).items(): - if gk not in section: - section[gk] = gv - daw_procs[dp.id] = section - cfg["daw_processors"] = daw_procs - - return cfg + return read_config_widgets( + self._session_widgets, + self._session_dawproject_templates_widget, + fallback_daw_sections=self._active_preset().get( + "daw_processors", {}), + ) def _on_session_config_reset(self): """Reset session config to the global config preset defaults.""" @@ -2754,6 +2602,10 @@ def _on_prepare(self): "output_folder", "processed") output_dir = os.path.join(self._source_dir, output_folder) + # Refresh pipeline config from current session widgets so that + # processor enabled/disabled changes made after analysis take effect. + self._session.config.update(self._flat_config()) + # Use the session's configured processors processors = list(self._session.processors) if self._session.processors else [] if not processors: diff --git a/sessionprepgui/param_widgets.py b/sessionprepgui/param_widgets.py index 7d0fa1a..b7f3a32 100644 --- a/sessionprepgui/param_widgets.py +++ b/sessionprepgui/param_widgets.py @@ -12,7 +12,7 @@ from typing import Any, Callable from PySide6.QtCore import QSize, Qt, Signal -from PySide6.QtGui import QColor, QIcon, QPixmap +from PySide6.QtGui import QColor, QFont, QIcon, QPixmap from PySide6.QtWidgets import ( QCheckBox, QComboBox, @@ -26,6 +26,7 @@ QSpinBox, QTableWidget, QTableWidgetItem, + QTreeWidgetItem, QVBoxLayout, QWidget, ) @@ -721,7 +722,7 @@ def set_templates(self, templates: list[dict]): row, tpl.get("name", ""), tpl.get("template_path", ""), - float(tpl.get("fader_ceiling_db", 24.0)), + float(tpl.get("fader_ceiling_db", 6.0)), ) self._table.blockSignals(False) @@ -751,7 +752,7 @@ def get_templates(self) -> list[dict]: # ── Row helpers ─────────────────────────────────────────────────── def _set_row(self, row: int, name: str, template_path: str, - fader_ceiling_db: float = 24.0): + fader_ceiling_db: float = 6.0): self._table.setItem(row, 0, QTableWidgetItem(name)) # Path cell: read-only text item + browse button via cell widget @@ -792,7 +793,7 @@ def _browse_template(self, line_edit: QLineEdit): def _on_add(self): row = self._table.rowCount() self._table.setRowCount(row + 1) - self._set_row(row, "", "", 24.0) + self._set_row(row, "", "", 6.0) self.templates_changed.emit() def _on_remove(self): @@ -801,3 +802,281 @@ def _on_remove(self): return self._table.removeRow(row) self.templates_changed.emit() + + +# --------------------------------------------------------------------------- +# Shared config page builder / loader / reader +# --------------------------------------------------------------------------- +# Used by both PreferencesDialog and the session Config tab to avoid +# duplicating tree+page construction, widget loading, and reading logic. + +def build_config_pages( + tree, + preset: dict[str, Any], + widgets_dict: dict, + register_page: Callable[[QTreeWidgetItem, QWidget], None], + *, + on_processor_enabled: Callable | None = None, +) -> DawProjectTemplatesWidget | None: + """Build the common config tree pages. + + Creates Analysis, Detectors (with Presentation parent), Processors, + and DAW Processors sections in *tree*, populating *widgets_dict*. + + Parameters + ---------- + tree: + QTreeWidget to add top-level items to. + preset: + Config preset dict with ``analysis``, ``detectors``, etc. sections. + widgets_dict: + Mutable dict to store ``(key, widget)`` lists per section key. + register_page: + Callback ``(tree_item, page_widget) -> None`` that adds the page + to the host's QStackedWidget (and optionally wraps in QScrollArea). + on_processor_enabled: + Optional slot connected to every processor's *enabled* checkbox. + + Returns + ------- + The :class:`DawProjectTemplatesWidget` instance if a DAWProject page + was created, otherwise ``None``. + """ + from sessionpreplib.config import ANALYSIS_PARAMS, PRESENTATION_PARAMS + from sessionpreplib.detectors import default_detectors + from sessionpreplib.processors import default_processors + from sessionpreplib.daw_processors import default_daw_processors + + dawproject_tpl_widget: DawProjectTemplatesWidget | None = None + + # ── Analysis ────────────────────────────────────────────────── + item = QTreeWidgetItem(tree, ["Analysis"]) + item.setFont(0, QFont("", -1, QFont.Bold)) + values = preset.get("analysis", {}) + pg, wdg = _build_param_page(ANALYSIS_PARAMS, values) + widgets_dict["analysis"] = wdg + register_page(item, pg) + + # ── Detectors (parent shows presentation params) ────────────── + det_parent = QTreeWidgetItem(tree, ["Detectors"]) + det_parent.setFont(0, QFont("", -1, QFont.Bold)) + pres_values = preset.get("presentation", {}) + pg, wdg = _build_param_page(PRESENTATION_PARAMS, pres_values) + widgets_dict["_presentation"] = wdg + register_page(det_parent, pg) + + det_sections = preset.get("detectors", {}) + for det in default_detectors(): + params = det.config_params() + if not params: + continue + child = QTreeWidgetItem(det_parent, [det.name]) + vals = det_sections.get(det.id, {}) + pg, wdg = _build_param_page(params, vals) + widgets_dict[f"detectors.{det.id}"] = wdg + register_page(child, pg) + + # ── Processors ──────────────────────────────────────────────── + proc_parent = QTreeWidgetItem(tree, ["Processors"]) + proc_parent.setFont(0, QFont("", -1, QFont.Bold)) + placeholder = QWidget() + pl = QVBoxLayout(placeholder) + pl.setContentsMargins(12, 12, 12, 12) + pl.addWidget(QLabel("Select a processor from the tree to configure.")) + pl.addStretch() + register_page(proc_parent, placeholder) + + proc_sections = preset.get("processors", {}) + for proc in default_processors(): + params = proc.config_params() + if not params: + continue + child = QTreeWidgetItem(proc_parent, [proc.name]) + vals = proc_sections.get(proc.id, {}) + pg, wdg = _build_param_page(params, vals) + widgets_dict[f"processors.{proc.id}"] = wdg + register_page(child, pg) + + if on_processor_enabled is not None: + enabled_key = f"{proc.id}_enabled" + for key, widget in wdg: + if key == enabled_key and isinstance(widget, QCheckBox): + widget.toggled.connect(on_processor_enabled) + break + + # ── DAW Processors ──────────────────────────────────────────── + daw_parent = QTreeWidgetItem(tree, ["DAW Processors"]) + daw_parent.setFont(0, QFont("", -1, QFont.Bold)) + placeholder2 = QWidget() + pl2 = QVBoxLayout(placeholder2) + pl2.setContentsMargins(12, 12, 12, 12) + pl2.addWidget(QLabel( + "Select a DAW processor from the tree to configure.")) + pl2.addStretch() + register_page(daw_parent, placeholder2) + + dp_sections = preset.get("daw_processors", {}) + for dp in default_daw_processors(): + params = dp.config_params() + if not params: + continue + child = QTreeWidgetItem(daw_parent, [dp.name]) + vals = dp_sections.get(dp.id, {}) + pg, wdg = _build_param_page(params, vals) + widgets_dict[f"daw_processors.{dp.id}"] = wdg + + if dp.id == "dawproject": + tpl_widget = DawProjectTemplatesWidget() + templates = vals.get("dawproject_templates", []) + tpl_widget.set_templates(templates) + dawproject_tpl_widget = tpl_widget + pg.layout().insertWidget( + pg.layout().count() - 1, tpl_widget) + + register_page(child, pg) + + return dawproject_tpl_widget + + +def load_config_widgets( + widgets_dict: dict, + preset: dict[str, Any], + dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, +) -> None: + """Load values from *preset* into widgets stored in *widgets_dict*. + + Shared between PreferencesDialog._load_cfg_preset_widgets and + SessionPrepWindow._load_session_widgets_inner. + """ + from sessionpreplib.detectors import default_detectors + from sessionpreplib.processors import default_processors + from sessionpreplib.daw_processors import default_daw_processors + + # Analysis + analysis = preset.get("analysis", {}) + for key, widget in widgets_dict.get("analysis", []): + if key in analysis: + _set_widget_value(widget, analysis[key]) + + # Presentation + pres = preset.get("presentation", {}) + for key, widget in widgets_dict.get("_presentation", []): + if key in pres: + _set_widget_value(widget, pres[key]) + + # Detectors + det_sections = preset.get("detectors", {}) + for det in default_detectors(): + wkey = f"detectors.{det.id}" + if wkey not in widgets_dict: + continue + vals = det_sections.get(det.id, {}) + for key, widget in widgets_dict[wkey]: + if key in vals: + _set_widget_value(widget, vals[key]) + + # Processors + proc_sections = preset.get("processors", {}) + for proc in default_processors(): + wkey = f"processors.{proc.id}" + if wkey not in widgets_dict: + continue + vals = proc_sections.get(proc.id, {}) + for key, widget in widgets_dict[wkey]: + if key in vals: + _set_widget_value(widget, vals[key]) + + # DAW Processors + dp_sections = preset.get("daw_processors", {}) + for dp in default_daw_processors(): + wkey = f"daw_processors.{dp.id}" + if wkey not in widgets_dict: + continue + vals = dp_sections.get(dp.id, {}) + for key, widget in widgets_dict[wkey]: + if key in vals: + _set_widget_value(widget, vals[key]) + if dp.id == "dawproject" and dawproject_tpl_widget is not None: + dawproject_tpl_widget.set_templates( + vals.get("dawproject_templates", [])) + + +def read_config_widgets( + widgets_dict: dict, + dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, + fallback_daw_sections: dict[str, dict] | None = None, +) -> dict[str, Any]: + """Read current widget values into a structured config dict. + + Shared between PreferencesDialog._save_cfg_preset_widgets and + SessionPrepWindow._read_session_config. + + Parameters + ---------- + fallback_daw_sections: + Optional dict of DAW-processor sections whose non-widget keys + are carried forward (used by session config to inherit global + preset keys that have no widget representation). + """ + from sessionpreplib.detectors import default_detectors + from sessionpreplib.processors import default_processors + from sessionpreplib.daw_processors import default_daw_processors + + cfg: dict[str, Any] = {} + + # Analysis + analysis: dict[str, Any] = {} + for key, widget in widgets_dict.get("analysis", []): + analysis[key] = _read_widget(widget) + cfg["analysis"] = analysis + + # Presentation + presentation: dict[str, Any] = {} + for key, widget in widgets_dict.get("_presentation", []): + presentation[key] = _read_widget(widget) + cfg["presentation"] = presentation + + # Detectors + detectors: dict[str, dict] = {} + for det in default_detectors(): + wkey = f"detectors.{det.id}" + if wkey not in widgets_dict: + continue + section: dict[str, Any] = {} + for key, widget in widgets_dict[wkey]: + section[key] = _read_widget(widget) + detectors[det.id] = section + cfg["detectors"] = detectors + + # Processors + processors: dict[str, dict] = {} + for proc in default_processors(): + wkey = f"processors.{proc.id}" + if wkey not in widgets_dict: + continue + section: dict[str, Any] = {} + for key, widget in widgets_dict[wkey]: + section[key] = _read_widget(widget) + processors[proc.id] = section + cfg["processors"] = processors + + # DAW Processors + daw_procs: dict[str, dict] = {} + for dp in default_daw_processors(): + wkey = f"daw_processors.{dp.id}" + if wkey not in widgets_dict: + continue + section: dict[str, Any] = {} + for key, widget in widgets_dict[wkey]: + section[key] = _read_widget(widget) + if dp.id == "dawproject" and dawproject_tpl_widget is not None: + section["dawproject_templates"] = ( + dawproject_tpl_widget.get_templates()) + if fallback_daw_sections: + for gk, gv in fallback_daw_sections.get(dp.id, {}).items(): + if gk not in section: + section[gk] = gv + daw_procs[dp.id] = section + cfg["daw_processors"] = daw_procs + + return cfg diff --git a/sessionprepgui/preferences.py b/sessionprepgui/preferences.py index 4ebfac9..768a62d 100644 --- a/sessionprepgui/preferences.py +++ b/sessionprepgui/preferences.py @@ -34,10 +34,7 @@ QWidget, ) -from sessionpreplib.config import ANALYSIS_PARAMS, PRESENTATION_PARAMS, ParamSpec -from sessionpreplib.detectors import default_detectors -from sessionpreplib.processors import default_processors -from sessionpreplib.daw_processors import default_daw_processors +from sessionpreplib.config import ParamSpec from .param_widgets import ( _argb_to_qcolor, _build_param_page, @@ -45,7 +42,9 @@ _build_tooltip, _read_widget, _set_widget_value, - DawProjectTemplatesWidget, + build_config_pages, + load_config_widgets, + read_config_widgets, GroupsTableWidget, sanitize_output_folder, ) @@ -204,10 +203,7 @@ def _init_ui(self): self._build_colors_page() self._build_groups_page() - self._build_analysis_page() - self._build_detector_pages() - self._build_processor_pages() - self._build_daw_processor_pages() + self._build_pipeline_pages() # Select first items self._global_tree.expandAll() @@ -372,101 +368,15 @@ def _build_general_page(self): self._general_widgets = widgets self._add_global_page(item, page) - # ── Analysis page ───────────────────────────────────────────────── + # ── Pipeline config pages (shared builder) ────────────────────── - def _build_analysis_page(self): - item = QTreeWidgetItem(self._preset_tree, ["Analysis"]) - item.setFont(0, QFont("", -1, QFont.Bold)) - - preset = self._active_preset() - values = preset.get("analysis", {}) - page, widgets = _build_param_page(ANALYSIS_PARAMS, values) - self._widgets["analysis"] = widgets - self._add_preset_page(item, page) - - # ── Detector pages ──────────────────────────────────────────────── - - def _build_detector_pages(self): - parent_item = QTreeWidgetItem(self._preset_tree, ["Detectors"]) - parent_item.setFont(0, QFont("", -1, QFont.Bold)) - - # Parent page: presentation params (config-preset-scoped) - preset = self._active_preset() - pres_values = preset.get("presentation", {}) - parent_page, pres_widgets = _build_param_page( - PRESENTATION_PARAMS, pres_values) - self._widgets["_presentation"] = pres_widgets - self._add_preset_page(parent_item, parent_page) - - det_sections = preset.get("detectors", {}) - for det in default_detectors(): - params = det.config_params() - if not params: - continue - child = QTreeWidgetItem(parent_item, [det.name]) - values = det_sections.get(det.id, {}) - page, widgets = _build_param_page(params, values) - self._widgets[f"detectors.{det.id}"] = widgets - self._add_preset_page(child, page) - - # ── Processor pages ─────────────────────────────────────────────── - - def _build_processor_pages(self): - parent_item = QTreeWidgetItem(self._preset_tree, ["Processors"]) - parent_item.setFont(0, QFont("", -1, QFont.Bold)) - - parent_page = QWidget() - pl = QVBoxLayout(parent_page) - pl.setContentsMargins(12, 12, 12, 12) - pl.addWidget(QLabel("Select a processor from the tree to configure it.")) - pl.addStretch() - self._add_preset_page(parent_item, parent_page) - - preset = self._active_preset() - proc_sections = preset.get("processors", {}) - for proc in default_processors(): - params = proc.config_params() - if not params: - continue - child = QTreeWidgetItem(parent_item, [proc.name]) - values = proc_sections.get(proc.id, {}) - page, widgets = _build_param_page(params, values) - self._widgets[f"processors.{proc.id}"] = widgets - self._add_preset_page(child, page) - - def _build_daw_processor_pages(self): - parent_item = QTreeWidgetItem(self._preset_tree, ["DAW Processors"]) - parent_item.setFont(0, QFont("", -1, QFont.Bold)) - - parent_page = QWidget() - pl = QVBoxLayout(parent_page) - pl.setContentsMargins(12, 12, 12, 12) - pl.addWidget(QLabel( - "Select a DAW processor from the tree to configure it.")) - pl.addStretch() - self._add_preset_page(parent_item, parent_page) - - preset = self._active_preset() - dp_sections = preset.get("daw_processors", {}) - for dp in default_daw_processors(): - params = dp.config_params() - if not params: - continue - child = QTreeWidgetItem(parent_item, [dp.name]) - values = dp_sections.get(dp.id, {}) - page, widgets = _build_param_page(params, values) - self._widgets[f"daw_processors.{dp.id}"] = widgets - - # DAWProject: append templates widget below the param widgets - if dp.id == "dawproject": - tpl_widget = DawProjectTemplatesWidget() - templates = values.get("dawproject_templates", []) - tpl_widget.set_templates(templates) - self._dawproject_templates_widget = tpl_widget - page.layout().insertWidget( - page.layout().count() - 1, tpl_widget) - - self._add_preset_page(child, page) + def _build_pipeline_pages(self): + self._dawproject_templates_widget = build_config_pages( + self._preset_tree, + self._active_preset(), + self._widgets, + self._add_preset_page, + ) # ── Colors page ──────────────────────────────────────────────────── @@ -839,103 +749,14 @@ def _save_cfg_preset_widgets(self): if not name: return preset = self._config_presets_data.setdefault(name, {}) - - # Analysis - analysis = preset.setdefault("analysis", {}) - for key, widget in self._widgets.get("analysis", []): - analysis[key] = _read_widget(widget) - - # Detectors - detectors = preset.setdefault("detectors", {}) - for det in default_detectors(): - wkey = f"detectors.{det.id}" - if wkey not in self._widgets: - continue - section = detectors.setdefault(det.id, {}) - for key, widget in self._widgets[wkey]: - section[key] = _read_widget(widget) - - # Processors - processors = preset.setdefault("processors", {}) - for proc in default_processors(): - wkey = f"processors.{proc.id}" - if wkey not in self._widgets: - continue - section = processors.setdefault(proc.id, {}) - for key, widget in self._widgets[wkey]: - section[key] = _read_widget(widget) - - # DAW Processors - daw_procs = preset.setdefault("daw_processors", {}) - for dp in default_daw_processors(): - wkey = f"daw_processors.{dp.id}" - if wkey not in self._widgets: - continue - section = daw_procs.setdefault(dp.id, {}) - for key, widget in self._widgets[wkey]: - section[key] = _read_widget(widget) - # DAWProject: persist templates list - if dp.id == "dawproject" and hasattr(self, "_dawproject_templates_widget"): - section["dawproject_templates"] = ( - self._dawproject_templates_widget.get_templates()) - - # Presentation - presentation = preset.setdefault("presentation", {}) - for key, widget in self._widgets.get("_presentation", []): - presentation[key] = _read_widget(widget) + preset.update(read_config_widgets( + self._widgets, self._dawproject_templates_widget)) def _load_cfg_preset_widgets(self, preset_name: str): """Load config preset values into pipeline widgets.""" preset = self._config_presets_data.get(preset_name, {}) - - # Analysis - analysis = preset.get("analysis", {}) - for key, widget in self._widgets.get("analysis", []): - if key in analysis: - _set_widget_value(widget, analysis[key]) - - # Detectors - det_sections = preset.get("detectors", {}) - for det in default_detectors(): - wkey = f"detectors.{det.id}" - if wkey not in self._widgets: - continue - values = det_sections.get(det.id, {}) - for key, widget in self._widgets[wkey]: - if key in values: - _set_widget_value(widget, values[key]) - - # Processors - proc_sections = preset.get("processors", {}) - for proc in default_processors(): - wkey = f"processors.{proc.id}" - if wkey not in self._widgets: - continue - values = proc_sections.get(proc.id, {}) - for key, widget in self._widgets[wkey]: - if key in values: - _set_widget_value(widget, values[key]) - - # DAW Processors - dp_sections = preset.get("daw_processors", {}) - for dp in default_daw_processors(): - wkey = f"daw_processors.{dp.id}" - if wkey not in self._widgets: - continue - values = dp_sections.get(dp.id, {}) - for key, widget in self._widgets[wkey]: - if key in values: - _set_widget_value(widget, values[key]) - # DAWProject: restore templates list - if dp.id == "dawproject" and hasattr(self, "_dawproject_templates_widget"): - self._dawproject_templates_widget.set_templates( - values.get("dawproject_templates", [])) - - # Presentation - pres = preset.get("presentation", {}) - for key, widget in self._widgets.get("_presentation", []): - if key in pres: - _set_widget_value(widget, pres[key]) + load_config_widgets( + self._widgets, preset, self._dawproject_templates_widget) def _update_cfg_preset_buttons(self): """Enable/disable Rename and Delete for config presets.""" diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index 06378c8..dc9679f 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -11,6 +11,12 @@ from ..daw_processor import DawProcessor from ..models import DawCommand, DawCommandResult, SessionContext +try: + from sessionprepgui.log import dbg +except ImportError: + def dbg(msg): # noqa: E302 + pass + log = logging.getLogger(__name__) @@ -42,7 +48,7 @@ class DawProjectDawProcessor(DawProcessor): id = "dawproject" name = "DAWproject" - fader_ceiling_db: float = 24.0 + fader_ceiling_db: float = 6.0 def __init__( self, @@ -50,7 +56,7 @@ def __init__( instance_index: int | None = None, template_name: str = "", template_path: str = "", - template_fader_ceiling_db: float = 24.0, + template_fader_ceiling_db: float = 6.0, ): self._instance_index = instance_index self._template_name = template_name @@ -83,7 +89,7 @@ def create_instances( continue name = tpl.get("name", "").strip() path = tpl.get("template_path", "").strip() - ceiling = float(tpl.get("fader_ceiling_db", 24.0)) + ceiling = float(tpl.get("fader_ceiling_db", 6.0)) if not name or not path: continue instances.append(cls( @@ -191,9 +197,10 @@ def transfer(self, session: SessionContext, progress_cb=None) -> list[DawCommandResult]: try: from dawproject import ( - Arrangement, Audio, Channel, Clips, ContentType, - DawProject, FileReference, Lanes, MetaData, - MixerRole, RealParameter, Referenceable, TimeUnit, + Arrangement, Audio, AutomationTarget, Channel, + Clips, ContentType, DawProject, ExpressionType, + FileReference, Lanes, MetaData, MixerRole, Points, + RealParameter, RealPoint, Referenceable, TimeUnit, Track, Unit, Utility, ) except ImportError: @@ -271,6 +278,12 @@ def transfer(self, session: SessionContext, use_processed = session.config.get("_use_processed", False) + proc_id = "bimodal_normalize" + bn_enabled = session.config.get(f"{proc_id}_enabled", True) + + dbg(f"transfer: use_processed={use_processed}, " + f"bn_enabled={bn_enabled}") + # ── Create tracks and clips ─────────────────────────────── for step, (fname, fid) in enumerate(work): folder_track = folder_track_map.get(fid) @@ -286,18 +299,35 @@ def transfer(self, session: SessionContext, continue # Resolve audio file path - if (use_processed and tc.processed_filepath - and os.path.isfile(tc.processed_filepath)): + actually_processed = ( + use_processed and tc.processed_filepath + and os.path.isfile(tc.processed_filepath)) + if actually_processed: audio_path = os.path.abspath(tc.processed_filepath) else: audio_path = os.path.abspath(tc.filepath) - # Compute fader volume (linear) + # Compute fader volume and clip gain fader_db = 0.0 - if tc.processor_results: - pr = next(iter(tc.processor_results.values()), None) - if pr and pr.data: + clip_gain_db = 0.0 + pr = tc.processor_results.get(proc_id) + skip = proc_id in getattr(tc, "processor_skip", set()) + dbg( + f" {fname}: bn_enabled={bn_enabled}, " + f"actually_processed={actually_processed}, " + f"has_pr={pr is not None}, " + f"classification={pr.classification if pr else None}, " + f"skip={skip}, " + f"gain_db={pr.gain_db if pr else None}, " + f"fader_offset={pr.data.get('fader_offset') if pr else None}" + ) + if bn_enabled and pr: + if pr.classification not in ("Silent", "Skip") and not skip: fader_db = pr.data.get("fader_offset", 0.0) + if not actually_processed: + clip_gain_db = pr.gain_db + dbg(f" → fader_db={fader_db:.2f}, " + f"clip_gain_db={clip_gain_db:.2f}") volume_linear = _db_to_linear(fader_db) # Resolve group color → #rrggbb @@ -341,18 +371,37 @@ def transfer(self, session: SessionContext, content=audio, time=0.0, duration=tc.duration_sec) clips = Utility.create_clips(clip) + # Build lane contents for this track + lane_contents = [clips] + + # TODO not working + # Add expression gain (clip gain) when processor is + # enabled but files are not baked into the audio. + if clip_gain_db != 0.0: + gain_linear = _db_to_linear(clip_gain_db) + gain_points = Points( + target=AutomationTarget( + expression=ExpressionType.GAIN), + points=[RealPoint(time=0.0, value=gain_linear)], + unit="linear", + time_unit=TimeUnit.SECONDS, + ) + lane_contents.append(gain_points) + # Create a Lanes entry for this track in the arrangement track_lane = Lanes( track=new_track, time_unit=TimeUnit.SECONDS, - lanes=[clips], + lanes=lane_contents, ) project.arrangement.lanes.lanes.append(track_lane) results.append(DawCommandResult( command=DawCommand("add_track", fname, {"folder": folder_dict.get("name", ""), - "fader_db": fader_db}), + "fader_db": fader_db, + "clip_gain_db": clip_gain_db}), + success=True)) if progress_cb: diff --git a/sessionpreplib/pipeline.py b/sessionpreplib/pipeline.py index 4790ce2..036f2d6 100644 --- a/sessionpreplib/pipeline.py +++ b/sessionpreplib/pipeline.py @@ -384,15 +384,15 @@ def prepare( ) -> SessionContext: """Apply enabled processors and write processed files. - Wipes *output_dir* before writing so stale files are never left - behind. Respects ``track.processor_skip`` for per-track - exclusions. + Removes stale *audio* files from *output_dir* before writing, + while preserving non-audio artefacts (e.g. ``.dawproject``). + Respects ``track.processor_skip`` for per-track exclusions. Parameters ---------- session : SessionContext output_dir : str - Target directory (wiped and recreated). + Target directory (stale audio files removed, then created). progress_cb : callable(current, total, message) or None Optional progress reporter. @@ -403,9 +403,13 @@ def prepare( ``applied_processors`` updated per track and ``prepare_state`` set to ``"ready"``. """ - # Clean output dir (best-effort: skip locked files on Windows) + # Clean stale *audio* files from output dir, preserving non-audio + # artefacts (e.g. .dawproject files written by DAW transfer). + from .audio import AUDIO_EXTENSIONS if os.path.isdir(output_dir): for entry in os.listdir(output_dir): + if not os.path.splitext(entry)[1].lower() in AUDIO_EXTENSIONS: + continue fp = os.path.join(output_dir, entry) try: if os.path.isfile(fp): From 6608e8d9f807f8d10a95c643c9f277deeb8b889c Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Tue, 17 Feb 2026 23:32:53 +0100 Subject: [PATCH 04/30] documentation updates. --- DEVELOPMENT.md | 117 ++++++++++++++++++++++++++++++++++++++++++++++--- TODO.md | 19 ++++++-- 2 files changed, 126 insertions(+), 10 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 4b5f8fb..7d7c92a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -289,6 +289,7 @@ sudo dnf install gcc patchelf ccache libatomic-static | `PySide6` | Optional (gui) | `sessionprepgui` (Qt widgets, main window, waveform) | | `sounddevice` | Optional (gui) | `sessionprepgui/playback.py` (audio playback via PortAudio) | | `py-ptsl` | Optional (gui) | `sessionpreplib/daw_processors/protools.py` (Pro Tools Scripting SDK gRPC client) | +| `dawproject` | Optional (gui) | `sessionpreplib/daw_processors/dawproject.py` (DAWproject file format library) | | `pytest` | Dev | Test runner | | `pytest-cov` | Dev | Coverage reporting | | `pyinstaller` | Dev | Standalone executable builds | @@ -339,9 +340,11 @@ sessionpreplib/ daw_processors/ __init__.py # Exports all DAW processors; provides default_daw_processors() protools.py # ProToolsDawProcessor — Pro Tools integration via py-ptsl gRPC SDK + dawproject.py # DawProjectDawProcessor — .dawproject file generation from templates sessionprepgui/ # GUI package (PySide6) __init__.py # Exports main() + log.py # Lightweight debug logging (SP_DEBUG env var gated) res/ # Application icons (SVG, PNG, ICO) settings.py # Persistent config (load/save/validate, OS paths) theme.py # Colors, FILE_COLOR_* constants, dark theme @@ -853,9 +856,17 @@ After all processors run, grouped tracks receive the minimum gain of the group. ### 7.6 Fader Offsets Implemented in `Pipeline._compute_fader_offsets()`. Calculates inverse of gain, -applies anchor adjustment (`--anchor` or `--normalize_faders`). Stored in +applies headroom rebalancing and optional anchor adjustment. Stored in `ProcessorResult.data["fader_offset"]`. +**Headroom rebalancing:** Ensures no fader exceeds `ceiling - headroom`. +The ceiling is set per-DAW processor via `_fader_ceiling_db` (e.g. +6 dB for +DAWproject, +12 dB for Pro Tools). The headroom margin is configured via +`fader_headroom_db` (default 8 dB). If the highest fader offset exceeds +`ceiling - headroom`, all fader offsets are shifted down uniformly by the +excess amount. The shift is stored in `ProcessorResult.data["fader_rebalance_shift"]` +and logged via `dbg()` when `SP_DEBUG` is active. + ### 7.7 Registration ```python @@ -984,6 +995,8 @@ processors: ```python def default_daw_processors() -> list[DawProcessor]: return [ProToolsDawProcessor()] + # DawProjectDawProcessor instances are created dynamically + # via create_instances() based on configured templates. ``` ### 8.9 ProToolsDawProcessor (`protools.py`) @@ -1001,11 +1014,19 @@ through the `py-ptsl` Python client. | Method | Behaviour | |--------|-----------| -| `check_connectivity()` | Opens a `ptsl.Engine`, calls `ptsl.open()`, returns success/failure + Pro Tools session name | +| `check_connectivity()` | Opens a `ptsl.Engine`, calls `ptsl.open()`, returns success/failure + PTSL protocol version. On failure, the GUI shows a `QMessageBox.warning` dialog with the error and keeps the toolbar functional. | | `fetch(session)` | Retrieves the folder track hierarchy and stores it in `session.daw_state["protools"]["folders"]`. Populates the GUI folder tree for drag-and-drop track assignment. | -| `transfer(session)` | Imports audio files into their assigned Pro Tools folders, sets track colors based on group → CIE L\*a\*b\* perceptual matching against the Pro Tools color palette. Accepts a `progress_callback(step, total, message)` for GUI progress reporting. Results are appended to `session.daw_command_log`. | +| `transfer(session)` | Wrapped in a PTSL batch job for modal progress + user-lock. Phases: (1) batch import all audio files in one call, (2) per-track create + spot clip (parallel, 6 workers), (3) batch colorize by group, (4) set fader offsets (when bimodal normalization is enabled and processed files are used). Accepts a `progress_callback(step, total, message)` for GUI progress. Results appended to `session.daw_command_log`. | | `sync(session)` | Not yet implemented (raises `NotImplementedError`). | +**Batch import optimisation:** All audio files are imported to the Pro Tools +clip list in a single `CId_ImportData` call (instead of one per track), +significantly reducing round-trip overhead on large sessions. + +**Automatic fader adjustment:** When bimodal normalization is enabled and +processed files are used (`_use_processed`), fader offsets from the processor +results are applied to newly created tracks via `CId_SetTrackVolume`. + **Color matching:** The `transfer()` method assigns colors to newly imported tracks based on their @@ -1022,6 +1043,44 @@ group. The matching pipeline is: Tracks with no group assignment skip colorization. +### 8.10 DawProjectDawProcessor (`dawproject.py`) + +File-based `DawProcessor` that generates `.dawproject` interchange files from +a template. Unlike Pro Tools, this processor does not communicate with a +running DAW — it reads/writes `.dawproject` ZIP archives directly. + +- **ID:** `dawproject` (base); `dawproject_0`, `dawproject_1`, … (per-template instances) +- **Config:** `dawproject_templates` — list of template dicts, each with + `name`, `template_path`, and optional `fader_ceiling_db` (default 6.0 dB) + +**Template-based instantiation:** The session Config tab includes a Mix +Templates widget where users configure one or more `.dawproject` template +files. Each template produces a separate `DawProjectDawProcessor` instance +(via `create_instances()`), which appears as its own entry in the DAW +processor dropdown (e.g. "DAWproject – MyTemplate"). + +**Lifecycle implementation:** + +| Method | Behaviour | +|--------|-----------| +| `check_connectivity()` | Validates template file exists and is a valid ZIP; returns `(True, "Template OK")` or failure message. | +| `fetch(session)` | Loads the template's folder track hierarchy into `session.daw_state`. Populates the GUI folder tree for drag-and-drop assignment. | +| `transfer(session)` | Loads template project, creates new audio tracks with clips in the arrangement, sets fader volumes and group colors, writes the result to `/.dawproject`. When bimodal normalization is enabled and files are not processed, expression gain (clip gain) is written as automation points (TODO: not yet working — see XML structure issue with dawproject-py). | + +**Gain application logic:** + +- `fader_db` → mapped to track channel volume (linear) +- `clip_gain_db` → expression gain automation (`Points` with `ExpressionType.GAIN`) +- Both are only set when bimodal normalization is enabled, the track is not + silent/skipped, and the processor is not in `track.processor_skip` +- `clip_gain_db` is set only when the track's actual audio file is the + original (not a processed/baked file) + +**Config refresh:** Both `_do_daw_transfer` and `_on_prepare` in the GUI +refresh `session.config` from the live session config widgets before starting +the worker, ensuring that processor enabled/disabled changes made after +analysis are reflected. + --- ## 9. Pipeline @@ -1065,7 +1124,8 @@ originals. **Behaviour:** -1. Wipe `output_dir` (clean slate on every run). +1. Remove stale *audio* files from `output_dir`, preserving non-audio + artefacts (e.g. `.dawproject` files written by DAW transfer). 2. For each OK track, determine applicable processors: all enabled processors whose ID is **not** in `track.processor_skip`. 3. Filter to processors with a valid (non-error) `ProcessorResult`. @@ -1083,7 +1143,31 @@ whenever gain, classification, RMS anchor, per-track processor selection, or re-analysis changes occur. The Prepare button and Use Processed toggle reflect the current state. -### 9.5 Parallel Execution +### 9.5 Profiling & Debugging + +When the `SP_DEBUG` environment variable is set to `1` or `true`, the pipeline +emits per-component timing via `dbg()` (from `sessionprepgui/log.py`). +Output goes to stderr with timestamps and caller class names. + +**Instrumented phases:** + +- **analyze:** per-detector per-track timing, per-track total, phase total + with per-track average +- **plan:** per-processor per-track timing, per-track total, phase total + with per-track average, group levelling time, fader offset time (including + rebalance shift if applied) +- **transfer (DAWproject):** per-track gain decision trace (`bn_enabled`, + `actually_processed`, `has_pr`, `classification`, `skip`, `gain_db`, + `fader_offset` → final `fader_db`, `clip_gain_db`) + +Example output: +``` +[22:29:05.304 Pipeline] detector audio_classifier on 01_KickTrigger.wav: 577.3 ms +[22:29:05.304 Pipeline] all detectors on 01_KickTrigger.wav: 1203.4 ms +[22:29:05.304 Pipeline] analyze phase (track detectors): 27 tracks in 8170.2 ms (302.6 ms/track avg) +``` + +### 9.6 Parallel Execution All three parallelizable stages use `concurrent.futures.ThreadPoolExecutor`: @@ -1116,7 +1200,7 @@ at the number of tracks. - `_apply_group_levels()` — grouped tracks get minimum gain - `_compute_fader_offsets()` — inverse gain + anchor adjustment -### 9.6 `load_session()` Helper +### 9.7 `load_session()` Helper ```python def load_session(source_dir, config, event_bus=None) -> SessionContext @@ -1125,7 +1209,7 @@ def load_session(source_dir, config, event_bus=None) -> SessionContext Loads all WAVs from `source_dir` in parallel, assigns groups (named, first-match-wins), appends overlap warnings to `session.warnings`. -### 9.7 Topological Sort +### 9.8 Topological Sort `_topo_sort_detectors()` uses Kahn's algorithm. Raises `ConfigError` on cycles or missing dependencies. @@ -1415,6 +1499,7 @@ group). | `theme.py` | `COLORS` dict, `FILE_COLOR_*` constants, dark palette + stylesheet | | `helpers.py` | `esc()`, `track_analysis_label(track, detectors=None)` (filters via `is_relevant()`), `fmt_time()`, severity maps | | `widgets.py` | `BatchEditTableWidget`, `BatchComboBox` — reusable batch-edit base classes preserving multi-row selection across cell-widget clicks (zero app imports) | +| `log.py` | `dbg(msg)` — lightweight debug logging to stderr, gated by `SP_DEBUG` env var. Timestamped output with caller class name. Used by `pipeline.py`, `dawproject.py`, and other modules via conditional import. | | `worker.py` | QThread workers: `AnalyzeWorker` (pipeline in background, thread-safe progress, per-track signals), `BatchReanalyzeWorker` (subset re-analysis after batch overrides), `PrepareWorker` (runs `Pipeline.prepare()` in background with progress), `DawCheckWorker` (connectivity check), `DawFetchWorker` (folder fetch), `DawTransferWorker` (transfer with progress + progress_value signals) | | `report.py` | HTML rendering: `render_summary_html()`, `render_fader_table_html()`, `render_track_detail_html()` | | `waveform.py` | `WaveformWidget` — two display modes (waveform + spectrogram), vectorised NumPy peak/RMS downsampling, mel spectrogram (256 mel bins via `scipy.signal.stft`, configurable FFT/window/dB range/colormap), dB and frequency scales, peak/RMS markers, crosshair mouse guide (dBFS in waveform, Hz in spectrogram), mouse-wheel zoom/pan (Ctrl+wheel h-zoom, Ctrl+Shift+wheel v-zoom, Shift+Alt+wheel freq pan, Shift+wheel scroll), keyboard shortcuts (R/T zoom), detector issue overlays with optional frequency bounds, RMS L/R and RMS AVG envelopes, playback cursor, tooltips | @@ -1591,6 +1676,24 @@ leaves. `preferences` reads `ParamSpec` metadata from detectors and processors. expensive phase (channel split, peak finding, per-channel RMS cumsum, spectrogram) and exits early if set, preventing CPU pileup from stacked background threads. +- **Auto-Group** — the analysis toolbar includes an "Auto-Group" button + (right-aligned, next to Prepare). When clicked, it reassigns all OK tracks + to groups based on the active group preset's keyword matching rules (using + `matches_keywords()` from `utils.py`). Tracks are matched against each + group's patterns in order; unmatched tracks are unassigned. A confirmation + dialog shows the track count before proceeding. After assignment, the + analysis table, setup table, and group combos are refreshed. The fader + table in the report is also rebuilt to reflect the new groupings. +- **DAW connection error handling** — when `check_connectivity()` fails + (e.g. Pro Tools not running), the GUI shows a `QMessageBox.warning` dialog + with the error message, constrains the status label width to prevent + toolbar overflow, and keeps all toolbar buttons functional for retry. +- **Config refresh before transfer/prepare** — both `_do_daw_transfer` and + `_on_prepare` call `self._session.config.update(self._flat_config())` + before starting the worker, ensuring that processor enabled/disabled + changes made in the session Config tab after analysis take effect in the + Pipeline (which re-configures and re-filters processors from `session.config` + in its constructor). - The GUI contains **zero** analysis, detection, processing, or DSP logic — all analysis runs through `sessionpreplib` via `AnalyzeWorker`. diff --git a/TODO.md b/TODO.md index 5233cb8..7be550f 100644 --- a/TODO.md +++ b/TODO.md @@ -34,7 +34,10 @@ - [x] **Concrete: ProToolsProcessor** (PTSL) — `ProToolsDawProcessor` in `daw_processors/protools.py`. `check_connectivity()`, `fetch()` (folder hierarchy), `transfer()` (audio import + CIE L*a*b* perceptual color matching). `sync()` not yet implemented. Configurable command delay. -- [ ] **Concrete: DAWProjectProcessor** (.dawproject files) +- [x] **Concrete: DAWProjectProcessor** (.dawproject files) — `DawProjectDawProcessor` in + `daw_processors/dawproject.py`. Template-based `.dawproject` file generation with track/clip + creation, fader volumes, group colors. Expression gain (clip gain) automation partially + implemented (TODO: XML structure issue with dawproject-py library). - [x] **GUI toolbar dropdown** for active DAW processor selection — combo box + Check/Fetch/Transfer/Sync actions in Session Setup toolbar - [ ] **GUI DAW Tools panel** (color picker, etc. → execute_commands) - [ ] **Undo execution** (rollback last transfer/sync batch) @@ -497,7 +500,7 @@ | **6** | Auto-fix capabilities | DC removal, SRC | | ~~**7**~~ | ~~Classification v2~~ | ~~Crest improvements~~ → ✅ Done (audio classifier with decay metric) | | ~~**7b**~~ | ~~Simplify CLI grouping~~ | ~~Overlap policies, anonymous IDs~~ → ✅ Done (named groups, first-match-wins, no overlap policy) | -| **8** | DAW scripting | ~~DawProcessor ABC~~, ~~PTSL integration (check/fetch/transfer)~~, sync, DAWProject backend | +| **8** | DAW scripting | ~~DawProcessor ABC~~, ~~PTSL integration (check/fetch/transfer)~~, ~~PT batch import~~, ~~PT fader levels~~, ~~DAWProject backend~~, sync, DAWProject expression gain fix | | ~~**9**~~ | ~~File-based processing~~ | ~~AudioProcessor enabled toggle, Pipeline.prepare(), Prepare button, Processing column, Use Processed toggle, staleness, MonoDownmix stub~~ → ✅ Done | | ~~**10**~~ | ~~Stereo compatibility~~ | ~~Merge StereoCorrelation + MonoFolddown, windowed analysis, waveform overlays~~ → ✅ Done | | **Ongoing** | Low-hanging fruit | Stereo narrowness, Start offset, Name mismatch, `rich` optional | @@ -567,4 +570,14 @@ | ~~Prepare error reporting~~ | — | ✅ Resolved (per-track write failures collected in `_prepare_errors`, displayed via `QMessageBox.warning` with file-locking guidance) | | ~~Mono playback button~~ | — | ✅ Resolved (checkable **M** button in playback controls; `PlaybackController.play(mono=True)` folds stereo to mono via (L+R)/2; orange when active) | | ~~Analysis column severity counts~~ | — | ✅ Resolved (replaced single worst-severity label with colored per-severity counts: `2P 1A 5I` format; QLabel cell widget with HTML rich text + hidden `_SortableItem` for sorting) | -| ~~Peak/RMS Max markers default off~~ | — | ✅ Resolved (`_show_markers` and toggle default changed to `False`) | \ No newline at end of file +| ~~Peak/RMS Max markers default off~~ | — | ✅ Resolved (`_show_markers` and toggle default changed to `False`) | +| ~~Auto-Group files by keywords~~ | — | ✅ Resolved (Auto-Group button in analysis toolbar; assigns tracks to groups via `matches_keywords()` pattern matching; confirmation dialog, refreshes tables + report) | +| ~~Pro Tools quicker imports~~ | — | ✅ Resolved (batch import: single `CId_ImportData` call for all files instead of one per track; PTSL batch job wraps entire transfer for modal progress) | +| ~~Pro Tools automatic fader levels~~ | — | ✅ Resolved (`CId_SetTrackVolume` applies fader offsets from bimodal normalization when processed files are used) | +| ~~Fader headroom rebalancing~~ | — | ✅ Resolved (`_compute_fader_offsets` ensures max fader ≤ ceiling − headroom; uniform downshift stored in `fader_rebalance_shift`; per-DAW ceiling via `_fader_ceiling_db`) | +| ~~Detector/processor profiling~~ | — | ✅ Resolved (per-component `time.perf_counter` timing via `dbg()` in pipeline.py; per-detector, per-processor, per-phase totals with averages; gated by `SP_DEBUG` env var) | +| ~~DAWProject processor~~ | — | ✅ Resolved (template-based `.dawproject` generation with tracks, clips, fader volumes, group colors; expression gain TODO) | +| ~~Mix Templates widget~~ | — | ✅ Resolved (session Config tab widget for configuring `.dawproject` template files with name, path, fader ceiling) | +| ~~Fetch error dialog~~ | — | ✅ Resolved (QMessageBox.warning on connectivity failure; status label width constrained; toolbar stays functional) | +| ~~Prepare preserves .dawproject~~ | — | ✅ Resolved (prepare phase removes only audio files from output dir, preserving `.dawproject` and other non-audio artefacts) | +| ~~Config refresh before transfer/prepare~~ | — | ✅ Resolved (`session.config.update(_flat_config())` in both `_do_daw_transfer` and `_on_prepare` ensures widget changes take effect) | \ No newline at end of file From 61d12a2ad49a0b15fddf86cb534d4a5b7aa8e400 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 17:40:37 +0100 Subject: [PATCH 05/30] DAWProject clipgain working correctly now. --- .gitignore | 1 + DEVELOPMENT.md | 5 +++- sessionpreplib/daw_processors/dawproject.py | 29 +++++++++++++-------- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 5ac50ea..1b73a0e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,4 @@ sessionpreplib/daw_processors/ptsl/DAWProject-Reference.html # Temporary build directories _dawproject_build/ +_dawproject_build/* diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7d7c92a..9260292 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1065,7 +1065,7 @@ processor dropdown (e.g. "DAWproject – MyTemplate"). |--------|-----------| | `check_connectivity()` | Validates template file exists and is a valid ZIP; returns `(True, "Template OK")` or failure message. | | `fetch(session)` | Loads the template's folder track hierarchy into `session.daw_state`. Populates the GUI folder tree for drag-and-drop assignment. | -| `transfer(session)` | Loads template project, creates new audio tracks with clips in the arrangement, sets fader volumes and group colors, writes the result to `/.dawproject`. When bimodal normalization is enabled and files are not processed, expression gain (clip gain) is written as automation points (TODO: not yet working — see XML structure issue with dawproject-py). | +| `transfer(session)` | Loads template project, creates new audio tracks with clips in the arrangement, sets fader volumes and group colors, writes the result to `/.dawproject`. When bimodal normalization is enabled and files are not processed, expression gain (clip gain) is written as automation `Points` nested inside a `Lanes` within the `Clip` (sibling of the `Audio` element). | **Gain application logic:** @@ -1075,6 +1075,9 @@ processor dropdown (e.g. "DAWproject – MyTemplate"). silent/skipped, and the processor is not in `track.processor_skip` - `clip_gain_db` is set only when the track's actual audio file is the original (not a processed/baked file) +- When `clip_gain_db != 0`, `Clip.content` is set to a `Lanes([Audio, Points])` + instead of `Audio` directly, matching the structure Bitwig writes for + per-clip gain automation **Config refresh:** Both `_do_daw_transfer` and `_on_prepare` in the GUI refresh `session.config` from the live session config widgets before starting diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index dc9679f..fc07132 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -367,16 +367,14 @@ def transfer(self, session: SessionContext, channels=tc.channels, duration=tc.duration_sec, ) - clip = Utility.create_clip( - content=audio, time=0.0, duration=tc.duration_sec) - clips = Utility.create_clips(clip) - - # Build lane contents for this track - lane_contents = [clips] - - # TODO not working - # Add expression gain (clip gain) when processor is - # enabled but files are not baked into the audio. + # When clip gain is needed, Clip.content must be a Lanes + # containing both the Audio and the gain Points as siblings: + # + # + # + # if clip_gain_db != 0.0: gain_linear = _db_to_linear(clip_gain_db) gain_points = Points( @@ -386,7 +384,16 @@ def transfer(self, session: SessionContext, unit="linear", time_unit=TimeUnit.SECONDS, ) - lane_contents.append(gain_points) + clip_content = Lanes(lanes=[audio, gain_points]) + else: + clip_content = audio + + clip = Utility.create_clip( + content=clip_content, time=0.0, duration=tc.duration_sec) + clips = Utility.create_clips(clip) + + # Build lane contents for this track + lane_contents = [clips] # Create a Lanes entry for this track in the arrangement track_lane = Lanes( From 39c32e2d1a0643853b06a946b275e92204509fe6 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 18:55:11 +0100 Subject: [PATCH 06/30] Added "Selected by default" config preference for audio processors. Fixed a bug how the processor dropdown selection was displayed. Introduced a detector and processor shorthand for multi-select dropdowns. --- DEVELOPMENT.md | 44 +++++++++++++------ sessionprepgui/mainwindow.py | 28 +++++++++--- sessionpreplib/detector.py | 2 + sessionpreplib/detectors/audio_classifier.py | 1 + sessionpreplib/detectors/clipping.py | 1 + sessionpreplib/detectors/dc_offset.py | 1 + sessionpreplib/detectors/dual_mono.py | 1 + .../detectors/format_consistency.py | 1 + .../detectors/length_consistency.py | 1 + sessionpreplib/detectors/one_sided_silence.py | 1 + sessionpreplib/detectors/silence.py | 1 + sessionpreplib/detectors/stereo_compat.py | 1 + sessionpreplib/detectors/subsonic.py | 1 + sessionpreplib/detectors/tail_exceedance.py | 1 + sessionpreplib/pipeline.py | 9 ++++ sessionpreplib/processor.py | 20 +++++++++ .../processors/bimodal_normalize.py | 1 + sessionpreplib/processors/mono_downmix.py | 1 + 18 files changed, 96 insertions(+), 20 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9260292..26d9a45 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -540,8 +540,11 @@ class TrackContext: `Pipeline.prepare()`), or `None` if not yet prepared. - `applied_processors` — list of processor IDs that were applied during the last `prepare()` run. -- `processor_skip` — set of processor IDs to skip for this track (per-track - override; empty = use all enabled processors, i.e. "Default"). +- `processor_skip` — set of processor IDs to skip for this track. Populated + in two ways: (1) automatically at the end of `plan()` for any processor + where `default=False`; (2) manually when the user toggles a processor in the + Processing column. "Default" in the UI means the current selection matches + each processor's configured `default` value (not necessarily "all active"). ### 4.8 SessionContext @@ -803,21 +806,31 @@ PRIORITY_FINALIZE = 900 class AudioProcessor(ABC): id: str name: str + shorthand: str # compact abbreviation for UI labels, e.g. "BN" priority: int - def config_params(cls) -> list[ParamSpec]: ... # base: {id}_enabled toggle + def config_params(cls) -> list[ParamSpec]: ... # base: {id}_enabled + {id}_default toggles def configure(self, config: dict[str, Any]) -> None: ... @property def enabled(self) -> bool: ... + @property + def default(self) -> bool: ... # whether pre-selected when a folder opens @abstractmethod def process(self, track: TrackContext) -> ProcessorResult: ... @abstractmethod def apply(self, track: TrackContext, result: ProcessorResult) -> np.ndarray: ... ``` -The base `config_params()` returns a single `{id}_enabled` `ParamSpec` (bool, -default `True`). Subclasses extend via `super().config_params() + [...]`. -The base `configure()` reads `self._enabled` from the config. The Pipeline +The base `config_params()` returns two `ParamSpec` entries: +- `{id}_enabled` (bool, default `True`) — whether the processor is available at + all. Disabled processors are excluded from the pipeline entirely. +- `{id}_default` (bool, default `True`, `presentation_only=True`) — whether the + processor is pre-selected for each track when a folder is opened. When `False`, + the processor's ID is added to `track.processor_skip` automatically at the end + of `plan()`, but users can still enable it per-track in the Processing column. + +Subclasses extend via `super().config_params() + [...]`. The base `configure()` +reads `self._enabled` and `self._default` from the config. The Pipeline configures all processors first, then filters to only enabled ones before sorting by priority. @@ -830,7 +843,7 @@ sorting by priority. ### 7.3 BimodalNormalizeProcessor (`bimodal_normalize.py`) -- **ID:** `bimodal_normalize` | **Priority:** `PRIORITY_NORMALIZE` (100) +- **ID:** `bimodal_normalize` | **Shorthand:** `BN` | **Priority:** `PRIORITY_NORMALIZE` (100) - **Reads:** `silence.data["is_silent"]`, `audio_classifier.data["peak_db", "rms_anchor_db", "classification", "is_transient"]` - **Config:** `target_rms`, `target_peak` - **Logic:** @@ -840,7 +853,7 @@ sorting by priority. ### 7.4 MonoDownmixProcessor (`mono_downmix.py`) -- **ID:** `mono_downmix` | **Priority:** `PRIORITY_POST` (200) +- **ID:** `mono_downmix` | **Shorthand:** `MD` | **Priority:** `PRIORITY_POST` (200) - **Status:** Stub — `apply()` returns audio unchanged. A real implementation would sum/average channels and return a mono array. - **Logic:** @@ -1095,6 +1108,7 @@ Defined in `sessionpreplib/pipeline.py`. Four implemented phases: ``` analyze() -> Run all detectors (track-level + session-level) plan() -> Run audio processors + group equalization + fader offsets + + populate processor_skip for default=False processors prepare() -> Apply processors per track, write processed files to output dir execute() -> Apply gains, backup originals, write processed files (CLI legacy) ``` @@ -1648,12 +1662,14 @@ leaves. `preferences` reads `ParamSpec` metadata from detectors and processors. Enabled after analysis completes. - **Processing column** (analysis table, column 7) — per-track multiselect `QToolButton` with a checkable `QMenu` listing all enabled - `AudioProcessor` instances. Label shows "Default" (all processors - active, i.e. `processor_skip` is empty), "None" (all skipped), or - comma-separated names (partial selection). When no processors are - enabled globally, the button is disabled and shows "None". Toggling - a processor adds/removes its ID from `track.processor_skip` and marks - the Prepare state as stale. Editable only in the analysis phase. + `AudioProcessor` instances. Label shows "Default" (current selection + matches each processor's configured `default` value), "None" (all + skipped), or comma-separated shorthands (e.g. `BN, MD`) for a + non-default partial selection. Tooltip always uses full names. When no + processors are enabled globally, the button is disabled and shows + "None". Toggling a processor adds/removes its ID from + `track.processor_skip` and marks the Prepare state as stale. Editable + only in the analysis phase. - **Use Processed toggle** (setup toolbar) — checkable `QAction` that sets `session.config["_use_processed"]`. Label shows "Use Processed: On/Off" with "(!) " appended when `prepare_state == "stale"`. Enabled diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 753923a..dd4c6b3 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -3023,16 +3023,32 @@ def _update_processing_button_label(self, btn, track, processors): btn.setText("None") btn.setToolTip("No audio processors enabled") return - active = [p.name for p in processors if p.id not in track.processor_skip] - if len(active) == len(processors): - btn.setText("Default") - btn.setToolTip("Using all enabled processors: " + ", ".join(p.name for p in processors)) + def _label(p): + return p.shorthand if p.shorthand else p.name + + active = [p for p in processors if p.id not in track.processor_skip] + active_labels = [_label(p) for p in active] + active_names = [p.name for p in active] + # "Default" means the current selection matches each processor's + # configured default (default=True → active, default=False → skipped). + is_default = all( + (p.id not in track.processor_skip) == p.default + for p in processors + ) + if is_default: + default_active_names = [p.name for p in processors if p.default] + if default_active_names: + btn.setText("Default") + btn.setToolTip("Default selection: " + ", ".join(default_active_names)) + else: + btn.setText("Default") + btn.setToolTip("Default: all processors deselected") elif not active: btn.setText("None") btn.setToolTip("All processors skipped for this track") else: - btn.setText(", ".join(active)) - btn.setToolTip("Active processors: " + ", ".join(active)) + btn.setText(", ".join(active_labels)) + btn.setToolTip("Active processors: " + ", ".join(active_names)) @Slot(bool) def _on_processing_toggled(self, checked: bool): diff --git a/sessionpreplib/detector.py b/sessionpreplib/detector.py index 03efc1e..8439544 100644 --- a/sessionpreplib/detector.py +++ b/sessionpreplib/detector.py @@ -31,6 +31,7 @@ class TrackDetector(ABC): """Operates on a single track.""" id: str = "" name: str = "" + shorthand: str = "" # short abbreviation for compact UI labels depends_on: list[str] = [] @classmethod @@ -150,6 +151,7 @@ class SessionDetector(ABC): """ id: str = "" name: str = "" + shorthand: str = "" # short abbreviation for compact UI labels @classmethod def config_params(cls) -> list[ParamSpec]: diff --git a/sessionpreplib/detectors/audio_classifier.py b/sessionpreplib/detectors/audio_classifier.py index d1591fa..aebd9ac 100644 --- a/sessionpreplib/detectors/audio_classifier.py +++ b/sessionpreplib/detectors/audio_classifier.py @@ -17,6 +17,7 @@ class AudioClassifierDetector(TrackDetector): id = "audio_classifier" name = "Audio Classifier" + shorthand = "AC" depends_on = ["silence"] @classmethod diff --git a/sessionpreplib/detectors/clipping.py b/sessionpreplib/detectors/clipping.py index ba2414b..a60f22d 100644 --- a/sessionpreplib/detectors/clipping.py +++ b/sessionpreplib/detectors/clipping.py @@ -9,6 +9,7 @@ class ClippingDetector(TrackDetector): id = "clipping" name = "Digital Clipping" + shorthand = "CL" depends_on = ["silence"] @classmethod diff --git a/sessionpreplib/detectors/dc_offset.py b/sessionpreplib/detectors/dc_offset.py index 28e75a1..46a17dd 100644 --- a/sessionpreplib/detectors/dc_offset.py +++ b/sessionpreplib/detectors/dc_offset.py @@ -11,6 +11,7 @@ class DCOffsetDetector(TrackDetector): id = "dc_offset" name = "DC Offset" + shorthand = "DC" depends_on = ["silence"] @classmethod diff --git a/sessionpreplib/detectors/dual_mono.py b/sessionpreplib/detectors/dual_mono.py index dde30b9..51e0ef4 100644 --- a/sessionpreplib/detectors/dual_mono.py +++ b/sessionpreplib/detectors/dual_mono.py @@ -11,6 +11,7 @@ class DualMonoDetector(TrackDetector): id = "dual_mono" name = "Dual-Mono (Identical L/R)" + shorthand = "DM" depends_on = ["silence"] @classmethod diff --git a/sessionpreplib/detectors/format_consistency.py b/sessionpreplib/detectors/format_consistency.py index 6e3617f..88af7b9 100644 --- a/sessionpreplib/detectors/format_consistency.py +++ b/sessionpreplib/detectors/format_consistency.py @@ -9,6 +9,7 @@ class FormatConsistencyDetector(SessionDetector): id = "format_consistency" name = "Session Format Consistency" + shorthand = "FC" @classmethod def html_help(cls) -> str: diff --git a/sessionpreplib/detectors/length_consistency.py b/sessionpreplib/detectors/length_consistency.py index 494b8b0..ab3f2d7 100644 --- a/sessionpreplib/detectors/length_consistency.py +++ b/sessionpreplib/detectors/length_consistency.py @@ -10,6 +10,7 @@ class LengthConsistencyDetector(SessionDetector): id = "length_consistency" name = "File Length Consistency" + shorthand = "LC" @classmethod def html_help(cls) -> str: diff --git a/sessionpreplib/detectors/one_sided_silence.py b/sessionpreplib/detectors/one_sided_silence.py index 435ad28..80bb5c1 100644 --- a/sessionpreplib/detectors/one_sided_silence.py +++ b/sessionpreplib/detectors/one_sided_silence.py @@ -11,6 +11,7 @@ class OneSidedSilenceDetector(TrackDetector): id = "one_sided_silence" name = "One-Sided Silence" + shorthand = "OS" depends_on = ["silence"] @classmethod diff --git a/sessionpreplib/detectors/silence.py b/sessionpreplib/detectors/silence.py index 9edf5c4..44af173 100644 --- a/sessionpreplib/detectors/silence.py +++ b/sessionpreplib/detectors/silence.py @@ -8,6 +8,7 @@ class SilenceDetector(TrackDetector): id = "silence" name = "Silent Files" + shorthand = "SI" depends_on = [] @classmethod diff --git a/sessionpreplib/detectors/stereo_compat.py b/sessionpreplib/detectors/stereo_compat.py index 6451b5c..9b47559 100644 --- a/sessionpreplib/detectors/stereo_compat.py +++ b/sessionpreplib/detectors/stereo_compat.py @@ -13,6 +13,7 @@ class StereoCompatDetector(TrackDetector): id = "stereo_compat" name = "Stereo Compatibility" + shorthand = "SC" depends_on = ["silence"] @classmethod diff --git a/sessionpreplib/detectors/subsonic.py b/sessionpreplib/detectors/subsonic.py index 515bcbb..10fcf33 100644 --- a/sessionpreplib/detectors/subsonic.py +++ b/sessionpreplib/detectors/subsonic.py @@ -11,6 +11,7 @@ class SubsonicDetector(TrackDetector): id = "subsonic" name = "Subsonic Content" + shorthand = "SB" depends_on = ["silence"] @classmethod diff --git a/sessionpreplib/detectors/tail_exceedance.py b/sessionpreplib/detectors/tail_exceedance.py index ab13586..04c674a 100644 --- a/sessionpreplib/detectors/tail_exceedance.py +++ b/sessionpreplib/detectors/tail_exceedance.py @@ -18,6 +18,7 @@ class TailExceedanceDetector(TrackDetector): id = "tail_exceedance" name = "Tail Regions Exceeded Anchor" + shorthand = "TE" depends_on = ["silence", "audio_classifier"] @classmethod diff --git a/sessionpreplib/pipeline.py b/sessionpreplib/pipeline.py index 036f2d6..f9271b4 100644 --- a/sessionpreplib/pipeline.py +++ b/sessionpreplib/pipeline.py @@ -276,6 +276,15 @@ def plan(self, session: SessionContext) -> SessionContext: # Store configured processor instances on the session for render-time access session.processors = list(self.audio_processors) + # Apply default-off: processors with default=False are added to + # processor_skip for tracks that don't already have an explicit + # override for that processor ID. + for proc in self.audio_processors: + if not proc.default: + for track in session.tracks: + if track.status == "OK" and proc.id not in track.processor_skip: + track.processor_skip.add(proc.id) + return session def _apply_group_levels(self, session: SessionContext): diff --git a/sessionpreplib/processor.py b/sessionpreplib/processor.py index 40b5757..75f50ec 100644 --- a/sessionpreplib/processor.py +++ b/sessionpreplib/processor.py @@ -24,6 +24,7 @@ class AudioProcessor(ABC): """ id: str = "" name: str = "" + shorthand: str = "" # short abbreviation for compact UI labels, e.g. "BN" priority: int = PRIORITY_NORMALIZE @classmethod @@ -40,17 +41,36 @@ def config_params(cls) -> list[ParamSpec]: "and preparation. Disable to skip it entirely." ), ), + ParamSpec( + key=f"{cls.id}_default", + type=bool, + default=True, + label="Selected by default", + description=( + "When enabled, this processor is pre-selected for all " + "tracks when a folder is opened. Uncheck to start with " + "it deselected — users can still enable it per-track in " + "the Processing column." + ), + presentation_only=True, + ), ] def configure(self, config: dict[str, Any]) -> None: """Read config values. Subclasses should call super().configure(config).""" self._enabled: bool = config.get(f"{self.id}_enabled", True) + self._default: bool = config.get(f"{self.id}_default", True) @property def enabled(self) -> bool: """Whether this processor is active.""" return self._enabled + @property + def default(self) -> bool: + """Whether this processor is pre-selected for tracks by default.""" + return self._default + @abstractmethod def process(self, track: TrackContext) -> ProcessorResult: """ diff --git a/sessionpreplib/processors/bimodal_normalize.py b/sessionpreplib/processors/bimodal_normalize.py index 00ac924..4f025ce 100644 --- a/sessionpreplib/processors/bimodal_normalize.py +++ b/sessionpreplib/processors/bimodal_normalize.py @@ -13,6 +13,7 @@ class BimodalNormalizeProcessor(AudioProcessor): id = "bimodal_normalize" name = "Bimodal Normalization" + shorthand = "BN" priority = PRIORITY_NORMALIZE @classmethod diff --git a/sessionpreplib/processors/mono_downmix.py b/sessionpreplib/processors/mono_downmix.py index 42bf295..72f6608 100644 --- a/sessionpreplib/processors/mono_downmix.py +++ b/sessionpreplib/processors/mono_downmix.py @@ -19,6 +19,7 @@ class MonoDownmixProcessor(AudioProcessor): id = "mono_downmix" name = "Mono Downmix" + shorthand = "MD" priority = PRIORITY_POST # runs after normalization @classmethod From 4c9d0cfd51f43b4f3c934bef4ed6f77a8d527c33 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 19:22:08 +0100 Subject: [PATCH 07/30] alt+shift batch edit for multiselect-dropdowns. --- sessionprepgui/mainwindow.py | 34 +++++++++++++++++++++++++++------- sessionprepgui/widgets.py | 25 +++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index dd4c6b3..498498b 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -78,7 +78,7 @@ ) from .preferences import PreferencesDialog, _argb_to_qcolor from .report import render_summary_html, render_track_detail_html -from .widgets import BatchEditTableWidget, BatchComboBox, ProgressPanel +from .widgets import BatchEditTableWidget, BatchComboBox, BatchToolButton, ProgressPanel from .worker import ( AnalyzeWorker, BatchReanalyzeWorker, DawCheckWorker, DawFetchWorker, DawTransferWorker, PrepareWorker, @@ -2993,7 +2993,7 @@ def _create_processing_button(self, row: int, track) -> None: processors = self._session.processors if self._session else [] - btn = QToolButton() + btn = BatchToolButton() btn.setProperty("track_filename", track.filename) if processors: @@ -3072,13 +3072,33 @@ def _on_processing_toggled(self, checked: bool): return proc_id = action.data() - if checked: - track.processor_skip.discard(proc_id) + processors = self._session.processors if self._session else [] + + if getattr(btn, 'batch_mode', False): + btn.batch_mode = False + batch_keys = self._track_table.batch_selected_keys() + track_map = {t.filename: t for t in self._session.tracks} + for fname in batch_keys: + t = track_map.get(fname) + if not t or t.status != "OK": + continue + if checked: + t.processor_skip.discard(proc_id) + else: + t.processor_skip.add(proc_id) + row = self._find_table_row(fname) + if row >= 0: + b = self._track_table.cellWidget(row, 7) + if b: + self._update_processing_button_label(b, t, processors) + self._track_table.restore_selection(batch_keys) else: - track.processor_skip.add(proc_id) + if checked: + track.processor_skip.discard(proc_id) + else: + track.processor_skip.add(proc_id) + self._update_processing_button_label(btn, track, processors) - processors = self._session.processors if self._session else [] - self._update_processing_button_label(btn, track, processors) self._mark_prepare_stale() def _populate_table(self, session): diff --git a/sessionprepgui/widgets.py b/sessionprepgui/widgets.py index 5ca3f05..8361554 100644 --- a/sessionprepgui/widgets.py +++ b/sessionprepgui/widgets.py @@ -45,6 +45,7 @@ QStyledItemDelegate, QStyleOptionViewItem, QTableWidget, + QToolButton, QVBoxLayout, QWidget, ) @@ -302,3 +303,27 @@ def mousePressEvent(self, event): self.batch_mode = bool( mods & Qt.AltModifier and mods & Qt.ShiftModifier) super().mousePressEvent(event) + + +class BatchToolButton(QToolButton): + """QToolButton that detects Alt+Shift on click for batch-edit mode. + + When the user holds **Alt+Shift** while clicking the button, + ``batch_mode`` is set to ``True``. The connected action-slot can + inspect this flag to decide whether to apply the toggle to all + selected rows or just the single row. + + After handling the batch, the slot should reset the flag:: + + btn.batch_mode = False + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.batch_mode: bool = False + + def mousePressEvent(self, event): + mods = QApplication.keyboardModifiers() + self.batch_mode = bool( + mods & Qt.AltModifier and mods & Qt.ShiftModifier) + super().mousePressEvent(event) From 96453a330422001920727eebe13928c851cd2ea3 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 21:00:34 +0100 Subject: [PATCH 08/30] load and save session. --- sessionprepgui/mainwindow.py | 232 ++++++++++++++++++++++++++++- sessionprepgui/session_io.py | 276 +++++++++++++++++++++++++++++++++++ sessionprepgui/worker.py | 35 +++++ sessionpreplib/pipeline.py | 7 + 4 files changed, 544 insertions(+), 6 deletions(-) create mode 100644 sessionprepgui/session_io.py diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 498498b..b74ade8 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -78,10 +78,11 @@ ) from .preferences import PreferencesDialog, _argb_to_qcolor from .report import render_summary_html, render_track_detail_html +from .session_io import save_session as _save_session_file, load_session as _load_session_file from .widgets import BatchEditTableWidget, BatchComboBox, BatchToolButton, ProgressPanel from .worker import ( - AnalyzeWorker, BatchReanalyzeWorker, DawCheckWorker, DawFetchWorker, - DawTransferWorker, PrepareWorker, + AnalyzeWorker, AudioLoadWorker, BatchReanalyzeWorker, DawCheckWorker, + DawFetchWorker, DawTransferWorker, PrepareWorker, ) from .waveform import WaveformWidget, WaveformLoadWorker from sessionpreplib.audio import AUDIO_EXTENSIONS @@ -401,6 +402,7 @@ def __init__(self): self._batch_worker: BatchReanalyzeWorker | None = None self._batch_filenames: set[str] = set() self._wf_worker: WaveformLoadWorker | None = None + self._audio_load_worker: AudioLoadWorker | None = None self._current_track = None self._session_groups: list[dict] = [] self._prev_group_assignments: dict[str, str | None] = {} @@ -526,6 +528,19 @@ def _init_menus(self): open_action.triggered.connect(self._on_open_path) file_menu.addAction(open_action) + load_session_action = QAction("&Load Session...", self) + load_session_action.setShortcut("Ctrl+Shift+O") + load_session_action.triggered.connect(self._on_load_session) + file_menu.addAction(load_session_action) + + file_menu.addSeparator() + + self._save_session_action = QAction("&Save Session...", self) + self._save_session_action.setShortcut("Ctrl+S") + self._save_session_action.setEnabled(False) + self._save_session_action.triggered.connect(self._on_save_session) + file_menu.addAction(self._save_session_action) + file_menu.addSeparator() prefs_action = QAction("&Preferences...", self) @@ -1357,6 +1372,7 @@ def _build_right_panel(self) -> QWidget: # Waveform toolbar + widget container wf_container = QWidget() + self._wf_container = wf_container wf_layout = QVBoxLayout(wf_container) wf_layout.setContentsMargins(0, 0, 0, 0) wf_layout.setSpacing(0) @@ -2320,7 +2336,7 @@ def _on_open_path(self): self._setup_table.setRowCount(0) self._summary_view.clear() self._file_report.clear() - self._waveform.setVisible(False) + self._wf_container.setVisible(False) self._play_btn.setEnabled(False) self._stop_btn.setEnabled(False) self._detail_tabs.setTabEnabled(_TAB_FILE, False) @@ -2363,6 +2379,167 @@ def _on_open_path(self): # Auto-start analysis self._on_analyze() + @Slot() + def _on_save_session(self): + """Save the current session state to a .spsession file.""" + if not self._session or not self._source_dir: + return + default_path = os.path.join(self._source_dir, "session.spsession") + path, _ = QFileDialog.getSaveFileName( + self, "Save Session", default_path, + "SessionPrep Session (*.spsession);;All Files (*)", + ) + if not path: + return + try: + _save_session_file(path, { + "source_dir": self._source_dir, + "active_config_preset": self._active_config_preset_name, + "session_config": self._session_config, + "session_groups": self._session_groups, + "daw_state": self._session.daw_state, + "tracks": self._session.tracks, + }) + self._status_bar.showMessage(f"Session saved to {path}") + except Exception as exc: + QMessageBox.critical( + self, "Save Session Failed", + f"Could not save session:\n\n{exc}", + ) + + @Slot() + def _on_load_session(self): + """Load a .spsession file and restore the full session state.""" + start_dir = self._source_dir or self._config.get("app", {}).get( + "default_project_dir", "") or "" + path, _ = QFileDialog.getOpenFileName( + self, "Load Session", start_dir, + "SessionPrep Session (*.spsession);;All Files (*)", + ) + if not path: + return + + try: + data = _load_session_file(path) + except Exception as exc: + QMessageBox.critical( + self, "Load Session Failed", + f"Could not load session:\n\n{exc}", + ) + return + + source_dir = data["source_dir"] + if not os.path.isdir(source_dir): + QMessageBox.warning( + self, "Load Session", + f"The session's audio directory no longer exists:\n\n{source_dir}\n\n" + "Please move the files back or open the directory manually.", + ) + return + + # ── Reset UI (same as _on_open_path but without auto-analyze) ──────── + self._on_stop() + self._source_dir = source_dir + self._track_table.set_source_dir(source_dir) + self._session = None + self._summary = None + self._current_track = None + + self._phase_tabs.setCurrentIndex(_PHASE_ANALYSIS) + self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) + self._track_table.setRowCount(0) + self._setup_table.setRowCount(0) + self._summary_view.clear() + self._file_report.clear() + self._wf_container.setVisible(False) + self._play_btn.setEnabled(False) + self._stop_btn.setEnabled(False) + self._detail_tabs.setTabEnabled(_TAB_FILE, False) + self._detail_tabs.setTabEnabled(_TAB_GROUPS, False) + self._detail_tabs.setTabEnabled(_TAB_SESSION, False) + self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) + self._right_stack.setCurrentIndex(_PAGE_TABS) + self._groups_tab_table.setRowCount(0) + + # ── Restore session-level state ─────────────────────────────────────── + preset_name = data.get("active_config_preset", "Default") + self._active_config_preset_name = preset_name + self._session_config = data.get("session_config") + self._session_groups = data.get("session_groups", []) + + # ── Reconstruct SessionContext from saved tracks ────────────────────── + from sessionpreplib.models import SessionContext + from sessionpreplib.rendering import build_diagnostic_summary + + tracks = data["tracks"] + flat = self._flat_config() + + # Re-instantiate detectors and processors (needed for label filtering) + all_detectors = default_detectors() + for d in all_detectors: + d.configure(flat) + all_processors = [] + for proc in default_processors(): + proc.configure(flat) + if proc.enabled: + all_processors.append(proc) + all_processors.sort(key=lambda p: p.priority) + + session_config_flat = dict(default_config()) + session_config_flat.update(flat) + session_config_flat["_source_dir"] = source_dir + + session = SessionContext( + tracks=tracks, + config=session_config_flat, + groups={}, + detectors=all_detectors, + processors=all_processors, + daw_state=data.get("daw_state", {}), + prepare_state="none", + ) + + self._session = session + self._summary = build_diagnostic_summary(session) + + # ── Populate file list in track table ───────────────────────────────── + self._track_table.setSortingEnabled(False) + self._track_table.setRowCount(len(tracks)) + for row, track in enumerate(tracks): + item = _SortableItem(track.filename, protools_sort_key(track.filename)) + item.setForeground(FILE_COLOR_OK if track.status == "OK" else FILE_COLOR_ERROR) + self._track_table.setItem(row, 0, item) + for col in range(1, 8): + cell = _SortableItem("", "") + cell.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, col, cell) + self._track_table.setSortingEnabled(True) + + # ── Populate all table widgets and tabs ─────────────────────────────── + self._populate_groups_tab() + self._populate_group_preset_combo() + self._populate_table(session) + self._render_summary() + + # ── Enable post-analysis UI ─────────────────────────────────────────── + self._right_stack.setCurrentIndex(_PAGE_TABS) + self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) + self._detail_tabs.setTabEnabled(_TAB_GROUPS, True) + self._detail_tabs.setTabEnabled(_TAB_SESSION, True) + self._phase_tabs.setTabEnabled(_PHASE_SETUP, True) + self._populate_setup_table() + self._analyze_action.setEnabled(True) + self._save_session_action.setEnabled(True) + self._update_prepare_button() + self._auto_fit_track_table() + + ok_count = sum(1 for t in tracks if t.status == "OK") + self._status_bar.showMessage( + f"Session loaded: {ok_count}/{len(tracks)} tracks OK" + " — click Reanalyze to refresh results" + ) + self.setWindowTitle("SessionPrep") + @Slot() def _on_analyze(self): if not self._source_dir: @@ -2569,6 +2746,8 @@ def _on_analyze_done(self, session, summary): session.prepare_state = "stale" self._update_prepare_button() + self._save_session_action.setEnabled(True) + ok_count = sum(1 for t in session.tracks if t.status == "OK") self._status_bar.showMessage( f"Analysis complete: {ok_count}/{len(session.tracks)} tracks OK" @@ -2813,17 +2992,39 @@ def _load_waveform(self, track): if self._current_track is not track: return - # Cancel any in-flight worker + # Cancel any in-flight workers if self._wf_worker is not None: self._wf_worker.cancel() self._wf_worker.finished.disconnect() self._wf_worker = None + if self._audio_load_worker is not None: + self._audio_load_worker.cancel() + self._audio_load_worker.finished.disconnect() + self._audio_load_worker = None + + # If audio_data is absent but the file exists, load it from disk first + if (track.audio_data is None or track.audio_data.size == 0) and \ + track.status == "OK" and os.path.isfile(track.filepath): + self._waveform.set_loading(True) + if self._detail_tabs.currentIndex() == _TAB_FILE: + self._wf_container.setVisible(True) + self._play_btn.setEnabled(False) + self._update_time_label(0) + + worker = AudioLoadWorker(track, parent=self) + self._audio_load_worker = worker + worker.finished.connect( + lambda t, orig=track: self._on_audio_loaded(t, orig)) + worker.error.connect( + lambda msg: self._on_audio_load_error(msg, track)) + worker.start() + return has_audio = track.audio_data is not None and track.audio_data.size > 0 if has_audio: self._waveform.set_loading(True) if self._detail_tabs.currentIndex() == _TAB_FILE: - self._waveform.setVisible(True) + self._wf_container.setVisible(True) self._play_btn.setEnabled(False) self._update_time_label(0) @@ -2843,7 +3044,7 @@ def _load_waveform(self, track): self._waveform.set_audio(None, 44100) self._update_overlay_menu([]) if self._detail_tabs.currentIndex() == _TAB_FILE: - self._waveform.setVisible(False) + self._wf_container.setVisible(False) self._play_btn.setEnabled(False) self._update_time_label(0) @@ -2873,6 +3074,25 @@ def _on_waveform_loaded(self, result: dict, track): self._play_btn.setEnabled(True) self._update_time_label(0) + def _on_audio_loaded(self, track, orig_track): + """Audio data loaded from disk; proceed to waveform rendering.""" + self._audio_load_worker = None + # Discard if user switched tracks while we were loading + if self._current_track is not orig_track: + return + # Now kick off the normal waveform worker path + self._load_waveform(track) + + def _on_audio_load_error(self, message: str, track): + """Audio file could not be read from disk.""" + self._audio_load_worker = None + if self._current_track is not track: + return + self._waveform.set_audio(None, 44100) + self._wf_container.setVisible(False) + self._play_btn.setEnabled(False) + self._status_bar.showMessage(f"Could not load audio: {message}") + # ── Overlay dropdown ──────────────────────────────────────────────── def _update_overlay_menu(self, issues: list): diff --git a/sessionprepgui/session_io.py b/sessionprepgui/session_io.py new file mode 100644 index 0000000..f2762ca --- /dev/null +++ b/sessionprepgui/session_io.py @@ -0,0 +1,276 @@ +"""Session save / load for SessionPrep GUI. + +Serialises the full analysis state (detector results, processor results, +user edits) to a ``.spsession`` JSON file so a session can be restored +without re-running analysis. + +Format versioning +----------------- +``CURRENT_VERSION`` is bumped whenever the schema changes. ``_MIGRATIONS`` +maps version N → a callable that upgrades a raw dict from version N to N+1. +``load_session()`` applies all necessary migrations before returning. +""" + +from __future__ import annotations + +import json +import os +from typing import Any, Callable + +from sessionpreplib.models import ( + DetectorResult, + IssueLocation, + ProcessorResult, + Severity, + TrackContext, +) + +# --------------------------------------------------------------------------- +# Version & migration table +# --------------------------------------------------------------------------- + +CURRENT_VERSION: int = 1 + +# Each entry upgrades from key-version to key+1. +# Example for a future v2: +# _MIGRATIONS[1] = lambda d: {**d, "new_field": "default", "version": 2} +_MIGRATIONS: dict[int, Callable[[dict], dict]] = {} + + +def _migrate(data: dict) -> dict: + """Upgrade *data* in-place from its stored version to CURRENT_VERSION.""" + v = data.get("version", 1) + if v > CURRENT_VERSION: + raise ValueError( + f"Session file was saved with a newer version of SessionPrep " + f"(file version {v}, this build supports up to {CURRENT_VERSION}). " + f"Please upgrade SessionPrep." + ) + while v < CURRENT_VERSION: + fn = _MIGRATIONS.get(v) + if fn is None: + raise ValueError( + f"No migration path from version {v} to {v + 1}." + ) + data = fn(data) + v += 1 + return data + + +# --------------------------------------------------------------------------- +# Serialisation helpers +# --------------------------------------------------------------------------- + +def _ser_issue(issue: IssueLocation) -> dict: + return { + "sample_start": issue.sample_start, + "sample_end": issue.sample_end, + "channel": issue.channel, + "severity": issue.severity.value, + "label": issue.label, + "description": issue.description, + "freq_min_hz": issue.freq_min_hz, + "freq_max_hz": issue.freq_max_hz, + } + + +def _deser_issue(d: dict) -> IssueLocation: + return IssueLocation( + sample_start=d["sample_start"], + sample_end=d.get("sample_end"), + channel=d.get("channel"), + severity=Severity(d["severity"]), + label=d.get("label", ""), + description=d.get("description", ""), + freq_min_hz=d.get("freq_min_hz"), + freq_max_hz=d.get("freq_max_hz"), + ) + + +def _ser_detector_result(r: DetectorResult) -> dict: + return { + "detector_id": r.detector_id, + "severity": r.severity.value, + "summary": r.summary, + "detail_lines": r.detail_lines, + "hint": r.hint, + "error": r.error, + "data": _make_json_safe(r.data), + "issues": [_ser_issue(i) for i in r.issues], + } + + +def _deser_detector_result(d: dict) -> DetectorResult: + return DetectorResult( + detector_id=d["detector_id"], + severity=Severity(d["severity"]), + summary=d.get("summary", ""), + data=d.get("data", {}), + detail_lines=d.get("detail_lines", []), + hint=d.get("hint"), + error=d.get("error"), + issues=[_deser_issue(i) for i in d.get("issues", [])], + ) + + +def _ser_processor_result(r: ProcessorResult) -> dict: + return { + "processor_id": r.processor_id, + "gain_db": r.gain_db, + "classification": r.classification, + "method": r.method, + "data": _make_json_safe(r.data), + "error": r.error, + } + + +def _deser_processor_result(d: dict) -> ProcessorResult: + return ProcessorResult( + processor_id=d["processor_id"], + gain_db=d.get("gain_db", 0.0), + classification=d.get("classification", ""), + method=d.get("method", ""), + data=d.get("data", {}), + error=d.get("error"), + ) + + +def _make_json_safe(obj: Any) -> Any: + """Recursively convert non-JSON-serialisable values to safe equivalents.""" + if isinstance(obj, dict): + return {k: _make_json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_make_json_safe(v) for v in obj] + if isinstance(obj, float): + # Handle inf / nan + if obj != obj or obj == float("inf") or obj == float("-inf"): + return None + return obj + if hasattr(obj, "value"): # Enum + return obj.value + if isinstance(obj, (int, str, bool, type(None))): + return obj + return str(obj) + + +def _serialize_track(track: TrackContext) -> dict: + return { + "status": track.status, + "channels": track.channels, + "samplerate": track.samplerate, + "total_samples": track.total_samples, + "bitdepth": track.bitdepth, + "subtype": track.subtype, + "duration_sec": track.duration_sec, + "group": track.group, + "classification_override": track.classification_override, + "rms_anchor_override": track.rms_anchor_override, + "processor_skip": sorted(track.processor_skip), + "detector_results": { + k: _ser_detector_result(v) + for k, v in track.detector_results.items() + }, + "processor_results": { + k: _ser_processor_result(v) + for k, v in track.processor_results.items() + }, + } + + +def _deserialize_track(filename: str, source_dir: str, d: dict) -> TrackContext: + filepath = os.path.join(source_dir, filename) + # If file no longer exists, mark as error + if not os.path.isfile(filepath): + status = "Error" + else: + status = d.get("status", "OK") + + track = TrackContext( + filename=filename, + filepath=filepath, + audio_data=None, + samplerate=d.get("samplerate", 0), + channels=d.get("channels", 0), + total_samples=d.get("total_samples", 0), + bitdepth=d.get("bitdepth", ""), + subtype=d.get("subtype", ""), + duration_sec=d.get("duration_sec", 0.0), + status=status, + ) + track.group = d.get("group") + track.classification_override = d.get("classification_override") + track.rms_anchor_override = d.get("rms_anchor_override") + track.processor_skip = set(d.get("processor_skip", [])) + track.detector_results = { + k: _deser_detector_result(v) + for k, v in d.get("detector_results", {}).items() + } + track.processor_results = { + k: _deser_processor_result(v) + for k, v in d.get("processor_results", {}).items() + } + return track + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def save_session(path: str, data: dict) -> None: + """Serialise *data* to a ``.spsession`` JSON file at *path*. + + *data* is the raw dict assembled by the mainwindow (already plain-Python + types except for ``TrackContext`` objects under ``"tracks"``). + """ + payload: dict[str, Any] = { + "version": CURRENT_VERSION, + "source_dir": data["source_dir"], + "active_config_preset": data.get("active_config_preset", "Default"), + "session_config": data.get("session_config"), + "session_groups": data.get("session_groups", []), + "daw_state": _make_json_safe(data.get("daw_state", {})), + "tracks": { + track.filename: _serialize_track(track) + for track in data.get("tracks", []) + }, + } + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, ensure_ascii=False) + + +def load_session(path: str) -> dict: + """Load and migrate a ``.spsession`` file. + + Returns a plain dict with keys: + - ``source_dir`` (str) + - ``active_config_preset`` (str) + - ``session_config`` (dict | None) + - ``session_groups`` (list) + - ``daw_state`` (dict) + - ``tracks`` (list[TrackContext]) — audio_data is None; filepath validated + + Raises ``ValueError`` on version mismatch or missing required fields. + Raises ``json.JSONDecodeError`` / ``OSError`` on file errors. + """ + with open(path, "r", encoding="utf-8") as fh: + raw = json.load(fh) + + raw = _migrate(raw) + + source_dir = raw.get("source_dir", "") + if not source_dir: + raise ValueError("Session file is missing 'source_dir'.") + + tracks = [ + _deserialize_track(fname, source_dir, tdata) + for fname, tdata in raw.get("tracks", {}).items() + ] + + return { + "source_dir": source_dir, + "active_config_preset": raw.get("active_config_preset", "Default"), + "session_config": raw.get("session_config"), + "session_groups": raw.get("session_groups", []), + "daw_state": raw.get("daw_state", {}), + "tracks": tracks, + } diff --git a/sessionprepgui/worker.py b/sessionprepgui/worker.py index 1ddca29..b80764e 100644 --- a/sessionprepgui/worker.py +++ b/sessionprepgui/worker.py @@ -195,6 +195,41 @@ def on_track_plan_complete(filename, **_kw): self.error.emit(str(e)) +class AudioLoadWorker(QThread): + """Load audio data from disk for a single track (no analysis). + + Used when a session is loaded from file and ``track.audio_data`` is + ``None`` but the source file still exists on disk. Emits ``finished`` + with the populated track on success, or ``error`` with a message. + """ + + finished = Signal(object) # track with audio_data populated + error = Signal(str) + + def __init__(self, track, parent=None): + super().__init__(parent) + self._track = track + self._cancelled = False + + def cancel(self): + self._cancelled = True + + def run(self): + try: + from sessionpreplib.audio import load_track + import soundfile as sf + import numpy as np + data, sr = sf.read(self._track.filepath, dtype='float64') + if self._cancelled: + return + self._track.audio_data = data + self._track.samplerate = sr + self._track.total_samples = len(data) + self.finished.emit(self._track) + except Exception as exc: + self.error.emit(str(exc)) + + class PrepareWorker(QThread): """Runs Pipeline.prepare() off the main thread with progress.""" diff --git a/sessionpreplib/pipeline.py b/sessionpreplib/pipeline.py index f9271b4..c19fd1e 100644 --- a/sessionpreplib/pipeline.py +++ b/sessionpreplib/pipeline.py @@ -458,6 +458,13 @@ def prepare( progress_cb(step, total, f"Preparing {track.filename}") try: + # Load audio from disk on demand (e.g. after session restore) + if track.audio_data is None or track.audio_data.size == 0: + loaded = load_track(track.filepath) + track.audio_data = loaded.audio_data + track.samplerate = loaded.samplerate + track.total_samples = loaded.total_samples + # Deep-copy audio data so the session's copy stays clean audio = track.audio_data.copy() From ea8d8349662dba3e2f23d1c297c76013271e5365 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 21:19:55 +0100 Subject: [PATCH 09/30] file selector for file-based daw processors. --- sessionprepgui/mainwindow.py | 27 ++++++++--- sessionprepgui/worker.py | 5 +- sessionpreplib/daw_processor.py | 51 ++++++++++++++++--- sessionpreplib/daw_processors/dawproject.py | 54 ++++++++++++++------- sessionpreplib/daw_processors/protools.py | 8 ++- 5 files changed, 109 insertions(+), 36 deletions(-) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index b74ade8..b8d9acc 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -945,9 +945,11 @@ def _on_daw_transfer(self): def _do_daw_transfer(self): """Actually start the transfer (called after successful connectivity check).""" - dp_name = self._active_daw_processor.name if self._active_daw_processor else "DAW" - self._status_bar.showMessage(f"Transferring to {dp_name}\u2026") - self._transfer_progress.start("Preparing\u2026") + if not self._active_daw_processor or not self._session: + return + + output_folder = self._config.get("app", {}).get("output_folder", "processed") + # Refresh pipeline config from current session widgets so that # processor enabled/disabled changes made after analysis take effect. self._session.config.update(self._flat_config()) @@ -957,12 +959,23 @@ def _do_daw_transfer(self): self._session_groups) colors = self._config.get("colors", PT_DEFAULT_COLORS) self._session.config["gui"]["colors"] = colors - # Inject source dir and output folder for file-based processors + # Keep source dir / output folder in config for processor.resolve_output_path() self._session.config["_source_dir"] = self._source_dir - self._session.config["_output_folder"] = self._config.get( - "app", {}).get("output_folder", "processed") + self._session.config["_output_folder"] = output_folder + + # ── Let the processor decide the output path (shows dialog if needed) ─ + output_path = self._active_daw_processor.resolve_output_path( + self._session, self) + if output_path is None: + self._update_daw_lifecycle_buttons() + return + + dp_name = self._active_daw_processor.name + self._status_bar.showMessage(f"Transferring to {dp_name}\u2026") + self._transfer_progress.start("Preparing\u2026") + self._daw_transfer_worker = DawTransferWorker( - self._active_daw_processor, self._session) + self._active_daw_processor, self._session, output_path) self._daw_transfer_worker.progress.connect(self._on_transfer_progress) self._daw_transfer_worker.progress_value.connect( self._on_transfer_progress_value) diff --git a/sessionprepgui/worker.py b/sessionprepgui/worker.py index b80764e..f2bd86c 100644 --- a/sessionprepgui/worker.py +++ b/sessionprepgui/worker.py @@ -57,10 +57,11 @@ class DawTransferWorker(QThread): progress_value = Signal(int, int) # (current, total) result = Signal(bool, str, object) # (ok, message, results_list) - def __init__(self, processor: DawProcessor, session): + def __init__(self, processor: DawProcessor, session, output_path: str): super().__init__() self._processor = processor self._session = session + self._output_path = output_path def _on_progress(self, current: int, total: int, message: str): self.progress.emit(message) @@ -69,7 +70,7 @@ def _on_progress(self, current: int, total: int, message: str): def run(self): try: results = self._processor.transfer( - self._session, progress_cb=self._on_progress) + self._session, self._output_path, progress_cb=self._on_progress) failures = [r for r in results if not r.success] if failures: msg = f"Transfer done: {len(results) - len(failures)}/{len(results)} OK" diff --git a/sessionpreplib/daw_processor.py b/sessionpreplib/daw_processor.py index c2d8865..919a9a8 100644 --- a/sessionpreplib/daw_processor.py +++ b/sessionpreplib/daw_processor.py @@ -88,17 +88,52 @@ def fetch(self, session: SessionContext) -> SessionContext: """ ... + def resolve_output_path( + self, + session: SessionContext, + parent_widget=None, + ) -> str | None: + """Resolve the output path for a transfer, optionally prompting the user. + + Called by the GUI before starting a transfer worker. + + Return values + ------------- + ``None`` + User cancelled — the GUI should abort. + ``""`` + No file path needed (e.g. gRPC-based processors like Pro Tools). + non-empty str + Absolute path to write the output file to. + + The default implementation returns ``""`` (no path, no dialog). + File-based processors (e.g. DAWproject) override this to compute a + default path and show a save-file dialog. + """ + return "" + @abstractmethod - def transfer(self, session: SessionContext) -> list[DawCommandResult]: + def transfer( + self, + session: SessionContext, + output_path: str, + progress_cb=None, + ) -> list[DawCommandResult]: """Initial full push of session data to the DAW. - Internally: - 1. Builds a list of DawCommand objects from session state - 2. Executes each via processor-private dispatch - 3. Appends results to session.daw_command_log - 4. Snapshots the transferred state for future sync() diffs - - Returns the list of results for this batch. + Parameters + ---------- + session: + The current session context. + output_path: + Explicit destination path for the output file (e.g. + ``/path/to/session.dawproject``). The caller is responsible + for choosing this path — supervised callers show a file dialog; + unsupervised (batch) callers derive it from track/session data. + progress_cb: + Optional ``(current, total, message)`` callback. + + Returns the list of DawCommandResult for this batch. """ ... diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index fc07132..5270e70 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -193,8 +193,39 @@ def _walk_structure( track.tracks, folders, parent_id=track.id, counter=counter) - def transfer(self, session: SessionContext, - progress_cb=None) -> list[DawCommandResult]: + def resolve_output_path( + self, + session: SessionContext, + parent_widget=None, + ) -> str | None: + """Compute default .dawproject path and show a save-file dialog. + + Returns the chosen path, or ``None`` if the user cancelled. + """ + from PySide6.QtWidgets import QFileDialog + + source_dir = session.config.get("_source_dir", "") + output_folder = session.config.get("_output_folder", "processed") + output_dir = os.path.join(source_dir, output_folder) if source_dir else "" + + safe_name = self._template_name or self.name or "dawproject" + safe_name = "".join( + c if c.isalnum() or c in " _-" else "_" for c in safe_name) + default_path = os.path.join(output_dir, f"{safe_name}.dawproject") \ + if output_dir else f"{safe_name}.dawproject" + + path, _ = QFileDialog.getSaveFileName( + parent_widget, "Save DAWproject", default_path, + "DAWproject (*.dawproject);;All Files (*)", + ) + return path if path else None + + def transfer( + self, + session: SessionContext, + output_path: str, + progress_cb=None, + ) -> list[DawCommandResult]: try: from dawproject import ( Arrangement, Audio, AutomationTarget, Channel, @@ -220,21 +251,10 @@ def transfer(self, session: SessionContext, results: list[DawCommandResult] = [] - # ── Determine output path ───────────────────────────────── - source_dir = session.config.get("_source_dir", "") - output_folder = session.config.get("_output_folder", "processed") - if not source_dir: - return [DawCommandResult( - command=DawCommand("transfer", "", {}), - success=False, error="No source directory set")] - - output_dir = os.path.join(source_dir, output_folder) - os.makedirs(output_dir, exist_ok=True) - - safe_name = self._template_name or "dawproject" - safe_name = "".join( - c if c.isalnum() or c in " _-" else "_" for c in safe_name) - output_path = os.path.join(output_dir, f"{safe_name}.dawproject") + # ── Ensure output directory exists ──────────────────────── + out_dir = os.path.dirname(output_path) + if out_dir: + os.makedirs(out_dir, exist_ok=True) # ── Load template ───────────────────────────────────────── Referenceable.reset_id() diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 4ed620f..d68549e 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -263,8 +263,12 @@ def _open_engine(self): address=address, ) - def transfer(self, session: SessionContext, - progress_cb=None) -> list[DawCommandResult]: + def transfer( + self, + session: SessionContext, + output_path: str, + progress_cb=None, + ) -> list[DawCommandResult]: """Import assigned tracks into Pro Tools folders and colorize. Uses a PTSL batch job to wrap all operations, providing a From 0bdf1e7190768242b89507b4ae468751ad6a6d5e Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 21:32:18 +0100 Subject: [PATCH 10/30] updated build workflows. --- .github/workflows/build-nuitka.yml | 25 ++++++++++++++++++++++--- .github/workflows/build-pyinstaller.yml | 25 ++++++++++++++++++++++--- build_nuitka.py | 20 +++++++++++++++++--- build_pyinstaller.py | 12 +----------- 4 files changed, 62 insertions(+), 20 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 1ea08e3..20a1f17 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest, macos-13] + os: [ubuntu-latest, windows-latest, macos-latest, macos-15-intel] steps: - uses: actions/checkout@v4 @@ -45,13 +45,32 @@ jobs: - name: Build with Nuitka run: uv run python build_nuitka.py all + - name: Package macOS .app bundles as DMG + if: runner.os == 'macOS' + run: | + brew install create-dmg + for app in dist_nuitka/*.app; do + [ -d "$app" ] || continue + name=$(basename "$app" .app) + create-dmg \ + --volname "$name" \ + --app-drop-link 600 185 \ + --sandbox-safe \ + "dist_nuitka/${name}.dmg" \ + "$app" + done + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: sessionprep-nuitka-${{ matrix.os }} + name: sessionprep-${{ matrix.os }} path: | - dist_nuitka/* + dist_nuitka/*.dmg + dist_nuitka/sessionprep-cli-* + dist_nuitka/sessionprep-gui-* !dist_nuitka/*.build !dist_nuitka/*.dist !dist_nuitka/*.onefile-build + !dist_nuitka/*.app + !dist_nuitka/*/ if-no-files-found: error \ No newline at end of file diff --git a/.github/workflows/build-pyinstaller.yml b/.github/workflows/build-pyinstaller.yml index bfe233b..0f21b93 100644 --- a/.github/workflows/build-pyinstaller.yml +++ b/.github/workflows/build-pyinstaller.yml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest, macos-13] + os: [ubuntu-latest, windows-latest, macos-latest, macos-15-intel] steps: - uses: actions/checkout@v4 @@ -30,12 +30,31 @@ jobs: # Using --onefile to produce single binaries where possible (standard distribution format) run: uv run python build_pyinstaller.py --onefile all + - name: Package macOS .app bundles as DMG + if: runner.os == 'macOS' + run: | + brew install create-dmg + for app in dist_pyinstaller/*.app; do + [ -d "$app" ] || continue + name=$(basename "$app" .app) + create-dmg \ + --volname "$name" \ + --app-drop-link 600 185 \ + --sandbox-safe \ + "dist_pyinstaller/${name}.dmg" \ + "$app" + done + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: sessionprep-pyinstaller-${{ matrix.os }} + name: sessionprep-${{ matrix.os }} path: | - dist_pyinstaller/* + dist_pyinstaller/*.dmg + dist_pyinstaller/sessionprep-cli-* + dist_pyinstaller/sessionprep-gui-* !dist_pyinstaller/*.build !dist_pyinstaller/*.spec + !dist_pyinstaller/*.app + !dist_pyinstaller/*/ if-no-files-found: error diff --git a/build_nuitka.py b/build_nuitka.py index 3f048af..1699027 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -59,7 +59,14 @@ def run_nuitka(target_key, clean=False): if sys.platform == "win32": cmd.append("--windows-disable-console") elif sys.platform == "darwin": - cmd.append("--macos-disable-console") + # GUI on macOS: produce a proper .app bundle instead of a bare onefile binary + cmd.remove("--onefile") + cmd.append("--macos-create-app-bundle") + icon_path = target.get("icon") + if icon_path and os.path.isfile(icon_path): + cmd.append(f"--macos-app-icon={icon_path}") + else: + pass # Linux GUI: keep --onefile # Plugins for plugin in target.get("nuitka_plugins", []): cmd.append(f"--enable-plugin={plugin}") @@ -97,8 +104,15 @@ def run_nuitka(target_key, clean=False): print(f" Renaming {bin_path} -> {output_exe}") os.rename(bin_path, output_exe) - print(f"[SUCCESS] Built {output_exe}") - print(f" Size: {os.path.getsize(output_exe) / (1024*1024):.2f} MB") + # On macOS GUI, output is a .app bundle (directory), not a single file + app_bundle = os.path.join(dist_dir, f"{os.path.splitext(target['name'])[0]}.app") + if sys.platform == "darwin" and not target["console"] and os.path.isdir(app_bundle): + print(f"[SUCCESS] Built {app_bundle}") + elif os.path.isfile(output_exe): + print(f"[SUCCESS] Built {output_exe}") + print(f" Size: {os.path.getsize(output_exe) / (1024*1024):.2f} MB") + else: + print(f"[SUCCESS] Build completed") def main(): parser = argparse.ArgumentParser(description="Build SessionPrep with Nuitka") diff --git a/build_pyinstaller.py b/build_pyinstaller.py index 50cb993..4e95592 100644 --- a/build_pyinstaller.py +++ b/build_pyinstaller.py @@ -102,7 +102,7 @@ def build(target_key: str, onefile: bool = False): cmd.append("--onefile") else: if onefile and is_macos and windowed: - print("Note: macOS GUI always builds as onedir (.app bundle will be zipped)") + print("Note: macOS GUI always builds as onedir (.app bundle — DMG created by workflow)") cmd.append("--onedir") cmd.append(entry_point) @@ -129,16 +129,6 @@ def build(target_key: str, onefile: bool = False): else: print(f"\nBuild completed but executable not found at expected path: {exe_path}") - # On macOS, --windowed always produces a .app bundle — zip it - if is_macos and windowed: - app_bundle = os.path.join(DIST_DIR, f"{app_name}.app") - if os.path.isdir(app_bundle): - zip_base = os.path.join(DIST_DIR, f"{app_name}") - shutil.make_archive(zip_base, "zip", DIST_DIR, f"{app_name}.app") - zip_path = f"{zip_base}.zip" - zip_mb = os.path.getsize(zip_path) / (1024 * 1024) - print(f"Zipped .app bundle: {zip_path} ({zip_mb:.1f} MB)") - return True From 41e64b7cc181dfbf9e5bdf286fcdeb54383c1c90 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 21:37:44 +0100 Subject: [PATCH 11/30] bump version to 0.2.4 --- sessionpreplib/_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sessionpreplib/_version.py b/sessionpreplib/_version.py index e7d3911..fd59979 100644 --- a/sessionpreplib/_version.py +++ b/sessionpreplib/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the SessionPrep version number.""" -__version__ = "0.2.1" +__version__ = "0.2.4" From 22e34622544aa8401c5253d8d5ad1380bda63980 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 22:16:58 +0100 Subject: [PATCH 12/30] add setuptools --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 58b187d..dc5364a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,9 @@ gui = [ "dawproject @ git+https://github.com/roex-audio/dawproject-py.git@e848bd06b00408018ff97dfb54942f3fa303a6a6", ] +[tool.uv.extra-build-dependencies] +dawproject = ["setuptools>=67"] + [dependency-groups] dev = [ "pytest>=8.0", From 1efdedd4cf99e0bf736717cbcf4e98875373bef2 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 22:21:38 +0100 Subject: [PATCH 13/30] build pipeline fix. --- pyproject.toml | 5 +++-- uv.lock | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dc5364a..a89972b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,8 +43,8 @@ gui = [ "dawproject @ git+https://github.com/roex-audio/dawproject-py.git@e848bd06b00408018ff97dfb54942f3fa303a6a6", ] -[tool.uv.extra-build-dependencies] -dawproject = ["setuptools>=67"] +[tool.uv] +no-build-isolation-package = ["dawproject"] [dependency-groups] dev = [ @@ -56,4 +56,5 @@ dev = [ "zstandard>=0.25.0", "rich>=13.0", "patchelf>=0.17.2.4; sys_platform != 'win32'", + "setuptools>=67", ] diff --git a/uv.lock b/uv.lock index 7823518..4c5e617 100644 --- a/uv.lock +++ b/uv.lock @@ -579,6 +579,7 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "rich" }, + { name = "setuptools" }, { name = "zstandard" }, ] @@ -604,6 +605,7 @@ dev = [ { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "rich", specifier = ">=13.0" }, + { name = "setuptools", specifier = ">=67" }, { name = "zstandard", specifier = ">=0.25.0" }, ] From c6a5726d2600b04dd9c2e5978e1008f61b89e0d2 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Wed, 18 Feb 2026 22:31:22 +0100 Subject: [PATCH 14/30] fix for dawproject --- .github/workflows/build-nuitka.yml | 19 ++++++++++++++++++- .github/workflows/build-pyinstaller.yml | 19 ++++++++++++++++++- pyproject.toml | 4 ---- uv.lock | 2 -- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 20a1f17..96d925f 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -40,7 +40,24 @@ jobs: ${{ runner.os }}-nuitka- - name: Install Dependencies - run: uv sync --all-extras + shell: bash + run: | + # Install base + dev + cli deps (skip gui extra to avoid dawproject build failure) + uv sync --extra cli + # Workaround: dawproject-py declares non-existent build-backend (setuptools.backends._legacy). + # Clone, patch to setuptools.build_meta, and install into the venv. + git clone --depth 1 https://github.com/roex-audio/dawproject-py.git "$RUNNER_TEMP/dawproject-py" + cd "$RUNNER_TEMP/dawproject-py" + git checkout e848bd06b00408018ff97dfb54942f3fa303a6a6 + python -c " + import pathlib + p = pathlib.Path('pyproject.toml') + p.write_text(p.read_text().replace('setuptools.backends._legacy:_Backend', 'setuptools.build_meta')) + " + cd "$GITHUB_WORKSPACE" + uv pip install --no-deps "$RUNNER_TEMP/dawproject-py" + # Install remaining GUI dependencies + uv pip install "PySide6>=6.10.2" "sounddevice>=0.5.5" "py-ptsl>=600.2.0" - name: Build with Nuitka run: uv run python build_nuitka.py all diff --git a/.github/workflows/build-pyinstaller.yml b/.github/workflows/build-pyinstaller.yml index 0f21b93..cf57af9 100644 --- a/.github/workflows/build-pyinstaller.yml +++ b/.github/workflows/build-pyinstaller.yml @@ -24,7 +24,24 @@ jobs: python-version-file: "pyproject.toml" - name: Install Dependencies - run: uv sync --all-extras + shell: bash + run: | + # Install base + dev + cli deps (skip gui extra to avoid dawproject build failure) + uv sync --extra cli + # Workaround: dawproject-py declares non-existent build-backend (setuptools.backends._legacy). + # Clone, patch to setuptools.build_meta, and install into the venv. + git clone --depth 1 https://github.com/roex-audio/dawproject-py.git "$RUNNER_TEMP/dawproject-py" + cd "$RUNNER_TEMP/dawproject-py" + git checkout e848bd06b00408018ff97dfb54942f3fa303a6a6 + python -c " + import pathlib + p = pathlib.Path('pyproject.toml') + p.write_text(p.read_text().replace('setuptools.backends._legacy:_Backend', 'setuptools.build_meta')) + " + cd "$GITHUB_WORKSPACE" + uv pip install --no-deps "$RUNNER_TEMP/dawproject-py" + # Install remaining GUI dependencies + uv pip install "PySide6>=6.10.2" "sounddevice>=0.5.5" "py-ptsl>=600.2.0" - name: Build with PyInstaller # Using --onefile to produce single binaries where possible (standard distribution format) diff --git a/pyproject.toml b/pyproject.toml index a89972b..58b187d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,9 +43,6 @@ gui = [ "dawproject @ git+https://github.com/roex-audio/dawproject-py.git@e848bd06b00408018ff97dfb54942f3fa303a6a6", ] -[tool.uv] -no-build-isolation-package = ["dawproject"] - [dependency-groups] dev = [ "pytest>=8.0", @@ -56,5 +53,4 @@ dev = [ "zstandard>=0.25.0", "rich>=13.0", "patchelf>=0.17.2.4; sys_platform != 'win32'", - "setuptools>=67", ] diff --git a/uv.lock b/uv.lock index 4c5e617..7823518 100644 --- a/uv.lock +++ b/uv.lock @@ -579,7 +579,6 @@ dev = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "rich" }, - { name = "setuptools" }, { name = "zstandard" }, ] @@ -605,7 +604,6 @@ dev = [ { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "rich", specifier = ">=13.0" }, - { name = "setuptools", specifier = ">=67" }, { name = "zstandard", specifier = ">=0.25.0" }, ] From 5a57ced38d68fec738659416899f005039d8bfdf Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Thu, 19 Feb 2026 18:51:12 +0100 Subject: [PATCH 15/30] big mainwindow.py refactoring. --- sessionprepgui/analysis_mixin.py | 841 ++++++ sessionprepgui/daw_mixin.py | 656 +++++ sessionprepgui/detail_mixin.py | 361 +++ sessionprepgui/groups_mixin.py | 914 ++++++ sessionprepgui/mainwindow.py | 3728 +------------------------ sessionprepgui/table_widgets.py | 308 ++ sessionprepgui/track_columns_mixin.py | 832 ++++++ sessionprepgui/widgets.py | 6 + 8 files changed, 3949 insertions(+), 3697 deletions(-) create mode 100644 sessionprepgui/analysis_mixin.py create mode 100644 sessionprepgui/daw_mixin.py create mode 100644 sessionprepgui/detail_mixin.py create mode 100644 sessionprepgui/groups_mixin.py create mode 100644 sessionprepgui/table_widgets.py create mode 100644 sessionprepgui/track_columns_mixin.py diff --git a/sessionprepgui/analysis_mixin.py b/sessionprepgui/analysis_mixin.py new file mode 100644 index 0000000..dc1325c --- /dev/null +++ b/sessionprepgui/analysis_mixin.py @@ -0,0 +1,841 @@ +"""Analysis mixin: open/save/load session, analyze, prepare, session config tab.""" + +from __future__ import annotations + +import copy +import os +from typing import Any + +from PySide6.QtCore import Qt, Slot, QSize +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QFileDialog, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSplitter, + QStackedWidget, + QTreeWidget, + QVBoxLayout, + QWidget, +) + +from sessionpreplib.audio import AUDIO_EXTENSIONS +from sessionpreplib.config import default_config, flatten_structured_config +from sessionpreplib.detectors import default_detectors +from sessionpreplib.processors import default_processors +from sessionpreplib.utils import protools_sort_key + +from .helpers import track_analysis_label +from .param_widgets import build_config_pages, load_config_widgets, read_config_widgets +from .report import render_track_detail_html +from .session_io import save_session as _save_session_file, load_session as _load_session_file +from .settings import build_defaults, resolve_config_preset +from .table_widgets import ( + _SortableItem, _make_analysis_cell, + _TAB_FILE, _TAB_GROUPS, _TAB_SESSION, _TAB_SUMMARY, + _PAGE_PROGRESS, _PAGE_TABS, + _PHASE_ANALYSIS, _PHASE_SETUP, +) +from .theme import COLORS, FILE_COLOR_OK, FILE_COLOR_ERROR +from .worker import AnalyzeWorker, PrepareWorker + + +class AnalysisMixin: + """Session lifecycle: open, save, load, analyze, prepare, session config tab. + + Mixed into ``SessionPrepWindow`` — not meant to be used standalone. + """ + + # ── Session config tab (per-session overrides) ──────────────────────── + + def _build_session_settings_tab(self) -> QWidget: + """Build a tree+stack config editor for per-session overrides.""" + page = QWidget() + page.setAutoFillBackground(True) + layout = QVBoxLayout(page) + layout.setContentsMargins(4, 4, 4, 4) + + # Header row + header = QHBoxLayout() + header.setSpacing(8) + self._session_preset_label = QLabel("Config Preset: —") + self._session_preset_label.setStyleSheet( + f"color: {COLORS['dim']}; font-style: italic;") + header.addWidget(self._session_preset_label) + header.addStretch() + reset_btn = QPushButton("Reset to Preset Defaults") + reset_btn.setToolTip( + "Discard all session-specific changes and reload from the " + "global config preset.") + reset_btn.clicked.connect(self._on_session_config_reset) + header.addWidget(reset_btn) + layout.addLayout(header) + + # Tree + Stack + splitter = QSplitter(Qt.Horizontal) + + self._session_tree = QTreeWidget() + self._session_tree.setHeaderHidden(True) + self._session_tree.setMinimumWidth(160) + self._session_tree.setMaximumWidth(220) + self._session_tree.currentItemChanged.connect( + self._on_session_tree_selection) + splitter.addWidget(self._session_tree) + + self._session_stack = QStackedWidget() + splitter.addWidget(self._session_stack) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + + layout.addWidget(splitter, 1) + + # Build initial pages from the active global preset + self._session_page_index: dict[int, int] = {} + self._build_session_pages() + + self._session_tree.expandAll() + first = self._session_tree.topLevelItem(0) + if first: + self._session_tree.setCurrentItem(first) + + return page + + def _build_session_pages(self): + """Populate the session config tree + stack from the active preset.""" + + def _register_page(tree_item, page): + idx = self._session_stack.addWidget(page) + self._session_page_index[id(tree_item)] = idx + + self._session_dawproject_templates_widget = build_config_pages( + self._session_tree, + self._active_preset(), + self._session_widgets, + _register_page, + on_processor_enabled=self._on_processor_enabled_changed, + ) + + def _on_session_tree_selection(self, current, _previous): + if current is None: + return + idx = self._session_page_index.get(id(current)) + if idx is not None: + self._session_stack.setCurrentIndex(idx) + + def _init_session_config(self): + """Snapshot the active global config preset into session config.""" + self._session_config = copy.deepcopy(self._active_preset()) + name = self._active_config_preset_name + self._session_preset_label.setText(f"Config Preset: {name}") + self._session_preset_label.setStyleSheet("") + self._load_session_widgets(self._session_config) + self._detail_tabs.setTabEnabled(_TAB_SESSION, True) + + def _load_session_widgets(self, preset: dict[str, Any]): + """Load values from a config preset dict into session widgets.""" + self._loading_session_widgets = True + try: + self._load_session_widgets_inner(preset) + finally: + self._loading_session_widgets = False + # Single refresh after all widgets are set + if self._session: + self._on_processor_enabled_changed(False) + + def _load_session_widgets_inner(self, preset: dict[str, Any]): + """Inner loader — sets widget values without triggering column refresh.""" + load_config_widgets( + self._session_widgets, preset, + self._session_dawproject_templates_widget) + + def _read_session_config(self) -> dict[str, Any]: + """Read current session widget values into a structured config dict.""" + return read_config_widgets( + self._session_widgets, + self._session_dawproject_templates_widget, + fallback_daw_sections=self._active_preset().get( + "daw_processors", {}), + ) + + def _on_session_config_reset(self): + """Reset session config to the global config preset defaults.""" + preset = self._active_preset() + self._session_config = copy.deepcopy(preset) + self._load_session_widgets(self._session_config) + self._status_bar.showMessage("Session config reset to preset defaults.") + + # ── Slots: file / analysis ──────────────────────────────────────────── + + @Slot() + def _on_open_path(self): + start_dir = self._config.get("app", {}).get("default_project_dir", "") or "" + path = QFileDialog.getExistingDirectory( + self, "Select Session Directory", start_dir, + QFileDialog.ShowDirsOnly, + ) + if not path: + return + + self._on_stop() + self._source_dir = path + self._track_table.set_source_dir(path) + self._session = None + self._summary = None + self._current_track = None + + # Reset UI + self._phase_tabs.setCurrentIndex(_PHASE_ANALYSIS) + self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) + self._track_table.setRowCount(0) + self._setup_table.setRowCount(0) + self._summary_view.clear() + self._file_report.clear() + self._wf_container.setVisible(False) + self._play_btn.setEnabled(False) + self._stop_btn.setEnabled(False) + self._detail_tabs.setTabEnabled(_TAB_FILE, False) + self._detail_tabs.setTabEnabled(_TAB_GROUPS, False) + self._detail_tabs.setTabEnabled(_TAB_SESSION, False) + self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) + self._right_stack.setCurrentIndex(_PAGE_TABS) + self._session_config = None # reset session overrides for new directory + self._session_groups = [] + self._groups_tab_table.setRowCount(0) + + wav_files = sorted( + f for f in os.listdir(path) if f.lower().endswith(AUDIO_EXTENSIONS) + ) + + if not wav_files: + self._status_bar.showMessage(f"No audio files found in {path}") + self._analyze_action.setEnabled(False) + return + + self._track_table.setSortingEnabled(False) + self._track_table.setRowCount(len(wav_files)) + for row, fname in enumerate(wav_files): + item = _SortableItem(fname, protools_sort_key(fname)) + item.setForeground(FILE_COLOR_OK) + self._track_table.setItem(row, 0, item) + for col in range(1, 6): + cell = _SortableItem("", "") + cell.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, col, cell) + self._track_table.setSortingEnabled(True) + self._auto_fit_track_table() + + self._analyze_action.setEnabled(True) + self._status_bar.showMessage( + f"Loaded {len(wav_files)} file(s) from {path}" + ) + self.setWindowTitle("SessionPrep") + + # Auto-start analysis + self._on_analyze() + + @Slot() + def _on_save_session(self): + """Save the current session state to a .spsession file.""" + if not self._session or not self._source_dir: + return + default_path = os.path.join(self._source_dir, "session.spsession") + path, _ = QFileDialog.getSaveFileName( + self, "Save Session", default_path, + "SessionPrep Session (*.spsession);;All Files (*)", + ) + if not path: + return + try: + _save_session_file(path, { + "source_dir": self._source_dir, + "active_config_preset": self._active_config_preset_name, + "session_config": self._session_config, + "session_groups": self._session_groups, + "daw_state": self._session.daw_state, + "tracks": self._session.tracks, + }) + self._status_bar.showMessage(f"Session saved to {path}") + except Exception as exc: + QMessageBox.critical( + self, "Save Session Failed", + f"Could not save session:\n\n{exc}", + ) + + @Slot() + def _on_load_session(self): + """Load a .spsession file and restore the full session state.""" + start_dir = self._source_dir or self._config.get("app", {}).get( + "default_project_dir", "") or "" + path, _ = QFileDialog.getOpenFileName( + self, "Load Session", start_dir, + "SessionPrep Session (*.spsession);;All Files (*)", + ) + if not path: + return + + try: + data = _load_session_file(path) + except Exception as exc: + QMessageBox.critical( + self, "Load Session Failed", + f"Could not load session:\n\n{exc}", + ) + return + + source_dir = data["source_dir"] + if not os.path.isdir(source_dir): + QMessageBox.warning( + self, "Load Session", + f"The session's audio directory no longer exists:\n\n{source_dir}\n\n" + "Please move the files back or open the directory manually.", + ) + return + + # ── Reset UI (same as _on_open_path but without auto-analyze) ──────── + self._on_stop() + self._source_dir = source_dir + self._track_table.set_source_dir(source_dir) + self._session = None + self._summary = None + self._current_track = None + + self._phase_tabs.setCurrentIndex(_PHASE_ANALYSIS) + self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) + self._track_table.setRowCount(0) + self._setup_table.setRowCount(0) + self._summary_view.clear() + self._file_report.clear() + self._wf_container.setVisible(False) + self._play_btn.setEnabled(False) + self._stop_btn.setEnabled(False) + self._detail_tabs.setTabEnabled(_TAB_FILE, False) + self._detail_tabs.setTabEnabled(_TAB_GROUPS, False) + self._detail_tabs.setTabEnabled(_TAB_SESSION, False) + self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) + self._right_stack.setCurrentIndex(_PAGE_TABS) + self._groups_tab_table.setRowCount(0) + + # ── Restore session-level state ─────────────────────────────────────── + preset_name = data.get("active_config_preset", "Default") + self._active_config_preset_name = preset_name + self._session_config = data.get("session_config") + self._session_groups = data.get("session_groups", []) + + # ── Reconstruct SessionContext from saved tracks ────────────────────── + from sessionpreplib.models import SessionContext + from sessionpreplib.rendering import build_diagnostic_summary + + tracks = data["tracks"] + flat = self._flat_config() + + # Re-instantiate detectors and processors (needed for label filtering) + all_detectors = default_detectors() + for d in all_detectors: + d.configure(flat) + all_processors = [] + for proc in default_processors(): + proc.configure(flat) + if proc.enabled: + all_processors.append(proc) + all_processors.sort(key=lambda p: p.priority) + + session_config_flat = dict(default_config()) + session_config_flat.update(flat) + session_config_flat["_source_dir"] = source_dir + + session = SessionContext( + tracks=tracks, + config=session_config_flat, + groups={}, + detectors=all_detectors, + processors=all_processors, + daw_state=data.get("daw_state", {}), + prepare_state="none", + ) + + self._session = session + self._summary = build_diagnostic_summary(session) + + # ── Populate file list in track table ───────────────────────────────── + self._track_table.setSortingEnabled(False) + self._track_table.setRowCount(len(tracks)) + for row, track in enumerate(tracks): + item = _SortableItem(track.filename, protools_sort_key(track.filename)) + item.setForeground(FILE_COLOR_OK if track.status == "OK" else FILE_COLOR_ERROR) + self._track_table.setItem(row, 0, item) + for col in range(1, 8): + cell = _SortableItem("", "") + cell.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, col, cell) + self._track_table.setSortingEnabled(True) + + # ── Populate all table widgets and tabs ─────────────────────────────── + self._populate_groups_tab() + self._populate_group_preset_combo() + self._populate_table(session) + self._render_summary() + + # ── Enable post-analysis UI ─────────────────────────────────────────── + self._right_stack.setCurrentIndex(_PAGE_TABS) + self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) + self._detail_tabs.setTabEnabled(_TAB_GROUPS, True) + self._detail_tabs.setTabEnabled(_TAB_SESSION, True) + self._phase_tabs.setTabEnabled(_PHASE_SETUP, True) + self._populate_setup_table() + self._analyze_action.setEnabled(True) + self._save_session_action.setEnabled(True) + self._update_prepare_button() + self._auto_fit_track_table() + + ok_count = sum(1 for t in tracks if t.status == "OK") + self._status_bar.showMessage( + f"Session loaded: {ok_count}/{len(tracks)} tracks OK" + " — click Reanalyze to refresh results" + ) + self.setWindowTitle("SessionPrep") + + # ── Analyze ────────────────────────────────────────────────────────── + + @Slot() + def _on_analyze(self): + if not self._source_dir: + return + + # Snapshot existing group assignments so we can restore after re-analysis + self._prev_group_assignments = {} + if self._session: + self._prev_group_assignments = { + t.filename: t.group for t in self._session.tracks if t.group} + + self._analyze_action.setEnabled(False) + self._current_track = None + self._detail_tabs.setTabEnabled(_TAB_FILE, False) + + # Initialise session config from global preset (first analysis) + # or keep existing session config (re-analysis with user edits) + if self._session_config is None: + self._init_session_config() + + # Show progress page + self._progress_label.setText("Analyzing…") + self._right_stack.setCurrentIndex(_PAGE_PROGRESS) + + config = self._flat_config() + config["_source_dir"] = self._source_dir + if self._active_daw_processor: + config["_fader_ceiling_db"] = self._active_daw_processor.fader_ceiling_db + + self._progress_bar.setRange(0, 0) # indeterminate until first value + + self._worker = AnalyzeWorker(self._source_dir, config) + self._worker.progress.connect(self._on_worker_progress) + self._worker.progress_value.connect(self._on_worker_progress_value) + self._worker.track_analyzed.connect(self._on_track_analyzed) + self._worker.track_planned.connect(self._on_track_planned) + self._worker.finished.connect(self._on_analyze_done) + self._worker.error.connect(self._on_analyze_error) + self._worker.start() + + @Slot(str) + def _on_worker_progress(self, message: str): + self._progress_label.setText(message) + self._status_bar.showMessage(message) + + @Slot(int, int) + def _on_worker_progress_value(self, current: int, total: int): + if self._progress_bar.maximum() != total: + self._progress_bar.setRange(0, total) + self._progress_bar.setValue(current) + + @Slot(str, object) + def _on_track_analyzed(self, filename: str, track): + """Update the severity column for a track after detectors complete.""" + row = self._find_table_row(filename) + if row < 0: + return + # Ch column + ch_item = _SortableItem(str(track.channels), track.channels) + ch_item.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, 1, ch_item) + # Analysis column + _plain, html, _color, sort_key = track_analysis_label(track) + lbl, item = _make_analysis_cell(html, sort_key) + self._track_table.setItem(row, 2, item) + self._track_table.setCellWidget(row, 2, lbl) + + @Slot(str, object) + def _on_track_planned(self, filename: str, track): + """Update classification and gain columns after processors complete.""" + row = self._find_table_row(filename) + if row < 0: + return + + # Re-evaluate severity now that processor results inform is_relevant() + dets = self._session.detectors if self._session else None + _plain, html, _color, sort_key = track_analysis_label(track, dets) + lbl, item = _make_analysis_cell(html, sort_key) + self._track_table.setItem(row, 2, item) + self._track_table.setCellWidget(row, 2, lbl) + + # Remove previous cell widgets + self._track_table.removeCellWidget(row, 3) + self._track_table.removeCellWidget(row, 4) + self._track_table.removeCellWidget(row, 5) + + from PySide6.QtWidgets import QDoubleSpinBox + from .widgets import BatchComboBox + from .theme import ( + FILE_COLOR_SILENT, FILE_COLOR_TRANSIENT, FILE_COLOR_SUSTAINED, + ) + + pr = ( + next(iter(track.processor_results.values()), None) + if track.processor_results + else None + ) + if track.status != "OK": + cls_item = _SortableItem("Error", "error") + cls_item.setForeground(FILE_COLOR_ERROR) + self._track_table.setItem(row, 3, cls_item) + gain_item = _SortableItem("", 0.0) + gain_item.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, 4, gain_item) + elif pr and pr.classification == "Silent": + cls_item = _SortableItem("Silent", "silent") + cls_item.setForeground(FILE_COLOR_SILENT) + self._track_table.setItem(row, 3, cls_item) + gain_item = _SortableItem("0.0 dB", 0.0) + gain_item.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, 4, gain_item) + elif pr: + cls_text = pr.classification or "Unknown" + if "Transient" in cls_text: + base_cls = "Transient" + elif cls_text == "Skip": + base_cls = "Skip" + elif "Sustained" in cls_text: + base_cls = "Sustained" + else: + base_cls = "Sustained" + + sort_item = _SortableItem(base_cls, base_cls.lower()) + self._track_table.setItem(row, 3, sort_item) + + combo = BatchComboBox() + combo.addItems(["Transient", "Sustained", "Skip"]) + combo.blockSignals(True) + combo.setCurrentText(base_cls) + combo.blockSignals(False) + combo.setProperty("track_filename", track.filename) + self._style_classification_combo(combo, base_cls) + combo.textActivated.connect( + lambda text, c=combo: self._on_classification_changed(text, c)) + self._track_table.setCellWidget(row, 3, combo) + + gain_db = pr.gain_db + gain_sort = _SortableItem(f"{gain_db:+.1f}", gain_db) + self._track_table.setItem(row, 4, gain_sort) + + spin = QDoubleSpinBox() + spin.setRange(-60.0, 60.0) + spin.setSingleStep(0.1) + spin.setDecimals(1) + spin.setSuffix(" dB") + spin.blockSignals(True) + spin.setValue(gain_db) + spin.blockSignals(False) + spin.setProperty("track_filename", track.filename) + spin.setEnabled(base_cls != "Skip") + spin.setStyleSheet( + f"QDoubleSpinBox {{ color: {COLORS['text']}; }}" + ) + spin.valueChanged.connect( + lambda value, s=spin: self._on_gain_changed(value, s)) + self._track_table.setCellWidget(row, 4, spin) + + # RMS Anchor combo (column 5) + self._create_anchor_combo(row, track) + + # Group combo (column 6) + self._create_group_combo(row, track) + + # Row background from group color + self._apply_row_group_color(row, track.group) + + self._auto_fit_group_column() + + @Slot(object, object) + def _on_analyze_done(self, session, summary): + self._session = session + self._summary = summary + self._analyze_action.setEnabled(True) + self._worker = None + + if not self._session_groups: + # First analysis — load from Default group preset + self._active_session_preset = "Default" + self._merge_groups_from_preset() + self._populate_group_preset_combo() + else: + # Re-analysis — restore previous group assignments by filename + prev = self._prev_group_assignments + for track in session.tracks: + track.group = prev.get(track.filename) + self._populate_groups_tab() + self._refresh_group_combos() + + self._populate_table(session) + self._render_summary() + + # Switch to tabs — summary tab + self._right_stack.setCurrentIndex(_PAGE_TABS) + self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) + self._detail_tabs.setTabEnabled(_TAB_GROUPS, True) + self._detail_tabs.setTabEnabled(_TAB_SESSION, True) + + # Enable Session Setup phase now that analysis is available + self._phase_tabs.setTabEnabled(_PHASE_SETUP, True) + self._populate_setup_table() + + # Enable Prepare button; mark stale if previously prepared + if session.prepare_state == "ready": + session.prepare_state = "stale" + self._update_prepare_button() + + self._save_session_action.setEnabled(True) + + ok_count = sum(1 for t in session.tracks if t.status == "OK") + self._status_bar.showMessage( + f"Analysis complete: {ok_count}/{len(session.tracks)} tracks OK" + ) + + @Slot(str) + def _on_analyze_error(self, message: str): + self._analyze_action.setEnabled(True) + self._worker = None + + from .helpers import esc + + self._right_stack.setCurrentIndex(_PAGE_TABS) + self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) + self._summary_view.setHtml(self._wrap_html( + f'
' + f'Analysis Error
' + f'
{esc(message)}
' + )) + self._status_bar.showMessage(f"Error: {message}") + + # ── Prepare handlers ───────────────────────────────────────────────── + + @Slot() + def _on_prepare(self): + """Run the Prepare pipeline to generate processed audio files.""" + if not self._session or not self._source_dir: + return + if self._prepare_worker is not None: + return # already running + + output_folder = self._config.get("app", {}).get( + "output_folder", "processed") + output_dir = os.path.join(self._source_dir, output_folder) + + # Refresh pipeline config from current session widgets so that + # processor enabled/disabled changes made after analysis take effect. + self._session.config.update(self._flat_config()) + + # Use the session's configured processors + processors = list(self._session.processors) if self._session.processors else [] + if not processors: + self._status_bar.showMessage("No audio processors enabled.") + return + + self._prepare_action.setEnabled(False) + self._status_bar.showMessage("Preparing processed files\u2026") + self._prepare_progress.start("Preparing\u2026") + + self._prepare_worker = PrepareWorker( + self._session, processors, output_dir) + self._prepare_worker.progress.connect(self._on_prepare_progress) + self._prepare_worker.progress_value.connect( + self._on_prepare_progress_value) + self._prepare_worker.finished.connect(self._on_prepare_done) + self._prepare_worker.error.connect(self._on_prepare_error) + self._prepare_worker.start() + + @Slot(str) + def _on_prepare_progress(self, message: str): + self._prepare_progress.set_message(message) + self._status_bar.showMessage(message) + + @Slot(int, int) + def _on_prepare_progress_value(self, current: int, total: int): + self._prepare_progress.set_progress(current, total) + + @Slot() + def _on_prepare_done(self): + self._prepare_worker = None + self._update_prepare_button() + self._update_use_processed_action() + prepared = sum( + 1 for t in self._session.tracks + if t.processed_filepath is not None + ) + errors = self._session.config.get("_prepare_errors", []) + if errors: + msg = f"Prepare complete: {prepared} file(s) written, {len(errors)} error(s)" + self._prepare_progress.finish(msg) + self._status_bar.showMessage(msg) + detail = "\n".join(f"• {fn}: {err}" for fn, err in errors) + QMessageBox.warning( + self, "Prepare — errors", + f"{len(errors)} file(s) could not be written:\n\n{detail}\n\n" + "This is usually caused by a file being open in another " + "application (e.g. the waveform player). Close the file " + "and try again.", + ) + else: + msg = f"Prepare complete: {prepared} file(s) written" + self._prepare_progress.finish(msg) + self._status_bar.showMessage(msg) + self._populate_setup_table() + + @Slot(str) + def _on_prepare_error(self, message: str): + self._prepare_worker = None + self._prepare_action.setEnabled(True) + self._prepare_progress.fail(message) + self._status_bar.showMessage(f"Prepare failed: {message}") + + def _update_prepare_button(self): + """Update the Prepare button text and enabled state based on prepare_state.""" + if not self._session: + self._prepare_action.setEnabled(False) + self._prepare_action.setText("Prepare") + self._auto_group_action.setEnabled(False) + return + + state = self._session.prepare_state + self._prepare_action.setEnabled(True) + self._auto_group_action.setEnabled(True) + if state == "ready": + self._prepare_action.setText("Prepare \u2713") + elif state == "stale": + self._prepare_action.setText("Prepare (!)") + else: + self._prepare_action.setText("Prepare") + + def _mark_prepare_stale(self): + """Mark prepared files as stale if they were previously ready.""" + if self._session and self._session.prepare_state == "ready": + self._session.prepare_state = "stale" + self._update_prepare_button() + self._update_use_processed_action() + + @Slot(bool) + def _on_processor_enabled_changed(self, _checked: bool): + """Live-update session.processors and Processing column when a + processor enabled toggle changes in the session config widgets.""" + if not self._session: + return + if getattr(self, "_loading_session_widgets", False): + return + # Re-evaluate which processors are enabled from current widget values + flat = self._flat_config() + new_processors = [] + for proc in default_processors(): + proc.configure(flat) + if proc.enabled: + new_processors.append(proc) + new_processors.sort(key=lambda p: p.priority) + self._session.processors = new_processors + self._refresh_processing_column() + self._mark_prepare_stale() + + def _refresh_processing_column(self): + """Rebuild all Processing column buttons from the current + session.processors list.""" + if not self._session: + return + processors = self._session.processors + for row in range(self._track_table.rowCount()): + fname_item = self._track_table.item(row, 0) + if not fname_item: + continue + track = next( + (t for t in self._session.tracks if t.filename == fname_item.text()), + None, + ) + if not track or track.status != "OK": + continue + # Remove old widget and recreate + self._track_table.removeCellWidget(row, 7) + self._create_processing_button(row, track) + + # ── Presentation refresh ───────────────────────────────────────────── + + def _refresh_presentation(self): + """Re-render all UI after presentation-only config changes (e.g. report_as). + + Reconfigures detector instances in-place, rebuilds the diagnostic + summary, and refreshes all visible components — without re-reading + audio or re-running analysis. + """ + if not self._session: + return + + # 1. Reconfigure detector instances with updated flat config + flat = self._flat_config() + for d in self._session.detectors: + d.configure(flat) + + # 2. Rebuild diagnostic summary (bucketing depends on report_as) + from sessionpreplib.rendering import build_diagnostic_summary + self._summary = build_diagnostic_summary(self._session) + + # 3. Re-render summary HTML + self._render_summary() + + # 4. Refresh track table Analysis column + self._refresh_analysis_column() + + # 5. Re-render current track detail + if self._current_track: + html = render_track_detail_html( + self._current_track, self._session, + show_clean=self._show_clean, verbose=self._verbose) + self._file_report.setHtml(self._wrap_html(html)) + + # 6. Refresh overlay menu (skipped detectors filtered out) + if self._current_track: + all_issues = [] + for det_result in self._current_track.detector_results.values(): + all_issues.extend(getattr(det_result, "issues", [])) + self._update_overlay_menu(all_issues) + + # 7. Apply any concurrent GUI-only changes + cmap = self._config.get("app", {}).get("spectrogram_colormap", "magma") + self._waveform.set_colormap(cmap) + + self._status_bar.showMessage("Preferences saved (display refreshed).") + + def _refresh_analysis_column(self): + """Update the Analysis column for all rows using current detector config.""" + if not self._session: + return + track_map = {t.filename: t for t in self._session.tracks} + dets = self._session.detectors if hasattr(self._session, 'detectors') else None + self._track_table.setSortingEnabled(False) + for row in range(self._track_table.rowCount()): + fname_item = self._track_table.item(row, 0) + if not fname_item: + continue + track = track_map.get(fname_item.text()) + if not track: + continue + _plain, html, _color, sort_key = track_analysis_label(track, dets) + lbl, item = _make_analysis_cell(html, sort_key) + self._track_table.setItem(row, 2, item) + self._track_table.setCellWidget(row, 2, lbl) + self._track_table.setSortingEnabled(True) diff --git a/sessionprepgui/daw_mixin.py b/sessionprepgui/daw_mixin.py new file mode 100644 index 0000000..a1a30d5 --- /dev/null +++ b/sessionprepgui/daw_mixin.py @@ -0,0 +1,656 @@ +"""DAW integration mixin: processors, fetch, transfer, folder tree, assignments.""" + +from __future__ import annotations + +from typing import Any + +from PySide6.QtCore import Qt, Slot, QSize +from PySide6.QtGui import QAction, QColor, QIcon, QPainter, QPen, QPixmap +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QSizePolicy, + QSplitter, + QStackedWidget, + QTableWidget, + QToolBar, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from sessionpreplib.daw_processors import create_runtime_daw_processors + +from .table_widgets import ( + _FolderDropTree, _SetupDragTable, + _SETUP_RIGHT_PLACEHOLDER, _SETUP_RIGHT_TREE, +) +from .theme import COLORS, PT_DEFAULT_COLORS +from .widgets import ProgressPanel +from .worker import DawCheckWorker, DawFetchWorker, DawTransferWorker + + +class DawMixin: + """DAW integration: processors, fetch, transfer, folder tree, assignments. + + Mixed into ``SessionPrepWindow`` — not meant to be used standalone. + """ + + # ── Setup page builder ─────────────────────────────────────────────── + + def _build_setup_page(self) -> QWidget: + """Build the Session Setup phase page with its own toolbar.""" + page = QWidget() + layout = QVBoxLayout(page) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + # Setup toolbar (embedded in page) + self._setup_toolbar = QToolBar("Session Setup") + self._setup_toolbar.setIconSize(QSize(16, 16)) + self._setup_toolbar.setMovable(False) + self._setup_toolbar.setFloatable(False) + + # ── Left: DAW processor selection + status label ──────────────── + self._daw_combo = QComboBox() + self._daw_combo.setMinimumWidth(140) + self._setup_toolbar.addWidget(self._daw_combo) + + self._daw_check_label = QLabel("") + self._daw_check_label.setContentsMargins(6, 0, 0, 0) + self._daw_check_label.setMaximumWidth(260) + self._setup_toolbar.addWidget(self._daw_check_label) + + self._setup_toolbar.addSeparator() + + # ── Use Processed checkbox ───────────────────────────────────── + self._use_processed_cb = QCheckBox("Use Processed") + self._use_processed_cb.setLayoutDirection(Qt.RightToLeft) + self._use_processed_cb.setEnabled(False) + self._use_processed_cb.toggled.connect(self._on_use_processed_toggled) + self._setup_toolbar.addWidget(self._use_processed_cb) + + # ── Spacer ───────────────────────────────────────────────────── + spacer = QWidget() + spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) + self._setup_toolbar.addWidget(spacer) + + # ── Right: lifecycle actions ─────────────────────────────────── + self._fetch_action = QAction("Fetch", self) + self._fetch_action.setEnabled(False) + self._fetch_action.triggered.connect(self._on_daw_fetch) + self._setup_toolbar.addAction(self._fetch_action) + + self._auto_assign_action = QAction("Auto-Assign", self) + self._auto_assign_action.setEnabled(False) + self._auto_assign_action.triggered.connect(self._on_auto_assign) + self._setup_toolbar.addAction(self._auto_assign_action) + + self._transfer_action = QAction("Transfer", self) + self._transfer_action.setEnabled(False) + self._transfer_action.triggered.connect(self._on_daw_transfer) + self._setup_toolbar.addAction(self._transfer_action) + + self._sync_action = QAction("Sync", self) + self._sync_action.setEnabled(False) + self._setup_toolbar.addAction(self._sync_action) + + # Populate combo after ALL toolbar widgets exist, then connect signal + self._populate_daw_combo() + self._daw_combo.currentIndexChanged.connect(self._on_daw_combo_changed) + + layout.addWidget(self._setup_toolbar) + + # Splitter: track table (left) + routing panel placeholder (right) + self._setup_splitter = setup_splitter = QSplitter(Qt.Horizontal) + + # ── Left: track table ───────────────────────────────────────────── + self._setup_table = _SetupDragTable() + self._setup_table.setColumnCount(6) + self._setup_table.setHorizontalHeaderLabels( + ["", "File", "Ch", "Clip Gain", "Fader Gain", "Group"] + ) + self._setup_table.setSelectionBehavior(QTableWidget.SelectRows) + self._setup_table.setSelectionMode(QTableWidget.ExtendedSelection) + self._setup_table.setEditTriggers(QTableWidget.NoEditTriggers) + self._setup_table.verticalHeader().setVisible(False) + self._setup_table.setMinimumWidth(300) + self._setup_table.setShowGrid(True) + self._setup_table.setAlternatingRowColors(True) + self._setup_table.setSortingEnabled(True) + + sh = self._setup_table.horizontalHeader() + sh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + sh.setSectionResizeMode(0, QHeaderView.Fixed) + sh.resizeSection(0, 24) + sh.setSectionResizeMode(1, QHeaderView.Stretch) + sh.setSectionResizeMode(2, QHeaderView.Fixed) + sh.setSectionResizeMode(3, QHeaderView.Interactive) + sh.setSectionResizeMode(4, QHeaderView.Interactive) + sh.setSectionResizeMode(5, QHeaderView.Interactive) + sh.resizeSection(2, 30) + sh.resizeSection(3, 90) + sh.resizeSection(4, 90) + sh.resizeSection(5, 110) + + setup_splitter.addWidget(self._setup_table) + + # ── Right: stacked widget (placeholder / folder tree) ───────────── + self._setup_right_stack = QStackedWidget() + + # Page 0: placeholder + right_placeholder = QWidget() + right_layout = QVBoxLayout(right_placeholder) + right_layout.setContentsMargins(40, 0, 40, 0) + right_layout.addStretch(2) + placeholder_label = QLabel("Connect to a DAW to configure routing") + placeholder_label.setAlignment(Qt.AlignCenter) + placeholder_label.setStyleSheet( + f"color: {COLORS['dim']}; font-size: 13pt;") + right_layout.addWidget(placeholder_label) + right_layout.addStretch(3) + self._setup_right_stack.addWidget(right_placeholder) + + # Page 1: folder tree + transfer progress panel + tree_page = QWidget() + tree_page_layout = QVBoxLayout(tree_page) + tree_page_layout.setContentsMargins(0, 0, 0, 0) + tree_page_layout.setSpacing(0) + + self._folder_tree = _FolderDropTree() + self._folder_tree.setHeaderLabels(["Folder / Track"]) + self._folder_tree.setSelectionMode(QTreeWidget.ExtendedSelection) + self._folder_tree.setAlternatingRowColors(True) + # Match visual size to the setup table; semi-transparent selection + self._folder_tree.setStyleSheet( + "QTreeWidget { font-size: 10pt; }" + "QTreeWidget::item { min-height: 22px; }" + "QTreeWidget::item:selected {" + " background-color: rgba(42, 109, 181, 128);" + "}" + ) + self._folder_tree.tracks_dropped.connect(self._assign_tracks_to_folder) + self._folder_tree.tracks_unassigned.connect(self._unassign_tracks) + tree_page_layout.addWidget(self._folder_tree, 1) + + # Transfer progress panel (hidden by default) + self._transfer_progress = ProgressPanel() + tree_page_layout.addWidget(self._transfer_progress) + + self._setup_right_stack.addWidget(tree_page) + + self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_PLACEHOLDER) + + setup_splitter.addWidget(self._setup_right_stack) + setup_splitter.setStretchFactor(0, 3) + setup_splitter.setStretchFactor(1, 2) + setup_splitter.setSizes([620, 480]) + + layout.addWidget(setup_splitter, 1) + + return page + + # ── DAW processor helpers ───────────────────────────────────────────── + + def _configure_daw_processors(self): + """Rebuild DAW processor list from the current flat config. + + Uses the runtime factory so DAWProject templates are expanded + into individual processor instances. + """ + flat = self._flat_config() + self._daw_processors = create_runtime_daw_processors(flat) + + def _populate_daw_combo(self): + """Fill the DAW dropdown with enabled processors.""" + self._daw_combo.blockSignals(True) + self._daw_combo.clear() + for i, dp in enumerate(self._daw_processors): + if dp.enabled: + self._daw_combo.addItem(dp.name, i) + self._daw_combo.blockSignals(False) + if self._daw_combo.count() > 0: + self._on_daw_combo_changed(0) + else: + self._active_daw_processor = None + + def _update_daw_lifecycle_buttons(self): + """Enable/disable Fetch/Transfer/Sync based on active processor state.""" + has_processor = self._active_daw_processor is not None + self._fetch_action.setEnabled(has_processor) + dp_id = self._active_daw_processor.id if has_processor else None + dp_state = ( + self._session.daw_state.get(dp_id, {}) + if self._session and dp_id else {} + ) + has_folders = bool(dp_state.get("folders")) + has_assignments = bool(dp_state.get("assignments")) + self._auto_assign_action.setEnabled(has_folders) + self._transfer_action.setEnabled(has_processor and has_assignments) + self._sync_action.setEnabled(False) + + @Slot(int) + def _on_daw_combo_changed(self, index: int): + if index < 0 or index >= self._daw_combo.count(): + self._active_daw_processor = None + else: + proc_idx = self._daw_combo.itemData(index) + self._active_daw_processor = self._daw_processors[proc_idx] + self._daw_check_label.setText("") + self._update_daw_lifecycle_buttons() + + def _run_daw_check_then(self, on_success): + """Run a connectivity check; on success call *on_success*.""" + if not self._active_daw_processor: + return + self._pending_after_check = on_success + self._daw_check_label.setText("Connecting\u2026") + self._daw_check_label.setStyleSheet(f"color: {COLORS['dim']};") + self._daw_check_worker = DawCheckWorker(self._active_daw_processor) + self._daw_check_worker.result.connect(self._on_daw_check_result) + self._daw_check_worker.start() + + @Slot(bool, str) + def _on_daw_check_result(self, ok: bool, message: str): + self._daw_check_worker = None + if ok: + self._daw_check_label.setText(message) + self._daw_check_label.setStyleSheet(f"color: {COLORS['clean']};") + cb = self._pending_after_check + self._pending_after_check = None + if cb: + cb() + else: + self._daw_check_label.setText("Connection failed") + self._daw_check_label.setStyleSheet(f"color: {COLORS['problems']};") + self._pending_after_check = None + QMessageBox.warning( + self, "Connection Failed", + f"{self._active_daw_processor.name} connection could " + f"not be established.\n\n{message}") + self._update_daw_lifecycle_buttons() + + # ── DAW Fetch + Folder Tree ─────────────────────────────────────────── + + @Slot() + def _on_daw_fetch(self): + if not self._active_daw_processor or not self._session: + return + self._fetch_action.setEnabled(False) + self._run_daw_check_then(self._do_daw_fetch) + + def _do_daw_fetch(self): + """Actually start the fetch (called after successful connectivity check).""" + self._status_bar.showMessage("Fetching folder structure\u2026") + self._daw_fetch_worker = DawFetchWorker( + self._active_daw_processor, self._session) + self._daw_fetch_worker.result.connect(self._on_daw_fetch_result) + self._daw_fetch_worker.start() + + @Slot(bool, str, object) + def _on_daw_fetch_result(self, ok: bool, message: str, session): + self._daw_fetch_worker = None + self._fetch_action.setEnabled(True) + if ok and session is not None: + self._session = session + self._populate_folder_tree() + self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_TREE) + self._populate_setup_table() + self._status_bar.showMessage(message) + else: + self._status_bar.showMessage(f"Fetch failed: {message}") + self._update_daw_lifecycle_buttons() + + # ── Use Processed checkbox ────────────────────────────────────────── + + @Slot(bool) + def _on_use_processed_toggled(self, checked: bool): + if self._session: + self._session.config["_use_processed"] = checked + self._update_use_processed_action() + + def _update_use_processed_action(self): + """Update the Use Processed checkbox enabled state and stale indicator.""" + if not self._session: + self._use_processed_cb.setEnabled(False) + self._use_processed_cb.setText("Use Processed") + return + + state = self._session.prepare_state + has_prepared = state in ("ready", "stale") + self._use_processed_cb.setEnabled(has_prepared) + + if state == "stale" and self._use_processed_cb.isChecked(): + self._use_processed_cb.setText("Use Processed (!)") + else: + self._use_processed_cb.setText("Use Processed") + + # ── DAW Transfer ───────────────────────────────────────────────────── + + @Slot() + def _on_daw_transfer(self): + if not self._active_daw_processor or not self._session: + return + self._transfer_action.setEnabled(False) + self._fetch_action.setEnabled(False) + self._run_daw_check_then(self._do_daw_transfer) + + def _do_daw_transfer(self): + """Actually start the transfer (called after successful connectivity check).""" + if not self._active_daw_processor or not self._session: + return + + output_folder = self._config.get("app", {}).get("output_folder", "processed") + + # Refresh pipeline config from current session widgets so that + # processor enabled/disabled changes made after analysis take effect. + self._session.config.update(self._flat_config()) + # Inject GUI config (groups + colors) into session.config so + # transfer() can resolve group → color ARGB + self._session.config.setdefault("gui", {})["groups"] = list( + self._session_groups) + colors = self._config.get("colors", PT_DEFAULT_COLORS) + self._session.config["gui"]["colors"] = colors + # Keep source dir / output folder in config for processor.resolve_output_path() + self._session.config["_source_dir"] = self._source_dir + self._session.config["_output_folder"] = output_folder + + # ── Let the processor decide the output path (shows dialog if needed) ─ + output_path = self._active_daw_processor.resolve_output_path( + self._session, self) + if output_path is None: + self._update_daw_lifecycle_buttons() + return + + dp_name = self._active_daw_processor.name + self._status_bar.showMessage(f"Transferring to {dp_name}\u2026") + self._transfer_progress.start("Preparing\u2026") + + self._daw_transfer_worker = DawTransferWorker( + self._active_daw_processor, self._session, output_path) + self._daw_transfer_worker.progress.connect(self._on_transfer_progress) + self._daw_transfer_worker.progress_value.connect( + self._on_transfer_progress_value) + self._daw_transfer_worker.result.connect(self._on_daw_transfer_result) + self._daw_transfer_worker.start() + + @Slot(str) + def _on_transfer_progress(self, message: str): + self._transfer_progress.set_message(message) + self._status_bar.showMessage(message) + + @Slot(int, int) + def _on_transfer_progress_value(self, current: int, total: int): + self._transfer_progress.set_progress(current, total) + + @Slot(bool, str, object) + def _on_daw_transfer_result(self, ok: bool, message: str, results): + self._daw_transfer_worker = None + self._update_daw_lifecycle_buttons() + if ok: + self._transfer_progress.finish(message) + self._status_bar.showMessage(message) + else: + self._transfer_progress.fail(message) + self._status_bar.showMessage(f"Transfer failed: {message}") + + # ── Folder tree ────────────────────────────────────────────────────── + + def _populate_folder_tree(self): + """Build the folder tree from the active DAW processor's daw_state.""" + self._folder_tree.clear() + if not self._session or not self._active_daw_processor: + return + dp_state = self._session.daw_state.get(self._active_daw_processor.id, {}) + folders = dp_state.get("folders", []) + assignments = dp_state.get("assignments", {}) + + # Build lookup: id -> folder dict + folder_map = {f["id"]: f for f in folders} + # Build children map: parent_id -> [child folders] + children_map: dict[str | None, list] = {} + for f in folders: + parent = f["parent_id"] + children_map.setdefault(parent, []).append(f) + + # Sort children by index + for k in children_map: + children_map[k].sort(key=lambda f: f["index"]) + + # Build inverse assignments: folder_id -> [filenames] + # Use track_order for stable ordering, fall back to sorted + track_order = dp_state.get("track_order", {}) + folder_tracks: dict[str, list[str]] = {} + for fname, fid in assignments.items(): + folder_tracks.setdefault(fid, []).append(fname) + for fid, fnames in folder_tracks.items(): + order = track_order.get(fid, []) + order_map = {n: i for i, n in enumerate(order)} + fnames.sort(key=lambda n: (order_map.get(n, len(order)), n)) + + # Group color map for track items + gcm = self._group_color_map() + track_map = {} + if self._session: + track_map = {t.filename: t for t in self._session.tracks} + + # Icons – small colored squares to distinguish folder types + def _folder_icon(color_hex: str) -> QIcon: + sz = 14 + pix = QPixmap(sz, sz) + pix.fill(Qt.transparent) + p = QPainter(pix) + p.setRenderHint(QPainter.Antialiasing) + p.setBrush(QColor(color_hex)) + p.setPen(QPen(QColor(color_hex).darker(130), 1)) + p.drawRoundedRect(1, 1, sz - 2, sz - 2, 3, 3) + p.end() + return QIcon(pix) + + routing_icon = _folder_icon(COLORS["information"]) # blue + basic_icon = _folder_icon(COLORS["dim"]) # grey + + def add_folder(parent_widget, folder): + item = QTreeWidgetItem(parent_widget) + item.setText(0, folder["name"]) + item.setData(0, Qt.UserRole, folder["id"]) + item.setData(0, Qt.UserRole + 1, "folder") + if folder["folder_type"] == "routing": + item.setIcon(0, routing_icon) + else: + item.setIcon(0, basic_icon) + item.setFlags( + (item.flags() | Qt.ItemIsDropEnabled) + & ~Qt.ItemIsDragEnabled) + + # Add assigned tracks as children + for fname in folder_tracks.get(folder["id"], []): + track_item = QTreeWidgetItem(item) + track_item.setText(0, fname) + track_item.setData(0, Qt.UserRole, fname) + track_item.setData(0, Qt.UserRole + 1, "track") + track_item.setFlags( + (track_item.flags() | Qt.ItemIsDragEnabled) + & ~Qt.ItemIsDropEnabled) + # Row background from group color (matches table tint) + tc = track_map.get(fname) + if tc and tc.group: + tint = self._tint_group_color(tc.group, gcm) + if tint: + track_item.setBackground(0, tint) + + # Recurse into child folders + for child in children_map.get(folder["id"], []): + add_folder(item, child) + + item.setExpanded(True) + + # Top-level folders (no parent) + for f in children_map.get(None, []): + add_folder(self._folder_tree, f) + + self._folder_tree.expandAll() + + # ── Track assignments ──────────────────────────────────────────────── + + @Slot(list, str, int) + def _assign_tracks_to_folder(self, filenames: list[str], + folder_id: str, insert_index: int = -1): + """Assign session tracks to a DAW folder in the local data model.""" + if not self._session or not self._active_daw_processor: + return + dp_state = self._session.daw_state.setdefault(self._active_daw_processor.id, {}) + assignments = dp_state.setdefault("assignments", {}) + track_order = dp_state.setdefault("track_order", {}) + + # Remove tracks from their previous folder order lists + for fname in filenames: + old_fid = assignments.get(fname) + if old_fid and old_fid in track_order: + try: + track_order[old_fid].remove(fname) + except ValueError: + pass + + # Update assignment mapping + for fname in filenames: + assignments[fname] = folder_id + + # Insert into track_order for the target folder + order = track_order.setdefault(folder_id, []) + # Remove duplicates already in the list + for fname in filenames: + try: + order.remove(fname) + except ValueError: + pass + if insert_index < 0 or insert_index >= len(order): + order.extend(filenames) + else: + for i, fname in enumerate(filenames): + order.insert(insert_index + i, fname) + + self._populate_folder_tree() + self._populate_setup_table() + self._update_daw_lifecycle_buttons() + + @Slot(list) + def _unassign_tracks(self, filenames: list[str]): + """Remove track-to-folder assignments and refresh UI.""" + if not self._session or not self._active_daw_processor: + return + dp_state = self._session.daw_state.get(self._active_daw_processor.id) + if not dp_state: + return + assignments = dp_state.get("assignments", {}) + track_order = dp_state.get("track_order", {}) + for fname in filenames: + fid = assignments.pop(fname, None) + if fid and fid in track_order: + try: + track_order[fid].remove(fname) + except ValueError: + pass + self._populate_folder_tree() + self._populate_setup_table() + self._update_daw_lifecycle_buttons() + + # ── Auto-Assign ────────────────────────────────────────────────────── + + @Slot() + def _on_auto_assign(self): + """Auto-assign unassigned tracks to folders based on group DAW targets.""" + if not self._session or not self._active_daw_processor: + return + dp_id = self._active_daw_processor.id + dp_state = self._session.daw_state.get(dp_id, {}) + folders = dp_state.get("folders", []) + assignments = dp_state.get("assignments", {}) + if not folders: + return + + # Build folder name lookup: lowered+trimmed name → folder id + folder_by_name: dict[str, str] = {} + for f in folders: + key = f["name"].strip().lower() + if key and key not in folder_by_name: + folder_by_name[key] = f["id"] + + # Build group → daw_target lookup from session groups + group_target: dict[str, str] = {} + for g in self._session_groups: + dt = g.get("daw_target", "").strip() + if dt: + group_target[g["name"]] = dt.lower() + + if not group_target: + QMessageBox.information( + self, "Auto-Assign", + "No DAW targets are configured.\n\n" + "Open the Groups tab and set a DAW Target for each " + "group that should be mapped to a DAW folder.") + return + + # Collect assignments: folder_id → [filenames] + batch: dict[str, list[str]] = {} + no_group = 0 + no_target = 0 + no_folder = 0 + already_assigned = 0 + for track in self._session.tracks: + # Skip already-assigned tracks + if track.filename in assignments: + already_assigned += 1 + continue + # Skip tracks without a group or without a DAW target + if not track.group: + no_group += 1 + continue + target_key = group_target.get(track.group) + if not target_key: + no_target += 1 + continue + folder_id = folder_by_name.get(target_key) + if not folder_id: + no_folder += 1 + continue + batch.setdefault(folder_id, []).append(track.filename) + + if not batch: + reasons: list[str] = [] + if no_group: + reasons.append( + f"\u2022 {no_group} track(s) have no group assigned.") + if no_target: + reasons.append( + f"\u2022 {no_target} track(s) belong to groups without " + "a DAW target.") + if no_folder: + reasons.append( + f"\u2022 {no_folder} track(s) have DAW targets that " + "don\u2019t match any fetched folder name.") + if already_assigned: + reasons.append( + f"\u2022 {already_assigned} track(s) are already " + "assigned.") + detail = "\n".join(reasons) if reasons else ( + "No unassigned tracks found.") + QMessageBox.information( + self, "Auto-Assign", + f"Nothing to assign.\n\n{detail}") + return + + # Apply assignments in bulk + total = 0 + for folder_id, fnames in batch.items(): + self._assign_tracks_to_folder(fnames, folder_id) + total += len(fnames) + + self._status_bar.showMessage( + f"Auto-Assign: assigned {total} track(s) to " + f"{len(batch)} folder(s).") diff --git a/sessionprepgui/detail_mixin.py b/sessionprepgui/detail_mixin.py new file mode 100644 index 0000000..d718b87 --- /dev/null +++ b/sessionprepgui/detail_mixin.py @@ -0,0 +1,361 @@ +"""Detail view mixin: file detail, waveform, overlays, and playback.""" + +from __future__ import annotations + +import os +from typing import Any + +from PySide6.QtCore import Qt, Slot, QTimer +from PySide6.QtGui import QAction + +from sessionpreplib.audio import get_window_samples + +from .helpers import fmt_time +from .report import render_summary_html, render_track_detail_html +from .table_widgets import _TAB_FILE, _TAB_SUMMARY +from .theme import COLORS +from .worker import AudioLoadWorker +from .waveform import WaveformLoadWorker + + +class DetailMixin: + """File detail view, waveform display, overlays, and playback. + + Mixed into ``SessionPrepWindow`` — not meant to be used standalone. + """ + + # ── Report rendering ────────────────────────────────────────────────── + + @property + def _show_clean(self) -> bool: + if self._session_config is not None: + cfg = self._read_session_config() + return cfg.get("presentation", {}).get( + "show_clean_detectors", False) + preset = self._active_preset() + return preset.get("presentation", {}).get("show_clean_detectors", False) + + @property + def _verbose(self) -> bool: + return self._config.get("app", {}).get("report_verbosity", "normal") == "verbose" + + def _render_summary(self): + """Render the diagnostic summary into the Summary tab.""" + if not self._summary or not self._session: + return + html = render_summary_html( + self._summary, show_faders=False, + show_clean=self._show_clean, + ) + self._summary_view.setHtml(self._wrap_html(html)) + + def _show_track_detail(self, track): + """Populate the File tab with per-track detail + waveform. + + The HTML report is rendered and displayed immediately so the UI + feels responsive. Waveform loading (dtype conversion, peak + finding, RMS setup) is deferred to the next event-loop iteration + via ``QTimer.singleShot`` so the tab switch paints first. + """ + self._on_stop() + self._current_track = track + + # Show HTML report immediately + html = render_track_detail_html(track, self._session, + show_clean=self._show_clean, + verbose=self._verbose) + self._file_report.setHtml(self._wrap_html(html)) + + # Enable and switch to File tab before heavy work + self._detail_tabs.setTabEnabled(_TAB_FILE, True) + self._detail_tabs.setCurrentIndex(_TAB_FILE) + + # Defer waveform loading so the UI repaints first + QTimer.singleShot(0, lambda: self._load_waveform(track)) + + def _load_waveform(self, track): + """Start background waveform loading for *track*.""" + # Guard: user may have clicked a different track while we were queued + if self._current_track is not track: + return + + # Cancel any in-flight workers + if self._wf_worker is not None: + self._wf_worker.cancel() + self._wf_worker.finished.disconnect() + self._wf_worker = None + if self._audio_load_worker is not None: + self._audio_load_worker.cancel() + self._audio_load_worker.finished.disconnect() + self._audio_load_worker = None + + # If audio_data is absent but the file exists, load it from disk first + if (track.audio_data is None or track.audio_data.size == 0) and \ + track.status == "OK" and os.path.isfile(track.filepath): + self._waveform.set_loading(True) + if self._detail_tabs.currentIndex() == _TAB_FILE: + self._wf_container.setVisible(True) + self._play_btn.setEnabled(False) + self._update_time_label(0) + + worker = AudioLoadWorker(track, parent=self) + self._audio_load_worker = worker + worker.finished.connect( + lambda t, orig=track: self._on_audio_loaded(t, orig)) + worker.error.connect( + lambda msg: self._on_audio_load_error(msg, track)) + worker.start() + return + + has_audio = track.audio_data is not None and track.audio_data.size > 0 + if has_audio: + self._waveform.set_loading(True) + if self._detail_tabs.currentIndex() == _TAB_FILE: + self._wf_container.setVisible(True) + self._play_btn.setEnabled(False) + self._update_time_label(0) + + flat_cfg = self._flat_config() + win_ms = flat_cfg.get("window", 400) + ws = get_window_samples(track, win_ms) + + self._wf_worker = WaveformLoadWorker( + track.audio_data, track.samplerate, ws, + spec_n_fft=self._waveform._spec_n_fft, + spec_window=self._waveform._spec_window, + parent=self) + self._wf_worker.finished.connect( + lambda result, t=track: self._on_waveform_loaded(result, t)) + self._wf_worker.start() + else: + self._waveform.set_audio(None, 44100) + self._update_overlay_menu([]) + if self._detail_tabs.currentIndex() == _TAB_FILE: + self._wf_container.setVisible(False) + self._play_btn.setEnabled(False) + self._update_time_label(0) + + @Slot(object, object) + def _on_waveform_loaded(self, result: dict, track): + """Receive pre-computed waveform data from the background worker.""" + self._wf_worker = None + + # Discard if user switched to a different track + if self._current_track is not track: + return + + self._waveform.set_precomputed(result) + cmap = self._config.get("app", {}).get("spectrogram_colormap", "magma") + self._waveform.set_colormap(cmap) + # Sync colormap dropdown with preference + for act in self._cmap_group.actions(): + if act.data() == cmap: + act.setChecked(True) + break + + all_issues = [] + for det_result in track.detector_results.values(): + all_issues.extend(getattr(det_result, "issues", [])) + self._waveform.set_issues(all_issues) + self._update_overlay_menu(all_issues) + self._play_btn.setEnabled(True) + self._update_time_label(0) + + def _on_audio_loaded(self, track, orig_track): + """Audio data loaded from disk; proceed to waveform rendering.""" + self._audio_load_worker = None + # Discard if user switched tracks while we were loading + if self._current_track is not orig_track: + return + # Now kick off the normal waveform worker path + self._load_waveform(track) + + def _on_audio_load_error(self, message: str, track): + """Audio file could not be read from disk.""" + self._audio_load_worker = None + if self._current_track is not track: + return + self._waveform.set_audio(None, 44100) + self._wf_container.setVisible(False) + self._play_btn.setEnabled(False) + self._status_bar.showMessage(f"Could not load audio: {message}") + + # ── Overlay dropdown ──────────────────────────────────────────────── + + def _update_overlay_menu(self, issues: list): + """Rebuild the overlay dropdown menu based on current track issues.""" + self._overlay_menu.clear() + self._waveform.set_enabled_overlays(set()) + + if not issues: + self._overlay_btn.setText("Detector Overlays") + return + + # Build detector instance map from session + det_map: dict[str, object] = {} + det_names: dict[str, str] = {} + if self._session and hasattr(self._session, "detectors"): + for d in self._session.detectors: + det_map[d.id] = d + det_names[d.id] = d.name + + # Filter out issues from detectors that suppress themselves or are skipped + track = self._current_track + filtered_issues = [] + for issue in issues: + det = det_map.get(issue.label) + if det and track: + result = track.detector_results.get(issue.label) + if result: + if hasattr(det, 'effective_severity') and det.effective_severity(result) is None: + continue + if not det.is_relevant(result, track): + continue + filtered_issues.append(issue) + + if not filtered_issues: + self._overlay_btn.setText("Detector Overlays") + return + + # Build {label: count} from filtered issue list + label_counts: dict[str, int] = {} + for issue in filtered_issues: + label_counts[issue.label] = label_counts.get(issue.label, 0) + 1 + + # Add a checkable action per detector that has issues + for label in sorted(label_counts, key=lambda lb: det_names.get(lb, lb).lower()): + name = det_names.get(label, label) + count = label_counts[label] + action = self._overlay_menu.addAction(f"{name} ({count})") + action.setCheckable(True) + action.setChecked(False) + action.setData(label) + action.toggled.connect(self._on_overlay_toggled) + + self._overlay_btn.setText("Detector Overlays") + + @Slot() + def _on_overlay_toggled(self): + """Collect checked overlay labels and update the waveform.""" + checked = set() + for action in self._overlay_menu.actions(): + if action.isChecked(): + checked.add(action.data()) + self._waveform.set_enabled_overlays(checked) + n = len(checked) + self._overlay_btn.setText(f"Detector Overlays ({n})" if n else "Detector Overlays") + + @Slot(QAction) + def _on_display_mode_changed(self, action): + """Switch waveform widget display mode and toggle toolbar controls.""" + is_waveform = action == self._wf_action + mode = "waveform" if is_waveform else "spectrogram" + self._display_mode_btn.setText(action.text()) + self._waveform.set_display_mode(mode) + + # Hide waveform-only toolbar controls in spectrogram mode + self._wf_settings_btn.setVisible(is_waveform) + self._markers_toggle.setVisible(is_waveform) + self._rms_lr_toggle.setVisible(is_waveform) + self._rms_avg_toggle.setVisible(is_waveform) + # Show spectrogram-only controls + self._spec_settings_btn.setVisible(not is_waveform) + + @Slot(bool) + def _on_wf_aa_changed(self, checked: bool): + self._waveform.set_wf_antialias(checked) + + @Slot(QAction) + def _on_wf_line_width_changed(self, action): + self._waveform.set_wf_line_width(int(action.data())) + + @Slot(QAction) + def _on_spec_fft_changed(self, action): + self._waveform.set_spec_fft(int(action.data())) + + @Slot(QAction) + def _on_spec_window_changed(self, action): + self._waveform.set_spec_window(action.data()) + + @Slot(QAction) + def _on_spec_cmap_changed(self, action): + self._waveform.set_colormap(action.data()) + + @Slot(QAction) + def _on_spec_floor_changed(self, action): + self._waveform.set_spec_db_floor(float(action.data())) + + @Slot(QAction) + def _on_spec_ceil_changed(self, action): + self._waveform.set_spec_db_ceil(float(action.data())) + + # ── Playback ────────────────────────────────────────────────────────── + + @Slot() + def _on_toggle_play(self): + if self._playback.is_playing: + self._on_stop() + elif self._current_track is not None: + self._on_play() + + @Slot() + def _on_play(self): + track = self._current_track + if track is None or track.audio_data is None: + return + self._on_stop() + start = self._waveform._cursor_sample + self._playback.play(track.audio_data, track.samplerate, start, + mono=self._mono_btn.isChecked()) + if self._playback.is_playing: + self._play_btn.setEnabled(False) + self._stop_btn.setEnabled(True) + + @Slot() + def _on_stop(self): + was_playing = self._playback.is_playing + start_sample = self._playback.play_start_sample + self._playback.stop() + self._stop_btn.setEnabled(False) + if self._current_track is not None: + self._play_btn.setEnabled(True) + if was_playing: + self._waveform.set_cursor(start_sample) + self._update_time_label(start_sample) + + @Slot(int) + def _on_cursor_updated(self, sample_pos: int): + self._waveform.set_cursor(sample_pos) + self._update_time_label(sample_pos) + + @Slot() + def _on_playback_finished(self): + self._stop_btn.setEnabled(False) + if self._current_track is not None: + self._play_btn.setEnabled(True) + self._waveform.set_cursor(0) + self._update_time_label(0) + + @Slot(str) + def _on_playback_error(self, message: str): + self._status_bar.showMessage(f"Playback error: {message}") + + @Slot(int) + def _on_waveform_seek(self, sample_index: int): + if self._playback.is_playing: + self._on_stop() + self._waveform.set_cursor(sample_index) + self._on_play() + else: + self._update_time_label(sample_index) + + def _update_time_label(self, sample_pos: int = 0): + track = self._current_track + if track is None or track.samplerate <= 0: + self._time_label.setText("00:00 / 00:00") + return + sr = track.samplerate + self._time_label.setText( + f"{fmt_time(sample_pos / sr)} / {fmt_time(track.total_samples / sr)}" + f" \u2022 {sample_pos:,}" + ) diff --git a/sessionprepgui/groups_mixin.py b/sessionprepgui/groups_mixin.py new file mode 100644 index 0000000..6db25ca --- /dev/null +++ b/sessionprepgui/groups_mixin.py @@ -0,0 +1,914 @@ +"""Groups mixin: group management, colors, group column, auto-group, linked levels.""" + +from __future__ import annotations + +import copy +import os +import re +from typing import Any + +from PySide6.QtCore import Qt, Slot, QSize +from PySide6.QtGui import QColor, QIcon, QPixmap +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QHBoxLayout, + QHeaderView, + QLabel, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from .preferences import _argb_to_qcolor +from .settings import build_defaults, save_config +from .table_widgets import _SortableItem +from .theme import COLORS, PT_DEFAULT_COLORS +from .widgets import BatchComboBox + + +class GroupsMixin: + """Group management: groups tab, colors, group column, auto-group, linked levels. + + Mixed into ``SessionPrepWindow`` — not meant to be used standalone. + """ + + # ── Groups tab (session-local group editor) ───────────────────────── + + def _build_groups_tab(self) -> QWidget: + """Build the session-local Groups editor tab.""" + page = QWidget() + page.setAutoFillBackground(True) + layout = QVBoxLayout(page) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(6) + + desc = QLabel( + "Session-local track groups. Changes here apply only to " + "the current session." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #888; font-size: 9pt;") + layout.addWidget(desc) + + self._groups_tab_table = QTableWidget() + self._groups_tab_table.setColumnCount(6) + self._groups_tab_table.setHorizontalHeaderLabels( + ["Name", "Color", "Gain-Linked", "DAW Target", + "Match", "Match Pattern"]) + vh = self._groups_tab_table.verticalHeader() + vh.setSectionsMovable(True) + vh.sectionMoved.connect(self._on_groups_tab_row_moved) + self._groups_tab_table.setSelectionBehavior(QTableWidget.SelectRows) + self._groups_tab_table.setSelectionMode(QTableWidget.SingleSelection) + gh = self._groups_tab_table.horizontalHeader() + gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + gh.setSectionResizeMode(0, QHeaderView.Stretch) + gh.setSectionResizeMode(1, QHeaderView.Fixed) + gh.resizeSection(1, 160) + gh.setSectionResizeMode(2, QHeaderView.Fixed) + gh.resizeSection(2, 80) + gh.setSectionResizeMode(3, QHeaderView.Interactive) + gh.resizeSection(3, 140) + gh.setSectionResizeMode(4, QHeaderView.Fixed) + gh.resizeSection(4, 90) + gh.setSectionResizeMode(5, QHeaderView.Interactive) + gh.resizeSection(5, 200) + + self._groups_tab_table.cellChanged.connect( + self._on_groups_tab_name_changed) + + layout.addWidget(self._groups_tab_table, 1) + + # Buttons + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(6) + + add_btn = QPushButton("Add") + add_btn.clicked.connect(self._on_groups_tab_add) + btn_row.addWidget(add_btn) + + remove_btn = QPushButton("Remove") + remove_btn.clicked.connect(self._on_groups_tab_remove) + btn_row.addWidget(remove_btn) + + reset_btn = QPushButton("Reset from Preset") + reset_btn.clicked.connect(self._on_groups_tab_reset) + btn_row.addWidget(reset_btn) + + btn_row.addStretch() + + az_btn = QPushButton("Sort A→Z") + az_btn.clicked.connect(self._on_groups_tab_sort_az) + btn_row.addWidget(az_btn) + + layout.addLayout(btn_row) + + return page + + # ── Color helpers ───────────────────────────────────────────────────── + + def _color_names_from_config(self) -> list[str]: + """Return color names from the current config (or defaults).""" + colors = self._config.get("colors", PT_DEFAULT_COLORS) + return [c["name"] for c in colors if c.get("name")] + + def _color_argb_by_name(self, name: str) -> str | None: + """Look up ARGB hex by color name from config, falling back to defaults.""" + colors = self._config.get("colors", PT_DEFAULT_COLORS) + for c in colors: + if c.get("name") == name: + return c.get("argb") + # Fallback: check built-in defaults (handles stale saved configs) + for c in PT_DEFAULT_COLORS: + if c.get("name") == name: + return c.get("argb") + return None + + @staticmethod + def _color_swatch_icon(argb: str, size: int = 16) -> QIcon: + """Create a small QIcon swatch from an ARGB hex string.""" + pm = QPixmap(size, size) + pm.fill(_argb_to_qcolor(argb)) + return QIcon(pm) + + _TINT_FACTOR = 0.15 # fraction of source alpha → subtle wash + + def _tint_group_color(self, group_name: str | None, + gcm: dict[str, str] | None = None) -> QColor | None: + """Return a pre-blended tint QColor for *group_name*, or None.""" + if gcm is None: + gcm = self._group_color_map() + argb = gcm.get(group_name) if group_name else None + if not argb: + return None + qc = _argb_to_qcolor(argb) + a = (qc.alpha() / 255.0) * self._TINT_FACTOR + bg_r, bg_g, bg_b = 0x1e, 0x1e, 0x1e # COLORS["bg"] + return QColor( + int(qc.red() * a + bg_r * (1 - a)), + int(qc.green() * a + bg_g * (1 - a)), + int(qc.blue() * a + bg_b * (1 - a)), + ) + + def _apply_row_group_color(self, row: int, group_name: str | None, + gcm: dict[str, str] | None = None, + table=None): + """Set tinted group background on *row* of *table* (default: track table).""" + if table is None: + table = self._track_table + table.apply_row_color(row, self._tint_group_color(group_name, gcm)) + + # ── Groups tab row helpers ─────────────────────────────────────────── + + def _set_groups_tab_row(self, row: int, name: str, color: str, + gain_linked: bool, daw_target: str = "", + match_method: str = "contains", + match_pattern: str = ""): + """Populate one row in the session-local groups table.""" + name_item = QTableWidgetItem(name) + self._groups_tab_table.setItem(row, 0, name_item) + + # Color dropdown with swatch icons + color_combo = QComboBox() + color_combo.setIconSize(QSize(16, 16)) + for cn in self._color_names_from_config(): + argb = self._color_argb_by_name(cn) + icon = self._color_swatch_icon(argb) if argb else QIcon() + color_combo.addItem(icon, cn) + ci = color_combo.findText(color) + if ci >= 0: + color_combo.setCurrentIndex(ci) + self._groups_tab_table.setCellWidget(row, 1, color_combo) + + # Gain-linked checkbox (centered) + chk = QCheckBox() + chk.setChecked(gain_linked) + chk_container = QWidget() + chk_layout = QHBoxLayout(chk_container) + chk_layout.setContentsMargins(0, 0, 0, 0) + chk_layout.setAlignment(Qt.AlignCenter) + chk_layout.addWidget(chk) + self._groups_tab_table.setCellWidget(row, 2, chk_container) + + # DAW Target name + daw_item = QTableWidgetItem(daw_target) + self._groups_tab_table.setItem(row, 3, daw_item) + + # Match method dropdown + match_combo = QComboBox() + match_combo.addItems(["contains", "regex"]) + mi = match_combo.findText(match_method) + if mi >= 0: + match_combo.setCurrentIndex(mi) + match_combo.setProperty("_row", row) + match_combo.currentTextChanged.connect( + lambda _text, r=row: self._validate_groups_tab_pattern(r)) + self._groups_tab_table.setCellWidget(row, 4, match_combo) + + # Match pattern text + pattern_item = QTableWidgetItem(match_pattern) + self._groups_tab_table.setItem(row, 5, pattern_item) + self._validate_groups_tab_pattern(row) + + def _populate_groups_tab(self): + """Populate the groups tab table from self._session_groups.""" + self._groups_tab_table.blockSignals(True) + self._groups_tab_table.setRowCount(0) + self._groups_tab_table.setRowCount(len(self._session_groups)) + for row, g in enumerate(self._session_groups): + self._set_groups_tab_row( + row, g["name"], g.get("color", ""), + g.get("gain_linked", False), g.get("daw_target", ""), + g.get("match_method", "contains"), + g.get("match_pattern", ""), + ) + self._groups_tab_table.blockSignals(False) + + def _read_session_groups(self) -> list[dict]: + """Read the session groups table back into a list of dicts.""" + groups: list[dict] = [] + for row in range(self._groups_tab_table.rowCount()): + name_item = self._groups_tab_table.item(row, 0) + if not name_item: + continue + name = name_item.text().strip() + if not name: + continue + color_combo = self._groups_tab_table.cellWidget(row, 1) + color = color_combo.currentText() if color_combo else "" + chk_container = self._groups_tab_table.cellWidget(row, 2) + gain_linked = False + if chk_container: + chk = chk_container.findChild(QCheckBox) + if chk: + gain_linked = chk.isChecked() + daw_item = self._groups_tab_table.item(row, 3) + daw_target = daw_item.text().strip() if daw_item else "" + match_combo = self._groups_tab_table.cellWidget(row, 4) + match_method = match_combo.currentText() if match_combo else "contains" + pattern_item = self._groups_tab_table.item(row, 5) + match_pattern = pattern_item.text().strip() if pattern_item else "" + groups.append({ + "name": name, + "color": color, + "gain_linked": gain_linked, + "daw_target": daw_target, + "match_method": match_method, + "match_pattern": match_pattern, + }) + return groups + + @staticmethod + def _group_names_in_table(table: QTableWidget, + exclude_row: int = -1) -> set[str]: + """Collect all group names from a table, optionally excluding one row.""" + names: set[str] = set() + for r in range(table.rowCount()): + if r == exclude_row: + continue + item = table.item(r, 0) + if item: + n = item.text().strip() + if n: + names.add(n) + return names + + def _unique_session_group_name(self, base: str = "New Group") -> str: + """Generate a unique group name for the session groups table.""" + existing = self._group_names_in_table(self._groups_tab_table) + if base not in existing: + return base + n = 2 + while f"{base} {n}" in existing: + n += 1 + return f"{base} {n}" + + def _on_groups_tab_name_changed(self, row: int, col: int): + """Handle cell edits in the groups tab (name, DAW target, pattern).""" + if col == 3: + # DAW Target changed — sync groups so auto-assign picks it up + self._sync_session_groups() + return + if col == 5: + # Match pattern changed — validate and sync + self._validate_groups_tab_pattern(row) + self._sync_session_groups() + return + if col != 0: + return + item = self._groups_tab_table.item(row, 0) + if not item: + return + name = item.text().strip() + others = self._group_names_in_table(self._groups_tab_table, + exclude_row=row) + if name in others: + self._groups_tab_table.blockSignals(True) + item.setText(self._unique_session_group_name(name)) + self._groups_tab_table.blockSignals(False) + self._sync_session_groups() + + def _validate_groups_tab_pattern(self, row: int): + """Validate the match pattern cell and set visual indicator. + + When match_method is "regex", tries to compile the pattern. + Sets the cell foreground to green (valid / empty) or red (invalid). + For "contains" mode, always shows default color. + """ + match_combo = self._groups_tab_table.cellWidget(row, 4) + pattern_item = self._groups_tab_table.item(row, 5) + if not pattern_item: + return + method = match_combo.currentText() if match_combo else "contains" + pattern = pattern_item.text().strip() + + if method == "regex" and pattern: + try: + re.compile(pattern) + pattern_item.setForeground(QColor("#4ec94e")) # green + pattern_item.setToolTip("") + except re.error as e: + pattern_item.setForeground(QColor("#e05050")) # red + pattern_item.setToolTip(f"Invalid regex: {e}") + else: + pattern_item.setForeground(QColor("#cccccc")) # default + pattern_item.setToolTip("") + + def _sync_session_groups(self): + """Read the groups tab table into _session_groups and refresh combos.""" + self._session_groups = self._read_session_groups() + self._refresh_group_combos() + + def _on_groups_tab_add(self): + row = self._groups_tab_table.rowCount() + self._groups_tab_table.insertRow(row) + color_names = self._color_names_from_config() + default_color = color_names[0] if color_names else "" + self._set_groups_tab_row( + row, self._unique_session_group_name(), default_color, False) + self._groups_tab_table.scrollToBottom() + self._groups_tab_table.editItem(self._groups_tab_table.item(row, 0)) + self._sync_session_groups() + + def _on_groups_tab_remove(self): + row = self._groups_tab_table.currentRow() + if row >= 0: + self._groups_tab_table.removeRow(row) + self._sync_session_groups() + + def _on_groups_tab_row_moved(self, logical: int, old_visual: int, + new_visual: int): + """Handle drag-and-drop row reorder on the session groups table.""" + table = self._groups_tab_table + vh = table.verticalHeader() + n = table.rowCount() + # Build visual order → logical index mapping + visual_to_logical = sorted(range(n), key=lambda i: vh.visualIndex(i)) + ordered: list[dict] = [] + for log_idx in visual_to_logical: + name_item = table.item(log_idx, 0) + if not name_item: + continue + name = name_item.text().strip() + if not name: + continue + cc = table.cellWidget(log_idx, 1) + color = cc.currentText() if cc else "" + chk_c = table.cellWidget(log_idx, 2) + gl = False + if chk_c: + chk = chk_c.findChild(QCheckBox) + if chk: + gl = chk.isChecked() + daw_item = table.item(log_idx, 3) + dt = daw_item.text().strip() if daw_item else "" + mc = table.cellWidget(log_idx, 4) + mm = mc.currentText() if mc else "contains" + pi = table.item(log_idx, 5) + mp = pi.text().strip() if pi else "" + ordered.append({"name": name, "color": color, + "gain_linked": gl, "daw_target": dt, + "match_method": mm, "match_pattern": mp}) + # Reset visual mapping, repopulate + vh.blockSignals(True) + table.blockSignals(True) + for i in range(n): + vh.moveSection(vh.visualIndex(i), i) + table.setRowCount(0) + table.setRowCount(len(ordered)) + for row, entry in enumerate(ordered): + self._set_groups_tab_row( + row, entry["name"], entry["color"], + entry["gain_linked"], entry.get("daw_target", ""), + entry.get("match_method", "contains"), + entry.get("match_pattern", "")) + table.blockSignals(False) + vh.blockSignals(False) + self._session_groups = ordered + self._refresh_group_combos() + + def _on_groups_tab_sort_az(self): + groups = self._read_session_groups() + groups.sort(key=lambda g: g["name"].lower()) + self._session_groups = groups + self._populate_groups_tab() + self._refresh_group_combos() + + def _on_groups_tab_reset(self): + """Reset session groups to the active preset from preferences.""" + self._merge_groups_from_preset() + + def _merge_groups_from_preset(self): + """Replace session groups with the active preset and name-match tracks.""" + presets = self._config.get("group_presets", + build_defaults().get("group_presets", {})) + preset = presets.get(self._active_session_preset, + presets.get("Default", [])) + new_groups = copy.deepcopy(preset) + new_names = {g["name"].strip().lower() for g in new_groups} + + if self._session: + for track in self._session.tracks: + if track.group is not None: + if track.group.strip().lower() not in new_names: + track.group = None + + self._session_groups = new_groups + self._populate_groups_tab() + self._refresh_group_combos() + self._populate_setup_table() + + # ── Auto-Group ──────────────────────────────────────────────────── + + @Slot() + def _on_auto_group(self): + """Auto-assign groups to all tracks based on filename matching rules.""" + if not self._session: + return + ok_tracks = [t for t in self._session.tracks if t.status == "OK"] + if not ok_tracks: + return + + reply = QMessageBox.question( + self, "Auto-Group", + f"Auto-Group will reassign all {len(ok_tracks)} tracks " + f"based on matching rules.\n\nContinue?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) + if reply != QMessageBox.Yes: + return + + assigned = 0 + glm = self._gain_linked_map() + gcm = self._group_color_map() + grm = self._group_rank_map() + + self._track_table.setSortingEnabled(False) + + for track in ok_tracks: + stem = os.path.splitext(track.filename)[0].lower() + matched_group: str | None = None + best_len = 0 + + for g in self._session_groups: + pattern = g.get("match_pattern", "").strip() + if not pattern: + continue + method = g.get("match_method", "contains") + + if method == "regex": + try: + m = re.search(pattern, stem, re.IGNORECASE) + if m: + span = m.end() - m.start() + if span > best_len: + best_len = span + matched_group = g["name"] + except re.error: + continue + else: + # contains: comma-separated tokens — pick longest hit + tokens = [t.strip().lower() for t in pattern.split(",") + if t.strip()] + for tok in tokens: + if tok in stem and len(tok) > best_len: + best_len = len(tok) + matched_group = g["name"] + + # Apply the match (or clear to None) + track.group = matched_group + if matched_group: + assigned += 1 + + # Update table combo + row = self._find_table_row(track.filename) + if row >= 0: + w = self._track_table.cellWidget(row, 6) + if isinstance(w, BatchComboBox): + w.blockSignals(True) + if matched_group: + for ci in range(w.count()): + if w.itemData(ci, Qt.UserRole) == matched_group: + w.setCurrentIndex(ci) + break + else: + w.setCurrentIndex(0) # (None) + w.blockSignals(False) + + # Update sort item + display = (self._group_display_name(matched_group, glm) + if matched_group else self._GROUP_NONE_LABEL) + rank = (grm.get(matched_group, len(grm)) + if matched_group else len(grm)) + sort_item = self._track_table.item(row, 6) + if sort_item: + sort_item.setText(display) + sort_item._sort_key = rank + + # Update row color + self._apply_row_group_color(row, matched_group, gcm) + + self._track_table.setSortingEnabled(True) + self._auto_fit_group_column() + self._apply_linked_group_levels() + self._populate_setup_table() + + self._status_bar.showMessage( + f"Auto-Group: assigned {assigned} of {len(ok_tracks)} tracks") + + # ── Group preset switching (Analysis toolbar) ───────────────────── + + @Slot(str) + def _on_group_preset_changed(self, preset_name: str): + """Switch the active group preset from the Analysis toolbar combo.""" + presets = self._config.get("group_presets", + build_defaults().get("group_presets", {})) + if preset_name not in presets: + return + self._active_session_preset = preset_name + self._merge_groups_from_preset() + + # ── Config preset switching (Analysis toolbar) ──────────────────── + + @Slot(str) + def _on_toolbar_config_preset_changed(self, name: str): + """Switch the active config preset from the Analysis toolbar combo.""" + presets = self._config.get("config_presets", + build_defaults().get("config_presets", {})) + if name not in presets: + return + + if self._session is not None: + ans = QMessageBox.question( + self, "Switch config preset?", + f"Switching to \u201c{name}\u201d will overwrite your " + "session config and re-analyze.\n\n" + "Group assignments will be preserved.\n\n" + "Continue?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No, + ) + if ans != QMessageBox.Yes: + # Revert combo to the current preset + self._config_preset_combo.blockSignals(True) + self._config_preset_combo.setCurrentText( + self._active_config_preset_name) + self._config_preset_combo.blockSignals(False) + return + + self._active_config_preset_name = name + self._config.setdefault("app", {})["active_config_preset"] = name + save_config(self._config) + + if self._session is not None: + self._session_config = None # re-init from new preset + self._on_analyze() + + # ── Group column (col 6) ──────────────────────────────────────────── + + _GROUP_NONE_LABEL = "(None)" + _LINK_INDICATOR = " 🔗" + + def _group_combo_items(self) -> list[str]: + """Return the items list for Group combo boxes.""" + return [self._GROUP_NONE_LABEL] + [ + g["name"] for g in self._session_groups] + + def _gain_linked_map(self) -> dict[str, bool]: + """Return {group_name: gain_linked} for all session groups.""" + return {g["name"]: g.get("gain_linked", False) + for g in self._session_groups} + + def _group_display_name(self, name: str, + glm: dict[str, bool] | None = None) -> str: + """Return display name with link indicator if gain-linked.""" + if glm is None: + glm = self._gain_linked_map() + if glm.get(name, False): + return name + self._LINK_INDICATOR + return name + + def _group_rank_map(self) -> dict[str, int]: + """Return {group_name: position_index} for sort-by-rank ordering.""" + return {g["name"]: i for i, g in enumerate(self._session_groups)} + + def _group_color_map(self) -> dict[str, str]: + """Return {group_name: argb_hex} for all session groups.""" + result: dict[str, str] = {} + for g in self._session_groups: + color_name = g.get("color", "") + argb = self._color_argb_by_name(color_name) + if argb: + result[g["name"]] = argb + return result + + def _create_group_combo(self, row: int, track): + """Create and install a Group combo in column 6.""" + glm = self._gain_linked_map() + display = self._group_display_name(track.group, glm) if track.group else self._GROUP_NONE_LABEL + grm = self._group_rank_map() + rank = grm.get(track.group, len(grm)) if track.group else len(grm) + sort_item = _SortableItem(display, rank) + self._track_table.setItem(row, 6, sort_item) + + combo = BatchComboBox() + combo.setIconSize(QSize(16, 16)) + gcm = self._group_color_map() + combo.addItem(self._GROUP_NONE_LABEL) + combo.setItemData(0, None, Qt.UserRole) + for i, gname in enumerate([g["name"] for g in self._session_groups]): + disp = self._group_display_name(gname, glm) + argb = gcm.get(gname) + if argb: + combo.addItem(self._color_swatch_icon(argb), disp) + else: + combo.addItem(disp) + combo.setItemData(i + 1, gname, Qt.UserRole) + combo.blockSignals(True) + # Find item by UserRole (clean name) + for ci in range(combo.count()): + if combo.itemData(ci, Qt.UserRole) == track.group: + combo.setCurrentIndex(ci) + break + combo.blockSignals(False) + combo.setProperty("track_filename", track.filename) + combo.setStyleSheet( + f"QComboBox {{ color: {COLORS['text']}; }}" + ) + combo.textActivated.connect( + lambda text, c=combo: self._on_group_changed(text, c)) + self._track_table.setCellWidget(row, 6, combo) + + def _on_group_changed(self, text: str, combo=None): + """Handle user changing the Group dropdown.""" + if combo is None: + combo = self.sender() + if not combo or not self._session: + return + fname = combo.property("track_filename") + if not fname: + return + track = next( + (t for t in self._session.tracks if t.filename == fname), None + ) + if not track: + return + + # Read clean group name from UserRole + new_group = combo.currentData(Qt.UserRole) + display = text # display text (with link indicator) + + # Batch path: synchronous — no reanalysis needed + if getattr(combo, 'batch_mode', False) or combo.property("_batch_mode"): + combo.setProperty("_batch_mode", False) + combo.batch_mode = False + track.group = new_group + batch_keys = self._track_table.batch_selected_keys() + track_map = {t.filename: t for t in self._session.tracks} + gcm = self._group_color_map() + grm = self._group_rank_map() + rank = grm.get(new_group, len(grm)) if new_group else len(grm) + self._track_table.setSortingEnabled(False) + for bfname in batch_keys: + bt = track_map.get(bfname) + if not bt or bt.status != "OK": + continue + bt.group = new_group + row = self._find_table_row(bfname) + if row >= 0: + w = self._track_table.cellWidget(row, 6) + if isinstance(w, BatchComboBox): + w.blockSignals(True) + # Find matching item by UserRole + for ci in range(w.count()): + if w.itemData(ci, Qt.UserRole) == new_group: + w.setCurrentIndex(ci) + break + w.blockSignals(False) + sort_item = self._track_table.item(row, 6) + if sort_item: + sort_item.setText(display) + sort_item._sort_key = rank + self._apply_row_group_color(row, new_group, gcm) + self._track_table.setSortingEnabled(True) + self._track_table.restore_selection(batch_keys) + self._auto_fit_group_column() + self._apply_linked_group_levels() + else: + if track.group == new_group: + return + track.group = new_group + # Update sort item + row color + grm = self._group_rank_map() + rank = grm.get(new_group, len(grm)) if new_group else len(grm) + row = self._find_table_row(fname) + if row >= 0: + sort_item = self._track_table.item(row, 6) + if sort_item: + sort_item.setText(display) + sort_item._sort_key = rank + self._apply_row_group_color(row, new_group) + self._auto_fit_group_column() + self._apply_linked_group_levels() + + def _refresh_group_combos(self): + """Refresh the items in all Group combo boxes from _session_groups.""" + gcm = self._group_color_map() + grm = self._group_rank_map() + glm = self._gain_linked_map() + for row in range(self._track_table.rowCount()): + w = self._track_table.cellWidget(row, 6) + if isinstance(w, BatchComboBox): + # Read clean group name via UserRole + old_group = w.currentData(Qt.UserRole) + w.blockSignals(True) + w.clear() + w.setIconSize(QSize(16, 16)) + w.addItem(self._GROUP_NONE_LABEL) + w.setItemData(0, None, Qt.UserRole) + for i, gname in enumerate( + [g["name"] for g in self._session_groups]): + disp = self._group_display_name(gname, glm) + argb = gcm.get(gname) + if argb: + w.addItem(self._color_swatch_icon(argb), disp) + else: + w.addItem(disp) + w.setItemData(i + 1, gname, Qt.UserRole) + # Restore selection by UserRole match + restored = False + if old_group is not None: + for ci in range(w.count()): + if w.itemData(ci, Qt.UserRole) == old_group: + w.setCurrentIndex(ci) + restored = True + break + if not restored: + w.setCurrentIndex(0) # (None) + # Also clear the track's group assignment + fname = w.property("track_filename") + if fname and self._session: + track = next( + (t for t in self._session.tracks + if t.filename == fname), None) + if track: + track.group = None + w.blockSignals(False) + # Update sort key, display text + row color + gname = w.currentData(Qt.UserRole) + sort_item = self._track_table.item(row, 6) + if sort_item: + rank = grm.get(gname, len(grm)) if gname else len(grm) + sort_item._sort_key = rank + sort_item.setText(w.currentText()) + self._apply_row_group_color(row, gname, gcm) + + self._auto_fit_group_column() + self._apply_linked_group_levels() + + # ── Linked group levels ────────────────────────────────────────────── + + def _apply_linked_group_levels(self): + """Apply group levels for gain-linked groups and update fader offsets. + + 1. Restore every track's ``gain_db`` to its ``original_gain_db``. + 2. For gain-linked groups, set all members to the group minimum. + 3. Recompute ``fader_offset`` using the stored anchor offset. + 4. Update the gain spin-boxes and the Session Setup table. + """ + if not self._session or not self._session.processors: + return + + glm = self._gain_linked_map() + linked_names = {name for name, linked in glm.items() if linked} + + for proc in self._session.processors: + pid = proc.id + # 1. Restore originals + for track in self._session.tracks: + if track.status != "OK": + continue + pr = track.processor_results.get(pid) + if pr is None or pr.classification == "Silent": + continue + if "original_gain_db" not in pr.data: + pr.data["original_gain_db"] = pr.gain_db + pr.gain_db = pr.data["original_gain_db"] + + # 2. Apply group levels for linked groups + by_group: dict[str, list] = {} + for track in self._session.tracks: + if track.status != "OK" or track.group is None: + continue + pr = track.processor_results.get(pid) + if pr is None or pr.classification == "Silent": + continue + by_group.setdefault(track.group, []).append(track) + + for gname, members in by_group.items(): + if gname not in linked_names: + continue + orig = [m.processor_results[pid].data["original_gain_db"] + for m in members] + group_gain = min(orig) if orig else 0.0 + for m in members: + m.processor_results[pid].gain_db = float(group_gain) + + # 3. Recompute fader offsets with headroom rebalancing + valid = [] + for track in self._session.tracks: + if track.status != "OK": + continue + pr = track.processor_results.get(pid) + if pr is None: + continue + if pr.classification == "Silent": + pr.data["fader_offset"] = 0.0 + else: + pr.data["fader_offset"] = -float(pr.gain_db) + valid.append(track) + + # Headroom rebalancing + ceiling = self._session.config.get("_fader_ceiling_db", 12.0) + headroom = self._session.config.get("fader_headroom_db", 8.0) + max_allowed = ceiling - headroom + rebalance_shift = 0.0 + if headroom > 0.0 and valid: + fader_offsets = [ + t.processor_results[pid].data.get("fader_offset", 0.0) + for t in valid + ] + max_fader = max(fader_offsets) + if max_fader > max_allowed: + rebalance_shift = max_fader - max_allowed + for track in valid: + pr = track.processor_results.get(pid) + if pr: + pr.data["fader_offset"] -= rebalance_shift + pr.data["fader_rebalance_shift"] = rebalance_shift + self._session.config[f"_fader_rebalance_{pid}"] = rebalance_shift + + # Anchor-track adjustment + anchor_offset = self._session.config.get( + f"_anchor_offset_{pid}", 0.0) + if anchor_offset != 0.0: + for track in valid: + pr = track.processor_results.get(pid) + if pr: + pr.data["fader_offset"] = pr.data.get("fader_offset", 0.0) - anchor_offset + + # 4. Update UI + self._track_table.setSortingEnabled(False) + for row in range(self._track_table.rowCount()): + fname_item = self._track_table.item(row, 0) + if not fname_item: + continue + fname = fname_item.text() + track = next( + (t for t in self._session.tracks if t.filename == fname), None) + if not track or track.status != "OK": + continue + pr = next(iter(track.processor_results.values()), None) + if not pr: + continue + new_gain = pr.gain_db + spin = self._track_table.cellWidget(row, 4) + if isinstance(spin, QDoubleSpinBox): + spin.blockSignals(True) + spin.setValue(new_gain) + spin.blockSignals(False) + gain_sort = self._track_table.item(row, 4) + if gain_sort: + gain_sort.setText(f"{new_gain:+.1f}") + gain_sort._sort_key = new_gain + self._track_table.setSortingEnabled(True) + self._populate_setup_table() + + # Refresh the File detail tab so it reflects the updated gain + if self._current_track and self._current_track.status == "OK": + self._refresh_file_tab(self._current_track) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index b8d9acc..b4939cc 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -3,23 +3,18 @@ from __future__ import annotations import copy -import json import os -import re import sys import time +from typing import Any -from PySide6.QtCore import Qt, Signal, Slot, QSize, QTimer, QUrl, QMimeData, QPoint +from PySide6.QtCore import Qt, Slot, QSize from PySide6.QtGui import ( - QAction, QActionGroup, QDrag, QFont, QColor, QIcon, QKeySequence, - QPainter, QPen, QPixmap, QShortcut, + QAction, QActionGroup, QFont, QColor, QIcon, QKeySequence, QShortcut, ) from PySide6.QtWidgets import ( QApplication, - QCheckBox, QComboBox, - QDoubleSpinBox, - QFileDialog, QHBoxLayout, QHeaderView, QLabel, @@ -33,348 +28,51 @@ QStackedWidget, QStyle, QTableWidget, - QTableWidgetItem, QTabWidget, - QTextBrowser, QToolBar, QToolButton, - QTreeWidget, - QTreeWidgetItem, QVBoxLayout, QStatusBar, QWidget, ) -from sessionpreplib.audio import get_window_samples from sessionpreplib.config import ( default_config, flatten_structured_config, ) -from sessionpreplib.daw_processors import create_runtime_daw_processors -from sessionpreplib.detector import TrackDetector -from sessionpreplib.detectors import default_detectors, detector_help_map -from sessionpreplib.processors import default_processors -from sessionpreplib.utils import protools_sort_key +from sessionpreplib.detectors import detector_help_map from .settings import ( - load_config, config_path, save_config, + load_config, save_config, resolve_config_preset, build_defaults, ) -from .theme import ( - COLORS, - FILE_COLOR_OK, - FILE_COLOR_ERROR, - FILE_COLOR_SILENT, - FILE_COLOR_TRANSIENT, - FILE_COLOR_SUSTAINED, - PT_DEFAULT_COLORS, - apply_dark_theme, -) -from .helpers import track_analysis_label, esc, fmt_time +from .theme import COLORS, apply_dark_theme from .log import dbg -from .param_widgets import ( - _build_param_page, _read_widget, _set_widget_value, - build_config_pages, load_config_widgets, read_config_widgets, -) -from .preferences import PreferencesDialog, _argb_to_qcolor -from .report import render_summary_html, render_track_detail_html -from .session_io import save_session as _save_session_file, load_session as _load_session_file -from .widgets import BatchEditTableWidget, BatchComboBox, BatchToolButton, ProgressPanel +from .preferences import PreferencesDialog +from .report import render_track_detail_html +from .waveform import WaveformWidget, WaveformLoadWorker +from .playback import PlaybackController +from .widgets import ProgressPanel from .worker import ( - AnalyzeWorker, AudioLoadWorker, BatchReanalyzeWorker, DawCheckWorker, + AudioLoadWorker, BatchReanalyzeWorker, DawCheckWorker, DawFetchWorker, DawTransferWorker, PrepareWorker, ) -from .waveform import WaveformWidget, WaveformLoadWorker -from sessionpreplib.audio import AUDIO_EXTENSIONS -from .playback import PlaybackController - -_TAB_SUMMARY = 0 -_TAB_FILE = 1 -_TAB_GROUPS = 2 -_TAB_SESSION = 3 - -_PAGE_PROGRESS = 0 -_PAGE_TABS = 1 - -_PHASE_ANALYSIS = 0 -_PHASE_SETUP = 1 - -_SETUP_RIGHT_PLACEHOLDER = 0 -_SETUP_RIGHT_TREE = 1 -_SEVERITY_SORT = {"PROBLEMS": 0, "Error": 0, "ATTENTION": 1, "OK": 2, "": 3} - - -def _make_analysis_cell(html: str, sort_key: int) -> tuple[QLabel, '_SortableItem']: - """Create a QLabel + hidden sort item for the Analysis column.""" - lbl = QLabel(html) - lbl.setStyleSheet( - "QLabel { background: transparent; font-size: 8pt;" - " font-family: Consolas, monospace; padding: 0 4px; }") - lbl.setTextFormat(Qt.RichText) - item = _SortableItem("", sort_key) - return lbl, item - - -class _HelpBrowser(QTextBrowser): - """QTextBrowser that shows detector help tooltips on hover.""" +from .table_widgets import ( + _HelpBrowser, _DraggableTrackTable, _SortableItem, + _TAB_SUMMARY, _TAB_FILE, _TAB_GROUPS, _TAB_SESSION, + _PAGE_PROGRESS, _PAGE_TABS, + _PHASE_ANALYSIS, _PHASE_SETUP, +) +from .analysis_mixin import AnalysisMixin +from .track_columns_mixin import TrackColumnsMixin +from .groups_mixin import GroupsMixin +from .daw_mixin import DawMixin +from .detail_mixin import DetailMixin - def __init__(self, help_map: dict[str, str], parent=None): - super().__init__(parent) - self._help_map = help_map - self.setOpenLinks(False) - self.setMouseTracking(True) - def mouseMoveEvent(self, event): - anchor = self.anchorAt(event.pos()) - if anchor.startswith("detector:"): - det_id = anchor[len("detector:"):] - html = self._help_map.get(det_id) - if html: - from PySide6.QtWidgets import QToolTip - QToolTip.showText(event.globalPosition().toPoint(), html, self) - else: - from PySide6.QtWidgets import QToolTip - QToolTip.hideText() - else: - from PySide6.QtWidgets import QToolTip - QToolTip.hideText() - super().mouseMoveEvent(event) - - -class _DraggableTrackTable(BatchEditTableWidget): - """BatchEditTableWidget with file-drag support for external applications.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setDragEnabled(True) - self.setDefaultDropAction(Qt.CopyAction) - self._source_dir: str | None = None - - def set_source_dir(self, path: str | None): - self._source_dir = path - - def mimeTypes(self): - return ["text/uri-list"] - - def mimeData(self, items): - if not self._source_dir: - return super().mimeData(items) - filenames: set[str] = set() - for item in items: - if item.column() == 0 and item.text(): - filenames.add(item.text()) - if not filenames: - return super().mimeData(items) - urls = [QUrl.fromLocalFile(os.path.join(self._source_dir, f)) - for f in filenames] - mime = QMimeData() - mime.setUrls(urls) - return mime - - def supportedDragActions(self): - return Qt.CopyAction - - -_MIME_TRACKS = "application/x-sessionprep-tracks" - - -class _SetupDragTable(BatchEditTableWidget): - """BatchEditTableWidget that produces custom MIME for internal drag.""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setDragEnabled(True) - self.setDefaultDropAction(Qt.CopyAction) - - def mimeTypes(self): - return [_MIME_TRACKS] - - def mimeData(self, items): - filenames: set[str] = set() - for item in items: - if item.column() == 1 and item.text(): # col 1 = File - filenames.add(item.text()) - if not filenames: - return super().mimeData(items) - mime = QMimeData() - mime.setData(_MIME_TRACKS, json.dumps(sorted(filenames)).encode()) - return mime - - def supportedDragActions(self): - return Qt.CopyAction - - def startDrag(self, supportedActions): - items = self.selectedItems() - mime = self.mimeData(items) - if mime is None: - return - drag = QDrag(self) - drag.setMimeData(mime) - # Build a compact, semi-transparent label listing dragged filenames - filenames = sorted({ - it.text() for it in items if it.column() == 1 and it.text()}) - if not filenames: - return - label = "\n".join(filenames[:8]) - if len(filenames) > 8: - label += f"\n… +{len(filenames) - 8} more" - fm = self.fontMetrics() - lines = label.split("\n") - line_h = fm.height() + 2 - w = max(fm.horizontalAdvance(ln) for ln in lines) + 12 - h = line_h * len(lines) + 6 - pix = QPixmap(w, h) - pix.fill(Qt.transparent) - painter = QPainter(pix) - painter.setOpacity(0.75) - painter.fillRect(pix.rect(), QColor(COLORS["accent"])) - painter.setOpacity(1.0) - painter.setPen(QColor(COLORS["text"])) - painter.setFont(self.font()) - y = 3 + fm.ascent() - for ln in lines: - painter.drawText(6, y, ln) - y += line_h - painter.end() - drag.setPixmap(pix) - drag.setHotSpot(QPoint(0, 0)) - drag.exec(Qt.CopyAction) - - -class _FolderDropTree(QTreeWidget): - """QTreeWidget that accepts track drops onto folder items. - - Supports external drops from the setup table and internal - drag-and-drop to reorder tracks within / across folders. - """ - - # (filenames, folder_id, insert_index) -1 = append - tracks_dropped = Signal(list, str, int) - tracks_unassigned = Signal(list) # [filenames] - - def __init__(self, parent=None): - super().__init__(parent) - self.setAcceptDrops(True) - self.setDragEnabled(True) - self.setDragDropMode(QTreeWidget.DragDrop) - self.setDefaultDropAction(Qt.MoveAction) - self.setDropIndicatorShown(True) - - # -- MIME production (for internal drag of track items) ----------------- - - def mimeTypes(self): - return [_MIME_TRACKS] - - def mimeData(self, items): - filenames = [ - it.data(0, Qt.UserRole) for it in items - if it.data(0, Qt.UserRole + 1) == "track" - ] - if not filenames: - return None # block drag of non-track items (folders) - mime = QMimeData() - mime.setData(_MIME_TRACKS, json.dumps(filenames).encode()) - return mime - - def supportedDropActions(self): - return Qt.CopyAction | Qt.MoveAction - - # -- Drop handling ----------------------------------------------------- - - def _is_valid_mime(self, mimeData) -> bool: - """Check that the MIME payload is our JSON, not Qt internal data.""" - if not mimeData.hasFormat(_MIME_TRACKS): - return False - try: - bytes(mimeData.data(_MIME_TRACKS)).decode("utf-8") - return True - except (UnicodeDecodeError, ValueError): - return False - - def _resolve_drop(self, pos): - """Return (folder_id, insert_index) for a drop at *pos*. - - Uses the item geometry to decide above / on / below placement. - Returns (None, -1) if the drop target is invalid. - """ - item = self.itemAt(pos) - if not item: - return None, -1 - kind = item.data(0, Qt.UserRole + 1) - if kind == "folder": - return item.data(0, Qt.UserRole), -1 - if kind == "track": - parent = item.parent() - if not parent or parent.data(0, Qt.UserRole + 1) != "folder": - return None, -1 - folder_id = parent.data(0, Qt.UserRole) - idx = parent.indexOfChild(item) - rect = self.visualItemRect(item) - mid = rect.top() + rect.height() // 2 - if pos.y() > mid: - idx += 1 # drop below → insert after - return folder_id, idx - return None, -1 - - def dragEnterEvent(self, event): - if self._is_valid_mime(event.mimeData()): - event.acceptProposedAction() - else: - event.ignore() - - def dragMoveEvent(self, event): - if not self._is_valid_mime(event.mimeData()): - event.ignore() - return - folder_id, _ = self._resolve_drop(event.position().toPoint()) - if folder_id is not None: - event.acceptProposedAction() - else: - event.ignore() - - def dropEvent(self, event): - if not self._is_valid_mime(event.mimeData()): - event.ignore() - return - pos = event.position().toPoint() - folder_id, idx = self._resolve_drop(pos) - if folder_id is None: - event.ignore() - return - data = bytes(event.mimeData().data(_MIME_TRACKS)).decode("utf-8") - filenames = json.loads(data) - self.tracks_dropped.emit(filenames, folder_id, idx) - event.acceptProposedAction() - - # -- Delete to unassign ------------------------------------------------ - - def keyPressEvent(self, event): - if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): - filenames = [] - for item in self.selectedItems(): - if item.data(0, Qt.UserRole + 1) == "track": - filenames.append(item.data(0, Qt.UserRole)) - if filenames: - self.tracks_unassigned.emit(filenames) - return - super().keyPressEvent(event) - - -class _SortableItem(QTableWidgetItem): - """QTableWidgetItem with a custom sort key.""" - - def __init__(self, text: str, sort_key=None): - super().__init__(text) - self._sort_key = sort_key if sort_key is not None else text - - def __lt__(self, other): - if isinstance(other, _SortableItem): - return self._sort_key < other._sort_key - return super().__lt__(other) - - -class SessionPrepWindow(QMainWindow): +class SessionPrepWindow(QMainWindow, AnalysisMixin, TrackColumnsMixin, + GroupsMixin, DawMixin, DetailMixin): def __init__(self): t_init = time.perf_counter() super().__init__() @@ -646,611 +344,7 @@ def _populate_group_preset_combo(self): self._group_preset_combo.setCurrentIndex(0) self._group_preset_combo.blockSignals(False) - def _build_setup_page(self) -> QWidget: - """Build the Session Setup phase page with its own toolbar.""" - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - - # Setup toolbar (embedded in page) - self._setup_toolbar = QToolBar("Session Setup") - self._setup_toolbar.setIconSize(QSize(16, 16)) - self._setup_toolbar.setMovable(False) - self._setup_toolbar.setFloatable(False) - - # ── Left: DAW processor selection + status label ──────────────── - self._daw_combo = QComboBox() - self._daw_combo.setMinimumWidth(140) - self._setup_toolbar.addWidget(self._daw_combo) - - self._daw_check_label = QLabel("") - self._daw_check_label.setContentsMargins(6, 0, 0, 0) - self._daw_check_label.setMaximumWidth(260) - self._setup_toolbar.addWidget(self._daw_check_label) - - self._setup_toolbar.addSeparator() - - # ── Use Processed checkbox ───────────────────────────────────── - self._use_processed_cb = QCheckBox("Use Processed") - self._use_processed_cb.setLayoutDirection(Qt.RightToLeft) - self._use_processed_cb.setEnabled(False) - self._use_processed_cb.toggled.connect(self._on_use_processed_toggled) - self._setup_toolbar.addWidget(self._use_processed_cb) - - # ── Spacer ───────────────────────────────────────────────────── - spacer = QWidget() - spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) - self._setup_toolbar.addWidget(spacer) - - # ── Right: lifecycle actions ─────────────────────────────────── - self._fetch_action = QAction("Fetch", self) - self._fetch_action.setEnabled(False) - self._fetch_action.triggered.connect(self._on_daw_fetch) - self._setup_toolbar.addAction(self._fetch_action) - - self._auto_assign_action = QAction("Auto-Assign", self) - self._auto_assign_action.setEnabled(False) - self._auto_assign_action.triggered.connect(self._on_auto_assign) - self._setup_toolbar.addAction(self._auto_assign_action) - - self._transfer_action = QAction("Transfer", self) - self._transfer_action.setEnabled(False) - self._transfer_action.triggered.connect(self._on_daw_transfer) - self._setup_toolbar.addAction(self._transfer_action) - - self._sync_action = QAction("Sync", self) - self._sync_action.setEnabled(False) - self._setup_toolbar.addAction(self._sync_action) - - # Populate combo after ALL toolbar widgets exist, then connect signal - self._populate_daw_combo() - self._daw_combo.currentIndexChanged.connect(self._on_daw_combo_changed) - - layout.addWidget(self._setup_toolbar) - - # Splitter: track table (left) + routing panel placeholder (right) - self._setup_splitter = setup_splitter = QSplitter(Qt.Horizontal) - - # ── Left: track table ───────────────────────────────────────────── - self._setup_table = _SetupDragTable() - self._setup_table.setColumnCount(6) - self._setup_table.setHorizontalHeaderLabels( - ["", "File", "Ch", "Clip Gain", "Fader Gain", "Group"] - ) - self._setup_table.setSelectionBehavior(QTableWidget.SelectRows) - self._setup_table.setSelectionMode(QTableWidget.ExtendedSelection) - self._setup_table.setEditTriggers(QTableWidget.NoEditTriggers) - self._setup_table.verticalHeader().setVisible(False) - self._setup_table.setMinimumWidth(300) - self._setup_table.setShowGrid(True) - self._setup_table.setAlternatingRowColors(True) - self._setup_table.setSortingEnabled(True) - - sh = self._setup_table.horizontalHeader() - sh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) - sh.setSectionResizeMode(0, QHeaderView.Fixed) - sh.resizeSection(0, 24) - sh.setSectionResizeMode(1, QHeaderView.Stretch) - sh.setSectionResizeMode(2, QHeaderView.Fixed) - sh.setSectionResizeMode(3, QHeaderView.Interactive) - sh.setSectionResizeMode(4, QHeaderView.Interactive) - sh.setSectionResizeMode(5, QHeaderView.Interactive) - sh.resizeSection(2, 30) - sh.resizeSection(3, 90) - sh.resizeSection(4, 90) - sh.resizeSection(5, 110) - - setup_splitter.addWidget(self._setup_table) - - # ── Right: stacked widget (placeholder / folder tree) ───────────── - self._setup_right_stack = QStackedWidget() - - # Page 0: placeholder - right_placeholder = QWidget() - right_layout = QVBoxLayout(right_placeholder) - right_layout.setContentsMargins(40, 0, 40, 0) - right_layout.addStretch(2) - placeholder_label = QLabel("Connect to a DAW to configure routing") - placeholder_label.setAlignment(Qt.AlignCenter) - placeholder_label.setStyleSheet( - f"color: {COLORS['dim']}; font-size: 13pt;") - right_layout.addWidget(placeholder_label) - right_layout.addStretch(3) - self._setup_right_stack.addWidget(right_placeholder) - - # Page 1: folder tree + transfer progress panel - tree_page = QWidget() - tree_page_layout = QVBoxLayout(tree_page) - tree_page_layout.setContentsMargins(0, 0, 0, 0) - tree_page_layout.setSpacing(0) - - self._folder_tree = _FolderDropTree() - self._folder_tree.setHeaderLabels(["Folder / Track"]) - self._folder_tree.setSelectionMode(QTreeWidget.ExtendedSelection) - self._folder_tree.setAlternatingRowColors(True) - # Match visual size to the setup table; semi-transparent selection - self._folder_tree.setStyleSheet( - "QTreeWidget { font-size: 10pt; }" - "QTreeWidget::item { min-height: 22px; }" - "QTreeWidget::item:selected {" - " background-color: rgba(42, 109, 181, 128);" - "}" - ) - self._folder_tree.tracks_dropped.connect(self._assign_tracks_to_folder) - self._folder_tree.tracks_unassigned.connect(self._unassign_tracks) - tree_page_layout.addWidget(self._folder_tree, 1) - - # Transfer progress panel (hidden by default) - self._transfer_progress = ProgressPanel() - tree_page_layout.addWidget(self._transfer_progress) - - self._setup_right_stack.addWidget(tree_page) - - self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_PLACEHOLDER) - - setup_splitter.addWidget(self._setup_right_stack) - setup_splitter.setStretchFactor(0, 3) - setup_splitter.setStretchFactor(1, 2) - setup_splitter.setSizes([620, 480]) - - layout.addWidget(setup_splitter, 1) - - return page - - # ── DAW processor helpers ───────────────────────────────────────────── - - def _configure_daw_processors(self): - """Rebuild DAW processor list from the current flat config. - - Uses the runtime factory so DAWProject templates are expanded - into individual processor instances. - """ - flat = self._flat_config() - self._daw_processors = create_runtime_daw_processors(flat) - - def _populate_daw_combo(self): - """Fill the DAW dropdown with enabled processors.""" - self._daw_combo.blockSignals(True) - self._daw_combo.clear() - for i, dp in enumerate(self._daw_processors): - if dp.enabled: - self._daw_combo.addItem(dp.name, i) - self._daw_combo.blockSignals(False) - if self._daw_combo.count() > 0: - self._on_daw_combo_changed(0) - else: - self._active_daw_processor = None - - def _update_daw_lifecycle_buttons(self): - """Enable/disable Fetch/Transfer/Sync based on active processor state.""" - has_processor = self._active_daw_processor is not None - self._fetch_action.setEnabled(has_processor) - dp_id = self._active_daw_processor.id if has_processor else None - dp_state = ( - self._session.daw_state.get(dp_id, {}) - if self._session and dp_id else {} - ) - has_folders = bool(dp_state.get("folders")) - has_assignments = bool(dp_state.get("assignments")) - self._auto_assign_action.setEnabled(has_folders) - self._transfer_action.setEnabled(has_processor and has_assignments) - self._sync_action.setEnabled(False) - - @Slot(int) - def _on_daw_combo_changed(self, index: int): - if index < 0 or index >= self._daw_combo.count(): - self._active_daw_processor = None - else: - proc_idx = self._daw_combo.itemData(index) - self._active_daw_processor = self._daw_processors[proc_idx] - self._daw_check_label.setText("") - self._update_daw_lifecycle_buttons() - - def _run_daw_check_then(self, on_success): - """Run a connectivity check; on success call *on_success*.""" - if not self._active_daw_processor: - return - self._pending_after_check = on_success - self._daw_check_label.setText("Connecting\u2026") - self._daw_check_label.setStyleSheet(f"color: {COLORS['dim']};") - self._daw_check_worker = DawCheckWorker(self._active_daw_processor) - self._daw_check_worker.result.connect(self._on_daw_check_result) - self._daw_check_worker.start() - - @Slot(bool, str) - def _on_daw_check_result(self, ok: bool, message: str): - self._daw_check_worker = None - if ok: - self._daw_check_label.setText(message) - self._daw_check_label.setStyleSheet(f"color: {COLORS['clean']};") - cb = self._pending_after_check - self._pending_after_check = None - if cb: - cb() - else: - self._daw_check_label.setText("Connection failed") - self._daw_check_label.setStyleSheet(f"color: {COLORS['problems']};") - self._pending_after_check = None - QMessageBox.warning( - self, "Connection Failed", - f"{self._active_daw_processor.name} connection could " - f"not be established.\n\n{message}") - self._update_daw_lifecycle_buttons() - - # ── DAW Fetch + Folder Tree ─────────────────────────────────────────── - - @Slot() - def _on_daw_fetch(self): - if not self._active_daw_processor or not self._session: - return - self._fetch_action.setEnabled(False) - self._run_daw_check_then(self._do_daw_fetch) - - def _do_daw_fetch(self): - """Actually start the fetch (called after successful connectivity check).""" - self._status_bar.showMessage("Fetching folder structure\u2026") - self._daw_fetch_worker = DawFetchWorker( - self._active_daw_processor, self._session) - self._daw_fetch_worker.result.connect(self._on_daw_fetch_result) - self._daw_fetch_worker.start() - - @Slot(bool, str, object) - def _on_daw_fetch_result(self, ok: bool, message: str, session): - self._daw_fetch_worker = None - self._fetch_action.setEnabled(True) - if ok and session is not None: - self._session = session - self._populate_folder_tree() - self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_TREE) - self._populate_setup_table() - self._status_bar.showMessage(message) - else: - self._status_bar.showMessage(f"Fetch failed: {message}") - self._update_daw_lifecycle_buttons() - - # ── Use Processed checkbox ────────────────────────────────────────── - - @Slot(bool) - def _on_use_processed_toggled(self, checked: bool): - if self._session: - self._session.config["_use_processed"] = checked - self._update_use_processed_action() - - def _update_use_processed_action(self): - """Update the Use Processed checkbox enabled state and stale indicator.""" - if not self._session: - self._use_processed_cb.setEnabled(False) - self._use_processed_cb.setText("Use Processed") - return - - state = self._session.prepare_state - has_prepared = state in ("ready", "stale") - self._use_processed_cb.setEnabled(has_prepared) - - if state == "stale" and self._use_processed_cb.isChecked(): - self._use_processed_cb.setText("Use Processed (!)") - else: - self._use_processed_cb.setText("Use Processed") - - # ── DAW Transfer ───────────────────────────────────────────────────── - - @Slot() - def _on_daw_transfer(self): - if not self._active_daw_processor or not self._session: - return - self._transfer_action.setEnabled(False) - self._fetch_action.setEnabled(False) - self._run_daw_check_then(self._do_daw_transfer) - - def _do_daw_transfer(self): - """Actually start the transfer (called after successful connectivity check).""" - if not self._active_daw_processor or not self._session: - return - - output_folder = self._config.get("app", {}).get("output_folder", "processed") - - # Refresh pipeline config from current session widgets so that - # processor enabled/disabled changes made after analysis take effect. - self._session.config.update(self._flat_config()) - # Inject GUI config (groups + colors) into session.config so - # transfer() can resolve group → color ARGB - self._session.config.setdefault("gui", {})["groups"] = list( - self._session_groups) - colors = self._config.get("colors", PT_DEFAULT_COLORS) - self._session.config["gui"]["colors"] = colors - # Keep source dir / output folder in config for processor.resolve_output_path() - self._session.config["_source_dir"] = self._source_dir - self._session.config["_output_folder"] = output_folder - - # ── Let the processor decide the output path (shows dialog if needed) ─ - output_path = self._active_daw_processor.resolve_output_path( - self._session, self) - if output_path is None: - self._update_daw_lifecycle_buttons() - return - - dp_name = self._active_daw_processor.name - self._status_bar.showMessage(f"Transferring to {dp_name}\u2026") - self._transfer_progress.start("Preparing\u2026") - - self._daw_transfer_worker = DawTransferWorker( - self._active_daw_processor, self._session, output_path) - self._daw_transfer_worker.progress.connect(self._on_transfer_progress) - self._daw_transfer_worker.progress_value.connect( - self._on_transfer_progress_value) - self._daw_transfer_worker.result.connect(self._on_daw_transfer_result) - self._daw_transfer_worker.start() - - @Slot(str) - def _on_transfer_progress(self, message: str): - self._transfer_progress.set_message(message) - self._status_bar.showMessage(message) - - @Slot(int, int) - def _on_transfer_progress_value(self, current: int, total: int): - self._transfer_progress.set_progress(current, total) - - @Slot(bool, str, object) - def _on_daw_transfer_result(self, ok: bool, message: str, results): - self._daw_transfer_worker = None - self._update_daw_lifecycle_buttons() - if ok: - self._transfer_progress.finish(message) - self._status_bar.showMessage(message) - else: - self._transfer_progress.fail(message) - self._status_bar.showMessage(f"Transfer failed: {message}") - - def _populate_folder_tree(self): - """Build the folder tree from the active DAW processor's daw_state.""" - self._folder_tree.clear() - if not self._session or not self._active_daw_processor: - return - dp_state = self._session.daw_state.get(self._active_daw_processor.id, {}) - folders = dp_state.get("folders", []) - assignments = dp_state.get("assignments", {}) - - # Build lookup: id -> folder dict - folder_map = {f["id"]: f for f in folders} - # Build children map: parent_id -> [child folders] - children_map: dict[str | None, list] = {} - for f in folders: - parent = f["parent_id"] - children_map.setdefault(parent, []).append(f) - - # Sort children by index - for k in children_map: - children_map[k].sort(key=lambda f: f["index"]) - - # Build inverse assignments: folder_id -> [filenames] - # Use track_order for stable ordering, fall back to sorted - track_order = dp_state.get("track_order", {}) - folder_tracks: dict[str, list[str]] = {} - for fname, fid in assignments.items(): - folder_tracks.setdefault(fid, []).append(fname) - for fid, fnames in folder_tracks.items(): - order = track_order.get(fid, []) - order_map = {n: i for i, n in enumerate(order)} - fnames.sort(key=lambda n: (order_map.get(n, len(order)), n)) - - # Group color map for track items - gcm = self._group_color_map() - track_map = {} - if self._session: - track_map = {t.filename: t for t in self._session.tracks} - - # Icons – small colored squares to distinguish folder types - def _folder_icon(color_hex: str) -> QIcon: - sz = 14 - pix = QPixmap(sz, sz) - pix.fill(Qt.transparent) - p = QPainter(pix) - p.setRenderHint(QPainter.Antialiasing) - p.setBrush(QColor(color_hex)) - p.setPen(QPen(QColor(color_hex).darker(130), 1)) - p.drawRoundedRect(1, 1, sz - 2, sz - 2, 3, 3) - p.end() - return QIcon(pix) - - routing_icon = _folder_icon(COLORS["information"]) # blue - basic_icon = _folder_icon(COLORS["dim"]) # grey - - def add_folder(parent_widget, folder): - item = QTreeWidgetItem(parent_widget) - item.setText(0, folder["name"]) - item.setData(0, Qt.UserRole, folder["id"]) - item.setData(0, Qt.UserRole + 1, "folder") - if folder["folder_type"] == "routing": - item.setIcon(0, routing_icon) - else: - item.setIcon(0, basic_icon) - item.setFlags( - (item.flags() | Qt.ItemIsDropEnabled) - & ~Qt.ItemIsDragEnabled) - - # Add assigned tracks as children - for fname in folder_tracks.get(folder["id"], []): - track_item = QTreeWidgetItem(item) - track_item.setText(0, fname) - track_item.setData(0, Qt.UserRole, fname) - track_item.setData(0, Qt.UserRole + 1, "track") - track_item.setFlags( - (track_item.flags() | Qt.ItemIsDragEnabled) - & ~Qt.ItemIsDropEnabled) - # Row background from group color (matches table tint) - tc = track_map.get(fname) - if tc and tc.group: - tint = self._tint_group_color(tc.group, gcm) - if tint: - track_item.setBackground(0, tint) - - # Recurse into child folders - for child in children_map.get(folder["id"], []): - add_folder(item, child) - - item.setExpanded(True) - - # Top-level folders (no parent) - for f in children_map.get(None, []): - add_folder(self._folder_tree, f) - - self._folder_tree.expandAll() - - @Slot(list, str, int) - def _assign_tracks_to_folder(self, filenames: list[str], - folder_id: str, insert_index: int = -1): - """Assign session tracks to a DAW folder in the local data model.""" - if not self._session or not self._active_daw_processor: - return - dp_state = self._session.daw_state.setdefault(self._active_daw_processor.id, {}) - assignments = dp_state.setdefault("assignments", {}) - track_order = dp_state.setdefault("track_order", {}) - - # Remove tracks from their previous folder order lists - for fname in filenames: - old_fid = assignments.get(fname) - if old_fid and old_fid in track_order: - try: - track_order[old_fid].remove(fname) - except ValueError: - pass - - # Update assignment mapping - for fname in filenames: - assignments[fname] = folder_id - - # Insert into track_order for the target folder - order = track_order.setdefault(folder_id, []) - # Remove duplicates already in the list - for fname in filenames: - try: - order.remove(fname) - except ValueError: - pass - if insert_index < 0 or insert_index >= len(order): - order.extend(filenames) - else: - for i, fname in enumerate(filenames): - order.insert(insert_index + i, fname) - - self._populate_folder_tree() - self._populate_setup_table() - self._update_daw_lifecycle_buttons() - - @Slot(list) - def _unassign_tracks(self, filenames: list[str]): - """Remove track-to-folder assignments and refresh UI.""" - if not self._session or not self._active_daw_processor: - return - dp_state = self._session.daw_state.get(self._active_daw_processor.id) - if not dp_state: - return - assignments = dp_state.get("assignments", {}) - track_order = dp_state.get("track_order", {}) - for fname in filenames: - fid = assignments.pop(fname, None) - if fid and fid in track_order: - try: - track_order[fid].remove(fname) - except ValueError: - pass - self._populate_folder_tree() - self._populate_setup_table() - self._update_daw_lifecycle_buttons() - - @Slot() - def _on_auto_assign(self): - """Auto-assign unassigned tracks to folders based on group DAW targets.""" - if not self._session or not self._active_daw_processor: - return - dp_id = self._active_daw_processor.id - dp_state = self._session.daw_state.get(dp_id, {}) - folders = dp_state.get("folders", []) - assignments = dp_state.get("assignments", {}) - if not folders: - return - - # Build folder name lookup: lowered+trimmed name → folder id - folder_by_name: dict[str, str] = {} - for f in folders: - key = f["name"].strip().lower() - if key and key not in folder_by_name: - folder_by_name[key] = f["id"] - - # Build group → daw_target lookup from session groups - group_target: dict[str, str] = {} - for g in self._session_groups: - dt = g.get("daw_target", "").strip() - if dt: - group_target[g["name"]] = dt.lower() - - if not group_target: - QMessageBox.information( - self, "Auto-Assign", - "No DAW targets are configured.\n\n" - "Open the Groups tab and set a DAW Target for each " - "group that should be mapped to a DAW folder.") - return - - # Collect assignments: folder_id → [filenames] - batch: dict[str, list[str]] = {} - no_group = 0 - no_target = 0 - no_folder = 0 - already_assigned = 0 - for track in self._session.tracks: - # Skip already-assigned tracks - if track.filename in assignments: - already_assigned += 1 - continue - # Skip tracks without a group or without a DAW target - if not track.group: - no_group += 1 - continue - target_key = group_target.get(track.group) - if not target_key: - no_target += 1 - continue - folder_id = folder_by_name.get(target_key) - if not folder_id: - no_folder += 1 - continue - batch.setdefault(folder_id, []).append(track.filename) - - if not batch: - reasons: list[str] = [] - if no_group: - reasons.append( - f"\u2022 {no_group} track(s) have no group assigned.") - if no_target: - reasons.append( - f"\u2022 {no_target} track(s) belong to groups without " - "a DAW target.") - if no_folder: - reasons.append( - f"\u2022 {no_folder} track(s) have DAW targets that " - "don\u2019t match any fetched folder name.") - if already_assigned: - reasons.append( - f"\u2022 {already_assigned} track(s) are already " - "assigned.") - detail = "\n".join(reasons) if reasons else ( - "No unassigned tracks found.") - QMessageBox.information( - self, "Auto-Assign", - f"Nothing to assign.\n\n{detail}") - return - - # Apply assignments in bulk - total = 0 - for folder_id, fnames in batch.items(): - self._assign_tracks_to_folder(fnames, folder_id) - total += len(fnames) - - self._status_bar.showMessage( - f"Auto-Assign: assigned {total} track(s) to " - f"{len(batch)} folder(s).") + # ── Panel builders ──────────────────────────────────────────────────── def _build_left_panel(self) -> QWidget: panel = QWidget() @@ -1346,7 +440,7 @@ def _build_right_panel(self) -> QWidget: progress_layout.addStretch(2) - self._progress_label = QLabel("Analyzing…") + self._progress_label = QLabel("Analyzing\u2026") self._progress_label.setAlignment(Qt.AlignCenter) self._progress_label.setStyleSheet( f"color: {COLORS['dim']}; font-size: 11pt;" @@ -1659,648 +753,7 @@ def _on_detail_tab_changed(self, index: int): if fs is not None: fs.setVisible(index == _TAB_FILE) - # ── Groups tab (session-local group editor) ───────────────────────── - - def _build_groups_tab(self) -> QWidget: - """Build the session-local Groups editor tab.""" - page = QWidget() - page.setAutoFillBackground(True) - layout = QVBoxLayout(page) - layout.setContentsMargins(8, 8, 8, 8) - layout.setSpacing(6) - - desc = QLabel( - "Session-local track groups. Changes here apply only to " - "the current session." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #888; font-size: 9pt;") - layout.addWidget(desc) - - self._groups_tab_table = QTableWidget() - self._groups_tab_table.setColumnCount(6) - self._groups_tab_table.setHorizontalHeaderLabels( - ["Name", "Color", "Gain-Linked", "DAW Target", - "Match", "Match Pattern"]) - vh = self._groups_tab_table.verticalHeader() - vh.setSectionsMovable(True) - vh.sectionMoved.connect(self._on_groups_tab_row_moved) - self._groups_tab_table.setSelectionBehavior(QTableWidget.SelectRows) - self._groups_tab_table.setSelectionMode(QTableWidget.SingleSelection) - gh = self._groups_tab_table.horizontalHeader() - gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) - gh.setSectionResizeMode(0, QHeaderView.Stretch) - gh.setSectionResizeMode(1, QHeaderView.Fixed) - gh.resizeSection(1, 160) - gh.setSectionResizeMode(2, QHeaderView.Fixed) - gh.resizeSection(2, 80) - gh.setSectionResizeMode(3, QHeaderView.Interactive) - gh.resizeSection(3, 140) - gh.setSectionResizeMode(4, QHeaderView.Fixed) - gh.resizeSection(4, 90) - gh.setSectionResizeMode(5, QHeaderView.Interactive) - gh.resizeSection(5, 200) - - self._groups_tab_table.cellChanged.connect( - self._on_groups_tab_name_changed) - - layout.addWidget(self._groups_tab_table, 1) - - # Buttons - btn_row = QHBoxLayout() - btn_row.setContentsMargins(0, 0, 0, 0) - btn_row.setSpacing(6) - - add_btn = QPushButton("Add") - add_btn.clicked.connect(self._on_groups_tab_add) - btn_row.addWidget(add_btn) - - remove_btn = QPushButton("Remove") - remove_btn.clicked.connect(self._on_groups_tab_remove) - btn_row.addWidget(remove_btn) - - reset_btn = QPushButton("Reset from Preset") - reset_btn.clicked.connect(self._on_groups_tab_reset) - btn_row.addWidget(reset_btn) - - btn_row.addStretch() - - az_btn = QPushButton("Sort A→Z") - az_btn.clicked.connect(self._on_groups_tab_sort_az) - btn_row.addWidget(az_btn) - - layout.addLayout(btn_row) - - return page - - # ── Config tab (per-session overrides) ──────────────────────────────── - - def _build_session_settings_tab(self) -> QWidget: - """Build a tree+stack config editor for per-session overrides.""" - page = QWidget() - page.setAutoFillBackground(True) - layout = QVBoxLayout(page) - layout.setContentsMargins(4, 4, 4, 4) - - # Header row - header = QHBoxLayout() - header.setSpacing(8) - self._session_preset_label = QLabel("Config Preset: —") - self._session_preset_label.setStyleSheet( - f"color: {COLORS['dim']}; font-style: italic;") - header.addWidget(self._session_preset_label) - header.addStretch() - reset_btn = QPushButton("Reset to Preset Defaults") - reset_btn.setToolTip( - "Discard all session-specific changes and reload from the " - "global config preset.") - reset_btn.clicked.connect(self._on_session_config_reset) - header.addWidget(reset_btn) - layout.addLayout(header) - - # Tree + Stack - splitter = QSplitter(Qt.Horizontal) - - self._session_tree = QTreeWidget() - self._session_tree.setHeaderHidden(True) - self._session_tree.setMinimumWidth(160) - self._session_tree.setMaximumWidth(220) - self._session_tree.currentItemChanged.connect( - self._on_session_tree_selection) - splitter.addWidget(self._session_tree) - - self._session_stack = QStackedWidget() - splitter.addWidget(self._session_stack) - splitter.setStretchFactor(0, 0) - splitter.setStretchFactor(1, 1) - - layout.addWidget(splitter, 1) - - # Build initial pages from the active global preset - self._session_page_index: dict[int, int] = {} - self._build_session_pages() - - self._session_tree.expandAll() - first = self._session_tree.topLevelItem(0) - if first: - self._session_tree.setCurrentItem(first) - - return page - - def _build_session_pages(self): - """Populate the session config tree + stack from the active preset.""" - - def _register_page(tree_item, page): - idx = self._session_stack.addWidget(page) - self._session_page_index[id(tree_item)] = idx - - self._session_dawproject_templates_widget = build_config_pages( - self._session_tree, - self._active_preset(), - self._session_widgets, - _register_page, - on_processor_enabled=self._on_processor_enabled_changed, - ) - - def _on_session_tree_selection(self, current, _previous): - if current is None: - return - idx = self._session_page_index.get(id(current)) - if idx is not None: - self._session_stack.setCurrentIndex(idx) - - def _init_session_config(self): - """Snapshot the active global config preset into session config.""" - self._session_config = copy.deepcopy(self._active_preset()) - name = self._active_config_preset_name - self._session_preset_label.setText(f"Config Preset: {name}") - self._session_preset_label.setStyleSheet("") - self._load_session_widgets(self._session_config) - self._detail_tabs.setTabEnabled(_TAB_SESSION, True) - - def _load_session_widgets(self, preset: dict[str, Any]): - """Load values from a config preset dict into session widgets.""" - self._loading_session_widgets = True - try: - self._load_session_widgets_inner(preset) - finally: - self._loading_session_widgets = False - # Single refresh after all widgets are set - if self._session: - self._on_processor_enabled_changed(False) - - def _load_session_widgets_inner(self, preset: dict[str, Any]): - """Inner loader — sets widget values without triggering column refresh.""" - load_config_widgets( - self._session_widgets, preset, - self._session_dawproject_templates_widget) - - def _read_session_config(self) -> dict[str, Any]: - """Read current session widget values into a structured config dict.""" - return read_config_widgets( - self._session_widgets, - self._session_dawproject_templates_widget, - fallback_daw_sections=self._active_preset().get( - "daw_processors", {}), - ) - - def _on_session_config_reset(self): - """Reset session config to the global config preset defaults.""" - preset = self._active_preset() - self._session_config = copy.deepcopy(preset) - self._load_session_widgets(self._session_config) - self._status_bar.showMessage("Session config reset to preset defaults.") - - # ── Color helpers ───────────────────────────────────────────────────── - - def _color_names_from_config(self) -> list[str]: - """Return color names from the current config (or defaults).""" - colors = self._config.get("colors", PT_DEFAULT_COLORS) - return [c["name"] for c in colors if c.get("name")] - - def _color_argb_by_name(self, name: str) -> str | None: - """Look up ARGB hex by color name from config, falling back to defaults.""" - colors = self._config.get("colors", PT_DEFAULT_COLORS) - for c in colors: - if c.get("name") == name: - return c.get("argb") - # Fallback: check built-in defaults (handles stale saved configs) - for c in PT_DEFAULT_COLORS: - if c.get("name") == name: - return c.get("argb") - return None - - @staticmethod - def _color_swatch_icon(argb: str, size: int = 16) -> QIcon: - """Create a small QIcon swatch from an ARGB hex string.""" - pm = QPixmap(size, size) - pm.fill(_argb_to_qcolor(argb)) - return QIcon(pm) - - def _set_groups_tab_row(self, row: int, name: str, color: str, - gain_linked: bool, daw_target: str = "", - match_method: str = "contains", - match_pattern: str = ""): - """Populate one row in the session-local groups table.""" - name_item = QTableWidgetItem(name) - self._groups_tab_table.setItem(row, 0, name_item) - - # Color dropdown with swatch icons - color_combo = QComboBox() - color_combo.setIconSize(QSize(16, 16)) - for cn in self._color_names_from_config(): - argb = self._color_argb_by_name(cn) - icon = self._color_swatch_icon(argb) if argb else QIcon() - color_combo.addItem(icon, cn) - ci = color_combo.findText(color) - if ci >= 0: - color_combo.setCurrentIndex(ci) - self._groups_tab_table.setCellWidget(row, 1, color_combo) - - # Gain-linked checkbox (centered) - chk = QCheckBox() - chk.setChecked(gain_linked) - chk_container = QWidget() - chk_layout = QHBoxLayout(chk_container) - chk_layout.setContentsMargins(0, 0, 0, 0) - chk_layout.setAlignment(Qt.AlignCenter) - chk_layout.addWidget(chk) - self._groups_tab_table.setCellWidget(row, 2, chk_container) - - # DAW Target name - daw_item = QTableWidgetItem(daw_target) - self._groups_tab_table.setItem(row, 3, daw_item) - - # Match method dropdown - match_combo = QComboBox() - match_combo.addItems(["contains", "regex"]) - mi = match_combo.findText(match_method) - if mi >= 0: - match_combo.setCurrentIndex(mi) - match_combo.setProperty("_row", row) - match_combo.currentTextChanged.connect( - lambda _text, r=row: self._validate_groups_tab_pattern(r)) - self._groups_tab_table.setCellWidget(row, 4, match_combo) - - # Match pattern text - pattern_item = QTableWidgetItem(match_pattern) - self._groups_tab_table.setItem(row, 5, pattern_item) - self._validate_groups_tab_pattern(row) - - def _populate_groups_tab(self): - """Populate the groups tab table from self._session_groups.""" - self._groups_tab_table.blockSignals(True) - self._groups_tab_table.setRowCount(0) - self._groups_tab_table.setRowCount(len(self._session_groups)) - for row, g in enumerate(self._session_groups): - self._set_groups_tab_row( - row, g["name"], g.get("color", ""), - g.get("gain_linked", False), g.get("daw_target", ""), - g.get("match_method", "contains"), - g.get("match_pattern", ""), - ) - self._groups_tab_table.blockSignals(False) - - def _read_session_groups(self) -> list[dict]: - """Read the session groups table back into a list of dicts.""" - groups: list[dict] = [] - for row in range(self._groups_tab_table.rowCount()): - name_item = self._groups_tab_table.item(row, 0) - if not name_item: - continue - name = name_item.text().strip() - if not name: - continue - color_combo = self._groups_tab_table.cellWidget(row, 1) - color = color_combo.currentText() if color_combo else "" - chk_container = self._groups_tab_table.cellWidget(row, 2) - gain_linked = False - if chk_container: - chk = chk_container.findChild(QCheckBox) - if chk: - gain_linked = chk.isChecked() - daw_item = self._groups_tab_table.item(row, 3) - daw_target = daw_item.text().strip() if daw_item else "" - match_combo = self._groups_tab_table.cellWidget(row, 4) - match_method = match_combo.currentText() if match_combo else "contains" - pattern_item = self._groups_tab_table.item(row, 5) - match_pattern = pattern_item.text().strip() if pattern_item else "" - groups.append({ - "name": name, - "color": color, - "gain_linked": gain_linked, - "daw_target": daw_target, - "match_method": match_method, - "match_pattern": match_pattern, - }) - return groups - - @staticmethod - def _group_names_in_table(table: QTableWidget, - exclude_row: int = -1) -> set[str]: - """Collect all group names from a table, optionally excluding one row.""" - names: set[str] = set() - for r in range(table.rowCount()): - if r == exclude_row: - continue - item = table.item(r, 0) - if item: - n = item.text().strip() - if n: - names.add(n) - return names - - def _unique_session_group_name(self, base: str = "New Group") -> str: - """Generate a unique group name for the session groups table.""" - existing = self._group_names_in_table(self._groups_tab_table) - if base not in existing: - return base - n = 2 - while f"{base} {n}" in existing: - n += 1 - return f"{base} {n}" - - def _on_groups_tab_name_changed(self, row: int, col: int): - """Handle cell edits in the groups tab (name, DAW target, pattern).""" - if col == 3: - # DAW Target changed — sync groups so auto-assign picks it up - self._sync_session_groups() - return - if col == 5: - # Match pattern changed — validate and sync - self._validate_groups_tab_pattern(row) - self._sync_session_groups() - return - if col != 0: - return - item = self._groups_tab_table.item(row, 0) - if not item: - return - name = item.text().strip() - others = self._group_names_in_table(self._groups_tab_table, - exclude_row=row) - if name in others: - self._groups_tab_table.blockSignals(True) - item.setText(self._unique_session_group_name(name)) - self._groups_tab_table.blockSignals(False) - self._sync_session_groups() - - def _validate_groups_tab_pattern(self, row: int): - """Validate the match pattern cell and set visual indicator. - - When match_method is "regex", tries to compile the pattern. - Sets the cell foreground to green (valid / empty) or red (invalid). - For "contains" mode, always shows default color. - """ - match_combo = self._groups_tab_table.cellWidget(row, 4) - pattern_item = self._groups_tab_table.item(row, 5) - if not pattern_item: - return - method = match_combo.currentText() if match_combo else "contains" - pattern = pattern_item.text().strip() - - if method == "regex" and pattern: - try: - re.compile(pattern) - pattern_item.setForeground(QColor("#4ec94e")) # green - pattern_item.setToolTip("") - except re.error as e: - pattern_item.setForeground(QColor("#e05050")) # red - pattern_item.setToolTip(f"Invalid regex: {e}") - else: - pattern_item.setForeground(QColor("#cccccc")) # default - pattern_item.setToolTip("") - - def _sync_session_groups(self): - """Read the groups tab table into _session_groups and refresh combos.""" - self._session_groups = self._read_session_groups() - self._refresh_group_combos() - - def _on_groups_tab_add(self): - row = self._groups_tab_table.rowCount() - self._groups_tab_table.insertRow(row) - color_names = self._color_names_from_config() - default_color = color_names[0] if color_names else "" - self._set_groups_tab_row( - row, self._unique_session_group_name(), default_color, False) - self._groups_tab_table.scrollToBottom() - self._groups_tab_table.editItem(self._groups_tab_table.item(row, 0)) - self._sync_session_groups() - - def _on_groups_tab_remove(self): - row = self._groups_tab_table.currentRow() - if row >= 0: - self._groups_tab_table.removeRow(row) - self._sync_session_groups() - - def _on_groups_tab_row_moved(self, logical: int, old_visual: int, - new_visual: int): - """Handle drag-and-drop row reorder on the session groups table.""" - table = self._groups_tab_table - vh = table.verticalHeader() - n = table.rowCount() - # Build visual order → logical index mapping - visual_to_logical = sorted(range(n), key=lambda i: vh.visualIndex(i)) - ordered: list[dict] = [] - for log_idx in visual_to_logical: - name_item = table.item(log_idx, 0) - if not name_item: - continue - name = name_item.text().strip() - if not name: - continue - cc = table.cellWidget(log_idx, 1) - color = cc.currentText() if cc else "" - chk_c = table.cellWidget(log_idx, 2) - gl = False - if chk_c: - chk = chk_c.findChild(QCheckBox) - if chk: - gl = chk.isChecked() - daw_item = table.item(log_idx, 3) - dt = daw_item.text().strip() if daw_item else "" - mc = table.cellWidget(log_idx, 4) - mm = mc.currentText() if mc else "contains" - pi = table.item(log_idx, 5) - mp = pi.text().strip() if pi else "" - ordered.append({"name": name, "color": color, - "gain_linked": gl, "daw_target": dt, - "match_method": mm, "match_pattern": mp}) - # Reset visual mapping, repopulate - vh.blockSignals(True) - table.blockSignals(True) - for i in range(n): - vh.moveSection(vh.visualIndex(i), i) - table.setRowCount(0) - table.setRowCount(len(ordered)) - for row, entry in enumerate(ordered): - self._set_groups_tab_row( - row, entry["name"], entry["color"], - entry["gain_linked"], entry.get("daw_target", ""), - entry.get("match_method", "contains"), - entry.get("match_pattern", "")) - table.blockSignals(False) - vh.blockSignals(False) - self._session_groups = ordered - self._refresh_group_combos() - - def _on_groups_tab_sort_az(self): - groups = self._read_session_groups() - groups.sort(key=lambda g: g["name"].lower()) - self._session_groups = groups - self._populate_groups_tab() - self._refresh_group_combos() - - def _on_groups_tab_reset(self): - """Reset session groups to the active preset from preferences.""" - self._merge_groups_from_preset() - - def _merge_groups_from_preset(self): - """Replace session groups with the active preset and name-match tracks.""" - presets = self._config.get("group_presets", - build_defaults().get("group_presets", {})) - preset = presets.get(self._active_session_preset, - presets.get("Default", [])) - new_groups = copy.deepcopy(preset) - new_names = {g["name"].strip().lower() for g in new_groups} - - if self._session: - for track in self._session.tracks: - if track.group is not None: - if track.group.strip().lower() not in new_names: - track.group = None - - self._session_groups = new_groups - self._populate_groups_tab() - self._refresh_group_combos() - self._populate_setup_table() - - # ── Auto-Group ──────────────────────────────────────────────────── - - @Slot() - def _on_auto_group(self): - """Auto-assign groups to all tracks based on filename matching rules.""" - if not self._session: - return - ok_tracks = [t for t in self._session.tracks if t.status == "OK"] - if not ok_tracks: - return - - reply = QMessageBox.question( - self, "Auto-Group", - f"Auto-Group will reassign all {len(ok_tracks)} tracks " - f"based on matching rules.\n\nContinue?", - QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes) - if reply != QMessageBox.Yes: - return - - assigned = 0 - glm = self._gain_linked_map() - gcm = self._group_color_map() - grm = self._group_rank_map() - - self._track_table.setSortingEnabled(False) - - for track in ok_tracks: - stem = os.path.splitext(track.filename)[0].lower() - matched_group: str | None = None - best_len = 0 - - for g in self._session_groups: - pattern = g.get("match_pattern", "").strip() - if not pattern: - continue - method = g.get("match_method", "contains") - - if method == "regex": - try: - m = re.search(pattern, stem, re.IGNORECASE) - if m: - span = m.end() - m.start() - if span > best_len: - best_len = span - matched_group = g["name"] - except re.error: - continue - else: - # contains: comma-separated tokens — pick longest hit - tokens = [t.strip().lower() for t in pattern.split(",") - if t.strip()] - for tok in tokens: - if tok in stem and len(tok) > best_len: - best_len = len(tok) - matched_group = g["name"] - - # Apply the match (or clear to None) - track.group = matched_group - if matched_group: - assigned += 1 - - # Update table combo - row = self._find_table_row(track.filename) - if row >= 0: - w = self._track_table.cellWidget(row, 6) - if isinstance(w, BatchComboBox): - w.blockSignals(True) - if matched_group: - for ci in range(w.count()): - if w.itemData(ci, Qt.UserRole) == matched_group: - w.setCurrentIndex(ci) - break - else: - w.setCurrentIndex(0) # (None) - w.blockSignals(False) - - # Update sort item - display = (self._group_display_name(matched_group, glm) - if matched_group else self._GROUP_NONE_LABEL) - rank = (grm.get(matched_group, len(grm)) - if matched_group else len(grm)) - sort_item = self._track_table.item(row, 6) - if sort_item: - sort_item.setText(display) - sort_item._sort_key = rank - - # Update row color - self._apply_row_group_color(row, matched_group, gcm) - - self._track_table.setSortingEnabled(True) - self._auto_fit_group_column() - self._apply_linked_group_levels() - self._populate_setup_table() - - self._status_bar.showMessage( - f"Auto-Group: assigned {assigned} of {len(ok_tracks)} tracks") - - # ── Group preset switching (Analysis toolbar) ───────────────────── - - @Slot(str) - def _on_group_preset_changed(self, preset_name: str): - """Switch the active group preset from the Analysis toolbar combo.""" - presets = self._config.get("group_presets", - build_defaults().get("group_presets", {})) - if preset_name not in presets: - return - self._active_session_preset = preset_name - self._merge_groups_from_preset() - - # ── Config preset switching (Analysis toolbar) ──────────────────── - - @Slot(str) - def _on_toolbar_config_preset_changed(self, name: str): - """Switch the active config preset from the Analysis toolbar combo.""" - presets = self._config.get("config_presets", - build_defaults().get("config_presets", {})) - if name not in presets: - return - - if self._session is not None: - ans = QMessageBox.question( - self, "Switch config preset?", - f"Switching to \u201c{name}\u201d will overwrite your " - "session config and re-analyze.\n\n" - "Group assignments will be preserved.\n\n" - "Continue?", - QMessageBox.Yes | QMessageBox.No, - QMessageBox.No, - ) - if ans != QMessageBox.Yes: - # Revert combo to the current preset - self._config_preset_combo.blockSignals(True) - self._config_preset_combo.setCurrentText( - self._active_config_preset_name) - self._config_preset_combo.blockSignals(False) - return - - self._active_config_preset_name = name - self._config.setdefault("app", {})["active_config_preset"] = name - save_config(self._config) - - if self._session is not None: - self._session_config = None # re-init from new preset - self._on_analyze() - - def _make_report_browser(self) -> QTextBrowser: + def _make_report_browser(self): """Create a consistently styled QTextBrowser for reports.""" browser = _HelpBrowser(self._detector_help) font = QFont("Consolas", 10) @@ -2323,1991 +776,7 @@ def _on_phase_tab_changed(self, index: int): if remaining > 0: self._setup_splitter.setSizes([total, remaining]) - # ── Slots: file / analysis ──────────────────────────────────────────── - - @Slot() - def _on_open_path(self): - start_dir = self._config.get("app", {}).get("default_project_dir", "") or "" - path = QFileDialog.getExistingDirectory( - self, "Select Session Directory", start_dir, - QFileDialog.ShowDirsOnly, - ) - if not path: - return - - self._on_stop() - self._source_dir = path - self._track_table.set_source_dir(path) - self._session = None - self._summary = None - self._current_track = None - - # Reset UI - self._phase_tabs.setCurrentIndex(_PHASE_ANALYSIS) - self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) - self._track_table.setRowCount(0) - self._setup_table.setRowCount(0) - self._summary_view.clear() - self._file_report.clear() - self._wf_container.setVisible(False) - self._play_btn.setEnabled(False) - self._stop_btn.setEnabled(False) - self._detail_tabs.setTabEnabled(_TAB_FILE, False) - self._detail_tabs.setTabEnabled(_TAB_GROUPS, False) - self._detail_tabs.setTabEnabled(_TAB_SESSION, False) - self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) - self._right_stack.setCurrentIndex(_PAGE_TABS) - self._session_config = None # reset session overrides for new directory - self._session_groups = [] - self._groups_tab_table.setRowCount(0) - - wav_files = sorted( - f for f in os.listdir(path) if f.lower().endswith(AUDIO_EXTENSIONS) - ) - - if not wav_files: - self._status_bar.showMessage(f"No audio files found in {path}") - self._analyze_action.setEnabled(False) - return - - self._track_table.setSortingEnabled(False) - self._track_table.setRowCount(len(wav_files)) - for row, fname in enumerate(wav_files): - item = _SortableItem(fname, protools_sort_key(fname)) - item.setForeground(FILE_COLOR_OK) - self._track_table.setItem(row, 0, item) - for col in range(1, 6): - cell = _SortableItem("", "") - cell.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, col, cell) - self._track_table.setSortingEnabled(True) - self._auto_fit_track_table() - - self._analyze_action.setEnabled(True) - self._status_bar.showMessage( - f"Loaded {len(wav_files)} file(s) from {path}" - ) - self.setWindowTitle("SessionPrep") - - # Auto-start analysis - self._on_analyze() - - @Slot() - def _on_save_session(self): - """Save the current session state to a .spsession file.""" - if not self._session or not self._source_dir: - return - default_path = os.path.join(self._source_dir, "session.spsession") - path, _ = QFileDialog.getSaveFileName( - self, "Save Session", default_path, - "SessionPrep Session (*.spsession);;All Files (*)", - ) - if not path: - return - try: - _save_session_file(path, { - "source_dir": self._source_dir, - "active_config_preset": self._active_config_preset_name, - "session_config": self._session_config, - "session_groups": self._session_groups, - "daw_state": self._session.daw_state, - "tracks": self._session.tracks, - }) - self._status_bar.showMessage(f"Session saved to {path}") - except Exception as exc: - QMessageBox.critical( - self, "Save Session Failed", - f"Could not save session:\n\n{exc}", - ) - - @Slot() - def _on_load_session(self): - """Load a .spsession file and restore the full session state.""" - start_dir = self._source_dir or self._config.get("app", {}).get( - "default_project_dir", "") or "" - path, _ = QFileDialog.getOpenFileName( - self, "Load Session", start_dir, - "SessionPrep Session (*.spsession);;All Files (*)", - ) - if not path: - return - - try: - data = _load_session_file(path) - except Exception as exc: - QMessageBox.critical( - self, "Load Session Failed", - f"Could not load session:\n\n{exc}", - ) - return - - source_dir = data["source_dir"] - if not os.path.isdir(source_dir): - QMessageBox.warning( - self, "Load Session", - f"The session's audio directory no longer exists:\n\n{source_dir}\n\n" - "Please move the files back or open the directory manually.", - ) - return - - # ── Reset UI (same as _on_open_path but without auto-analyze) ──────── - self._on_stop() - self._source_dir = source_dir - self._track_table.set_source_dir(source_dir) - self._session = None - self._summary = None - self._current_track = None - - self._phase_tabs.setCurrentIndex(_PHASE_ANALYSIS) - self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) - self._track_table.setRowCount(0) - self._setup_table.setRowCount(0) - self._summary_view.clear() - self._file_report.clear() - self._wf_container.setVisible(False) - self._play_btn.setEnabled(False) - self._stop_btn.setEnabled(False) - self._detail_tabs.setTabEnabled(_TAB_FILE, False) - self._detail_tabs.setTabEnabled(_TAB_GROUPS, False) - self._detail_tabs.setTabEnabled(_TAB_SESSION, False) - self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) - self._right_stack.setCurrentIndex(_PAGE_TABS) - self._groups_tab_table.setRowCount(0) - - # ── Restore session-level state ─────────────────────────────────────── - preset_name = data.get("active_config_preset", "Default") - self._active_config_preset_name = preset_name - self._session_config = data.get("session_config") - self._session_groups = data.get("session_groups", []) - - # ── Reconstruct SessionContext from saved tracks ────────────────────── - from sessionpreplib.models import SessionContext - from sessionpreplib.rendering import build_diagnostic_summary - - tracks = data["tracks"] - flat = self._flat_config() - - # Re-instantiate detectors and processors (needed for label filtering) - all_detectors = default_detectors() - for d in all_detectors: - d.configure(flat) - all_processors = [] - for proc in default_processors(): - proc.configure(flat) - if proc.enabled: - all_processors.append(proc) - all_processors.sort(key=lambda p: p.priority) - - session_config_flat = dict(default_config()) - session_config_flat.update(flat) - session_config_flat["_source_dir"] = source_dir - - session = SessionContext( - tracks=tracks, - config=session_config_flat, - groups={}, - detectors=all_detectors, - processors=all_processors, - daw_state=data.get("daw_state", {}), - prepare_state="none", - ) - - self._session = session - self._summary = build_diagnostic_summary(session) - - # ── Populate file list in track table ───────────────────────────────── - self._track_table.setSortingEnabled(False) - self._track_table.setRowCount(len(tracks)) - for row, track in enumerate(tracks): - item = _SortableItem(track.filename, protools_sort_key(track.filename)) - item.setForeground(FILE_COLOR_OK if track.status == "OK" else FILE_COLOR_ERROR) - self._track_table.setItem(row, 0, item) - for col in range(1, 8): - cell = _SortableItem("", "") - cell.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, col, cell) - self._track_table.setSortingEnabled(True) - - # ── Populate all table widgets and tabs ─────────────────────────────── - self._populate_groups_tab() - self._populate_group_preset_combo() - self._populate_table(session) - self._render_summary() - - # ── Enable post-analysis UI ─────────────────────────────────────────── - self._right_stack.setCurrentIndex(_PAGE_TABS) - self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) - self._detail_tabs.setTabEnabled(_TAB_GROUPS, True) - self._detail_tabs.setTabEnabled(_TAB_SESSION, True) - self._phase_tabs.setTabEnabled(_PHASE_SETUP, True) - self._populate_setup_table() - self._analyze_action.setEnabled(True) - self._save_session_action.setEnabled(True) - self._update_prepare_button() - self._auto_fit_track_table() - - ok_count = sum(1 for t in tracks if t.status == "OK") - self._status_bar.showMessage( - f"Session loaded: {ok_count}/{len(tracks)} tracks OK" - " — click Reanalyze to refresh results" - ) - self.setWindowTitle("SessionPrep") - - @Slot() - def _on_analyze(self): - if not self._source_dir: - return - - # Snapshot existing group assignments so we can restore after re-analysis - self._prev_group_assignments = {} - if self._session: - self._prev_group_assignments = { - t.filename: t.group for t in self._session.tracks if t.group} - - self._analyze_action.setEnabled(False) - self._current_track = None - self._detail_tabs.setTabEnabled(_TAB_FILE, False) - - # Initialise session config from global preset (first analysis) - # or keep existing session config (re-analysis with user edits) - if self._session_config is None: - self._init_session_config() - - # Show progress page - self._progress_label.setText("Analyzing…") - self._right_stack.setCurrentIndex(_PAGE_PROGRESS) - - config = self._flat_config() - config["_source_dir"] = self._source_dir - if self._active_daw_processor: - config["_fader_ceiling_db"] = self._active_daw_processor.fader_ceiling_db - - self._progress_bar.setRange(0, 0) # indeterminate until first value - - self._worker = AnalyzeWorker(self._source_dir, config) - self._worker.progress.connect(self._on_worker_progress) - self._worker.progress_value.connect(self._on_worker_progress_value) - self._worker.track_analyzed.connect(self._on_track_analyzed) - self._worker.track_planned.connect(self._on_track_planned) - self._worker.finished.connect(self._on_analyze_done) - self._worker.error.connect(self._on_analyze_error) - self._worker.start() - - @Slot(str) - def _on_worker_progress(self, message: str): - self._progress_label.setText(message) - self._status_bar.showMessage(message) - - @Slot(int, int) - def _on_worker_progress_value(self, current: int, total: int): - if self._progress_bar.maximum() != total: - self._progress_bar.setRange(0, total) - self._progress_bar.setValue(current) - - def _find_table_row(self, filename: str) -> int: - """Return the table row index for *filename*, or -1 if not found.""" - for row in range(self._track_table.rowCount()): - item = self._track_table.item(row, 0) - if item and item.text() == filename: - return row - return -1 - - @Slot(str, object) - def _on_track_analyzed(self, filename: str, track): - """Update the severity column for a track after detectors complete.""" - row = self._find_table_row(filename) - if row < 0: - return - # Ch column - ch_item = _SortableItem(str(track.channels), track.channels) - ch_item.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, 1, ch_item) - # Analysis column - _plain, html, _color, sort_key = track_analysis_label(track) - lbl, item = _make_analysis_cell(html, sort_key) - self._track_table.setItem(row, 2, item) - self._track_table.setCellWidget(row, 2, lbl) - - @Slot(str, object) - def _on_track_planned(self, filename: str, track): - """Update classification and gain columns after processors complete.""" - row = self._find_table_row(filename) - if row < 0: - return - - # Re-evaluate severity now that processor results inform is_relevant() - dets = self._session.detectors if self._session else None - _plain, html, _color, sort_key = track_analysis_label(track, dets) - lbl, item = _make_analysis_cell(html, sort_key) - self._track_table.setItem(row, 2, item) - self._track_table.setCellWidget(row, 2, lbl) - - # Remove previous cell widgets - self._track_table.removeCellWidget(row, 3) - self._track_table.removeCellWidget(row, 4) - self._track_table.removeCellWidget(row, 5) - - pr = ( - next(iter(track.processor_results.values()), None) - if track.processor_results - else None - ) - if track.status != "OK": - cls_item = _SortableItem("Error", "error") - cls_item.setForeground(FILE_COLOR_ERROR) - self._track_table.setItem(row, 3, cls_item) - gain_item = _SortableItem("", 0.0) - gain_item.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, 4, gain_item) - elif pr and pr.classification == "Silent": - cls_item = _SortableItem("Silent", "silent") - cls_item.setForeground(FILE_COLOR_SILENT) - self._track_table.setItem(row, 3, cls_item) - gain_item = _SortableItem("0.0 dB", 0.0) - gain_item.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, 4, gain_item) - elif pr: - cls_text = pr.classification or "Unknown" - if "Transient" in cls_text: - base_cls = "Transient" - elif cls_text == "Skip": - base_cls = "Skip" - elif "Sustained" in cls_text: - base_cls = "Sustained" - else: - base_cls = "Sustained" - - sort_item = _SortableItem(base_cls, base_cls.lower()) - self._track_table.setItem(row, 3, sort_item) - - combo = BatchComboBox() - combo.addItems(["Transient", "Sustained", "Skip"]) - combo.blockSignals(True) - combo.setCurrentText(base_cls) - combo.blockSignals(False) - combo.setProperty("track_filename", track.filename) - self._style_classification_combo(combo, base_cls) - combo.textActivated.connect(self._on_classification_changed) - self._track_table.setCellWidget(row, 3, combo) - - gain_db = pr.gain_db - gain_sort = _SortableItem(f"{gain_db:+.1f}", gain_db) - self._track_table.setItem(row, 4, gain_sort) - - spin = QDoubleSpinBox() - spin.setRange(-60.0, 60.0) - spin.setSingleStep(0.1) - spin.setDecimals(1) - spin.setSuffix(" dB") - spin.blockSignals(True) - spin.setValue(gain_db) - spin.blockSignals(False) - spin.setProperty("track_filename", track.filename) - spin.setEnabled(base_cls != "Skip") - spin.setStyleSheet( - f"QDoubleSpinBox {{ color: {COLORS['text']}; }}" - ) - spin.valueChanged.connect(self._on_gain_changed) - self._track_table.setCellWidget(row, 4, spin) - - # RMS Anchor combo (column 5) - self._create_anchor_combo(row, track) - - # Group combo (column 6) - self._create_group_combo(row, track) - - # Row background from group color - self._apply_row_group_color(row, track.group) - - self._auto_fit_group_column() - - @Slot(object, object) - def _on_analyze_done(self, session, summary): - self._session = session - self._summary = summary - self._analyze_action.setEnabled(True) - self._worker = None - - if not self._session_groups: - # First analysis — load from Default group preset - self._active_session_preset = "Default" - self._merge_groups_from_preset() - self._populate_group_preset_combo() - else: - # Re-analysis — restore previous group assignments by filename - prev = self._prev_group_assignments - for track in session.tracks: - track.group = prev.get(track.filename) - self._populate_groups_tab() - self._refresh_group_combos() - - self._populate_table(session) - self._render_summary() - - # Switch to tabs — summary tab - self._right_stack.setCurrentIndex(_PAGE_TABS) - self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) - self._detail_tabs.setTabEnabled(_TAB_GROUPS, True) - self._detail_tabs.setTabEnabled(_TAB_SESSION, True) - - # Enable Session Setup phase now that analysis is available - self._phase_tabs.setTabEnabled(_PHASE_SETUP, True) - self._populate_setup_table() - - # Enable Prepare button; mark stale if previously prepared - if session.prepare_state == "ready": - session.prepare_state = "stale" - self._update_prepare_button() - - self._save_session_action.setEnabled(True) - - ok_count = sum(1 for t in session.tracks if t.status == "OK") - self._status_bar.showMessage( - f"Analysis complete: {ok_count}/{len(session.tracks)} tracks OK" - ) - - @Slot(str) - def _on_analyze_error(self, message: str): - self._analyze_action.setEnabled(True) - self._worker = None - - self._right_stack.setCurrentIndex(_PAGE_TABS) - self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) - self._summary_view.setHtml(self._wrap_html( - f'
' - f'Analysis Error
' - f'
{esc(message)}
' - )) - self._status_bar.showMessage(f"Error: {message}") - - # ── Prepare handlers ───────────────────────────────────────────────── - - @Slot() - def _on_prepare(self): - """Run the Prepare pipeline to generate processed audio files.""" - if not self._session or not self._source_dir: - return - if self._prepare_worker is not None: - return # already running - - output_folder = self._config.get("app", {}).get( - "output_folder", "processed") - output_dir = os.path.join(self._source_dir, output_folder) - - # Refresh pipeline config from current session widgets so that - # processor enabled/disabled changes made after analysis take effect. - self._session.config.update(self._flat_config()) - - # Use the session's configured processors - processors = list(self._session.processors) if self._session.processors else [] - if not processors: - self._status_bar.showMessage("No audio processors enabled.") - return - - self._prepare_action.setEnabled(False) - self._status_bar.showMessage("Preparing processed files\u2026") - self._prepare_progress.start("Preparing\u2026") - - self._prepare_worker = PrepareWorker( - self._session, processors, output_dir) - self._prepare_worker.progress.connect(self._on_prepare_progress) - self._prepare_worker.progress_value.connect( - self._on_prepare_progress_value) - self._prepare_worker.finished.connect(self._on_prepare_done) - self._prepare_worker.error.connect(self._on_prepare_error) - self._prepare_worker.start() - - @Slot(str) - def _on_prepare_progress(self, message: str): - self._prepare_progress.set_message(message) - self._status_bar.showMessage(message) - - @Slot(int, int) - def _on_prepare_progress_value(self, current: int, total: int): - self._prepare_progress.set_progress(current, total) - - @Slot() - def _on_prepare_done(self): - self._prepare_worker = None - self._update_prepare_button() - self._update_use_processed_action() - prepared = sum( - 1 for t in self._session.tracks - if t.processed_filepath is not None - ) - errors = self._session.config.get("_prepare_errors", []) - if errors: - msg = f"Prepare complete: {prepared} file(s) written, {len(errors)} error(s)" - self._prepare_progress.finish(msg) - self._status_bar.showMessage(msg) - detail = "\n".join(f"• {fn}: {err}" for fn, err in errors) - QMessageBox.warning( - self, "Prepare — errors", - f"{len(errors)} file(s) could not be written:\n\n{detail}\n\n" - "This is usually caused by a file being open in another " - "application (e.g. the waveform player). Close the file " - "and try again.", - ) - else: - msg = f"Prepare complete: {prepared} file(s) written" - self._prepare_progress.finish(msg) - self._status_bar.showMessage(msg) - self._populate_setup_table() - - @Slot(str) - def _on_prepare_error(self, message: str): - self._prepare_worker = None - self._prepare_action.setEnabled(True) - self._prepare_progress.fail(message) - self._status_bar.showMessage(f"Prepare failed: {message}") - - def _update_prepare_button(self): - """Update the Prepare button text and enabled state based on prepare_state.""" - if not self._session: - self._prepare_action.setEnabled(False) - self._prepare_action.setText("Prepare") - self._auto_group_action.setEnabled(False) - return - - state = self._session.prepare_state - self._prepare_action.setEnabled(True) - self._auto_group_action.setEnabled(True) - if state == "ready": - self._prepare_action.setText("Prepare \u2713") - elif state == "stale": - self._prepare_action.setText("Prepare (!)") - else: - self._prepare_action.setText("Prepare") - - def _mark_prepare_stale(self): - """Mark prepared files as stale if they were previously ready.""" - if self._session and self._session.prepare_state == "ready": - self._session.prepare_state = "stale" - self._update_prepare_button() - self._update_use_processed_action() - - @Slot(bool) - def _on_processor_enabled_changed(self, _checked: bool): - """Live-update session.processors and Processing column when a - processor enabled toggle changes in the session config widgets.""" - if not self._session: - return - if getattr(self, "_loading_session_widgets", False): - return - # Re-evaluate which processors are enabled from current widget values - flat = self._flat_config() - new_processors = [] - for proc in default_processors(): - proc.configure(flat) - if proc.enabled: - new_processors.append(proc) - new_processors.sort(key=lambda p: p.priority) - self._session.processors = new_processors - self._refresh_processing_column() - self._mark_prepare_stale() - - def _refresh_processing_column(self): - """Rebuild all Processing column buttons from the current - session.processors list.""" - if not self._session: - return - processors = self._session.processors - for row in range(self._track_table.rowCount()): - fname_item = self._track_table.item(row, 0) - if not fname_item: - continue - track = next( - (t for t in self._session.tracks if t.filename == fname_item.text()), - None, - ) - if not track or track.status != "OK": - continue - # Remove old widget and recreate - self._track_table.removeCellWidget(row, 7) - self._create_processing_button(row, track) - - # ── Slots: track selection ──────────────────────────────────────────── - - @Slot(int, int) - def _on_row_clicked(self, row, _column): - self._select_row(row) - - @Slot(int, int, int, int) - def _on_current_cell_changed(self, row, _col, _prev_row, _prev_col): - self._select_row(row) - - def _select_row(self, row: int): - if not self._session or row < 0: - return - fname_item = self._track_table.item(row, 0) - if not fname_item: - return - fname = fname_item.text() - track = next( - (t for t in self._session.tracks if t.filename == fname), None - ) - if not track: - return - self._show_track_detail(track) - - # ── Report rendering ────────────────────────────────────────────────── - - @property - def _show_clean(self) -> bool: - if self._session_config is not None: - cfg = self._read_session_config() - return cfg.get("presentation", {}).get( - "show_clean_detectors", False) - preset = self._active_preset() - return preset.get("presentation", {}).get("show_clean_detectors", False) - - @property - def _verbose(self) -> bool: - return self._config.get("app", {}).get("report_verbosity", "normal") == "verbose" - - def _render_summary(self): - """Render the diagnostic summary into the Summary tab.""" - if not self._summary or not self._session: - return - html = render_summary_html( - self._summary, show_faders=False, - show_clean=self._show_clean, - ) - self._summary_view.setHtml(self._wrap_html(html)) - - def _show_track_detail(self, track): - """Populate the File tab with per-track detail + waveform. - - The HTML report is rendered and displayed immediately so the UI - feels responsive. Waveform loading (dtype conversion, peak - finding, RMS setup) is deferred to the next event-loop iteration - via ``QTimer.singleShot`` so the tab switch paints first. - """ - self._on_stop() - self._current_track = track - - # Show HTML report immediately - html = render_track_detail_html(track, self._session, - show_clean=self._show_clean, - verbose=self._verbose) - self._file_report.setHtml(self._wrap_html(html)) - - # Enable and switch to File tab before heavy work - self._detail_tabs.setTabEnabled(_TAB_FILE, True) - self._detail_tabs.setCurrentIndex(_TAB_FILE) - - # Defer waveform loading so the UI repaints first - QTimer.singleShot(0, lambda: self._load_waveform(track)) - - def _load_waveform(self, track): - """Start background waveform loading for *track*.""" - # Guard: user may have clicked a different track while we were queued - if self._current_track is not track: - return - - # Cancel any in-flight workers - if self._wf_worker is not None: - self._wf_worker.cancel() - self._wf_worker.finished.disconnect() - self._wf_worker = None - if self._audio_load_worker is not None: - self._audio_load_worker.cancel() - self._audio_load_worker.finished.disconnect() - self._audio_load_worker = None - - # If audio_data is absent but the file exists, load it from disk first - if (track.audio_data is None or track.audio_data.size == 0) and \ - track.status == "OK" and os.path.isfile(track.filepath): - self._waveform.set_loading(True) - if self._detail_tabs.currentIndex() == _TAB_FILE: - self._wf_container.setVisible(True) - self._play_btn.setEnabled(False) - self._update_time_label(0) - - worker = AudioLoadWorker(track, parent=self) - self._audio_load_worker = worker - worker.finished.connect( - lambda t, orig=track: self._on_audio_loaded(t, orig)) - worker.error.connect( - lambda msg: self._on_audio_load_error(msg, track)) - worker.start() - return - - has_audio = track.audio_data is not None and track.audio_data.size > 0 - if has_audio: - self._waveform.set_loading(True) - if self._detail_tabs.currentIndex() == _TAB_FILE: - self._wf_container.setVisible(True) - self._play_btn.setEnabled(False) - self._update_time_label(0) - - flat_cfg = self._flat_config() - win_ms = flat_cfg.get("window", 400) - ws = get_window_samples(track, win_ms) - - self._wf_worker = WaveformLoadWorker( - track.audio_data, track.samplerate, ws, - spec_n_fft=self._waveform._spec_n_fft, - spec_window=self._waveform._spec_window, - parent=self) - self._wf_worker.finished.connect( - lambda result, t=track: self._on_waveform_loaded(result, t)) - self._wf_worker.start() - else: - self._waveform.set_audio(None, 44100) - self._update_overlay_menu([]) - if self._detail_tabs.currentIndex() == _TAB_FILE: - self._wf_container.setVisible(False) - self._play_btn.setEnabled(False) - self._update_time_label(0) - - @Slot(object, object) - def _on_waveform_loaded(self, result: dict, track): - """Receive pre-computed waveform data from the background worker.""" - self._wf_worker = None - - # Discard if user switched to a different track - if self._current_track is not track: - return - - self._waveform.set_precomputed(result) - cmap = self._config.get("app", {}).get("spectrogram_colormap", "magma") - self._waveform.set_colormap(cmap) - # Sync colormap dropdown with preference - for act in self._cmap_group.actions(): - if act.data() == cmap: - act.setChecked(True) - break - - all_issues = [] - for det_result in track.detector_results.values(): - all_issues.extend(getattr(det_result, "issues", [])) - self._waveform.set_issues(all_issues) - self._update_overlay_menu(all_issues) - self._play_btn.setEnabled(True) - self._update_time_label(0) - - def _on_audio_loaded(self, track, orig_track): - """Audio data loaded from disk; proceed to waveform rendering.""" - self._audio_load_worker = None - # Discard if user switched tracks while we were loading - if self._current_track is not orig_track: - return - # Now kick off the normal waveform worker path - self._load_waveform(track) - - def _on_audio_load_error(self, message: str, track): - """Audio file could not be read from disk.""" - self._audio_load_worker = None - if self._current_track is not track: - return - self._waveform.set_audio(None, 44100) - self._wf_container.setVisible(False) - self._play_btn.setEnabled(False) - self._status_bar.showMessage(f"Could not load audio: {message}") - - # ── Overlay dropdown ──────────────────────────────────────────────── - - def _update_overlay_menu(self, issues: list): - """Rebuild the overlay dropdown menu based on current track issues.""" - self._overlay_menu.clear() - self._waveform.set_enabled_overlays(set()) - - if not issues: - self._overlay_btn.setText("Detector Overlays") - return - - # Build detector instance map from session - det_map: dict[str, object] = {} - det_names: dict[str, str] = {} - if self._session and hasattr(self._session, "detectors"): - for d in self._session.detectors: - det_map[d.id] = d - det_names[d.id] = d.name - - # Filter out issues from detectors that suppress themselves or are skipped - track = self._current_track - filtered_issues = [] - for issue in issues: - det = det_map.get(issue.label) - if det and track: - result = track.detector_results.get(issue.label) - if result: - if hasattr(det, 'effective_severity') and det.effective_severity(result) is None: - continue - if not det.is_relevant(result, track): - continue - filtered_issues.append(issue) - - if not filtered_issues: - self._overlay_btn.setText("Detector Overlays") - return - - # Build {label: count} from filtered issue list - label_counts: dict[str, int] = {} - for issue in filtered_issues: - label_counts[issue.label] = label_counts.get(issue.label, 0) + 1 - - # Add a checkable action per detector that has issues - for label in sorted(label_counts, key=lambda lb: det_names.get(lb, lb).lower()): - name = det_names.get(label, label) - count = label_counts[label] - action = self._overlay_menu.addAction(f"{name} ({count})") - action.setCheckable(True) - action.setChecked(False) - action.setData(label) - action.toggled.connect(self._on_overlay_toggled) - - self._overlay_btn.setText("Detector Overlays") - - @Slot() - def _on_overlay_toggled(self): - """Collect checked overlay labels and update the waveform.""" - checked = set() - for action in self._overlay_menu.actions(): - if action.isChecked(): - checked.add(action.data()) - self._waveform.set_enabled_overlays(checked) - n = len(checked) - self._overlay_btn.setText(f"Detector Overlays ({n})" if n else "Detector Overlays") - - @Slot(QAction) - def _on_display_mode_changed(self, action): - """Switch waveform widget display mode and toggle toolbar controls.""" - is_waveform = action == self._wf_action - mode = "waveform" if is_waveform else "spectrogram" - self._display_mode_btn.setText(action.text()) - self._waveform.set_display_mode(mode) - - # Hide waveform-only toolbar controls in spectrogram mode - self._wf_settings_btn.setVisible(is_waveform) - self._markers_toggle.setVisible(is_waveform) - self._rms_lr_toggle.setVisible(is_waveform) - self._rms_avg_toggle.setVisible(is_waveform) - # Show spectrogram-only controls - self._spec_settings_btn.setVisible(not is_waveform) - - @Slot(bool) - def _on_wf_aa_changed(self, checked: bool): - self._waveform.set_wf_antialias(checked) - - @Slot(QAction) - def _on_wf_line_width_changed(self, action): - self._waveform.set_wf_line_width(int(action.data())) - - @Slot(QAction) - def _on_spec_fft_changed(self, action): - self._waveform.set_spec_fft(int(action.data())) - - @Slot(QAction) - def _on_spec_window_changed(self, action): - self._waveform.set_spec_window(action.data()) - - @Slot(QAction) - def _on_spec_cmap_changed(self, action): - self._waveform.set_colormap(action.data()) - - @Slot(QAction) - def _on_spec_floor_changed(self, action): - self._waveform.set_spec_db_floor(float(action.data())) - - @Slot(QAction) - def _on_spec_ceil_changed(self, action): - self._waveform.set_spec_db_ceil(float(action.data())) - - # ── Processing column (col 7) ────────────────────────────────────── - - def _create_processing_button(self, row: int, track) -> None: - """Create a multiselect tool button for the Processing column.""" - if track.status != "OK": - item = _SortableItem("", "zzz") - self._track_table.setItem(row, 7, item) - return - - processors = self._session.processors if self._session else [] - - btn = BatchToolButton() - btn.setProperty("track_filename", track.filename) - - if processors: - btn.setPopupMode(QToolButton.InstantPopup) - menu = QMenu(btn) - for proc in processors: - action = menu.addAction(proc.name) - action.setCheckable(True) - checked = proc.id not in track.processor_skip - action.setChecked(checked) - action.setData(proc.id) - action.toggled.connect(self._on_processing_toggled) - btn.setMenu(menu) - else: - btn.setEnabled(False) - - self._update_processing_button_label(btn, track, processors) - - # Hidden sort item - sort_item = _SortableItem("", len(track.processor_skip)) - self._track_table.setItem(row, 7, sort_item) - self._track_table.setCellWidget(row, 7, btn) - - def _update_processing_button_label(self, btn, track, processors): - """Set the button label based on current processor_skip state.""" - if not processors: - btn.setText("None") - btn.setToolTip("No audio processors enabled") - return - def _label(p): - return p.shorthand if p.shorthand else p.name - - active = [p for p in processors if p.id not in track.processor_skip] - active_labels = [_label(p) for p in active] - active_names = [p.name for p in active] - # "Default" means the current selection matches each processor's - # configured default (default=True → active, default=False → skipped). - is_default = all( - (p.id not in track.processor_skip) == p.default - for p in processors - ) - if is_default: - default_active_names = [p.name for p in processors if p.default] - if default_active_names: - btn.setText("Default") - btn.setToolTip("Default selection: " + ", ".join(default_active_names)) - else: - btn.setText("Default") - btn.setToolTip("Default: all processors deselected") - elif not active: - btn.setText("None") - btn.setToolTip("All processors skipped for this track") - else: - btn.setText(", ".join(active_labels)) - btn.setToolTip("Active processors: " + ", ".join(active_names)) - - @Slot(bool) - def _on_processing_toggled(self, checked: bool): - """Handle user toggling a processor in the Processing column menu.""" - action = self.sender() - if not action: - return - menu = action.parent() - if not menu: - return - btn = menu.parent() - if not btn: - return - fname = btn.property("track_filename") - if not fname or not self._session: - return - track = next( - (t for t in self._session.tracks if t.filename == fname), None - ) - if not track: - return - - proc_id = action.data() - processors = self._session.processors if self._session else [] - - if getattr(btn, 'batch_mode', False): - btn.batch_mode = False - batch_keys = self._track_table.batch_selected_keys() - track_map = {t.filename: t for t in self._session.tracks} - for fname in batch_keys: - t = track_map.get(fname) - if not t or t.status != "OK": - continue - if checked: - t.processor_skip.discard(proc_id) - else: - t.processor_skip.add(proc_id) - row = self._find_table_row(fname) - if row >= 0: - b = self._track_table.cellWidget(row, 7) - if b: - self._update_processing_button_label(b, t, processors) - self._track_table.restore_selection(batch_keys) - else: - if checked: - track.processor_skip.discard(proc_id) - else: - track.processor_skip.add(proc_id) - self._update_processing_button_label(btn, track, processors) - - self._mark_prepare_stale() - - def _populate_table(self, session): - """Update the track table with analysis results.""" - self._track_table.setSortingEnabled(False) - track_map = {t.filename: t for t in session.tracks} - for row in range(self._track_table.rowCount()): - # Remove any previous cell widgets before repopulating - self._track_table.removeCellWidget(row, 3) - self._track_table.removeCellWidget(row, 4) - self._track_table.removeCellWidget(row, 5) - self._track_table.removeCellWidget(row, 6) - self._track_table.removeCellWidget(row, 7) - - fname_item = self._track_table.item(row, 0) - if not fname_item: - continue - track = track_map.get(fname_item.text()) - if not track: - continue - - # Column 1: channel count - ch_item = _SortableItem(str(track.channels), track.channels) - ch_item.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, 1, ch_item) - - # Column 2: severity counts - dets = session.detectors if hasattr(session, 'detectors') else None - _plain, html, _color, sort_key = track_analysis_label(track, dets) - lbl, item = _make_analysis_cell(html, sort_key) - self._track_table.setItem(row, 2, item) - self._track_table.setCellWidget(row, 2, lbl) - - # Column 3: classification (combo or static) - # Column 4: gain (spin box or static) - pr = ( - next(iter(track.processor_results.values()), None) - if track.processor_results - else None - ) - if track.status != "OK": - cls_item = _SortableItem("Error", "error") - cls_item.setForeground(FILE_COLOR_ERROR) - self._track_table.setItem(row, 3, cls_item) - gain_item = _SortableItem("", 0.0) - gain_item.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, 4, gain_item) - elif pr and pr.classification == "Silent": - cls_item = _SortableItem("Silent", "silent") - cls_item.setForeground(FILE_COLOR_SILENT) - self._track_table.setItem(row, 3, cls_item) - gain_item = _SortableItem("0.0 dB", 0.0) - gain_item.setForeground(QColor(COLORS["dim"])) - self._track_table.setItem(row, 4, gain_item) - elif pr: - # Determine effective classification - cls_text = pr.classification or "Unknown" - if "Transient" in cls_text: - base_cls = "Transient" - elif cls_text == "Skip": - base_cls = "Skip" - elif "Sustained" in cls_text: - base_cls = "Sustained" - else: - base_cls = "Sustained" - - # Hidden sort item (widget overlays it) - sort_item = _SortableItem(base_cls, base_cls.lower()) - self._track_table.setItem(row, 3, sort_item) - - # Classification combo widget - combo = BatchComboBox() - combo.addItems(["Transient", "Sustained", "Skip"]) - combo.blockSignals(True) - combo.setCurrentText(base_cls) - combo.blockSignals(False) - combo.setProperty("track_filename", track.filename) - self._style_classification_combo(combo, base_cls) - combo.textActivated.connect(self._on_classification_changed) - self._track_table.setCellWidget(row, 3, combo) - - # Gain spin box - gain_db = pr.gain_db - gain_sort = _SortableItem(f"{gain_db:+.1f}", gain_db) - self._track_table.setItem(row, 4, gain_sort) - - spin = QDoubleSpinBox() - spin.setRange(-60.0, 60.0) - spin.setSingleStep(0.1) - spin.setDecimals(1) - spin.setSuffix(" dB") - spin.blockSignals(True) - spin.setValue(gain_db) - spin.blockSignals(False) - spin.setProperty("track_filename", track.filename) - spin.setEnabled(base_cls != "Skip") - spin.setStyleSheet( - f"QDoubleSpinBox {{ color: {COLORS['text']}; }}" - ) - spin.valueChanged.connect(self._on_gain_changed) - self._track_table.setCellWidget(row, 4, spin) - - # RMS Anchor combo (column 5) - self._create_anchor_combo(row, track) - elif track.status == "OK": - # OK track but no processor results (all processors disabled) - cls_item = _SortableItem("", "zzz") - self._track_table.setItem(row, 3, cls_item) - gain_item = _SortableItem("", 0.0) - self._track_table.setItem(row, 4, gain_item) - else: - cls_item = _SortableItem("", "zzz") - self._track_table.setItem(row, 3, cls_item) - gain_item = _SortableItem("", 0.0) - self._track_table.setItem(row, 4, gain_item) - - # Group combo, processing button, and row color for all OK tracks - if track.status == "OK": - # Group combo (column 6) - self._create_group_combo(row, track) - - # Processing multiselect (column 7) - self._create_processing_button(row, track) - - # Row background from group color - self._apply_row_group_color(row, track.group) - self._track_table.setSortingEnabled(True) - - # Auto-fit columns 2–7 to content, File column stays Stretch, Ch stays Fixed - header = self._track_table.horizontalHeader() - for col in (2, 3, 4, 5, 6, 7): - header.setSectionResizeMode(col, QHeaderView.ResizeToContents) - self._track_table.resizeColumnsToContents() - for col in (2, 3, 4, 5, 6, 7): - header.setSectionResizeMode(col, QHeaderView.Interactive) - self._auto_fit_group_column() - self._auto_fit_track_table() - - def _populate_setup_table(self): - """Refresh the Session Setup track table from the current session.""" - if not self._session: - return - self._setup_table.setSortingEnabled(False) - self._setup_table.setRowCount(0) - - ok_tracks = [t for t in self._session.tracks if t.status == "OK"] - self._setup_table.setRowCount(len(ok_tracks)) - gcm = self._group_color_map() - gcm_rank = self._group_rank_map() - glm = self._gain_linked_map() - - # Determine which tracks are assigned to a DAW folder - assignments = {} - if self._session.daw_state and self._active_daw_processor: - dp_state = self._session.daw_state.get( - self._active_daw_processor.id, {}) - assignments = dp_state.get("assignments", {}) - - for row, track in enumerate(ok_tracks): - pr = ( - next(iter(track.processor_results.values()), None) - if track.processor_results - else None - ) - # Column 0: checkmark (assigned to folder?) - assigned = track.filename in assignments - chk_item = _SortableItem("✓" if assigned else "", int(not assigned)) - if assigned: - chk_item.setForeground(QColor(COLORS["clean"])) - self._setup_table.setItem(row, 0, chk_item) - - # Column 1: filename - fname_item = _SortableItem( - track.filename, protools_sort_key(track.filename)) - fname_item.setForeground(FILE_COLOR_OK) - self._setup_table.setItem(row, 1, fname_item) - - # Column 2: channels - ch_item = _SortableItem(str(track.channels), track.channels) - ch_item.setForeground(QColor(COLORS["dim"])) - self._setup_table.setItem(row, 2, ch_item) - - # Column 3: clip gain - clip_gain = pr.gain_db if pr else 0.0 - cg_item = _SortableItem(f"{clip_gain:+.1f} dB", clip_gain) - cg_item.setForeground(QColor(COLORS["text"])) - self._setup_table.setItem(row, 3, cg_item) - - # Column 4: fader gain - fader_gain = pr.data.get("fader_offset", 0.0) if pr else 0.0 - fg_item = _SortableItem(f"{fader_gain:+.1f} dB", fader_gain) - fg_item.setForeground(QColor(COLORS["text"])) - self._setup_table.setItem(row, 4, fg_item) - - # Column 5: group (read-only, with link indicator) - grp_label = self._group_display_name(track.group, glm) if track.group else "" - grp_rank = gcm_rank.get(track.group, len(gcm_rank)) if track.group else len(gcm_rank) - grp_item = _SortableItem(grp_label, grp_rank) - grp_item.setForeground(QColor(COLORS["text"])) - self._setup_table.setItem(row, 5, grp_item) - - # Row background from group color - self._apply_row_group_color(row, track.group, gcm, - table=self._setup_table) - - self._setup_table.setSortingEnabled(True) - - # Auto-fit columns to content - sh = self._setup_table.horizontalHeader() - for col in range(self._setup_table.columnCount()): - sh.setSectionResizeMode(col, QHeaderView.ResizeToContents) - self._setup_table.resizeColumnsToContents() - sh.setSectionResizeMode(0, QHeaderView.Fixed) - sh.resizeSection(0, 24) - sh.setSectionResizeMode(1, QHeaderView.Stretch) - sh.setSectionResizeMode(2, QHeaderView.Fixed) - for col in range(3, self._setup_table.columnCount()): - sh.setSectionResizeMode(col, QHeaderView.Interactive) - - # ── Classification override helpers ─────────────────────────────────── - - def _style_classification_combo(self, combo: QComboBox, cls_text: str): - """Apply classification-specific color to a combo box.""" - if cls_text == "Transient": - color = FILE_COLOR_TRANSIENT.name() - elif cls_text == "Sustained": - color = FILE_COLOR_SUSTAINED.name() - else: - color = FILE_COLOR_SILENT.name() - combo.setStyleSheet(f"QComboBox {{ color: {color}; font-weight: bold; }}") - - @Slot(str) - def _on_classification_changed(self, text: str): - """Handle user changing the classification dropdown.""" - combo = self.sender() - if not combo or not self._session: - return - fname = combo.property("track_filename") - if not fname: - return - track = next( - (t for t in self._session.tracks if t.filename == fname), None - ) - if not track: - return - - # Batch path: async re-analysis for all selected rows - if getattr(combo, 'batch_mode', False): - combo.batch_mode = False - track.classification_override = text - def _prepare(t): - t.classification_override = text - self._batch_apply_combo(combo, 3, text, _prepare, - run_detectors=False) - else: - # Skip if the value didn't actually change - if track.classification_override == text: - return - track.classification_override = text - # Single-track sync path - self._recalculate_processor(track) - self._style_classification_combo(combo, text) - self._update_track_row(fname) - self._refresh_file_tab(track) - self._mark_prepare_stale() - - @Slot(float) - def _on_gain_changed(self, value: float): - """Handle user manually editing the gain spin box.""" - spin = self.sender() - if not spin or not self._session: - return - fname = spin.property("track_filename") - if not fname: - return - track = next( - (t for t in self._session.tracks if t.filename == fname), None - ) - if not track: - return - - # Write gain directly to the processor result - pr = next(iter(track.processor_results.values()), None) - if pr: - pr.gain_db = value - self._mark_prepare_stale() - - # Update hidden sort item - for row in range(self._track_table.rowCount()): - item = self._track_table.item(row, 0) - if item and item.text() == fname: - gain_sort = self._track_table.item(row, 4) - if gain_sort: - gain_sort.setText(f"{value:+.1f}") - gain_sort._sort_key = value - break - - # Refresh File tab if this track is currently displayed - if self._current_track and self._current_track.filename == fname: - html = render_track_detail_html(track, self._session, - show_clean=self._show_clean, - verbose=self._verbose) - self._file_report.setHtml(self._wrap_html(html)) - - def _recalculate_processor(self, track): - """Re-run the normalization processor for a single track.""" - if not self._session or not self._session.processors: - return - for proc in self._session.processors: - result = proc.process(track) - result.data["original_gain_db"] = result.gain_db - track.processor_results[proc.id] = result - - # ── Batch combo helper ──────────────────────────────────────────────── - - def _batch_apply_combo(self, source_combo, column: int, value: str, - prepare_fn, run_detectors: bool = True): - """Apply *value* to the combo in *column* for every selected row. - - 1. **Sync** — set overrides via *prepare_fn(track)* and update - combo widgets instantly. - 2. **Async** — start a ``BatchReanalyzeWorker`` that re-runs - detectors/processors in the background, updating table rows - as each track completes and restoring the multi-selection at - the end. - - *prepare_fn(track)* must only mutate the data model (e.g. set an - override field). It must **not** run analysis. - """ - if not self._session: - return - if self._batch_worker and self._batch_worker.isRunning(): - return - if self._worker and self._worker.isRunning(): - return - - track_map = {t.filename: t for t in self._session.tracks} - batch_keys = self._track_table.batch_selected_keys() - - # Collect tracks and update combo widgets (sync, instant) - tracks_to_reanalyze: list = [] - self._track_table.setSortingEnabled(False) - for fname in batch_keys: - track = track_map.get(fname) - if not track or track.status != "OK": - continue - prepare_fn(track) - tracks_to_reanalyze.append(track) - row = self._find_table_row(fname) - if row >= 0: - w = self._track_table.cellWidget(row, column) - if isinstance(w, BatchComboBox): - w.blockSignals(True) - w.setCurrentText(value) - w.blockSignals(False) - if not tracks_to_reanalyze: - self._track_table.setSortingEnabled(True) - return - - # Save filenames for selection restore after worker completes - self._batch_filenames = batch_keys - - # Show progress UI - self._progress_label.setText("Re-analyzing…") - self._progress_bar.setRange(0, len(tracks_to_reanalyze)) - self._progress_bar.setValue(0) - self._right_stack.setCurrentIndex(_PAGE_PROGRESS) - self._analyze_action.setEnabled(False) - - # Start async worker - self._batch_worker = BatchReanalyzeWorker( - tracks_to_reanalyze, - self._session.detectors, - self._session.processors, - run_detectors=run_detectors, - ) - self._batch_worker.progress.connect(self._on_worker_progress) - self._batch_worker.progress_value.connect(self._on_worker_progress_value) - self._batch_worker.track_done.connect(self._on_batch_track_done) - self._batch_worker.batch_finished.connect(self._on_batch_done) - self._batch_worker.error.connect(self._on_batch_error) - self._batch_worker.start() - - @Slot(str) - def _on_batch_track_done(self, filename: str): - """Update one table row after the worker finishes re-analyzing it.""" - self._update_track_row(filename) - - @Slot() - def _on_batch_done(self): - """Finalize the batch: restore selection, switch back to tabs.""" - self._batch_worker = None - self._analyze_action.setEnabled(True) - self._right_stack.setCurrentIndex(_PAGE_TABS) - - # Re-enable sorting (was disabled in _batch_apply_combo); - # rows may reorder, so restore selection by key afterward. - self._track_table.setSortingEnabled(True) - self._track_table.restore_selection(self._batch_filenames) - self._batch_filenames = set() - - # Refresh setup table and file tab - self._populate_setup_table() - if self._current_track: - self._refresh_file_tab(self._current_track) - - @Slot(str) - def _on_batch_error(self, message: str): - """Handle fatal error from the batch worker.""" - self._batch_worker = None - self._analyze_action.setEnabled(True) - self._track_table.setSortingEnabled(True) - self._track_table.restore_selection(self._batch_filenames) - self._batch_filenames = set() - self._right_stack.setCurrentIndex(_PAGE_TABS) - self._status_bar.showMessage(f"Batch error: {message}") - - # ── RMS Anchor override helpers ────────────────────────────────────── - - _ANCHOR_LABELS = ["Default", "Max", "P99", "P95", "P90", "P85"] - _ANCHOR_TO_OVERRIDE = { - "Default": None, "Max": "max", - "P99": "p99", "P95": "p95", "P90": "p90", "P85": "p85", - } - _OVERRIDE_TO_LABEL = {v: k for k, v in _ANCHOR_TO_OVERRIDE.items()} - - def _create_anchor_combo(self, row: int, track): - """Create and install an RMS Anchor combo in column 5.""" - anchor_sort = _SortableItem("Default", "default") - self._track_table.setItem(row, 5, anchor_sort) - - combo = BatchComboBox() - combo.addItems(self._ANCHOR_LABELS) - combo.blockSignals(True) - current = self._OVERRIDE_TO_LABEL.get( - track.rms_anchor_override, "Default") - combo.setCurrentText(current) - combo.blockSignals(False) - combo.setProperty("track_filename", track.filename) - combo.setStyleSheet( - f"QComboBox {{ color: {COLORS['text']}; }}" - ) - combo.textActivated.connect(self._on_rms_anchor_changed) - self._track_table.setCellWidget(row, 5, combo) - - @Slot(str) - def _on_rms_anchor_changed(self, text: str): - """Handle user changing the RMS Anchor dropdown.""" - combo = self.sender() - if not combo or not self._session: - return - fname = combo.property("track_filename") - if not fname: - return - track = next( - (t for t in self._session.tracks if t.filename == fname), None - ) - if not track: - return - - new_override = self._ANCHOR_TO_OVERRIDE.get(text) - - # Batch path: async re-analysis for all selected rows - if getattr(combo, 'batch_mode', False): - combo.batch_mode = False - track.rms_anchor_override = new_override - def _prepare(t): - t.rms_anchor_override = new_override - self._batch_apply_combo(combo, 5, text, _prepare, - run_detectors=True) - else: - # Skip if the value didn't actually change (textActivated - # fires even when the user re-selects the same item) - if track.rms_anchor_override == new_override: - return - track.rms_anchor_override = new_override - self._reanalyze_single_track(track) - self._mark_prepare_stale() - - # ── Group column (col 6) ──────────────────────────────────────────── - - _GROUP_NONE_LABEL = "(None)" - _LINK_INDICATOR = " 🔗" - - def _group_combo_items(self) -> list[str]: - """Return the items list for Group combo boxes.""" - return [self._GROUP_NONE_LABEL] + [ - g["name"] for g in self._session_groups] - - def _gain_linked_map(self) -> dict[str, bool]: - """Return {group_name: gain_linked} for all session groups.""" - return {g["name"]: g.get("gain_linked", False) - for g in self._session_groups} - - def _group_display_name(self, name: str, - glm: dict[str, bool] | None = None) -> str: - """Return display name with link indicator if gain-linked.""" - if glm is None: - glm = self._gain_linked_map() - if glm.get(name, False): - return name + self._LINK_INDICATOR - return name - - def _group_rank_map(self) -> dict[str, int]: - """Return {group_name: position_index} for sort-by-rank ordering.""" - return {g["name"]: i for i, g in enumerate(self._session_groups)} - - def _group_color_map(self) -> dict[str, str]: - """Return {group_name: argb_hex} for all session groups.""" - result: dict[str, str] = {} - for g in self._session_groups: - color_name = g.get("color", "") - argb = self._color_argb_by_name(color_name) - if argb: - result[g["name"]] = argb - return result - - _TINT_FACTOR = 0.15 # fraction of source alpha → subtle wash - - def _tint_group_color(self, group_name: str | None, - gcm: dict[str, str] | None = None) -> QColor | None: - """Return a pre-blended tint QColor for *group_name*, or None.""" - if gcm is None: - gcm = self._group_color_map() - argb = gcm.get(group_name) if group_name else None - if not argb: - return None - qc = _argb_to_qcolor(argb) - a = (qc.alpha() / 255.0) * self._TINT_FACTOR - bg_r, bg_g, bg_b = 0x1e, 0x1e, 0x1e # COLORS["bg"] - return QColor( - int(qc.red() * a + bg_r * (1 - a)), - int(qc.green() * a + bg_g * (1 - a)), - int(qc.blue() * a + bg_b * (1 - a)), - ) - - def _apply_row_group_color(self, row: int, group_name: str | None, - gcm: dict[str, str] | None = None, - table=None): - """Set tinted group background on *row* of *table* (default: track table).""" - if table is None: - table = self._track_table - table.apply_row_color(row, self._tint_group_color(group_name, gcm)) - - def _create_group_combo(self, row: int, track): - """Create and install a Group combo in column 6.""" - glm = self._gain_linked_map() - display = self._group_display_name(track.group, glm) if track.group else self._GROUP_NONE_LABEL - grm = self._group_rank_map() - rank = grm.get(track.group, len(grm)) if track.group else len(grm) - sort_item = _SortableItem(display, rank) - self._track_table.setItem(row, 6, sort_item) - - combo = BatchComboBox() - combo.setIconSize(QSize(16, 16)) - gcm = self._group_color_map() - combo.addItem(self._GROUP_NONE_LABEL) - combo.setItemData(0, None, Qt.UserRole) - for i, gname in enumerate([g["name"] for g in self._session_groups]): - disp = self._group_display_name(gname, glm) - argb = gcm.get(gname) - if argb: - combo.addItem(self._color_swatch_icon(argb), disp) - else: - combo.addItem(disp) - combo.setItemData(i + 1, gname, Qt.UserRole) - combo.blockSignals(True) - # Find item by UserRole (clean name) - for ci in range(combo.count()): - if combo.itemData(ci, Qt.UserRole) == track.group: - combo.setCurrentIndex(ci) - break - combo.blockSignals(False) - combo.setProperty("track_filename", track.filename) - combo.setStyleSheet( - f"QComboBox {{ color: {COLORS['text']}; }}" - ) - combo.textActivated.connect(self._on_group_changed) - self._track_table.setCellWidget(row, 6, combo) - - def _apply_linked_group_levels(self): - """Apply group levels for gain-linked groups and update fader offsets. - - 1. Restore every track's ``gain_db`` to its ``original_gain_db``. - 2. For gain-linked groups, set all members to the group minimum. - 3. Recompute ``fader_offset`` using the stored anchor offset. - 4. Update the gain spin-boxes and the Session Setup table. - """ - if not self._session or not self._session.processors: - return - - glm = self._gain_linked_map() - linked_names = {name for name, linked in glm.items() if linked} - - for proc in self._session.processors: - pid = proc.id - # 1. Restore originals - for track in self._session.tracks: - if track.status != "OK": - continue - pr = track.processor_results.get(pid) - if pr is None or pr.classification == "Silent": - continue - if "original_gain_db" not in pr.data: - pr.data["original_gain_db"] = pr.gain_db - pr.gain_db = pr.data["original_gain_db"] - - # 2. Apply group levels for linked groups - by_group: dict[str, list] = {} - for track in self._session.tracks: - if track.status != "OK" or track.group is None: - continue - pr = track.processor_results.get(pid) - if pr is None or pr.classification == "Silent": - continue - by_group.setdefault(track.group, []).append(track) - - for gname, members in by_group.items(): - if gname not in linked_names: - continue - orig = [m.processor_results[pid].data["original_gain_db"] - for m in members] - group_gain = min(orig) if orig else 0.0 - for m in members: - m.processor_results[pid].gain_db = float(group_gain) - - # 3. Recompute fader offsets with headroom rebalancing - valid = [] - for track in self._session.tracks: - if track.status != "OK": - continue - pr = track.processor_results.get(pid) - if pr is None: - continue - if pr.classification == "Silent": - pr.data["fader_offset"] = 0.0 - else: - pr.data["fader_offset"] = -float(pr.gain_db) - valid.append(track) - - # Headroom rebalancing - ceiling = self._session.config.get("_fader_ceiling_db", 12.0) - headroom = self._session.config.get("fader_headroom_db", 8.0) - max_allowed = ceiling - headroom - rebalance_shift = 0.0 - if headroom > 0.0 and valid: - fader_offsets = [ - t.processor_results[pid].data.get("fader_offset", 0.0) - for t in valid - ] - max_fader = max(fader_offsets) - if max_fader > max_allowed: - rebalance_shift = max_fader - max_allowed - for track in valid: - pr = track.processor_results.get(pid) - if pr: - pr.data["fader_offset"] -= rebalance_shift - pr.data["fader_rebalance_shift"] = rebalance_shift - self._session.config[f"_fader_rebalance_{pid}"] = rebalance_shift - - # Anchor-track adjustment - anchor_offset = self._session.config.get( - f"_anchor_offset_{pid}", 0.0) - if anchor_offset != 0.0: - for track in valid: - pr = track.processor_results.get(pid) - if pr: - pr.data["fader_offset"] = pr.data.get("fader_offset", 0.0) - anchor_offset - - # 4. Update UI - self._track_table.setSortingEnabled(False) - for row in range(self._track_table.rowCount()): - fname_item = self._track_table.item(row, 0) - if not fname_item: - continue - fname = fname_item.text() - track = next( - (t for t in self._session.tracks if t.filename == fname), None) - if not track or track.status != "OK": - continue - pr = next(iter(track.processor_results.values()), None) - if not pr: - continue - new_gain = pr.gain_db - spin = self._track_table.cellWidget(row, 4) - if isinstance(spin, QDoubleSpinBox): - spin.blockSignals(True) - spin.setValue(new_gain) - spin.blockSignals(False) - gain_sort = self._track_table.item(row, 4) - if gain_sort: - gain_sort.setText(f"{new_gain:+.1f}") - gain_sort._sort_key = new_gain - self._track_table.setSortingEnabled(True) - self._populate_setup_table() - - # Refresh the File detail tab so it reflects the updated gain - if self._current_track and self._current_track.status == "OK": - self._refresh_file_tab(self._current_track) - - def _auto_fit_track_table(self): - """Shrink the left panel to fit the track table columns, giving - more space to the right detail panel. - - Temporarily switches the File column from Stretch to - ResizeToContents so we can measure its true content width, - then adjusts the splitter and restores Stretch mode. - """ - header = self._track_table.horizontalHeader() - - # Temporarily fit File column to content so we get a true width - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) - self._track_table.resizeColumnToContents(0) - total_w = sum(header.sectionSize(c) for c in range(header.count())) - # Restore File column to Stretch - header.setSectionResizeMode(0, QHeaderView.Stretch) - - # vertical-header (hidden=0) + scrollbar (~20) + frame borders (~4) - vhw = self._track_table.verticalHeader().width() if self._track_table.verticalHeader().isVisible() else 0 - padding = vhw + 20 + 4 - needed = total_w + padding - - splitter_total = self._main_splitter.width() - if splitter_total > 0: - right_w = max(splitter_total - needed, 300) - left_w = splitter_total - right_w - self._main_splitter.setSizes([left_w, right_w]) - - def _auto_fit_group_column(self): - """Resize the Group column (6) to fit the widest current combo text.""" - max_w = 0 - for row in range(self._track_table.rowCount()): - w = self._track_table.cellWidget(row, 6) - if isinstance(w, BatchComboBox): - fm = w.fontMetrics() - tw = fm.horizontalAdvance(w.currentText()) - max_w = max(max_w, tw) - if max_w > 0: - # icon (16) + icon gap (4) + text + dropdown arrow (~24) + margins (16) - needed = 16 + 4 + max_w + 24 + 16 - header = self._track_table.horizontalHeader() - header.resizeSection(6, max(needed, 100)) - - @Slot(str) - def _on_group_changed(self, text: str): - """Handle user changing the Group dropdown.""" - combo = self.sender() - if not combo or not self._session: - return - fname = combo.property("track_filename") - if not fname: - return - track = next( - (t for t in self._session.tracks if t.filename == fname), None - ) - if not track: - return - - # Read clean group name from UserRole - new_group = combo.currentData(Qt.UserRole) - display = text # display text (with link indicator) - - # Batch path: synchronous — no reanalysis needed - if getattr(combo, 'batch_mode', False): - combo.batch_mode = False - track.group = new_group - batch_keys = self._track_table.batch_selected_keys() - track_map = {t.filename: t for t in self._session.tracks} - gcm = self._group_color_map() - grm = self._group_rank_map() - rank = grm.get(new_group, len(grm)) if new_group else len(grm) - self._track_table.setSortingEnabled(False) - for bfname in batch_keys: - bt = track_map.get(bfname) - if not bt or bt.status != "OK": - continue - bt.group = new_group - row = self._find_table_row(bfname) - if row >= 0: - w = self._track_table.cellWidget(row, 6) - if isinstance(w, BatchComboBox): - w.blockSignals(True) - # Find matching item by UserRole - for ci in range(w.count()): - if w.itemData(ci, Qt.UserRole) == new_group: - w.setCurrentIndex(ci) - break - w.blockSignals(False) - sort_item = self._track_table.item(row, 6) - if sort_item: - sort_item.setText(display) - sort_item._sort_key = rank - self._apply_row_group_color(row, new_group, gcm) - self._track_table.setSortingEnabled(True) - self._track_table.restore_selection(batch_keys) - self._auto_fit_group_column() - self._apply_linked_group_levels() - else: - if track.group == new_group: - return - track.group = new_group - # Update sort item + row color - grm = self._group_rank_map() - rank = grm.get(new_group, len(grm)) if new_group else len(grm) - row = self._find_table_row(fname) - if row >= 0: - sort_item = self._track_table.item(row, 6) - if sort_item: - sort_item.setText(display) - sort_item._sort_key = rank - self._apply_row_group_color(row, new_group) - self._auto_fit_group_column() - self._apply_linked_group_levels() - - def _refresh_group_combos(self): - """Refresh the items in all Group combo boxes from _session_groups.""" - gcm = self._group_color_map() - grm = self._group_rank_map() - glm = self._gain_linked_map() - for row in range(self._track_table.rowCount()): - w = self._track_table.cellWidget(row, 6) - if isinstance(w, BatchComboBox): - # Read clean group name via UserRole - old_group = w.currentData(Qt.UserRole) - w.blockSignals(True) - w.clear() - w.setIconSize(QSize(16, 16)) - w.addItem(self._GROUP_NONE_LABEL) - w.setItemData(0, None, Qt.UserRole) - for i, gname in enumerate( - [g["name"] for g in self._session_groups]): - disp = self._group_display_name(gname, glm) - argb = gcm.get(gname) - if argb: - w.addItem(self._color_swatch_icon(argb), disp) - else: - w.addItem(disp) - w.setItemData(i + 1, gname, Qt.UserRole) - # Restore selection by UserRole match - restored = False - if old_group is not None: - for ci in range(w.count()): - if w.itemData(ci, Qt.UserRole) == old_group: - w.setCurrentIndex(ci) - restored = True - break - if not restored: - w.setCurrentIndex(0) # (None) - # Also clear the track's group assignment - fname = w.property("track_filename") - if fname and self._session: - track = next( - (t for t in self._session.tracks - if t.filename == fname), None) - if track: - track.group = None - w.blockSignals(False) - # Update sort key, display text + row color - gname = w.currentData(Qt.UserRole) - sort_item = self._track_table.item(row, 6) - if sort_item: - rank = grm.get(gname, len(grm)) if gname else len(grm) - sort_item._sort_key = rank - sort_item.setText(w.currentText()) - self._apply_row_group_color(row, gname, gcm) - - self._auto_fit_group_column() - self._apply_linked_group_levels() - - def _reanalyze_single_track(self, track): - """Re-run all track detectors + processors for a single track (sync).""" - if not self._session: - return - - # Re-run track-level detectors (already sorted by dependency) - for det in self._session.detectors: - if isinstance(det, TrackDetector): - try: - result = det.analyze(track) - track.detector_results[det.id] = result - except Exception: - pass - - # Re-run processors - self._recalculate_processor(track) - - # Re-apply group levels for any gain-linked groups this track belongs to - self._apply_linked_group_levels() - - # Update UI - self._update_track_row(track.filename) - self._refresh_file_tab(track) - - # ── Track-row UI helpers ───────────────────────────────────────────── - - def _update_track_row(self, filename: str): - """Refresh analysis label, classification, gain, and sort items - for the table row matching *filename*. - - Called from: - - ``_reanalyze_single_track`` (sync single-track path) - - ``_on_batch_track_done`` (per-track signal from async worker) - """ - if not self._session: - return - track = next( - (t for t in self._session.tracks if t.filename == filename), None - ) - if not track: - return - row = self._find_table_row(filename) - if row < 0: - return - - # Analysis label - dets = self._session.detectors - _plain, html, _color, sort_key = track_analysis_label(track, dets) - lbl, item = _make_analysis_cell(html, sort_key) - self._track_table.setItem(row, 2, item) - self._track_table.setCellWidget(row, 2, lbl) - - # Gain spin box + sort item + classification - pr = next(iter(track.processor_results.values()), None) - new_gain = pr.gain_db if pr else 0.0 - base_cls = None - if pr: - cls_text = pr.classification or "Unknown" - if "Transient" in cls_text: - base_cls = "Transient" - elif cls_text == "Skip": - base_cls = "Skip" - else: - base_cls = "Sustained" - - spin = self._track_table.cellWidget(row, 4) - if isinstance(spin, QDoubleSpinBox): - spin.blockSignals(True) - spin.setValue(new_gain) - if base_cls is not None: - spin.setEnabled(base_cls != "Skip") - spin.blockSignals(False) - gain_sort = self._track_table.item(row, 4) - if gain_sort: - gain_sort.setText(f"{new_gain:+.1f}") - gain_sort._sort_key = new_gain - - if base_cls is not None: - cls_combo = self._track_table.cellWidget(row, 3) - if isinstance(cls_combo, QComboBox): - cls_combo.blockSignals(True) - cls_combo.setCurrentText(base_cls) - cls_combo.blockSignals(False) - self._style_classification_combo(cls_combo, base_cls) - sort_item = self._track_table.item(row, 3) - if sort_item: - sort_item.setText(base_cls) - sort_item._sort_key = base_cls.lower() - - # Re-apply row group color (new items lose their background) - self._apply_row_group_color(row, track.group) - - # Keep the Session Setup table in sync - self._populate_setup_table() - - def _refresh_file_tab(self, track): - """Refresh File tab + waveform overlays if *track* is displayed.""" - if not self._current_track or self._current_track.filename != track.filename: - return - html = render_track_detail_html(track, self._session, - show_clean=self._show_clean, - verbose=self._verbose) - self._file_report.setHtml(self._wrap_html(html)) - all_issues = [] - for result in track.detector_results.values(): - all_issues.extend(getattr(result, "issues", [])) - self._update_overlay_menu(all_issues) + # ── HTML helpers ────────────────────────────────────────────────────── @staticmethod def _wrap_html(body: str) -> str: @@ -4318,76 +787,7 @@ def _wrap_html(body: str) -> str: f'{body}' ) - # ── Playback ────────────────────────────────────────────────────────── - - @Slot() - def _on_toggle_play(self): - if self._playback.is_playing: - self._on_stop() - elif self._current_track is not None: - self._on_play() - - @Slot() - def _on_play(self): - track = self._current_track - if track is None or track.audio_data is None: - return - self._on_stop() - start = self._waveform._cursor_sample - self._playback.play(track.audio_data, track.samplerate, start, - mono=self._mono_btn.isChecked()) - if self._playback.is_playing: - self._play_btn.setEnabled(False) - self._stop_btn.setEnabled(True) - - @Slot() - def _on_stop(self): - was_playing = self._playback.is_playing - start_sample = self._playback.play_start_sample - self._playback.stop() - self._stop_btn.setEnabled(False) - if self._current_track is not None: - self._play_btn.setEnabled(True) - if was_playing: - self._waveform.set_cursor(start_sample) - self._update_time_label(start_sample) - - @Slot(int) - def _on_cursor_updated(self, sample_pos: int): - self._waveform.set_cursor(sample_pos) - self._update_time_label(sample_pos) - - @Slot() - def _on_playback_finished(self): - self._stop_btn.setEnabled(False) - if self._current_track is not None: - self._play_btn.setEnabled(True) - self._waveform.set_cursor(0) - self._update_time_label(0) - - @Slot(str) - def _on_playback_error(self, message: str): - self._status_bar.showMessage(f"Playback error: {message}") - - @Slot(int) - def _on_waveform_seek(self, sample_index: int): - if self._playback.is_playing: - self._on_stop() - self._waveform.set_cursor(sample_index) - self._on_play() - else: - self._update_time_label(sample_index) - - def _update_time_label(self, sample_pos: int = 0): - track = self._current_track - if track is None or track.samplerate <= 0: - self._time_label.setText("00:00 / 00:00") - return - sr = track.samplerate - self._time_label.setText( - f"{fmt_time(sample_pos / sr)} / {fmt_time(track.total_samples / sr)}" - f" \u2022 {sample_pos:,}" - ) + # ── Preferences ─────────────────────────────────────────────────────── @Slot() def _on_preferences(self): @@ -4487,71 +887,6 @@ def _on_preferences(self): "Please restart SessionPrep for the new scaling to take effect.", ) - def _refresh_presentation(self): - """Re-render all UI after presentation-only config changes (e.g. report_as). - - Reconfigures detector instances in-place, rebuilds the diagnostic - summary, and refreshes all visible components — without re-reading - audio or re-running analysis. - """ - if not self._session: - return - - # 1. Reconfigure detector instances with updated flat config - flat = self._flat_config() - for d in self._session.detectors: - d.configure(flat) - - # 2. Rebuild diagnostic summary (bucketing depends on report_as) - from sessionpreplib.rendering import build_diagnostic_summary - self._summary = build_diagnostic_summary(self._session) - - # 3. Re-render summary HTML - self._render_summary() - - # 4. Refresh track table Analysis column - self._refresh_analysis_column() - - # 5. Re-render current track detail - if self._current_track: - html = render_track_detail_html( - self._current_track, self._session, - show_clean=self._show_clean, verbose=self._verbose) - self._file_report.setHtml(self._wrap_html(html)) - - # 6. Refresh overlay menu (skipped detectors filtered out) - if self._current_track: - all_issues = [] - for det_result in self._current_track.detector_results.values(): - all_issues.extend(getattr(det_result, "issues", [])) - self._update_overlay_menu(all_issues) - - # 7. Apply any concurrent GUI-only changes - cmap = self._config.get("app", {}).get("spectrogram_colormap", "magma") - self._waveform.set_colormap(cmap) - - self._status_bar.showMessage("Preferences saved (display refreshed).") - - def _refresh_analysis_column(self): - """Update the Analysis column for all rows using current detector config.""" - if not self._session: - return - track_map = {t.filename: t for t in self._session.tracks} - dets = self._session.detectors if hasattr(self._session, 'detectors') else None - self._track_table.setSortingEnabled(False) - for row in range(self._track_table.rowCount()): - fname_item = self._track_table.item(row, 0) - if not fname_item: - continue - track = track_map.get(fname_item.text()) - if not track: - continue - _plain, html, _color, sort_key = track_analysis_label(track, dets) - lbl, item = _make_analysis_cell(html, sort_key) - self._track_table.setItem(row, 2, item) - self._track_table.setCellWidget(row, 2, lbl) - self._track_table.setSortingEnabled(True) - @Slot() def _on_about(self): from sessionpreplib import __version__ as ver @@ -4617,6 +952,5 @@ def main(): window.show() dbg(f"window.show: {(time.perf_counter() - t0) * 1000:.1f} ms") - dbg(f"main() startup total: {(time.perf_counter() - t_main) * 1000:.1f} ms") - + dbg(f"main() total: {(time.perf_counter() - t_main) * 1000:.1f} ms") sys.exit(app.exec()) diff --git a/sessionprepgui/table_widgets.py b/sessionprepgui/table_widgets.py new file mode 100644 index 0000000..4cd73a1 --- /dev/null +++ b/sessionprepgui/table_widgets.py @@ -0,0 +1,308 @@ +"""Standalone widget classes and helpers used by the main window.""" + +from __future__ import annotations + +import json +import os + +from PySide6.QtCore import Qt, Signal, QUrl, QMimeData, QPoint +from PySide6.QtGui import QColor, QDrag, QPainter, QPixmap +from PySide6.QtWidgets import ( + QLabel, + QTableWidgetItem, + QTextBrowser, + QTreeWidget, +) + +from .theme import COLORS +from .widgets import BatchEditTableWidget + +# ── Constants ──────────────────────────────────────────────────────────────── + +_TAB_SUMMARY = 0 +_TAB_FILE = 1 +_TAB_GROUPS = 2 +_TAB_SESSION = 3 + +_PAGE_PROGRESS = 0 +_PAGE_TABS = 1 + +_PHASE_ANALYSIS = 0 +_PHASE_SETUP = 1 + +_SETUP_RIGHT_PLACEHOLDER = 0 +_SETUP_RIGHT_TREE = 1 + +_SEVERITY_SORT = {"PROBLEMS": 0, "Error": 0, "ATTENTION": 1, "OK": 2, "": 3} + +_MIME_TRACKS = "application/x-sessionprep-tracks" + + +# ── Helper functions ───────────────────────────────────────────────────────── + +def _make_analysis_cell(html: str, sort_key: int) -> tuple[QLabel, '_SortableItem']: + """Create a QLabel + hidden sort item for the Analysis column.""" + lbl = QLabel(html) + lbl.setStyleSheet( + "QLabel { background: transparent; font-size: 8pt;" + " font-family: Consolas, monospace; padding: 0 4px; }") + lbl.setTextFormat(Qt.RichText) + item = _SortableItem("", sort_key) + return lbl, item + + +# ── Widget classes ─────────────────────────────────────────────────────────── + +class _SortableItem(QTableWidgetItem): + """QTableWidgetItem with a custom sort key.""" + + def __init__(self, text: str, sort_key=None): + super().__init__(text) + self._sort_key = sort_key if sort_key is not None else text + + def __lt__(self, other): + if isinstance(other, _SortableItem): + return self._sort_key < other._sort_key + return super().__lt__(other) + + +class _HelpBrowser(QTextBrowser): + """QTextBrowser that shows detector help tooltips on hover.""" + + def __init__(self, help_map: dict[str, str], parent=None): + super().__init__(parent) + self._help_map = help_map + self.setOpenLinks(False) + self.setMouseTracking(True) + + def mouseMoveEvent(self, event): + anchor = self.anchorAt(event.pos()) + if anchor.startswith("detector:"): + det_id = anchor[len("detector:"):] + html = self._help_map.get(det_id) + if html: + from PySide6.QtWidgets import QToolTip + QToolTip.showText(event.globalPosition().toPoint(), html, self) + else: + from PySide6.QtWidgets import QToolTip + QToolTip.hideText() + else: + from PySide6.QtWidgets import QToolTip + QToolTip.hideText() + super().mouseMoveEvent(event) + + +class _DraggableTrackTable(BatchEditTableWidget): + """BatchEditTableWidget with file-drag support for external applications.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setDragEnabled(True) + self.setDefaultDropAction(Qt.CopyAction) + self._source_dir: str | None = None + + def set_source_dir(self, path: str | None): + self._source_dir = path + + def mimeTypes(self): + return ["text/uri-list"] + + def mimeData(self, items): + if not self._source_dir: + return super().mimeData(items) + filenames: set[str] = set() + for item in items: + if item.column() == 0 and item.text(): + filenames.add(item.text()) + if not filenames: + return super().mimeData(items) + urls = [QUrl.fromLocalFile(os.path.join(self._source_dir, f)) + for f in filenames] + mime = QMimeData() + mime.setUrls(urls) + return mime + + def supportedDragActions(self): + return Qt.CopyAction + + +class _SetupDragTable(BatchEditTableWidget): + """BatchEditTableWidget that produces custom MIME for internal drag.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setDragEnabled(True) + self.setDefaultDropAction(Qt.CopyAction) + + def mimeTypes(self): + return [_MIME_TRACKS] + + def mimeData(self, items): + filenames: set[str] = set() + for item in items: + if item.column() == 1 and item.text(): # col 1 = File + filenames.add(item.text()) + if not filenames: + return super().mimeData(items) + mime = QMimeData() + mime.setData(_MIME_TRACKS, json.dumps(sorted(filenames)).encode()) + return mime + + def supportedDragActions(self): + return Qt.CopyAction + + def startDrag(self, supportedActions): + items = self.selectedItems() + mime = self.mimeData(items) + if mime is None: + return + drag = QDrag(self) + drag.setMimeData(mime) + # Build a compact, semi-transparent label listing dragged filenames + filenames = sorted({ + it.text() for it in items if it.column() == 1 and it.text()}) + if not filenames: + return + label = "\n".join(filenames[:8]) + if len(filenames) > 8: + label += f"\n… +{len(filenames) - 8} more" + fm = self.fontMetrics() + lines = label.split("\n") + line_h = fm.height() + 2 + w = max(fm.horizontalAdvance(ln) for ln in lines) + 12 + h = line_h * len(lines) + 6 + pix = QPixmap(w, h) + pix.fill(Qt.transparent) + painter = QPainter(pix) + painter.setOpacity(0.75) + painter.fillRect(pix.rect(), QColor(COLORS["accent"])) + painter.setOpacity(1.0) + painter.setPen(QColor(COLORS["text"])) + painter.setFont(self.font()) + y = 3 + fm.ascent() + for ln in lines: + painter.drawText(6, y, ln) + y += line_h + painter.end() + drag.setPixmap(pix) + drag.setHotSpot(QPoint(0, 0)) + drag.exec(Qt.CopyAction) + + +class _FolderDropTree(QTreeWidget): + """QTreeWidget that accepts track drops onto folder items. + + Supports external drops from the setup table and internal + drag-and-drop to reorder tracks within / across folders. + """ + + # (filenames, folder_id, insert_index) -1 = append + tracks_dropped = Signal(list, str, int) + tracks_unassigned = Signal(list) # [filenames] + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + self.setDragEnabled(True) + self.setDragDropMode(QTreeWidget.DragDrop) + self.setDefaultDropAction(Qt.MoveAction) + self.setDropIndicatorShown(True) + + # -- MIME production (for internal drag of track items) ----------------- + + def mimeTypes(self): + return [_MIME_TRACKS] + + def mimeData(self, items): + filenames = [ + it.data(0, Qt.UserRole) for it in items + if it.data(0, Qt.UserRole + 1) == "track" + ] + if not filenames: + return None # block drag of non-track items (folders) + mime = QMimeData() + mime.setData(_MIME_TRACKS, json.dumps(filenames).encode()) + return mime + + def supportedDropActions(self): + return Qt.CopyAction | Qt.MoveAction + + # -- Drop handling ----------------------------------------------------- + + def _is_valid_mime(self, mimeData) -> bool: + """Check that the MIME payload is our JSON, not Qt internal data.""" + if not mimeData.hasFormat(_MIME_TRACKS): + return False + try: + bytes(mimeData.data(_MIME_TRACKS)).decode("utf-8") + return True + except (UnicodeDecodeError, ValueError): + return False + + def _resolve_drop(self, pos): + """Return (folder_id, insert_index) for a drop at *pos*. + + Uses the item geometry to decide above / on / below placement. + Returns (None, -1) if the drop target is invalid. + """ + item = self.itemAt(pos) + if not item: + return None, -1 + kind = item.data(0, Qt.UserRole + 1) + if kind == "folder": + return item.data(0, Qt.UserRole), -1 + if kind == "track": + parent = item.parent() + if not parent or parent.data(0, Qt.UserRole + 1) != "folder": + return None, -1 + folder_id = parent.data(0, Qt.UserRole) + idx = parent.indexOfChild(item) + rect = self.visualItemRect(item) + mid = rect.top() + rect.height() // 2 + if pos.y() > mid: + idx += 1 # drop below → insert after + return folder_id, idx + return None, -1 + + def dragEnterEvent(self, event): + if self._is_valid_mime(event.mimeData()): + event.acceptProposedAction() + else: + event.ignore() + + def dragMoveEvent(self, event): + if not self._is_valid_mime(event.mimeData()): + event.ignore() + return + folder_id, _ = self._resolve_drop(event.position().toPoint()) + if folder_id is not None: + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + if not self._is_valid_mime(event.mimeData()): + event.ignore() + return + pos = event.position().toPoint() + folder_id, idx = self._resolve_drop(pos) + if folder_id is None: + event.ignore() + return + data = bytes(event.mimeData().data(_MIME_TRACKS)).decode("utf-8") + filenames = json.loads(data) + self.tracks_dropped.emit(filenames, folder_id, idx) + event.acceptProposedAction() + + # -- Delete to unassign ------------------------------------------------ + + def keyPressEvent(self, event): + if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): + filenames = [] + for item in self.selectedItems(): + if item.data(0, Qt.UserRole + 1) == "track": + filenames.append(item.data(0, Qt.UserRole)) + if filenames: + self.tracks_unassigned.emit(filenames) + return + super().keyPressEvent(event) diff --git a/sessionprepgui/track_columns_mixin.py b/sessionprepgui/track_columns_mixin.py new file mode 100644 index 0000000..f9818ef --- /dev/null +++ b/sessionprepgui/track_columns_mixin.py @@ -0,0 +1,832 @@ +"""Track table mixin: population, column widgets, batch ops, row helpers.""" + +from __future__ import annotations + +from typing import Any + +from PySide6.QtCore import Qt, Slot +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QComboBox, + QDoubleSpinBox, + QHeaderView, + QMenu, + QToolButton, +) + +from sessionpreplib.detector import TrackDetector +from sessionpreplib.processors import default_processors +from sessionpreplib.utils import protools_sort_key + +from .helpers import track_analysis_label +from .report import render_track_detail_html +from .table_widgets import _SortableItem, _make_analysis_cell +from .theme import ( + COLORS, + FILE_COLOR_OK, + FILE_COLOR_ERROR, + FILE_COLOR_SILENT, + FILE_COLOR_TRANSIENT, + FILE_COLOR_SUSTAINED, +) +from .widgets import BatchComboBox, BatchToolButton +from .worker import BatchReanalyzeWorker + + +class TrackColumnsMixin: + """Track table population, column widgets, batch operations, row helpers. + + Mixed into ``SessionPrepWindow`` — not meant to be used standalone. + """ + + # ── Track selection ──────────────────────────────────────────────── + + @Slot(int, int) + def _on_row_clicked(self, row, _column): + self._select_row(row) + + @Slot(int, int, int, int) + def _on_current_cell_changed(self, row, _col, _prev_row, _prev_col): + self._select_row(row) + + def _select_row(self, row: int): + if not self._session or row < 0: + return + fname_item = self._track_table.item(row, 0) + if not fname_item: + return + fname = fname_item.text() + track = next( + (t for t in self._session.tracks if t.filename == fname), None + ) + if not track: + return + self._show_track_detail(track) + + # ── Row lookup ──────────────────────────────────────────────────────── + + def _find_table_row(self, filename: str) -> int: + """Return the table row index for *filename*, or -1 if not found.""" + for row in range(self._track_table.rowCount()): + item = self._track_table.item(row, 0) + if item and item.text() == filename: + return row + return -1 + + # ── Table population ───────────────────────────────────────────────── + + def _populate_table(self, session): + """Update the track table with analysis results.""" + self._track_table.setSortingEnabled(False) + track_map = {t.filename: t for t in session.tracks} + for row in range(self._track_table.rowCount()): + # Remove any previous cell widgets before repopulating + self._track_table.removeCellWidget(row, 3) + self._track_table.removeCellWidget(row, 4) + self._track_table.removeCellWidget(row, 5) + self._track_table.removeCellWidget(row, 6) + self._track_table.removeCellWidget(row, 7) + + fname_item = self._track_table.item(row, 0) + if not fname_item: + continue + track = track_map.get(fname_item.text()) + if not track: + continue + + # Column 1: channel count + ch_item = _SortableItem(str(track.channels), track.channels) + ch_item.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, 1, ch_item) + + # Column 2: severity counts + dets = session.detectors if hasattr(session, 'detectors') else None + _plain, html, _color, sort_key = track_analysis_label(track, dets) + lbl, item = _make_analysis_cell(html, sort_key) + self._track_table.setItem(row, 2, item) + self._track_table.setCellWidget(row, 2, lbl) + + # Column 3: classification (combo or static) + # Column 4: gain (spin box or static) + pr = ( + next(iter(track.processor_results.values()), None) + if track.processor_results + else None + ) + if track.status != "OK": + cls_item = _SortableItem("Error", "error") + cls_item.setForeground(FILE_COLOR_ERROR) + self._track_table.setItem(row, 3, cls_item) + gain_item = _SortableItem("", 0.0) + gain_item.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, 4, gain_item) + elif pr and pr.classification == "Silent": + cls_item = _SortableItem("Silent", "silent") + cls_item.setForeground(FILE_COLOR_SILENT) + self._track_table.setItem(row, 3, cls_item) + gain_item = _SortableItem("0.0 dB", 0.0) + gain_item.setForeground(QColor(COLORS["dim"])) + self._track_table.setItem(row, 4, gain_item) + elif pr: + # Determine effective classification + cls_text = pr.classification or "Unknown" + if "Transient" in cls_text: + base_cls = "Transient" + elif cls_text == "Skip": + base_cls = "Skip" + elif "Sustained" in cls_text: + base_cls = "Sustained" + else: + base_cls = "Sustained" + + # Hidden sort item (widget overlays it) + sort_item = _SortableItem(base_cls, base_cls.lower()) + self._track_table.setItem(row, 3, sort_item) + + # Classification combo widget + combo = BatchComboBox() + combo.addItems(["Transient", "Sustained", "Skip"]) + combo.blockSignals(True) + combo.setCurrentText(base_cls) + combo.blockSignals(False) + combo.setProperty("track_filename", track.filename) + self._style_classification_combo(combo, base_cls) + combo.textActivated.connect( + lambda text, c=combo: self._on_classification_changed(text, c)) + self._track_table.setCellWidget(row, 3, combo) + + # Gain spin box + gain_db = pr.gain_db + gain_sort = _SortableItem(f"{gain_db:+.1f}", gain_db) + self._track_table.setItem(row, 4, gain_sort) + + spin = QDoubleSpinBox() + spin.setRange(-60.0, 60.0) + spin.setSingleStep(0.1) + spin.setDecimals(1) + spin.setSuffix(" dB") + spin.blockSignals(True) + spin.setValue(gain_db) + spin.blockSignals(False) + spin.setProperty("track_filename", track.filename) + spin.setEnabled(base_cls != "Skip") + spin.setStyleSheet( + f"QDoubleSpinBox {{ color: {COLORS['text']}; }}" + ) + spin.valueChanged.connect( + lambda value, s=spin: self._on_gain_changed(value, s)) + self._track_table.setCellWidget(row, 4, spin) + + # RMS Anchor combo (column 5) + self._create_anchor_combo(row, track) + elif track.status == "OK": + # OK track but no processor results (all processors disabled) + cls_item = _SortableItem("", "zzz") + self._track_table.setItem(row, 3, cls_item) + gain_item = _SortableItem("", 0.0) + self._track_table.setItem(row, 4, gain_item) + else: + cls_item = _SortableItem("", "zzz") + self._track_table.setItem(row, 3, cls_item) + gain_item = _SortableItem("", 0.0) + self._track_table.setItem(row, 4, gain_item) + + # Group combo, processing button, and row color for all OK tracks + if track.status == "OK": + # Group combo (column 6) + self._create_group_combo(row, track) + + # Processing multiselect (column 7) + self._create_processing_button(row, track) + + # Row background from group color + self._apply_row_group_color(row, track.group) + self._track_table.setSortingEnabled(True) + + # Auto-fit columns 2–7 to content, File column stays Stretch, Ch stays Fixed + header = self._track_table.horizontalHeader() + for col in (2, 3, 4, 5, 6, 7): + header.setSectionResizeMode(col, QHeaderView.ResizeToContents) + self._track_table.resizeColumnsToContents() + for col in (2, 3, 4, 5, 6, 7): + header.setSectionResizeMode(col, QHeaderView.Interactive) + self._auto_fit_group_column() + self._auto_fit_track_table() + + def _populate_setup_table(self): + """Refresh the Session Setup track table from the current session.""" + if not self._session: + return + self._setup_table.setSortingEnabled(False) + self._setup_table.setRowCount(0) + + ok_tracks = [t for t in self._session.tracks if t.status == "OK"] + self._setup_table.setRowCount(len(ok_tracks)) + gcm = self._group_color_map() + gcm_rank = self._group_rank_map() + glm = self._gain_linked_map() + + # Determine which tracks are assigned to a DAW folder + assignments = {} + if self._session.daw_state and self._active_daw_processor: + dp_state = self._session.daw_state.get( + self._active_daw_processor.id, {}) + assignments = dp_state.get("assignments", {}) + + for row, track in enumerate(ok_tracks): + pr = ( + next(iter(track.processor_results.values()), None) + if track.processor_results + else None + ) + # Column 0: checkmark (assigned to folder?) + assigned = track.filename in assignments + chk_item = _SortableItem("✓" if assigned else "", int(not assigned)) + if assigned: + chk_item.setForeground(QColor(COLORS["clean"])) + self._setup_table.setItem(row, 0, chk_item) + + # Column 1: filename + fname_item = _SortableItem( + track.filename, protools_sort_key(track.filename)) + fname_item.setForeground(FILE_COLOR_OK) + self._setup_table.setItem(row, 1, fname_item) + + # Column 2: channels + ch_item = _SortableItem(str(track.channels), track.channels) + ch_item.setForeground(QColor(COLORS["dim"])) + self._setup_table.setItem(row, 2, ch_item) + + # Column 3: clip gain + clip_gain = pr.gain_db if pr else 0.0 + cg_item = _SortableItem(f"{clip_gain:+.1f} dB", clip_gain) + cg_item.setForeground(QColor(COLORS["text"])) + self._setup_table.setItem(row, 3, cg_item) + + # Column 4: fader gain + fader_gain = pr.data.get("fader_offset", 0.0) if pr else 0.0 + fg_item = _SortableItem(f"{fader_gain:+.1f} dB", fader_gain) + fg_item.setForeground(QColor(COLORS["text"])) + self._setup_table.setItem(row, 4, fg_item) + + # Column 5: group (read-only, with link indicator) + grp_label = self._group_display_name(track.group, glm) if track.group else "" + grp_rank = gcm_rank.get(track.group, len(gcm_rank)) if track.group else len(gcm_rank) + grp_item = _SortableItem(grp_label, grp_rank) + grp_item.setForeground(QColor(COLORS["text"])) + self._setup_table.setItem(row, 5, grp_item) + + # Row background from group color + self._apply_row_group_color(row, track.group, gcm, + table=self._setup_table) + + self._setup_table.setSortingEnabled(True) + + # Auto-fit columns to content + sh = self._setup_table.horizontalHeader() + for col in range(self._setup_table.columnCount()): + sh.setSectionResizeMode(col, QHeaderView.ResizeToContents) + self._setup_table.resizeColumnsToContents() + sh.setSectionResizeMode(0, QHeaderView.Fixed) + sh.resizeSection(0, 24) + sh.setSectionResizeMode(1, QHeaderView.Stretch) + sh.setSectionResizeMode(2, QHeaderView.Fixed) + for col in range(3, self._setup_table.columnCount()): + sh.setSectionResizeMode(col, QHeaderView.Interactive) + + # ── Classification override helpers ─────────────────────────────────── + + def _style_classification_combo(self, combo: QComboBox, cls_text: str): + """Apply classification-specific color to a combo box.""" + if cls_text == "Transient": + color = FILE_COLOR_TRANSIENT.name() + elif cls_text == "Sustained": + color = FILE_COLOR_SUSTAINED.name() + else: + color = FILE_COLOR_SILENT.name() + combo.setStyleSheet(f"QComboBox {{ color: {color}; font-weight: bold; }}") + + def _on_classification_changed(self, text: str, combo=None): + """Handle user changing the classification dropdown.""" + if combo is None: + combo = self.sender() + if not combo or not self._session: + return + fname = combo.property("track_filename") + if not fname: + return + track = next( + (t for t in self._session.tracks if t.filename == fname), None + ) + if not track: + return + if getattr(combo, 'batch_mode', False) or combo.property("_batch_mode"): + combo.setProperty("_batch_mode", False) + combo.batch_mode = False + track.classification_override = text + def _prepare(t): + t.classification_override = text + self._batch_apply_combo(combo, 3, text, _prepare, + run_detectors=False) + else: + # Skip if the value didn't actually change + if track.classification_override == text: + return + track.classification_override = text + # Single-track sync path + self._recalculate_processor(track) + self._style_classification_combo(combo, text) + self._update_track_row(fname) + self._refresh_file_tab(track) + self._mark_prepare_stale() + + def _on_gain_changed(self, value: float, spin=None): + """Handle user manually editing the gain spin box.""" + if spin is None: + spin = self.sender() + if not spin or not self._session: + return + fname = spin.property("track_filename") + if not fname: + return + track = next( + (t for t in self._session.tracks if t.filename == fname), None + ) + if not track: + return + + # Write gain directly to the processor result + pr = next(iter(track.processor_results.values()), None) + if pr: + pr.gain_db = value + self._mark_prepare_stale() + + # Update hidden sort item + for row in range(self._track_table.rowCount()): + item = self._track_table.item(row, 0) + if item and item.text() == fname: + gain_sort = self._track_table.item(row, 4) + if gain_sort: + gain_sort.setText(f"{value:+.1f}") + gain_sort._sort_key = value + break + + # Refresh File tab if this track is currently displayed + if self._current_track and self._current_track.filename == fname: + html = render_track_detail_html(track, self._session, + show_clean=self._show_clean, + verbose=self._verbose) + self._file_report.setHtml(self._wrap_html(html)) + + # ── RMS Anchor override helpers ────────────────────────────────────── + + _ANCHOR_LABELS = ["Default", "Max", "P99", "P95", "P90", "P85"] + _ANCHOR_TO_OVERRIDE = { + "Default": None, "Max": "max", + "P99": "p99", "P95": "p95", "P90": "p90", "P85": "p85", + } + _OVERRIDE_TO_LABEL = {v: k for k, v in _ANCHOR_TO_OVERRIDE.items()} + + def _create_anchor_combo(self, row: int, track): + """Create and install an RMS Anchor combo in column 5.""" + anchor_sort = _SortableItem("Default", "default") + self._track_table.setItem(row, 5, anchor_sort) + + combo = BatchComboBox() + combo.addItems(self._ANCHOR_LABELS) + combo.blockSignals(True) + current = self._OVERRIDE_TO_LABEL.get( + track.rms_anchor_override, "Default") + combo.setCurrentText(current) + combo.blockSignals(False) + combo.setProperty("track_filename", track.filename) + combo.setStyleSheet( + f"QComboBox {{ color: {COLORS['text']}; }}" + ) + combo.textActivated.connect( + lambda text, c=combo: self._on_rms_anchor_changed(text, c)) + self._track_table.setCellWidget(row, 5, combo) + + def _on_rms_anchor_changed(self, text: str, combo=None): + """Handle user changing the RMS Anchor dropdown.""" + if combo is None: + combo = self.sender() + if not combo or not self._session: + return + fname = combo.property("track_filename") + if not fname: + return + track = next( + (t for t in self._session.tracks if t.filename == fname), None + ) + if not track: + return + + new_override = self._ANCHOR_TO_OVERRIDE.get(text) + + # Batch path: async re-analysis for all selected rows + if combo.property("_batch_mode"): + combo.setProperty("_batch_mode", False) + combo.batch_mode = False + track.rms_anchor_override = new_override + def _prepare(t): + t.rms_anchor_override = new_override + self._batch_apply_combo(combo, 5, text, _prepare, + run_detectors=True) + else: + # Skip if the value didn't actually change (textActivated + # fires even when the user re-selects the same item) + if track.rms_anchor_override == new_override: + return + track.rms_anchor_override = new_override + self._reanalyze_single_track(track) + self._mark_prepare_stale() + + # ── Processing column (col 7) ────────────────────────────────────── + + def _create_processing_button(self, row: int, track) -> None: + """Create a multiselect tool button for the Processing column.""" + if track.status != "OK": + item = _SortableItem("", "zzz") + self._track_table.setItem(row, 7, item) + return + + processors = self._session.processors if self._session else [] + + btn = BatchToolButton() + btn.setProperty("track_filename", track.filename) + + if processors: + btn.setPopupMode(QToolButton.InstantPopup) + menu = QMenu(btn) + for proc in processors: + action = menu.addAction(proc.name) + action.setCheckable(True) + checked = proc.id not in track.processor_skip + action.setChecked(checked) + action.setData(proc.id) + action.toggled.connect( + lambda checked, a=action: self._on_processing_toggled(checked, a)) + btn.setMenu(menu) + else: + btn.setEnabled(False) + + self._update_processing_button_label(btn, track, processors) + + # Hidden sort item + sort_item = _SortableItem("", len(track.processor_skip)) + self._track_table.setItem(row, 7, sort_item) + self._track_table.setCellWidget(row, 7, btn) + + def _update_processing_button_label(self, btn, track, processors): + """Set the button label based on current processor_skip state.""" + if not processors: + btn.setText("None") + btn.setToolTip("No audio processors enabled") + return + def _label(p): + return p.shorthand if p.shorthand else p.name + + active = [p for p in processors if p.id not in track.processor_skip] + active_labels = [_label(p) for p in active] + active_names = [p.name for p in active] + # "Default" means the current selection matches each processor's + # configured default (default=True → active, default=False → skipped). + is_default = all( + (p.id not in track.processor_skip) == p.default + for p in processors + ) + if is_default: + default_active_names = [p.name for p in processors if p.default] + if default_active_names: + btn.setText("Default") + btn.setToolTip("Default selection: " + ", ".join(default_active_names)) + else: + btn.setText("Default") + btn.setToolTip("Default: all processors deselected") + elif not active: + btn.setText("None") + btn.setToolTip("All processors skipped for this track") + else: + btn.setText(", ".join(active_labels)) + btn.setToolTip("Active processors: " + ", ".join(active_names)) + + def _on_processing_toggled(self, checked: bool, action=None): + """Handle user toggling a processor in the Processing column menu.""" + if action is None: + action = self.sender() + if not action: + return + menu = action.parent() + if not menu: + return + btn = menu.parent() + if not btn: + return + fname = btn.property("track_filename") + if not fname or not self._session: + return + track = next( + (t for t in self._session.tracks if t.filename == fname), None + ) + if not track: + return + + proc_id = action.data() + processors = self._session.processors if self._session else [] + + if btn.property("_batch_mode"): + btn.setProperty("_batch_mode", False) + btn.batch_mode = False + batch_keys = self._track_table.batch_selected_keys() + track_map = {t.filename: t for t in self._session.tracks} + for fname in batch_keys: + t = track_map.get(fname) + if not t or t.status != "OK": + continue + if checked: + t.processor_skip.discard(proc_id) + else: + t.processor_skip.add(proc_id) + row = self._find_table_row(fname) + if row >= 0: + b = self._track_table.cellWidget(row, 7) + if b: + self._update_processing_button_label(b, t, processors) + self._track_table.restore_selection(batch_keys) + else: + if checked: + track.processor_skip.discard(proc_id) + else: + track.processor_skip.add(proc_id) + self._update_processing_button_label(btn, track, processors) + + self._mark_prepare_stale() + + # ── Batch combo helper ──────────────────────────────────────────────── + + def _batch_apply_combo(self, source_combo, column: int, value: str, + prepare_fn, run_detectors: bool = True): + """Apply *value* to the combo in *column* for every selected row. + + 1. **Sync** — set overrides via *prepare_fn(track)* and update + combo widgets instantly. + 2. **Async** — start a ``BatchReanalyzeWorker`` that re-runs + detectors/processors in the background, updating table rows + as each track completes and restoring the multi-selection at + the end. + + *prepare_fn(track)* must only mutate the data model (e.g. set an + override field). It must **not** run analysis. + """ + if not self._session: + return + if self._batch_worker and self._batch_worker.isRunning(): + return + if self._worker and self._worker.isRunning(): + return + + track_map = {t.filename: t for t in self._session.tracks} + batch_keys = self._track_table.batch_selected_keys() + + # Collect tracks and update combo widgets (sync, instant) + tracks_to_reanalyze: list = [] + self._track_table.setSortingEnabled(False) + for fname in batch_keys: + track = track_map.get(fname) + if not track or track.status != "OK": + continue + prepare_fn(track) + tracks_to_reanalyze.append(track) + row = self._find_table_row(fname) + if row >= 0: + w = self._track_table.cellWidget(row, column) + if isinstance(w, BatchComboBox): + w.blockSignals(True) + w.setCurrentText(value) + w.blockSignals(False) + if not tracks_to_reanalyze: + self._track_table.setSortingEnabled(True) + return + + # Save filenames for selection restore after worker completes + self._batch_filenames = batch_keys + + # Show progress UI + self._progress_label.setText("Re-analyzing…") + self._progress_bar.setRange(0, len(tracks_to_reanalyze)) + self._progress_bar.setValue(0) + self._right_stack.setCurrentIndex(0) # _PAGE_PROGRESS + self._analyze_action.setEnabled(False) + + # Start async worker + self._batch_worker = BatchReanalyzeWorker( + tracks_to_reanalyze, + self._session.detectors, + self._session.processors, + run_detectors=run_detectors, + ) + self._batch_worker.progress.connect(self._on_worker_progress) + self._batch_worker.progress_value.connect(self._on_worker_progress_value) + self._batch_worker.track_done.connect(self._on_batch_track_done) + self._batch_worker.batch_finished.connect(self._on_batch_done) + self._batch_worker.error.connect(self._on_batch_error) + self._batch_worker.start() + + @Slot(str) + def _on_batch_track_done(self, filename: str): + """Update one table row after the worker finishes re-analyzing it.""" + self._update_track_row(filename) + + @Slot() + def _on_batch_done(self): + """Finalize the batch: restore selection, switch back to tabs.""" + self._batch_worker = None + self._analyze_action.setEnabled(True) + self._right_stack.setCurrentIndex(1) # _PAGE_TABS + + # Re-enable sorting (was disabled in _batch_apply_combo); + # rows may reorder, so restore selection by key afterward. + self._track_table.setSortingEnabled(True) + self._track_table.restore_selection(self._batch_filenames) + self._batch_filenames = set() + + # Refresh setup table and file tab + self._populate_setup_table() + if self._current_track: + self._refresh_file_tab(self._current_track) + + @Slot(str) + def _on_batch_error(self, message: str): + """Handle fatal error from the batch worker.""" + self._batch_worker = None + self._analyze_action.setEnabled(True) + self._track_table.setSortingEnabled(True) + self._track_table.restore_selection(self._batch_filenames) + self._batch_filenames = set() + self._right_stack.setCurrentIndex(1) # _PAGE_TABS + self._status_bar.showMessage(f"Batch error: {message}") + + # ── Recalculation ──────────────────────────────────────────────────── + + def _recalculate_processor(self, track): + """Re-run the normalization processor for a single track.""" + if not self._session or not self._session.processors: + return + for proc in self._session.processors: + result = proc.process(track) + result.data["original_gain_db"] = result.gain_db + track.processor_results[proc.id] = result + + def _reanalyze_single_track(self, track): + """Re-run all track detectors + processors for a single track (sync).""" + if not self._session: + return + + # Re-run track-level detectors (already sorted by dependency) + for det in self._session.detectors: + if isinstance(det, TrackDetector): + try: + result = det.analyze(track) + track.detector_results[det.id] = result + except Exception: + pass + + # Re-run processors + self._recalculate_processor(track) + + # Re-apply group levels for any gain-linked groups this track belongs to + self._apply_linked_group_levels() + + # Update UI + self._update_track_row(track.filename) + self._refresh_file_tab(track) + + # ── Track-row UI helpers ───────────────────────────────────────────── + + def _update_track_row(self, filename: str): + """Refresh analysis label, classification, gain, and sort items + for the table row matching *filename*. + + Called from: + - ``_reanalyze_single_track`` (sync single-track path) + - ``_on_batch_track_done`` (per-track signal from async worker) + """ + if not self._session: + return + track = next( + (t for t in self._session.tracks if t.filename == filename), None + ) + if not track: + return + row = self._find_table_row(filename) + if row < 0: + return + + # Analysis label + dets = self._session.detectors + _plain, html, _color, sort_key = track_analysis_label(track, dets) + lbl, item = _make_analysis_cell(html, sort_key) + self._track_table.setItem(row, 2, item) + self._track_table.setCellWidget(row, 2, lbl) + + # Gain spin box + sort item + classification + pr = next(iter(track.processor_results.values()), None) + new_gain = pr.gain_db if pr else 0.0 + base_cls = None + if pr: + cls_text = pr.classification or "Unknown" + if "Transient" in cls_text: + base_cls = "Transient" + elif cls_text == "Skip": + base_cls = "Skip" + else: + base_cls = "Sustained" + + spin = self._track_table.cellWidget(row, 4) + if isinstance(spin, QDoubleSpinBox): + spin.blockSignals(True) + spin.setValue(new_gain) + if base_cls is not None: + spin.setEnabled(base_cls != "Skip") + spin.blockSignals(False) + gain_sort = self._track_table.item(row, 4) + if gain_sort: + gain_sort.setText(f"{new_gain:+.1f}") + gain_sort._sort_key = new_gain + + if base_cls is not None: + cls_combo = self._track_table.cellWidget(row, 3) + if isinstance(cls_combo, QComboBox): + cls_combo.blockSignals(True) + cls_combo.setCurrentText(base_cls) + cls_combo.blockSignals(False) + self._style_classification_combo(cls_combo, base_cls) + sort_item = self._track_table.item(row, 3) + if sort_item: + sort_item.setText(base_cls) + sort_item._sort_key = base_cls.lower() + + # Re-apply row group color (new items lose their background) + self._apply_row_group_color(row, track.group) + + # Keep the Session Setup table in sync + self._populate_setup_table() + + def _refresh_file_tab(self, track): + """Refresh File tab + waveform overlays if *track* is displayed.""" + if not self._current_track or self._current_track.filename != track.filename: + return + html = render_track_detail_html(track, self._session, + show_clean=self._show_clean, + verbose=self._verbose) + self._file_report.setHtml(self._wrap_html(html)) + all_issues = [] + for result in track.detector_results.values(): + all_issues.extend(getattr(result, "issues", [])) + self._update_overlay_menu(all_issues) + + # ── Table fitting ──────────────────────────────────────────────────── + + def _auto_fit_track_table(self): + """Shrink the left panel to fit the track table columns, giving + more space to the right detail panel. + + Temporarily switches the File column from Stretch to + ResizeToContents so we can measure its true content width, + then adjusts the splitter and restores Stretch mode. + """ + header = self._track_table.horizontalHeader() + + # Temporarily fit File column to content so we get a true width + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + self._track_table.resizeColumnToContents(0) + total_w = sum(header.sectionSize(c) for c in range(header.count())) + # Restore File column to Stretch + header.setSectionResizeMode(0, QHeaderView.Stretch) + + # vertical-header (hidden=0) + scrollbar (~20) + frame borders (~4) + vhw = self._track_table.verticalHeader().width() if self._track_table.verticalHeader().isVisible() else 0 + padding = vhw + 20 + 4 + needed = total_w + padding + + splitter_total = self._main_splitter.width() + if splitter_total > 0: + right_w = max(splitter_total - needed, 300) + left_w = splitter_total - right_w + self._main_splitter.setSizes([left_w, right_w]) + + def _auto_fit_group_column(self): + """Resize the Group column (6) to fit the widest current combo text.""" + max_w = 0 + for row in range(self._track_table.rowCount()): + w = self._track_table.cellWidget(row, 6) + if isinstance(w, BatchComboBox): + fm = w.fontMetrics() + tw = fm.horizontalAdvance(w.currentText()) + max_w = max(max_w, tw) + if max_w > 0: + # icon (16) + icon gap (4) + text + dropdown arrow (~24) + margins (16) + needed = 16 + 4 + max_w + 24 + 16 + header = self._track_table.horizontalHeader() + header.resizeSection(6, max(needed, 100)) diff --git a/sessionprepgui/widgets.py b/sessionprepgui/widgets.py index 8361554..f7bddea 100644 --- a/sessionprepgui/widgets.py +++ b/sessionprepgui/widgets.py @@ -302,6 +302,9 @@ def mousePressEvent(self, event): mods = QApplication.keyboardModifiers() self.batch_mode = bool( mods & Qt.AltModifier and mods & Qt.ShiftModifier) + # Also store as Qt dynamic property so it survives sender() + # wrapper recreation when slots live on mixin classes. + self.setProperty("_batch_mode", self.batch_mode) super().mousePressEvent(event) @@ -326,4 +329,7 @@ def mousePressEvent(self, event): mods = QApplication.keyboardModifiers() self.batch_mode = bool( mods & Qt.AltModifier and mods & Qt.ShiftModifier) + # Also store as Qt dynamic property so it survives sender() + # wrapper recreation when slots live on mixin classes. + self.setProperty("_batch_mode", self.batch_mode) super().mousePressEvent(event) From 017ef3503ca9fb2deadf82a74104649d0b743d7a Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Thu, 19 Feb 2026 21:24:14 +0100 Subject: [PATCH 16/30] waveform.py refactoring. --- sessionprepgui/detail_mixin.py | 6 +- sessionprepgui/mainwindow.py | 3 +- sessionprepgui/waveform.py | 1789 +++--------------------- sessionprepgui/waveform_compute.py | 299 ++++ sessionprepgui/waveform_overlay.py | 189 +++ sessionprepgui/waveform_renderer.py | 638 +++++++++ sessionprepgui/waveform_spectrogram.py | 315 +++++ 7 files changed, 1620 insertions(+), 1619 deletions(-) create mode 100644 sessionprepgui/waveform_compute.py create mode 100644 sessionprepgui/waveform_overlay.py create mode 100644 sessionprepgui/waveform_renderer.py create mode 100644 sessionprepgui/waveform_spectrogram.py diff --git a/sessionprepgui/detail_mixin.py b/sessionprepgui/detail_mixin.py index d718b87..bb6546e 100644 --- a/sessionprepgui/detail_mixin.py +++ b/sessionprepgui/detail_mixin.py @@ -15,7 +15,7 @@ from .table_widgets import _TAB_FILE, _TAB_SUMMARY from .theme import COLORS from .worker import AudioLoadWorker -from .waveform import WaveformLoadWorker +from .waveform_compute import WaveformLoadWorker class DetailMixin: @@ -121,8 +121,8 @@ def _load_waveform(self, track): self._wf_worker = WaveformLoadWorker( track.audio_data, track.samplerate, ws, - spec_n_fft=self._waveform._spec_n_fft, - spec_window=self._waveform._spec_window, + spec_n_fft=self._waveform.spec_n_fft, + spec_window=self._waveform.spec_window, parent=self) self._wf_worker.finished.connect( lambda result, t=track: self._on_waveform_loaded(result, t)) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index b4939cc..2e47d59 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -50,7 +50,8 @@ from .log import dbg from .preferences import PreferencesDialog from .report import render_track_detail_html -from .waveform import WaveformWidget, WaveformLoadWorker +from .waveform import WaveformWidget +from .waveform_compute import WaveformLoadWorker from .playback import PlaybackController from .widgets import ProgressPanel from .worker import ( diff --git a/sessionprepgui/waveform.py b/sessionprepgui/waveform.py index b93765b..bc241ec 100644 --- a/sessionprepgui/waveform.py +++ b/sessionprepgui/waveform.py @@ -2,302 +2,17 @@ from __future__ import annotations -import threading - import numpy as np -from PySide6.QtCore import QPointF, Qt, QThread, QTimer, Signal -from PySide6.QtGui import (QColor, QFont, QImage, QLinearGradient, QPainter, - QPen, QPolygonF) +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QColor, QFont, QPainter, QPen from PySide6.QtWidgets import QToolTip, QWidget -from scipy.signal import stft as scipy_stft from .theme import COLORS - - -# --------------------------------------------------------------------------- -# Spectrogram colormaps -# --------------------------------------------------------------------------- - -SPECTROGRAM_COLORMAPS: dict[str, np.ndarray] = {} # name → (256, 4) uint8 RGBA - - -def _register_colormap(name: str, - controls: list[tuple[float, tuple[int, int, int]]]): - """Build a 256-entry RGBA LUT from control points via linear interpolation.""" - lut = np.zeros((256, 4), dtype=np.uint8) - lut[:, 3] = 255 # fully opaque - positions = np.array([c[0] for c in controls]) - for ch in range(3): - values = np.array([c[1][ch] for c in controls], dtype=np.float64) - lut[:, ch] = np.clip( - np.interp(np.linspace(0, 1, 256), positions, values), 0, 255 - ).astype(np.uint8) - SPECTROGRAM_COLORMAPS[name] = lut - - -_register_colormap("magma", [ - (0.0, (0, 0, 4)), - (0.25, (81, 18, 124)), - (0.5, (183, 55, 121)), - (0.75, (254, 159, 109)), - (1.0, (252, 253, 191)), -]) - -_register_colormap("viridis", [ - (0.0, (68, 1, 84)), - (0.25, (59, 82, 139)), - (0.5, (33, 145, 140)), - (0.75, (94, 201, 98)), - (1.0, (253, 231, 37)), -]) - -_register_colormap("grayscale", [ - (0.0, (0, 0, 0)), - (1.0, (255, 255, 255)), -]) - - -# --------------------------------------------------------------------------- -# Mel filterbank -# --------------------------------------------------------------------------- - -def _hz_to_mel(f: float) -> float: - return 2595.0 * np.log10(1.0 + f / 700.0) - - -def _mel_to_hz(m: float) -> float: - return 700.0 * (10.0 ** (m / 2595.0) - 1.0) - - -def _mel_filterbank(sr: int, n_fft: int, n_mels: int = 128, - f_min: float = 20.0, f_max: float = 22050.0 - ) -> np.ndarray: - """Build a Mel filterbank matrix (n_mels, n_fft // 2 + 1).""" - f_max = min(f_max, sr / 2.0) - n_freqs = n_fft // 2 + 1 - mel_min = _hz_to_mel(f_min) - mel_max = _hz_to_mel(f_max) - mel_points = np.linspace(mel_min, mel_max, n_mels + 2) - hz_points = 700.0 * (10.0 ** (mel_points / 2595.0) - 1.0) - bin_points = np.floor((n_fft + 1) * hz_points / sr).astype(np.intp) - - fb = np.zeros((n_mels, n_freqs), dtype=np.float64) - for i in range(n_mels): - left, center, right = bin_points[i], bin_points[i + 1], bin_points[i + 2] - if center == left: - center = left + 1 - if right == center: - right = center + 1 - for j in range(int(left), int(center)): - if j < n_freqs: - fb[i, j] = (j - left) / (center - left) - for j in range(int(center), int(right)): - if j < n_freqs: - fb[i, j] = (right - j) / (right - center) - return fb - - -# --------------------------------------------------------------------------- -# Spectrogram computation (used by background worker) -# --------------------------------------------------------------------------- - -_SPEC_N_FFT = 2048 -_SPEC_HOP = 512 -_SPEC_N_MELS = 256 -_SPEC_F_MIN = 20.0 -_SPEC_F_MAX = 22050.0 -_SPEC_DB_FLOOR = -80.0 # dB floor for normalization - - -def compute_mel_spectrogram(channels: list[np.ndarray], sr: int, *, - n_fft: int = _SPEC_N_FFT, - hop: int | None = None, - window: str = "hann", - ) -> np.ndarray | None: - """Compute a full-file mel spectrogram from channel data. - - Returns a float32 array of shape (n_mels, n_frames) in dB, or None - if the audio is too short. - """ - if not channels: - return None - if hop is None: - hop = n_fft // 4 - # Mix to mono - if len(channels) == 1: - mono = channels[0].astype(np.float64) - else: - mono = np.mean( - np.column_stack([ch.astype(np.float64) for ch in channels]), - axis=1, - ) - if len(mono) < n_fft: - return None - # STFT - _f, _t, Zxx = scipy_stft( - mono, fs=sr, nperseg=n_fft, - noverlap=n_fft - hop, window=window, boundary=None, - ) - power = np.abs(Zxx) ** 2 - # Mel filterbank - f_max = min(_SPEC_F_MAX, sr / 2.0) - fb = _mel_filterbank(sr, n_fft, _SPEC_N_MELS, _SPEC_F_MIN, f_max) - mel_spec = fb @ power # (n_mels, n_frames) - # To dB - mel_spec = 10.0 * np.log10(np.maximum(mel_spec, 1e-10)) - return mel_spec.astype(np.float32) - - -class WaveformLoadWorker(QThread): - """Background thread for heavy waveform preparation work. - - Splits channels, finds peak position, and computes RMS-max position - so the main thread stays responsive. - """ - - finished = Signal(object) # emits a dict with all computed results - - def __init__(self, audio_data: np.ndarray, samplerate: int, - rms_window_samples: int, *, - spec_n_fft: int = _SPEC_N_FFT, - spec_window: str = "hann", - parent=None): - super().__init__(parent) - self._audio_data = audio_data - self._samplerate = samplerate - self._rms_win = rms_window_samples - self._spec_n_fft = spec_n_fft - self._spec_window = spec_window - self._cancelled = threading.Event() - - def cancel(self): - """Request early termination of the computation.""" - self._cancelled.set() - - def run(self): - data = self._audio_data - sr = self._samplerate - win = self._rms_win - - # --- Channel splitting --- - if data.ndim == 1: - channels = [np.ascontiguousarray(data)] - else: - channels = [ - np.ascontiguousarray(data[:, ch]) - for ch in range(data.shape[1]) - ] - nch = len(channels) - total = len(channels[0]) - - if self._cancelled.is_set(): - return - - # --- Peak finding --- - if nch == 1: - peak_sample = int(np.argmax(np.abs(channels[0]))) - peak_channel = 0 - else: - abs_cols = np.column_stack([np.abs(ch) for ch in channels]) - max_per_sample = np.max(abs_cols, axis=1) - peak_sample = int(np.argmax(max_per_sample)) - peak_channel = int(np.argmax(abs_cols[peak_sample])) - peak_lin = abs(float(channels[peak_channel][peak_sample])) - peak_db = 20.0 * np.log10(peak_lin) if peak_lin > 0 else float('-inf') - peak_amplitude = float(channels[peak_channel][peak_sample]) - - if self._cancelled.is_set(): - return - - # --- RMS cumsum (computed once, reused for envelope drawing) --- - rms_max_sample = -1 - rms_max_db = float('-inf') - rms_max_amplitude = 0.0 - rms_cumsums: list[np.ndarray] = [] - if win > 0: - ch_wms: list[np.ndarray] = [] - for ch_data in channels: - if self._cancelled.is_set(): - return - n = len(ch_data) - if n <= win: - rms_cumsums.append(np.zeros(2, dtype=np.float64)) - ch_wms.append(np.zeros(1, dtype=np.float64)) - continue - sq = ch_data.astype(np.float64) ** 2 - cs = np.empty(n + 1, dtype=np.float64) - cs[0] = 0.0 - np.cumsum(sq, out=cs[1:]) - rms_cumsums.append(cs) - ch_wms.append((cs[win:] - cs[:n - win + 1]) / win) - min_len = min(len(wm) for wm in ch_wms) - if min_len > 0: - combined = np.mean( - np.column_stack([wm[:min_len] for wm in ch_wms]), axis=1 - ) - max_idx = int(np.argmax(combined)) - rms_max_sample = max_idx + win // 2 - rms_lin = float(np.sqrt(combined[max_idx])) - rms_max_db = 20.0 * np.log10(rms_lin) if rms_lin > 0 else float('-inf') - rms_max_amplitude = rms_lin - - if self._cancelled.is_set(): - return - - # --- Spectrogram --- - spec_db = compute_mel_spectrogram( - channels, sr, - n_fft=self._spec_n_fft, window=self._spec_window, - ) - - if self._cancelled.is_set(): - return - - self.finished.emit({ - "channels": channels, - "samplerate": sr, - "total_samples": total, - "peak_sample": peak_sample, - "peak_channel": peak_channel, - "peak_db": peak_db, - "peak_amplitude": peak_amplitude, - "rms_window_samples": win, - "rms_max_sample": rms_max_sample, - "rms_max_db": rms_max_db, - "rms_max_amplitude": rms_max_amplitude, - "rms_cumsums": rms_cumsums, - "spec_db": spec_db, - }) - - -class SpectrogramRecomputeWorker(QThread): - """Lightweight background thread to recompute the mel spectrogram.""" - - finished = Signal(object) # emits np.ndarray | None - - def __init__(self, channels: list[np.ndarray], sr: int, *, - n_fft: int = _SPEC_N_FFT, window: str = "hann", - parent=None): - super().__init__(parent) - self._channels = channels - self._sr = sr - self._n_fft = n_fft - self._window = window - self._cancelled = threading.Event() - - def cancel(self): - """Request early termination.""" - self._cancelled.set() - - def run(self): - result = compute_mel_spectrogram( - self._channels, self._sr, - n_fft=self._n_fft, window=self._window, - ) - if self._cancelled.is_set(): - return - self.finished.emit(result) +from .waveform_compute import WaveformLoadWorker, _mel_to_hz # noqa: F401 (re-export) +from .waveform_overlay import draw_issue_overlays, draw_time_scale +from .waveform_renderer import WaveformRenderCtx, WaveformRenderer +from .waveform_spectrogram import SpecRenderCtx, SpectrogramRenderer class WaveformWidget(QWidget): @@ -305,85 +20,41 @@ class WaveformWidget(QWidget): position_clicked = Signal(int) # sample index - _CHANNEL_COLORS = [ - "#44aa44", "#44aaaa", "#aa44aa", "#aaaa44", - "#4488cc", "#cc8844", "#88cc44", "#cc4488", - ] - _SEVERITY_OVERLAY = { - "problem": QColor(255, 68, 68, 55), - "attention": QColor(255, 170, 0, 45), - "information": QColor(68, 153, 255, 40), - "info": QColor(68, 153, 255, 40), - } - _SEVERITY_BORDER = { - "problem": QColor(255, 68, 68, 140), - "attention": QColor(255, 170, 0, 120), - "information": QColor(68, 153, 255, 100), - "info": QColor(68, 153, 255, 100), - } _MARGIN_LEFT = 38 _MARGIN_RIGHT = 38 _MARGIN_BOTTOM = 20 def __init__(self, parent=None): super().__init__(parent) - self._channels: list[np.ndarray] = [] # one 1-D array per channel + # Renderer objects (composition) + self._wf_renderer = WaveformRenderer() + self._spec_renderer = SpectrogramRenderer() + # View / audio state + self._channels: list[np.ndarray] = [] self._num_channels: int = 0 self._samplerate: int = 44100 self._total_samples: int = 0 self._cursor_sample: int = 0 - self._cursor_y_value: float | None = None # amplitude (waveform) or mel (spectrogram) - self._cursor_y_channel: int = 0 # channel lane (waveform only) - self._peaks_cache: list[tuple[np.ndarray, np.ndarray]] = [] # (mins, maxs) per channel - self._cached_view: tuple[int, int, int] = (0, 0, 0) # (width, view_start, view_end) - self._issues: list = [] # list of IssueLocation objects + self._cursor_y_value: float | None = None + self._cursor_y_channel: int = 0 + self._issues: list = [] self._view_start: int = 0 self._view_end: int = 0 self._vscale: float = 1.0 - # RMS overlay + # RMS / overlay / marker toggles self._rms_window_samples: int = 0 self._show_rms_lr: bool = False self._show_rms_avg: bool = False - self._rms_envelope: list[list[float]] = [] - self._rms_combined: list[float] = [] - self._rms_cache_key: tuple[int, int, int] = (0, 0, 0) - # Overlay filtering self._enabled_overlays: set[str] = set() - # Markers self._show_markers: bool = False - self._peak_sample: int = -1 - self._peak_channel: int = -1 - self._peak_db: float = float('-inf') - self._peak_amplitude: float = 0.0 # signed amplitude on the peak channel - self._peak_dirty: bool = False # True = needs recomputation - self._rms_max_sample: int = -1 - self._rms_max_db: float = float('-inf') - self._rms_max_amplitude: float = 0.0 # linear RMS at max window - self._rms_max_dirty: bool = False # True = needs recomputation - # Loading state + # Loading / display state self._loading: bool = False - # Mouse guide (crosshair) - self._mouse_x: int = -1 # -1 = not hovering - self._mouse_y: int = -1 - # Display mode - self._display_mode: str = "waveform" # "waveform" | "spectrogram" - # Spectrogram data - self._spec_db: np.ndarray | None = None # (n_mels, n_frames) dB - self._spec_image: QImage | None = None - self._spec_cache_key: tuple = () - self._colormap: str = "magma" - self._mel_view_min: float = _hz_to_mel(_SPEC_F_MIN) - self._mel_view_max: float = _hz_to_mel(_SPEC_F_MAX) - self._spec_n_fft: int = _SPEC_N_FFT - self._spec_window: str = "hann" - self._spec_db_floor: float = _SPEC_DB_FLOOR - self._spec_db_ceil: float = 0.0 - self._spec_recompute_worker: SpectrogramRecomputeWorker | None = None - # Cached RMS cumsums (computed once per track in background worker) - self._rms_cumsums: list[np.ndarray] = [] - # Waveform display settings + self._display_mode: str = "waveform" self._wf_antialias: bool = False self._wf_line_width: int = 1 + # Mouse crosshair + self._mouse_x: int = -1 + self._mouse_y: int = -1 # Scroll inversion self._invert_h: bool = False self._invert_v: bool = False @@ -391,18 +62,16 @@ def __init__(self, parent=None): self._scroll_pending: bool = False self._scroll_timer: QTimer = QTimer(self) self._scroll_timer.setSingleShot(True) - self._scroll_timer.setInterval(8) # ~120 fps cap + self._scroll_timer.setInterval(8) self._scroll_timer.timeout.connect(self._flush_scroll) self.setMinimumHeight(80) self.setMouseTracking(True) self.setFocusPolicy(Qt.StrongFocus) - def set_audio(self, audio_data: np.ndarray | None, samplerate: int): - """Load audio data (numpy array, shape (samples,) or (samples, channels)). + # ── Data management ──────────────────────────────────────────────────── - Stores contiguous per-channel arrays (no dtype conversion). - Peak finding is deferred until markers are first painted. - """ + def set_audio(self, audio_data: np.ndarray | None, samplerate: int): + """Load raw audio data. Peak finding is deferred to first paint.""" if audio_data is None or audio_data.size == 0: self._channels = [] self._num_channels = 0 @@ -417,34 +86,19 @@ def set_audio(self, audio_data: np.ndarray | None, samplerate: int): ] self._num_channels = len(self._channels) self._total_samples = len(self._channels[0]) - # Mark peak as needing computation (deferred to first paint) - self._peak_sample = -1 - self._peak_channel = -1 - self._peak_db = float('-inf') - self._peak_amplitude = 0.0 - self._peak_dirty = bool(self._channels) - self._rms_max_sample = -1 - self._rms_max_db = float('-inf') - self._rms_max_dirty = False self._samplerate = samplerate self._cursor_sample = 0 self._cursor_y_value = None self._view_start = 0 self._view_end = self._total_samples self._vscale = 1.0 - self._peaks_cache = [] - self._cached_view = (0, 0, 0) self._issues = [] self._rms_window_samples = 0 - self._rms_envelope = [] - self._rms_combined = [] - self._rms_cache_key = (0, 0, 0) - self._rms_cumsums = [] - self._spec_db = None - self._spec_image = None - self._spec_cache_key = () - self._mel_view_min = _hz_to_mel(_SPEC_F_MIN) - self._mel_view_max = _hz_to_mel(min(_SPEC_F_MAX, samplerate / 2.0)) + self._wf_renderer.set_track_data( + self._channels, + peak_dirty=bool(self._channels), + ) + self._spec_renderer.reset(samplerate) self.update() def set_loading(self, loading: bool): @@ -454,8 +108,7 @@ def set_loading(self, loading: bool): self._channels = [] self._num_channels = 0 self._total_samples = 0 - self._peaks_cache = [] - self._cached_view = (0, 0, 0) + self._wf_renderer.reset() self.update() def set_precomputed(self, result: dict): @@ -464,33 +117,26 @@ def set_precomputed(self, result: dict): self._num_channels = len(self._channels) self._total_samples = result["total_samples"] self._samplerate = result["samplerate"] - self._peak_sample = result["peak_sample"] - self._peak_channel = result["peak_channel"] - self._peak_db = result["peak_db"] - self._peak_amplitude = result["peak_amplitude"] - self._peak_dirty = False - self._rms_window_samples = result["rms_window_samples"] - self._rms_max_sample = result["rms_max_sample"] - self._rms_max_db = result["rms_max_db"] - self._rms_max_amplitude = result["rms_max_amplitude"] - self._rms_max_dirty = False self._cursor_sample = 0 self._cursor_y_value = None self._view_start = 0 self._view_end = self._total_samples self._vscale = 1.0 - self._peaks_cache = [] - self._cached_view = (0, 0, 0) - self._rms_envelope = [] - self._rms_combined = [] - self._rms_cache_key = (0, 0, 0) - self._rms_cumsums = result.get("rms_cumsums", []) - self._spec_db = result.get("spec_db") - self._spec_image = None - self._spec_cache_key = () - self._mel_view_min = _hz_to_mel(_SPEC_F_MIN) - self._mel_view_max = _hz_to_mel(min(_SPEC_F_MAX, - self._samplerate / 2.0)) + self._rms_window_samples = result["rms_window_samples"] + self._wf_renderer.set_track_data( + self._channels, + peak_sample=result["peak_sample"], + peak_channel=result["peak_channel"], + peak_db=result["peak_db"], + peak_amplitude=result["peak_amplitude"], + rms_cumsums=result.get("rms_cumsums", []), + rms_window=result["rms_window_samples"], + rms_max_sample=result["rms_max_sample"], + rms_max_db=result["rms_max_db"], + rms_max_amplitude=result["rms_max_amplitude"], + ) + self._spec_renderer.reset(result["samplerate"]) + self._spec_renderer.set_spec_data(result.get("spec_db")) self._loading = False self.update() @@ -500,138 +146,22 @@ def set_issues(self, issues: list): self.update() def set_cursor(self, sample_index: int): - """Update the playback cursor position. - - If the cursor moves past the right edge of the current view, - the view pages forward so the cursor appears at the left edge. - """ + """Update the playback cursor position, auto-paging if needed.""" self._cursor_sample = max(0, min(sample_index, self._total_samples)) - # Auto-page when cursor exceeds the visible range if self._cursor_sample >= self._view_end and self._view_end < self._total_samples: view_len = self._view_end - self._view_start self._view_start = self._cursor_sample self._view_end = min(self._cursor_sample + view_len, self._total_samples) - self._invalidate_peaks() + self._wf_renderer.invalidate() self.update() + # ── Coordinate helpers ───────────────────────────────────────────────── + def _draw_area(self) -> tuple[int, int]: """Return (x0, draw_w) for the waveform drawing area.""" - w = self.width() - draw_w = max(1, w - self._MARGIN_LEFT - self._MARGIN_RIGHT) + draw_w = max(1, self.width() - self._MARGIN_LEFT - self._MARGIN_RIGHT) return self._MARGIN_LEFT, draw_w - @staticmethod - def _peaks_for_view(ch_data, vs, ve, width): - """Compute (mins, maxs) arrays for one channel over samples [vs:ve]. - - Uses proportional bin edges (matching ``_sample_to_x`` mapping) - via ``np.reduceat`` so marker positions align pixel-perfectly - with the waveform envelope. - """ - view_data = ch_data[vs:ve] - n = len(view_data) - if n == 0: - return np.zeros(width, dtype=np.float64), np.zeros(width, dtype=np.float64) - if n >= width: - # Proportional bin edges — same mapping as _sample_to_x - starts = np.arange(width, dtype=np.int64) * n // width - maxs = np.maximum.reduceat(view_data, starts).astype(np.float64) - mins = np.minimum.reduceat(view_data, starts).astype(np.float64) - else: - mins = np.zeros(width, dtype=np.float64) - maxs = np.zeros(width, dtype=np.float64) - starts = np.arange(width) * n // width - ends = np.minimum((np.arange(width) + 1) * n // width, n) - valid = ends > starts - if valid.any(): - single = valid & ((ends - starts) == 1) - if single.any(): - mins[single] = view_data[starts[single]] - maxs[single] = view_data[starts[single]] - multi = valid & ((ends - starts) > 1) - for i in np.nonzero(multi)[0]: - chunk = view_data[starts[i]:ends[i]] - mins[i] = chunk.min() - maxs[i] = chunk.max() - return mins, maxs - - def _build_peaks(self, width: int): - """Downsample audio to peak envelope for the given pixel width, per channel. - - Supports incremental updates on horizontal scroll: when the view - shifts by a fraction, existing peak data is shifted and only the - newly exposed bins are recomputed. - """ - if not self._channels or width <= 0: - self._peaks_cache = [] - return - cache_key = (width, self._view_start, self._view_end) - if self._cached_view == cache_key and self._peaks_cache: - return - - vs, ve = self._view_start, self._view_end - view_len = ve - vs - if view_len <= 0: - self._peaks_cache = [] - return - - # Try incremental update: same width & view_len, shifted start - old_w, old_vs, old_ve = self._cached_view - can_inc = ( - self._peaks_cache - and old_w == width - and (old_ve - old_vs) == view_len - and vs != old_vs - and len(self._peaks_cache) == len(self._channels) - ) - if can_inc: - # How many bins shifted? - shift_samples = vs - old_vs # positive = scrolled right - shift_bins = int(round(shift_samples * width / view_len)) - if 0 < abs(shift_bins) < width: - new_cache = [] - for ch_idx, ch_data in enumerate(self._channels): - old_mins, old_maxs = self._peaks_cache[ch_idx] - mins = np.empty(width, dtype=np.float64) - maxs = np.empty(width, dtype=np.float64) - if shift_bins > 0: - # Scrolled right: keep left portion, compute right - keep = width - shift_bins - mins[:keep] = old_mins[shift_bins:] - maxs[:keep] = old_maxs[shift_bins:] - # Compute new bins [keep:width] - new_vs = vs + keep * view_len // width - new_ve = ve - new_w = width - keep - nm, nx = self._peaks_for_view( - ch_data, new_vs, new_ve, new_w) - mins[keep:] = nm - maxs[keep:] = nx - else: - # Scrolled left: keep right portion, compute left - sb = -shift_bins - keep = width - sb - mins[sb:] = old_mins[:keep] - maxs[sb:] = old_maxs[:keep] - # Compute new bins [0:sb] - new_vs = vs - new_ve = vs + sb * view_len // width - nm, nx = self._peaks_for_view( - ch_data, new_vs, new_ve, sb) - mins[:sb] = nm - maxs[:sb] = nx - new_cache.append((mins, maxs)) - self._peaks_cache = new_cache - self._cached_view = cache_key - return - - # Full recompute (zoom change, resize, or large jump) - self._peaks_cache = [] - for ch_data in self._channels: - mins, maxs = self._peaks_for_view(ch_data, vs, ve, width) - self._peaks_cache.append((mins, maxs)) - self._cached_view = cache_key - def _sample_to_x(self, sample: int, w: int) -> int: view_len = self._view_end - self._view_start if view_len <= 0: @@ -639,12 +169,32 @@ def _sample_to_x(self, sample: int, w: int) -> int: return int((sample - self._view_start) / view_len * w) def _x_to_sample(self, x: float, w: int) -> int: - """Convert a pixel x coordinate to a sample index within the view.""" view_len = self._view_end - self._view_start if w <= 0 or view_len <= 0: return 0 - sample = self._view_start + int(x / w * view_len) - return max(0, min(sample, self._total_samples - 1)) + return max(0, min(self._view_start + int(x / w * view_len), + self._total_samples - 1)) + + def _make_wf_ctx(self, x0: int, draw_w: int, draw_h: int) -> WaveformRenderCtx: + return WaveformRenderCtx( + x0=x0, draw_w=draw_w, draw_h=draw_h, + margin_right=self._MARGIN_RIGHT, + view_start=self._view_start, view_end=self._view_end, + vscale=self._vscale, + channels=self._channels, num_channels=self._num_channels, + show_rms_lr=self._show_rms_lr, show_rms_avg=self._show_rms_avg, + show_markers=self._show_markers, + wf_antialias=self._wf_antialias, wf_line_width=self._wf_line_width, + ) + + def _make_spec_ctx(self, x0: int, draw_w: int, draw_h: int) -> SpecRenderCtx: + return SpecRenderCtx( + x0=x0, draw_w=draw_w, draw_h=draw_h, + view_start=self._view_start, view_end=self._view_end, + total_samples=self._total_samples, samplerate=self._samplerate, + ) + + # ── paintEvent ───────────────────────────────────────────────────────── def paintEvent(self, event): w = self.width() @@ -652,7 +202,6 @@ def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.Antialiasing, True) - # Background painter.fillRect(0, 0, w, h, QColor(COLORS["bg"])) if self._loading: @@ -670,40 +219,41 @@ def paintEvent(self, event): x0, draw_w = self._draw_area() draw_h = h - self._MARGIN_BOTTOM - # Dispatch to mode-specific painter if self._display_mode == "spectrogram": - self._paint_spectrogram(painter, x0, draw_w, draw_h) + self._spec_renderer.paint(painter, self._make_spec_ctx(x0, draw_w, draw_h)) else: - self._paint_waveform(painter, x0, draw_w, draw_h) - - # --- Issue overlays (shared, drawn on top of waveform/spectrogram) --- - self._draw_issue_overlays(painter, x0, draw_w, draw_h) - - # --- Time scale (shared) --- - self._draw_time_scale(painter, x0, draw_w, draw_h) + self._wf_renderer.paint(painter, self._make_wf_ctx(x0, draw_w, draw_h)) + + draw_issue_overlays( + painter, x0, draw_w, draw_h, + self._view_start, self._view_end, self._total_samples, + self._issues, self._enabled_overlays, + self._display_mode, self._num_channels, + self._spec_renderer.mel_view_min, self._spec_renderer.mel_view_max, + ) + draw_time_scale(painter, x0, draw_w, draw_h, + self._view_start, self._view_end, self._samplerate) - # Playback cursor — 2D crosshair (shared, spans all channels) + # Playback cursor if self._total_samples > 0: cursor_x = x0 + self._sample_to_x(self._cursor_sample, draw_w) if x0 <= cursor_x <= x0 + draw_w: painter.setPen(QPen(QColor("#ffffff"), 1)) painter.drawLine(cursor_x, 0, cursor_x, int(draw_h)) - # Horizontal crosshair line at stored y value if self._cursor_y_value is not None: cursor_y = -1 cursor_label = "" if self._display_mode == "spectrogram": - mel_range = self._mel_view_max - self._mel_view_min + mel_min = self._spec_renderer.mel_view_min + mel_max = self._spec_renderer.mel_view_max + mel_range = mel_max - mel_min if mel_range > 0 and draw_h > 0: - frac = ((self._cursor_y_value - self._mel_view_min) - / mel_range) + frac = (self._cursor_y_value - mel_min) / mel_range cursor_y = int(draw_h * (1.0 - frac)) freq = _mel_to_hz(self._cursor_y_value) - if freq >= 1000: - cursor_label = f"{freq / 1000:.1f} kHz" - else: - cursor_label = f"{freq:.0f} Hz" + cursor_label = (f"{freq / 1000:.1f} kHz" if freq >= 1000 + else f"{freq:.0f} Hz") else: nch = self._num_channels if nch > 0 and draw_h > 0: @@ -713,16 +263,11 @@ def paintEvent(self, event): scale = (lane_h / 2.0) * 0.85 * self._vscale cursor_y = int(mid_y - self._cursor_y_value * scale) amp = abs(self._cursor_y_value) - if amp > 0: - cursor_label = f"{20.0 * np.log10(amp):.1f} dBFS" - else: - cursor_label = "-\u221e dBFS" - + cursor_label = (f"{20.0 * np.log10(amp):.1f} dBFS" + if amp > 0 else "-\u221e dBFS") if 0 <= cursor_y <= int(draw_h): - h_pen = QPen(QColor(255, 255, 255, 80), 1, Qt.DotLine) - painter.setPen(h_pen) + painter.setPen(QPen(QColor(255, 255, 255, 80), 1, Qt.DotLine)) painter.drawLine(x0, cursor_y, x0 + draw_w, cursor_y) - if cursor_label: painter.setFont(QFont("Consolas", 7)) painter.setPen(QColor(255, 255, 255, 180)) @@ -736,7 +281,7 @@ def paintEvent(self, event): ly = cursor_y + cfm.ascent() + 4 painter.drawText(int(lx), int(ly), cursor_label) - # --- Crosshair mouse guide (shared vertical, mode-specific readout) --- + # Crosshair mouse guide if self._mouse_y >= 0: mx = self._mouse_x my = self._mouse_y @@ -745,8 +290,6 @@ def paintEvent(self, event): painter.drawLine(0, my, w, my) if mx >= 0: painter.drawLine(mx, 0, mx, int(draw_h)) - - # Time label at top of vertical guide sample = self._x_to_sample(mx - x0, draw_w) if self._samplerate > 0: secs = sample / self._samplerate @@ -754,711 +297,34 @@ def paintEvent(self, event): s = secs - m * 60 time_label = f"{m}:{s:05.2f} ({sample:,})" painter.setFont(QFont("Consolas", 7)) - time_color = QColor(200, 200, 200, 180) - painter.setPen(time_color) + painter.setPen(QColor(200, 200, 200, 180)) tfm = painter.fontMetrics() ttw = tfm.horizontalAdvance(time_label) - lx = mx - ttw // 2 - lx = max(x0, min(lx, x0 + draw_w - ttw)) + lx = max(x0, min(mx - ttw // 2, x0 + draw_w - ttw)) painter.drawText(int(lx), tfm.ascent() + 2, time_label) if self._display_mode == "spectrogram": - self._draw_freq_guide(painter, x0, draw_w, draw_h, my) + self._spec_renderer.draw_freq_guide( + painter, self._make_spec_ctx(x0, draw_w, draw_h), my) else: nch = self._num_channels if nch > 0: lane_h = draw_h / nch - self._draw_db_guide(painter, x0, draw_w, draw_h, - nch, lane_h, my) + self._wf_renderer.draw_db_guide( + painter, self._make_wf_ctx(x0, draw_w, draw_h), + nch, lane_h, my) painter.end() - def _draw_db_guide(self, painter, x0, draw_w, draw_h, nch, lane_h, my): - """Draw dBFS readout labels at mouse y position.""" - mouse_ch = int(my / lane_h) if lane_h > 0 else 0 - mouse_ch = max(0, min(mouse_ch, nch - 1)) - ch_y_off = mouse_ch * lane_h - ch_mid_y = ch_y_off + lane_h / 2.0 - ch_scale = (lane_h / 2.0) * 0.85 * self._vscale - - if ch_scale > 0: - amp = abs(ch_mid_y - my) / ch_scale - if amp > 0: - db_val = 20.0 * np.log10(amp) - db_label = f"{db_val:.1f}" - else: - db_label = "-\u221e" - painter.setFont(QFont("Consolas", 7)) - label_color = QColor(180, 180, 180, 120) - painter.setPen(label_color) - fm = painter.fontMetrics() - tw = fm.horizontalAdvance(db_label) - painter.drawText(x0 - 5 - tw, int(ch_y_off) + fm.ascent() + 1, - db_label) - painter.drawText(x0 + draw_w + 5, int(ch_y_off) + fm.ascent() + 1, - db_label) - - def _paint_waveform(self, painter, x0, draw_w, draw_h): - """Paint the waveform display with channels, overlays, RMS, and markers.""" - # AA for waveform paths — user-configurable (default off for perf) - painter.setRenderHint(QPainter.Antialiasing, self._wf_antialias) - w = self.width() - self._build_peaks(draw_w) - if self._show_rms_lr or self._show_rms_avg: - self._build_rms_envelope(draw_w) - - nch = self._num_channels - lane_h = draw_h / nch - - # --- dB scale and grid lines --- - self._draw_db_scale(painter, x0, draw_w, draw_h, nch, lane_h) - - # --- Draw waveforms --- - for ch in range(nch): - y_off = ch * lane_h - mid_y = y_off + lane_h / 2.0 - scale = (lane_h / 2.0) * 0.85 * self._vscale - - lane_top = int(y_off) - lane_bot = int(y_off + lane_h) - painter.setClipRect(x0, lane_top, draw_w, lane_bot - lane_top) - - color = QColor(self._CHANNEL_COLORS[ch % len(self._CHANNEL_COLORS)]) - mins, maxs = self._peaks_cache[ch] - - # Build x/y arrays in numpy, then construct QPolygonF once - n_pts = len(mins) - xs = np.arange(n_pts, dtype=np.float64) + x0 - ys_top = mid_y - maxs * scale - ys_bot = mid_y - mins * scale - - # Filled envelope: top L→R then bottom R→L - env_x = np.concatenate([xs, xs[::-1]]) - env_y = np.concatenate([ys_top, ys_bot[::-1]]) - env_poly = QPolygonF([QPointF(env_x[i], env_y[i]) - for i in range(len(env_x))]) - - grad = QLinearGradient(0, y_off, 0, y_off + lane_h) - view_len = self._view_end - self._view_start - spp = view_len / max(draw_w, 1) # samples per pixel - color_edge = QColor(color) - color_edge.setAlpha(30) - color_mid = QColor(color) - color_mid.setAlpha(140) - grad.setColorAt(0.0, color_edge) - grad.setColorAt(0.5, color_mid) - grad.setColorAt(1.0, color_edge) - - # Solid dark fill first to occlude grid lines behind - painter.setPen(Qt.NoPen) - painter.setBrush(QColor(COLORS["bg"])) - painter.drawPolygon(env_poly) - # Gradient on top - painter.setBrush(grad) - painter.drawPolygon(env_poly) - - # Outline polylines — softer when zoomed out - outline_alpha = max(100, min(200, int(200 - spp * 0.1))) - outline = QColor(color) - outline.setAlpha(outline_alpha) - painter.setBrush(Qt.NoBrush) - painter.setPen(QPen(outline, self._wf_line_width)) - top_poly = QPolygonF([QPointF(xs[i], ys_top[i]) - for i in range(n_pts)]) - bot_poly = QPolygonF([QPointF(xs[i], ys_bot[i]) - for i in range(n_pts)]) - painter.drawPolyline(top_poly) - painter.drawPolyline(bot_poly) - - center_color = QColor(160, 100, 220, 160) # violet - painter.setPen(QPen(center_color, 2, Qt.DotLine)) - painter.drawLine(x0, int(mid_y), x0 + draw_w, int(mid_y)) - - painter.setClipping(False) - - if ch < nch - 1: - sep_y = int(y_off + lane_h) - painter.setPen(QPen(QColor("#555555"), 1)) - painter.drawLine(0, sep_y, w, sep_y) - - # --- RMS overlay --- - if (self._show_rms_lr or self._show_rms_avg) and self._rms_envelope: - lw = self._wf_line_width - ch_pen = QPen(QColor(255, 220, 60, 200), float(lw)) - comb_pen = QPen(QColor(255, 100, 40, 220), float(lw) * 1.5) - for ch in range(nch): - if ch >= len(self._rms_envelope): - break - y_off = ch * lane_h - mid_y = y_off + lane_h / 2.0 - scale = (lane_h / 2.0) * 0.85 * self._vscale - lane_top = int(y_off) - painter.setClipRect(x0, lane_top, draw_w, int(lane_h)) - painter.setBrush(Qt.NoBrush) - - if self._show_rms_lr: - ch_env = self._rms_envelope[ch] - n_rms = len(ch_env) - rxs = np.arange(n_rms, dtype=np.float64) + x0 - rys = mid_y - ch_env * scale - painter.setPen(ch_pen) - painter.drawPolyline(QPolygonF( - [QPointF(rxs[i], rys[i]) for i in range(n_rms)])) - - if self._show_rms_avg and len(self._rms_combined) > 0: - n_comb = len(self._rms_combined) - cxs = np.arange(n_comb, dtype=np.float64) + x0 - cys = mid_y - self._rms_combined * scale - painter.setPen(comb_pen) - painter.drawPolyline(QPolygonF( - [QPointF(cxs[i], cys[i]) for i in range(n_comb)])) - - painter.setClipping(False) - - # Re-enable AA for markers and text - painter.setRenderHint(QPainter.Antialiasing, True) - - # --- Peak and RMS max markers --- - if self._show_markers: - self._draw_markers(painter, x0, draw_w, draw_h, nch, lane_h) - - def _paint_spectrogram(self, painter, x0, draw_w, draw_h): - """Paint the spectrogram display with mel-scale frequency axis.""" - if self._spec_db is None: - painter.setPen(QPen(QColor(COLORS["dim"]))) - painter.drawText(x0, int(draw_h / 2), - "Spectrogram not available (audio too short)") - return - - # Build or reuse cached spectrogram image - cache_key = (self._view_start, self._view_end, draw_w, - int(draw_h), self._colormap, - self._mel_view_min, self._mel_view_max, - self._spec_db_floor, self._spec_db_ceil) - if self._spec_cache_key != cache_key or self._spec_image is None: - self._build_spec_image(draw_w, int(draw_h)) - self._spec_cache_key = cache_key - - if self._spec_image is not None: - painter.drawImage(x0, 0, self._spec_image) - - # Frequency scale - self._draw_freq_scale(painter, x0, draw_w, int(draw_h)) - - def _build_spec_image(self, width: int, height: int): - """Render the visible portion of the spectrogram to a cached QImage.""" - spec = self._spec_db - if spec is None or width <= 0 or height <= 0: - self._spec_image = None - return - - n_mels, n_frames = spec.shape - - # Map view range to spectrogram frame indices - frame_start = max(0, self._view_start * n_frames // self._total_samples) - frame_end = min(n_frames, self._view_end * n_frames // self._total_samples) - if frame_end <= frame_start: - frame_end = min(frame_start + 1, n_frames) - - # Slice mel rows by frequency view range - mel_full_min = _hz_to_mel(_SPEC_F_MIN) - mel_full_max = _hz_to_mel(min(_SPEC_F_MAX, self._samplerate / 2.0)) - mel_full_range = mel_full_max - mel_full_min - if mel_full_range <= 0: - self._spec_image = None - return - row_lo = int((self._mel_view_min - mel_full_min) - / mel_full_range * (n_mels - 1)) - row_hi = int(np.ceil((self._mel_view_max - mel_full_min) - / mel_full_range * (n_mels - 1))) - row_lo = max(0, min(row_lo, n_mels - 1)) - row_hi = max(row_lo + 1, min(row_hi + 1, n_mels)) - - view_spec = spec[row_lo:row_hi, frame_start:frame_end] - - # Normalize to 0..1 (clamp to dB floor..ceiling) - db_floor = self._spec_db_floor - db_ceil = self._spec_db_ceil - norm = np.clip((view_spec - db_floor) / max(db_ceil - db_floor, 1.0), - 0.0, 1.0) - - # Flip vertically (low freq at bottom) - norm = norm[::-1, :] - - # Apply colormap at native spectrogram resolution - lut = SPECTROGRAM_COLORMAPS.get(self._colormap) - if lut is None: - lut = SPECTROGRAM_COLORMAPS.get("magma", np.zeros((256, 4), np.uint8)) - indices = (norm * 255).astype(np.uint8) - rgba = lut[indices] # (n_mels, view_frames, 4) - - # Build QImage at native resolution, then smooth-scale to display size - nat_h, nat_w = rgba.shape[:2] - rgba_c = np.ascontiguousarray(rgba) - self._spec_image_data = rgba_c # prevent garbage collection - native_img = QImage( - rgba_c.data, nat_w, nat_h, nat_w * 4, - QImage.Format.Format_RGBA8888, - ) - self._spec_image = native_img.scaled( - width, height, Qt.IgnoreAspectRatio, Qt.SmoothTransformation, - ) - - def _draw_freq_scale(self, painter, x0, draw_w, draw_h): - """Draw frequency scale on left/right margins for spectrogram mode.""" - if self._spec_db is None or draw_h <= 0: - return - - _FREQ_TICKS = [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000] - _MIN_TICK_SPACING = 20 - - mel_min = self._mel_view_min - mel_max = self._mel_view_max - mel_range = mel_max - mel_min - if mel_range <= 0: - return - - scale_font = QFont("Consolas", 7) - painter.setFont(scale_font) - fm = painter.fontMetrics() - - label_color = QColor(COLORS["dim"]) - tick_pen = QPen(label_color, 1) - - grid_color = QColor(COLORS["accent"]) - grid_color.setAlpha(35) - grid_pen = QPen(grid_color, 1, Qt.DotLine) - - text_h = fm.height() - used_ys: list[int] = [] - for freq in _FREQ_TICKS: - mel = _hz_to_mel(freq) - if mel < mel_min or mel > mel_max: - continue - frac = (mel - mel_min) / mel_range - y = int(draw_h * (1.0 - frac)) # low freq at bottom - if y < text_h or y > draw_h - text_h: - continue - - too_close = any(abs(uy - y) < _MIN_TICK_SPACING for uy in used_ys) - if too_close: - continue - used_ys.append(y) - - # Label - if freq >= 1000: - label = f"{freq // 1000}k" - else: - label = str(freq) - - # Grid line - painter.setPen(grid_pen) - painter.drawLine(x0, y, x0 + draw_w, y) - - # Left margin - painter.setPen(tick_pen) - tw = fm.horizontalAdvance(label) - painter.drawText(x0 - 5 - tw, y + fm.ascent() // 2, label) - - # Right margin - painter.drawText(x0 + draw_w + 5, y + fm.ascent() // 2, label) - - # Tick marks - painter.drawLine(x0 - 3, y, x0, y) - painter.drawLine(x0 + draw_w, y, x0 + draw_w + 3, y) - - def _draw_freq_guide(self, painter, x0, draw_w, draw_h, my): - """Draw frequency readout at mouse position in spectrogram mode.""" - if self._spec_db is None or draw_h <= 0: - return - - mel_min = self._mel_view_min - mel_max = self._mel_view_max - mel_range = mel_max - mel_min - if mel_range <= 0: - return - - frac = 1.0 - (my / draw_h) - frac = max(0.0, min(frac, 1.0)) - mel = mel_min + frac * mel_range - freq = _mel_to_hz(mel) - - if freq >= 1000: - freq_label = f"{freq / 1000:.1f} kHz" - else: - freq_label = f"{freq:.0f} Hz" - - painter.setFont(QFont("Consolas", 7)) - label_color = QColor(180, 180, 180, 120) - painter.setPen(label_color) - fm = painter.fontMetrics() - tw = fm.horizontalAdvance(freq_label) - label_y = int(my) + fm.ascent() // 2 - # Draw inside the waveform area (labels are too wide for the margin) - painter.drawText(x0 + 4, label_y, freq_label) - painter.drawText(x0 + draw_w - tw - 4, label_y, freq_label) - - def _draw_db_scale(self, painter, x0, draw_w, h, nch, lane_h): - """Draw dB measurement scale on left/right margins and grid lines.""" - _DB_TICKS = [0, -3, -6, -12, -18, -24, -36, -48, -60] - _MIN_TICK_SPACING = 18 # minimum pixels between ticks - - scale_font = QFont("Consolas", 7) - painter.setFont(scale_font) - fm = painter.fontMetrics() - text_h = fm.height() - - grid_color = QColor(COLORS["accent"]) - grid_color.setAlpha(35) - grid_pen = QPen(grid_color, 1, Qt.DotLine) - - label_color = QColor(COLORS["dim"]) - tick_pen = QPen(label_color, 1) - - for ch in range(nch): - y_off = ch * lane_h - mid_y = y_off + lane_h / 2.0 - scale = (lane_h / 2.0) * 0.85 * self._vscale - - lane_top = int(y_off) - lane_bot = int(y_off + lane_h) - painter.setClipRect(0, lane_top, - x0 + draw_w + self._MARGIN_RIGHT, - lane_bot - lane_top) - - visible_ticks: list[tuple[int, float, float]] = [] - used_ys: list[float] = [] # all placed label y-positions - for db_val in _DB_TICKS: - amp = 10.0 ** (db_val / 20.0) - pixel_offset = amp * scale - if pixel_offset >= lane_h / 2.0: - continue # outside visible lane - y_top = mid_y - pixel_offset - y_bot = mid_y + pixel_offset - # Skip if labels would be too close to lane edges - if y_top < lane_top + text_h or y_bot > lane_bot - text_h: - continue - # Check that both y_top and y_bot are far enough from - # every previously placed label position - too_close = False - for uy in used_ys: - if abs(uy - y_top) < _MIN_TICK_SPACING: - too_close = True - break - if db_val != 0 and abs(uy - y_bot) < _MIN_TICK_SPACING: - too_close = True - break - if too_close: - continue - visible_ticks.append((db_val, y_top, y_bot)) - used_ys.append(y_top) - if db_val != 0: - used_ys.append(y_bot) - - for db_val, y_top, y_bot in visible_ticks: - label = str(db_val) - - # Horizontal grid lines across waveform area - painter.setPen(grid_pen) - painter.drawLine(x0, int(y_top), x0 + draw_w, int(y_top)) - if db_val != 0: - painter.drawLine(x0, int(y_bot), x0 + draw_w, int(y_bot)) - - # Left margin labels (right-aligned) - painter.setPen(tick_pen) - text_w = fm.horizontalAdvance(label) - lx = x0 - 5 - text_w - painter.drawText(int(lx), int(y_top + text_h / 3), label) - if db_val != 0: - painter.drawText(int(lx), int(y_bot + text_h / 3), label) - - # Right margin labels (left-aligned) - rx = x0 + draw_w + 5 - painter.drawText(int(rx), int(y_top + text_h / 3), label) - if db_val != 0: - painter.drawText(int(rx), int(y_bot + text_h / 3), label) - - # Small tick marks at edges of waveform area - painter.drawLine(x0 - 3, int(y_top), x0, int(y_top)) - painter.drawLine(x0 + draw_w, int(y_top), - x0 + draw_w + 3, int(y_top)) - if db_val != 0: - painter.drawLine(x0 - 3, int(y_bot), x0, int(y_bot)) - painter.drawLine(x0 + draw_w, int(y_bot), - x0 + draw_w + 3, int(y_bot)) - - # Thin connecting lines spanning full width (behind waveform) - conn_color = QColor(45, 45, 45) - painter.setPen(QPen(conn_color, 1)) - painter.drawLine(0, int(y_top), - x0 + draw_w + self._MARGIN_RIGHT, int(y_top)) - if db_val != 0: - painter.drawLine(0, int(y_bot), - x0 + draw_w + self._MARGIN_RIGHT, int(y_bot)) - - painter.setClipping(False) - - def _draw_issue_overlays(self, painter, x0, draw_w, draw_h): - """Draw detector issue overlays. Works in both display modes. - - In waveform mode, overlays span full height or per-channel lanes. - In spectrogram mode, overlays with frequency bounds are mapped to - the visible mel range; overlays without bounds span full height. - """ - if not self._issues or not self._enabled_overlays: - return - - nch = self._num_channels - lane_h = draw_h / max(nch, 1) - is_spec = self._display_mode == "spectrogram" - - # Precompute mel range for spectrogram frequency mapping - if is_spec: - mel_range = self._mel_view_max - self._mel_view_min - else: - mel_range = 0.0 - - for issue in self._issues: - if issue.label not in self._enabled_overlays: - continue - sev_val = issue.severity.value if hasattr(issue.severity, "value") else str(issue.severity) - fill = self._SEVERITY_OVERLAY.get(sev_val, QColor(255, 255, 255, 30)) - border = self._SEVERITY_BORDER.get(sev_val, QColor(255, 255, 255, 60)) - - # Horizontal bounds (time) — same in both modes - ix1 = x0 + self._sample_to_x(issue.sample_start, draw_w) - ix2 = (x0 + self._sample_to_x(issue.sample_end + 1, draw_w) - if issue.sample_end is not None else ix1) - rx = ix1 - rw = max(ix2 - ix1, 2) - - # Vertical bounds - if is_spec and issue.freq_min_hz is not None and issue.freq_max_hz is not None and mel_range > 0: - # Map frequency bounds to pixel y via mel scale - mel_lo = _hz_to_mel(issue.freq_min_hz) - mel_hi = _hz_to_mel(issue.freq_max_hz) - # y=0 is top (high freq), y=draw_h is bottom (low freq) - frac_top = (mel_hi - self._mel_view_min) / mel_range - frac_bot = (mel_lo - self._mel_view_min) / mel_range - y_top = int(draw_h * (1.0 - frac_top)) - y_bot = int(draw_h * (1.0 - frac_bot)) - # Clamp to visible area - y_top = max(0, min(y_top, int(draw_h))) - y_bot = max(0, min(y_bot, int(draw_h))) - if y_top >= y_bot: - continue # entirely outside visible freq range - ry = y_top - rh = y_bot - y_top - elif not is_spec: - # Waveform mode: per-channel or full height - if issue.channel is None: - ry = 0 - rh = int(draw_h) - else: - ch = issue.channel - if ch < nch: - ry = int(ch * lane_h) - rh = int(lane_h) - else: - continue - else: - # Spectrogram mode, no frequency bounds — full height - ry = 0 - rh = int(draw_h) - - painter.fillRect(rx, ry, rw, rh, fill) - painter.setPen(QPen(border, 1)) - painter.drawRect(rx, ry, rw, rh) - - def _draw_time_scale(self, painter, x0, draw_w, draw_h): - """Draw horizontal time axis with adaptive tick labels below the waveform.""" - if self._samplerate <= 0 or draw_w <= 0: - return - - view_start_sec = self._view_start / self._samplerate - view_end_sec = self._view_end / self._samplerate - visible_dur = view_end_sec - view_start_sec - if visible_dur <= 0: - return - - _NICE_INTERVALS = [ - 0.001, 0.002, 0.005, - 0.01, 0.02, 0.05, - 0.1, 0.2, 0.5, - 1, 2, 5, 10, 15, 30, - 60, 120, 300, 600, 1800, 3600, - ] - _MIN_TICK_PX = 60 - - # Pick smallest nice interval that keeps ticks ≥ _MIN_TICK_PX apart - interval = _NICE_INTERVALS[-1] - for ni in _NICE_INTERVALS: - px_per_tick = ni / visible_dur * draw_w - if px_per_tick >= _MIN_TICK_PX: - interval = ni - break - - # Determine label format based on tick interval - if interval >= 1.0: - def _fmt(t): - m = int(t) // 60 - s = int(t) % 60 - return f"{m}:{s:02d}" - elif interval >= 0.1: - def _fmt(t): - m = int(t) // 60 - s = t - m * 60 - return f"{m}:{s:04.1f}" - elif interval >= 0.01: - def _fmt(t): - m = int(t) // 60 - s = t - m * 60 - return f"{m}:{s:05.2f}" - else: - def _fmt(t): - m = int(t) // 60 - s = t - m * 60 - return f"{m}:{s:06.3f}" - - scale_font = QFont("Consolas", 7) - painter.setFont(scale_font) - fm = painter.fontMetrics() - - label_color = QColor(COLORS["dim"]) - tick_pen = QPen(label_color, 1) - - grid_color = QColor(COLORS["accent"]) - grid_color.setAlpha(25) - grid_pen = QPen(grid_color, 1, Qt.DotLine) - - # First tick at or after view_start, aligned to interval - first_tick = (int(view_start_sec / interval) + 1) * interval - if abs(view_start_sec / interval - round(view_start_sec / interval)) < 1e-9: - first_tick = round(view_start_sec / interval) * interval - - t = first_tick - bottom_y = int(draw_h) - while t <= view_end_sec + interval * 0.01: - frac = (t - view_start_sec) / visible_dur - px = x0 + int(frac * draw_w) - - if px < x0 or px > x0 + draw_w: - t += interval - continue - - # Vertical grid line through waveform area - painter.setPen(grid_pen) - painter.drawLine(px, 0, px, bottom_y) - - # Tick mark - painter.setPen(tick_pen) - painter.drawLine(px, bottom_y, px, bottom_y + 4) - - # Label - label = _fmt(t) - tw = fm.horizontalAdvance(label) - lx = px - tw // 2 - ly = bottom_y + 4 + fm.ascent() - painter.drawText(int(lx), int(ly), label) - - t += interval - - def _ensure_peak_computed(self): - """Lazily compute peak sample position on first demand.""" - if not self._peak_dirty: - return - self._peak_dirty = False - if not self._channels: - return - if self._num_channels == 1: - self._peak_sample = int(np.argmax(np.abs(self._channels[0]))) - self._peak_channel = 0 - else: - abs_cols = np.column_stack([np.abs(ch) for ch in self._channels]) - max_per_sample = np.max(abs_cols, axis=1) - self._peak_sample = int(np.argmax(max_per_sample)) - self._peak_channel = int( - np.argmax(abs_cols[self._peak_sample]) - ) - peak_lin = abs(float(self._channels[self._peak_channel][self._peak_sample])) - self._peak_db = 20.0 * np.log10(peak_lin) if peak_lin > 0 else float('-inf') - self._peak_amplitude = float( - self._channels[self._peak_channel][self._peak_sample] - ) - - def _ensure_rms_max_computed(self): - """Lazily compute RMS max sample position on first demand.""" - if not self._rms_max_dirty: - return - self._rms_max_dirty = False - self._compute_rms_max_sample() - - def _draw_markers(self, painter, x0, draw_w, h, nch, lane_h): - """Draw peak and max RMS marker vertical lines.""" - self._ensure_peak_computed() - self._ensure_rms_max_computed() - - marker_font = QFont("Consolas", 7, QFont.Bold) - _CROSS_HALF = 6 # half-width of horizontal crosshair - - # Peak marker (magenta, solid) - if self._peak_sample >= 0: - px = x0 + self._sample_to_x(self._peak_sample, draw_w) - if x0 <= px <= x0 + draw_w: - peak_color = QColor(180, 50, 220, 250) - painter.setPen(QPen(peak_color, 1)) - painter.drawLine(px, 0, px, h) - painter.setFont(marker_font) - painter.setPen(peak_color) - painter.drawText(px + 3, 12, "P") - - # Horizontal crosshair at peak amplitude on the peak channel - if 0 <= self._peak_channel < nch: - painter.setPen(QPen(peak_color, 1)) - ch = self._peak_channel - amp = self._peak_amplitude - y_off = ch * lane_h - mid_y = y_off + lane_h / 2.0 - scale = (lane_h / 2.0) * 0.85 * self._vscale - cy = int(mid_y - amp * scale) - painter.drawLine(px - _CROSS_HALF, cy, - px + _CROSS_HALF, cy) - - # Max RMS marker (cyan, solid) - if self._rms_max_sample >= 0: - rx = x0 + self._sample_to_x(self._rms_max_sample, draw_w) - if x0 <= rx <= x0 + draw_w: - rms_color = QColor(40, 160, 220, 250) - painter.setPen(QPen(rms_color, 1)) - painter.drawLine(rx, 0, rx, h) - painter.setFont(marker_font) - painter.setPen(rms_color) - painter.drawText(rx + 3, 24, "R") - - # Horizontal crosshair at RMS amplitude (positive side only) - amp = self._rms_max_amplitude - if amp > 0: - painter.setPen(QPen(rms_color, 1)) - for ch in range(nch): - y_off = ch * lane_h - mid_y = y_off + lane_h / 2.0 - scale = (lane_h / 2.0) * 0.85 * self._vscale - cy = int(mid_y - amp * scale) - painter.drawLine(rx - _CROSS_HALF, cy, - rx + _CROSS_HALF, cy) + # ── Qt event handlers ────────────────────────────────────────────────── def resizeEvent(self, event): - self._peaks_cache = [] - self._cached_view = (0, 0, 0) - self._rms_envelope = [] - self._rms_combined = [] - self._rms_cache_key = (0, 0, 0) - self._spec_image = None - self._spec_cache_key = () + self._wf_renderer.invalidate() + self._spec_renderer.invalidate() super().resizeEvent(event) def mousePressEvent(self, event): - self.setFocus() # grab keyboard focus for R/T shortcuts + self.setFocus() if self._total_samples > 0 and event.button() == Qt.LeftButton: x0, draw_w = self._draw_area() h = self.height() @@ -1466,13 +332,13 @@ def mousePressEvent(self, event): my = event.position().y() sample = self._x_to_sample(event.position().x() - x0, draw_w) self._cursor_sample = sample - - # Compute semantic y value if self._display_mode == "spectrogram": - mel_range = self._mel_view_max - self._mel_view_min + mel_min = self._spec_renderer.mel_view_min + mel_max = self._spec_renderer.mel_view_max + mel_range = mel_max - mel_min if draw_h > 0 and mel_range > 0: frac = max(0.0, min(1.0 - my / draw_h, 1.0)) - self._cursor_y_value = self._mel_view_min + frac * mel_range + self._cursor_y_value = mel_min + frac * mel_range else: self._cursor_y_value = None else: @@ -1490,7 +356,6 @@ def mousePressEvent(self, event): self._cursor_y_value = None else: self._cursor_y_value = None - self.update() self.position_clicked.emit(sample) @@ -1498,12 +363,10 @@ def mouseMoveEvent(self, event): """Show tooltip when hovering over an issue region or marker.""" self._mouse_x = int(event.position().x()) self._mouse_y = int(event.position().y()) - self.update() # repaint for crosshair guide - + self.update() if self._total_samples <= 0: QToolTip.hideText() return - x0, draw_w = self._draw_area() h = self.height() draw_h = h - self._MARGIN_BOTTOM @@ -1511,49 +374,32 @@ def mouseMoveEvent(self, event): my = event.position().y() nch = self._num_channels lane_h = draw_h / nch if nch > 0 else draw_h - - # Convert mouse x to sample position sample = self._x_to_sample(mx - x0, draw_w) - # Determine which channel lane the mouse is in - mouse_ch = int(my / lane_h) if lane_h > 0 else 0 - mouse_ch = max(0, min(mouse_ch, nch - 1)) - - # Hit tolerance: at least 5 pixels worth of samples on each side + mouse_ch = max(0, min(int(my / lane_h) if lane_h > 0 else 0, nch - 1)) view_len = self._view_end - self._view_start - samples_per_px = view_len / max(draw_w, 1) - tolerance = int(samples_per_px * 5) - + tolerance = int(view_len / max(draw_w, 1) * 5) tips: list[str] = [] - - # Marker tooltips (waveform mode only) if self._display_mode == "waveform": _MARKER_PX_TOL = 6 - if self._show_markers and self._peak_sample >= 0: - peak_px = x0 + self._sample_to_x(self._peak_sample, draw_w) + if self._show_markers and self._wf_renderer.peak_sample >= 0: + peak_px = x0 + self._sample_to_x(self._wf_renderer.peak_sample, draw_w) if abs(mx - peak_px) <= _MARKER_PX_TOL: - tips.append(f"Peak: {self._peak_db:.1f} dBFS") - if self._show_markers and self._rms_max_sample >= 0: - rms_px = x0 + self._sample_to_x(self._rms_max_sample, draw_w) + tips.append(f"Peak: {self._wf_renderer.peak_db:.1f} dBFS") + if self._show_markers and self._wf_renderer.rms_max_sample >= 0: + rms_px = x0 + self._sample_to_x(self._wf_renderer.rms_max_sample, draw_w) if abs(mx - rms_px) <= _MARKER_PX_TOL: - tips.append(f"Max RMS: {self._rms_max_db:.1f} dBFS") - - # Issue tooltips (both modes, only for enabled overlays) + tips.append(f"Max RMS: {self._wf_renderer.rms_max_db:.1f} dBFS") for issue in self._issues: if issue.label not in self._enabled_overlays: continue s_start = issue.sample_start s_end = issue.sample_end if issue.sample_end is not None else s_start - # Expand narrow regions by tolerance for easier hit-testing - hit_start = s_start - tolerance - hit_end = s_end + tolerance - if sample < hit_start or sample > hit_end: + if sample < s_start - tolerance or sample > s_end + tolerance: continue - # In waveform mode check channel match; in spectrogram mode skip channel check if self._display_mode == "waveform": if issue.channel is not None and issue.channel != mouse_ch: continue tips.append(issue.description) - if tips: QToolTip.showText(event.globalPosition().toPoint(), "\n".join(tips), self) else: @@ -1566,69 +412,45 @@ def leaveEvent(self, event): super().leaveEvent(event) def wheelEvent(self, event): - """Mouse-wheel navigation. - - Ctrl + wheel — horizontal zoom (centered on pointer) - Ctrl + Shift + wheel — vertical zoom - Shift + Alt + wheel — scroll up / down (frequency pan, spectrogram) - Shift + wheel — scroll left / right - """ if self._total_samples <= 0: event.ignore() return - mods = event.modifiers() - delta = event.angleDelta().y() - if delta == 0: - delta = event.angleDelta().x() + delta = event.angleDelta().y() or event.angleDelta().x() if delta == 0: event.ignore() return - ctrl = bool(mods & Qt.ControlModifier) shift = bool(mods & Qt.ShiftModifier) alt = bool(mods & Qt.AltModifier) - if ctrl and shift: - # ── Vertical zoom ───────────────────────────────────────── if self._display_mode == "spectrogram": - # Frequency zoom anchored at mouse cursor draw_h = self.height() - self._MARGIN_BOTTOM my = event.position().y() - mel_range = self._mel_view_max - self._mel_view_min + mel_range = self._spec_renderer.mel_view_max - self._spec_renderer.mel_view_min anchor_mel = None if draw_h > 0 and mel_range > 0: frac = max(0.0, min(1.0 - my / draw_h, 1.0)) - anchor_mel = self._mel_view_min + frac * mel_range - factor = 2 / 3 if delta > 0 else 3 / 2 - self._freq_zoom(factor, anchor_mel) + anchor_mel = self._spec_renderer.mel_view_min + frac * mel_range + self._spec_renderer.freq_zoom(2 / 3 if delta > 0 else 3 / 2, + anchor_mel, self._samplerate) else: - if delta > 0: - self._vscale = min(self._vscale * 1.25, 20.0) - else: - self._vscale = max(self._vscale / 1.25, 0.1) + self._vscale = (min(self._vscale * 1.25, 20.0) if delta > 0 + else max(self._vscale / 1.25, 0.1)) self.update() event.accept() - elif ctrl: - # ── Horizontal zoom (centered on pointer) ───────────────── x0, draw_w = self._draw_area() mx = event.position().x() frac = max(0.0, min((mx - x0) / max(draw_w, 1), 1.0)) - anchor_sample = self._x_to_sample(mx - x0, draw_w) anchor_sample = max(self._view_start, - min(anchor_sample, self._view_end)) - + min(self._x_to_sample(mx - x0, draw_w), self._view_end)) view_len = self._view_end - self._view_start - if delta > 0: - new_len = max(view_len * 2 // 3, 100) - else: - new_len = min(view_len * 3 // 2, self._total_samples) - + new_len = (max(view_len * 2 // 3, 100) if delta > 0 + else min(view_len * 3 // 2, self._total_samples)) if new_len == view_len: event.accept() return - new_start = int(anchor_sample - frac * new_len) new_end = new_start + new_len if new_start < 0: @@ -1639,42 +461,25 @@ def wheelEvent(self, event): new_start = max(0, new_end - new_len) self._view_start = new_start self._view_end = new_end - self._invalidate_peaks() + self._wf_renderer.invalidate() self.update() event.accept() - elif shift and alt: - # ── Scroll up / down (frequency pan, spectrogram only) ─── if self._display_mode == "spectrogram": - mel_range = self._mel_view_max - self._mel_view_min - mel_full_min = _hz_to_mel(_SPEC_F_MIN) - mel_full_max = _hz_to_mel(min(_SPEC_F_MAX, self._samplerate / 2.0)) + mel_range = self._spec_renderer.mel_view_max - self._spec_renderer.mel_view_min scroll = mel_range / 8 if delta < 0: scroll = -scroll if self._invert_v: scroll = -scroll - new_min = self._mel_view_min + scroll - new_max = self._mel_view_max + scroll - if new_min < mel_full_min: - new_min = mel_full_min - new_max = new_min + mel_range - if new_max > mel_full_max: - new_max = mel_full_max - new_min = new_max - mel_range - self._mel_view_min = max(new_min, mel_full_min) - self._mel_view_max = min(new_max, mel_full_max) - self._spec_image = None - self._spec_cache_key = () + self._spec_renderer.scroll_freq(scroll, self._samplerate) self.update() event.accept() - elif shift: - # ── Scroll left / right ─────────────────────────────────── view_len = self._view_end - self._view_start scroll_amount = max(1, view_len // 8) if delta < 0: - scroll_amount = -scroll_amount # scroll left + scroll_amount = -scroll_amount if self._invert_h: scroll_amount = -scroll_amount new_start = self._view_start + scroll_amount @@ -1687,26 +492,19 @@ def wheelEvent(self, event): new_start = max(0, new_end - view_len) self._view_start = new_start self._view_end = new_end - self._invalidate_rms_only() + self._wf_renderer.invalidate_rms_only() if not self._scroll_pending: self._scroll_pending = True self._scroll_timer.start() event.accept() - else: event.ignore() def _flush_scroll(self): - """Coalesce rapid scroll events into a single repaint.""" self._scroll_pending = False self.update() def keyPressEvent(self, event): - """DAW keyboard shortcuts: R = zoom in, T = zoom out. - - When the mouse is hovering over the waveform, zoom is centered on - the mouse guide position. Otherwise falls back to cursor position. - """ key = event.key() if key == Qt.Key_R: self._zoom_at_guide(zoom_in=True) @@ -1716,30 +514,22 @@ def keyPressEvent(self, event): super().keyPressEvent(event) def _zoom_at_guide(self, zoom_in: bool): - """Zoom centered on mouse guide position (or cursor if not hovering).""" if self._total_samples <= 0: return view_len = self._view_end - self._view_start - x0, draw_w = self._draw_area() if self._mouse_x >= 0: - # Mouse is hovering — use guide position frac = max(0.0, min((self._mouse_x - x0) / max(draw_w, 1), 1.0)) - anchor = self._x_to_sample(self._mouse_x - x0, draw_w) - anchor = max(self._view_start, min(anchor, self._view_end)) + anchor = max(self._view_start, + min(self._x_to_sample(self._mouse_x - x0, draw_w), + self._view_end)) else: - # Not hovering — fall back to cursor anchor = max(self._view_start, min(self._cursor_sample, self._view_end)) frac = (anchor - self._view_start) / max(view_len, 1) - - if zoom_in: - new_len = max(view_len * 2 // 3, 100) - else: - new_len = min(view_len * 3 // 2, self._total_samples) - + new_len = (max(view_len * 2 // 3, 100) if zoom_in + else min(view_len * 3 // 2, self._total_samples)) if new_len == view_len: return - new_start = int(anchor - frac * new_len) new_end = new_start + new_len if new_start < 0: @@ -1750,33 +540,18 @@ def _zoom_at_guide(self, zoom_in: bool): new_start = max(0, new_end - new_len) self._view_start = new_start self._view_end = new_end - self._invalidate_peaks() + self._wf_renderer.invalidate() self.update() - # ── Zoom / vertical-scale public API ────────────────────────────────── - - def _invalidate_peaks(self): - self._peaks_cache = [] - self._cached_view = (0, 0, 0) - self._rms_envelope = [] - self._rms_combined = [] - self._rms_cache_key = (0, 0, 0) - - def _invalidate_rms_only(self): - """Invalidate RMS envelope cache but keep peaks for incremental updates.""" - self._rms_envelope = [] - self._rms_combined = [] - self._rms_cache_key = (0, 0, 0) + # ── Zoom / vertical-scale public API ─────────────────────────────────── def zoom_fit(self): """Reset horizontal zoom and vertical scale to show the entire file.""" self._view_start = 0 self._view_end = self._total_samples self._vscale = 1.0 - self._mel_view_min = _hz_to_mel(_SPEC_F_MIN) - self._mel_view_max = _hz_to_mel(min(_SPEC_F_MAX, - self._samplerate / 2.0)) - self._invalidate_peaks() + self._spec_renderer.reset_freq_view(self._samplerate) + self._wf_renderer.invalidate() self.update() def zoom_in(self): @@ -1796,7 +571,7 @@ def zoom_in(self): new_start = max(0, new_end - new_len) self._view_start = new_start self._view_end = new_end - self._invalidate_peaks() + self._wf_renderer.invalidate() self.update() def zoom_out(self): @@ -1816,323 +591,107 @@ def zoom_out(self): new_start = max(0, new_end - new_len) self._view_start = new_start self._view_end = new_end - self._invalidate_peaks() + self._wf_renderer.invalidate() self.update() def scale_up(self): - """Increase vertical amplitude scale / zoom freq in spectrogram.""" + """Increase vertical amplitude scale / zoom freq in (spectrogram).""" if self._display_mode == "spectrogram": - anchor = self._cursor_y_value if self._cursor_y_value is not None else None - self._freq_zoom(2 / 3, anchor) + self._spec_renderer.freq_zoom( + 2 / 3, self._cursor_y_value, self._samplerate) else: self._vscale = min(self._vscale * 1.5, 20.0) self.update() def scale_down(self): - """Decrease vertical amplitude scale / zoom freq out spectrogram.""" + """Decrease vertical amplitude scale / zoom freq out (spectrogram).""" if self._display_mode == "spectrogram": - anchor = self._cursor_y_value if self._cursor_y_value is not None else None - self._freq_zoom(3 / 2, anchor) + self._spec_renderer.freq_zoom( + 3 / 2, self._cursor_y_value, self._samplerate) else: self._vscale = max(self._vscale / 1.5, 0.1) self.update() - def _freq_zoom(self, factor: float, anchor_mel: float | None = None): - """Zoom the mel frequency range by *factor* around *anchor_mel*. - - If anchor_mel is None, zoom around the view center. - """ - mel_range = self._mel_view_max - self._mel_view_min - mel_full_min = _hz_to_mel(_SPEC_F_MIN) - mel_full_max = _hz_to_mel(min(_SPEC_F_MAX, self._samplerate / 2.0)) - if anchor_mel is not None: - anchor = max(self._mel_view_min, min(anchor_mel, self._mel_view_max)) - frac = ((anchor - self._mel_view_min) / mel_range - if mel_range > 0 else 0.5) - else: - anchor = (self._mel_view_min + self._mel_view_max) / 2.0 - frac = 0.5 - new_range = mel_range * factor - new_range = max(new_range, 50.0) - new_range = min(new_range, mel_full_max - mel_full_min) - new_min = anchor - frac * new_range - new_max = anchor + (1.0 - frac) * new_range - if new_min < mel_full_min: - new_min = mel_full_min - new_max = new_min + new_range - if new_max > mel_full_max: - new_max = mel_full_max - new_min = new_max - new_range - self._mel_view_min = max(new_min, mel_full_min) - self._mel_view_max = min(new_max, mel_full_max) - self._spec_image = None - self._spec_cache_key = () - - # ── RMS overlay ─────────────────────────────────────────────────────── + # ── Public setters ───────────────────────────────────────────────────── def set_rms_data(self, window_samples: int): - """Set the RMS window size. Per-channel envelopes are computed - on demand from the already-loaded channel data.""" + """Set the RMS window size.""" self._rms_window_samples = max(window_samples, 0) - self._rms_envelope = [] - self._rms_cache_key = (0, 0, 0) - # Defer RMS max computation to first paint - self._rms_max_sample = -1 - self._rms_max_db = float('-inf') - self._rms_max_amplitude = 0.0 - self._rms_max_dirty = bool(self._channels and window_samples > 0) + self._wf_renderer.set_rms_window(window_samples) self.update() def toggle_markers(self, on: bool): - """Enable or disable the peak and RMS max markers.""" self._show_markers = on self.update() def toggle_rms_lr(self, on: bool): - """Enable or disable the per-channel RMS overlay.""" self._show_rms_lr = on self.update() def toggle_rms_avg(self, on: bool): - """Enable or disable the combined (average) RMS overlay.""" self._show_rms_avg = on self.update() def set_enabled_overlays(self, labels: set[str]): - """Set which detector issue overlays are visible by label.""" self._enabled_overlays = set(labels) self.update() def set_display_mode(self, mode: str): - """Switch between 'waveform' and 'spectrogram' display modes.""" if mode not in ("waveform", "spectrogram"): return self._display_mode = mode - self._spec_image = None - self._spec_cache_key = () + self._spec_renderer.invalidate() self.update() def set_invert_scroll(self, mode: str): - """Set scroll inversion mode: 'default', 'horizontal', 'vertical', 'both'.""" self._invert_h = mode in ("horizontal", "both") self._invert_v = mode in ("vertical", "both") def set_wf_antialias(self, enabled: bool): - """Enable or disable anti-aliased waveform lines.""" self._wf_antialias = enabled self.update() def set_wf_line_width(self, width: int): - """Set waveform outline / RMS line width in pixels (1 or 2).""" self._wf_line_width = max(1, min(width, 3)) self.update() def set_colormap(self, name: str): - """Set the spectrogram colormap by name.""" - if name not in SPECTROGRAM_COLORMAPS: - return - self._colormap = name - self._spec_image = None - self._spec_cache_key = () + self._spec_renderer.set_colormap(name) self.update() def set_spec_fft(self, n_fft: int): - """Change the FFT size and recompute the spectrogram.""" - if n_fft == self._spec_n_fft: + if n_fft == self._spec_renderer.spec_n_fft: return - self._spec_n_fft = n_fft + self._spec_renderer.set_n_fft(n_fft) self._recompute_spectrogram() def set_spec_window(self, window: str): - """Change the FFT window function and recompute the spectrogram.""" - if window == self._spec_window: + if window == self._spec_renderer.spec_window: return - self._spec_window = window + self._spec_renderer.set_window(window) self._recompute_spectrogram() def set_spec_db_floor(self, val: float): - """Change the dB floor for spectrogram normalization.""" - if val == self._spec_db_floor: - return - self._spec_db_floor = val - self._spec_image = None - self._spec_cache_key = () + self._spec_renderer.set_db_floor(val) self.update() def set_spec_db_ceil(self, val: float): - """Change the dB ceiling for spectrogram normalization.""" - if val == self._spec_db_ceil: - return - self._spec_db_ceil = val - self._spec_image = None - self._spec_cache_key = () + self._spec_renderer.set_db_ceil(val) self.update() + @property + def spec_n_fft(self) -> int: + return self._spec_renderer.spec_n_fft + + @property + def spec_window(self) -> str: + return self._spec_renderer.spec_window + def _recompute_spectrogram(self): - """Launch a background thread to recompute the mel spectrogram.""" if not self._channels: return - # Cancel any in-flight spectrogram worker - if self._spec_recompute_worker is not None: - self._spec_recompute_worker.cancel() - self._spec_recompute_worker.finished.disconnect() - self._spec_recompute_worker = None - self._spec_db = None - self._spec_image = None - self._spec_cache_key = () - self.update() - worker = SpectrogramRecomputeWorker( + self._spec_renderer.recompute( self._channels, self._samplerate, - n_fft=self._spec_n_fft, window=self._spec_window, - parent=self, + on_done=self.update, parent=self, ) - worker.finished.connect(self._on_spec_recomputed) - self._spec_recompute_worker = worker - worker.start() - - def _on_spec_recomputed(self, spec_db): - """Slot called when the spectrogram recompute finishes.""" - self._spec_db = spec_db - self._spec_image = None - self._spec_cache_key = () - self._spec_recompute_worker = None self.update() - - def _compute_rms_max_sample(self): - """Find the sample position of the maximum momentary RMS window.""" - win = self._rms_window_samples - if not self._channels or win <= 0: - self._rms_max_sample = -1 - return - ch_wms: list[np.ndarray] = [] - have_cumsums = len(self._rms_cumsums) == len(self._channels) - for ch_idx, ch_data in enumerate(self._channels): - n = len(ch_data) - if n <= win: - ch_wms.append(np.zeros(1, dtype=np.float64)) - continue - if have_cumsums: - cs = self._rms_cumsums[ch_idx] - else: - sq = ch_data.astype(np.float64) ** 2 - cs = np.empty(n + 1, dtype=np.float64) - cs[0] = 0.0 - np.cumsum(sq, out=cs[1:]) - ch_wms.append((cs[win:] - cs[:n - win + 1]) / win) - min_len = min(len(wm) for wm in ch_wms) - if min_len == 0: - self._rms_max_sample = -1 - return - combined = np.mean( - np.column_stack([wm[:min_len] for wm in ch_wms]), axis=1 - ) - max_idx = int(np.argmax(combined)) - self._rms_max_sample = max_idx + win // 2 - max_rms_lin = float(np.sqrt(combined[max_idx])) - self._rms_max_db = 20.0 * np.log10(max_rms_lin) if max_rms_lin > 0 else float('-inf') - self._rms_max_amplitude = max_rms_lin - - def _build_rms_envelope(self, width: int): - """Compute per-channel AND combined RMS envelopes for *width* pixels. - - Uses pre-cached cumsum arrays (computed once per track by the - background worker) to derive sliding-window RMS, then downsamples - to pixel resolution. - - Results: - ``_rms_envelope`` – list (per channel) of np.ndarray - ``_rms_combined`` – np.ndarray (avg across channels) - """ - win = self._rms_window_samples - if not self._channels or width <= 0 or win <= 0: - self._rms_envelope = [] - self._rms_combined = [] - return - cache_key = (width, self._view_start, self._view_end) - if self._rms_cache_key == cache_key and self._rms_envelope: - return - - vs, ve = self._view_start, self._view_end - view_len = ve - vs - if view_len <= 0: - self._rms_envelope = [] - self._rms_combined = [] - return - - half_win = win // 2 - - # Compute window-means only for the view-relevant slice of the - # cumsum — O(view_length + 2*win) instead of O(total_samples). - have_cumsums = len(self._rms_cumsums) == len(self._channels) - - ch_wms: list[np.ndarray] = [] - wm_offset = 0 # global wm index of ch_wms[][0] - for ch_idx, ch_data in enumerate(self._channels): - n = len(ch_data) - n_wm_total = n - win + 1 - if n <= win: - ch_wms.append(np.zeros(1, dtype=np.float64)) - continue - if have_cumsums: - cs = self._rms_cumsums[ch_idx] - else: - sq = ch_data.astype(np.float64) ** 2 - cs = np.empty(n + 1, dtype=np.float64) - cs[0] = 0.0 - np.cumsum(sq, out=cs[1:]) - # View-local slice only - wm_lo = max(0, vs - half_win - win) - wm_hi = min(n_wm_total, ve + half_win + win) - wm_offset = wm_lo - ch_wms.append( - (cs[wm_lo + win : wm_hi + win] - cs[wm_lo : wm_hi]) / win) - - # Combined (average across channels) — already view-local sized - min_len = min(len(wm) for wm in ch_wms) - if min_len > 1 and len(ch_wms) > 1: - combined_wm = np.mean( - np.column_stack([wm[:min_len] for wm in ch_wms]), axis=1) - elif ch_wms: - combined_wm = ch_wms[0][:min_len].copy() - else: - combined_wm = np.zeros(1, dtype=np.float64) - - def _downsample(wm: np.ndarray, offset: int) -> np.ndarray: - """Downsample a view-local wm slice to *width* pixels. - - *offset* is the global wm index of wm[0]. - """ - n_wm = len(wm) - if n_wm == 0: - return np.zeros(width) - - # Map pixel bins to global wm indices, then to local - pixel_edges = np.arange(width + 1) - s_edges = vs + pixel_edges * view_len // width - global_wm = np.clip(s_edges - half_win, 0, offset + n_wm) - local_wm = np.clip(global_wm - offset, 0, n_wm) - - first = int(local_wm[0]) - last = int(local_wm[-1]) - last = max(last, first + 1) - last = min(last, n_wm) - wm_slice = wm[first:last] - n_slice = len(wm_slice) - - if n_slice >= width: - spb = n_slice // width - n_use = spb * width - reshaped = wm_slice[:n_use].reshape(width, spb) - result = np.sqrt(np.maximum(reshaped.max(axis=1), 0.0)) - if n_use < n_slice: - tail_max = float(wm_slice[n_use:].max()) - result[-1] = np.sqrt(max(float(result[-1]) ** 2, tail_max)) - return result - else: - local = np.clip(local_wm[:-1] - first, 0, - max(n_slice - 1, 0)).astype(np.intp) - return np.sqrt(np.maximum(wm_slice[local], 0.0)) - - self._rms_envelope = [_downsample(wm, wm_offset) for wm in ch_wms] - self._rms_combined = _downsample(combined_wm, wm_offset) - self._rms_cache_key = cache_key diff --git a/sessionprepgui/waveform_compute.py b/sessionprepgui/waveform_compute.py new file mode 100644 index 0000000..f6b441a --- /dev/null +++ b/sessionprepgui/waveform_compute.py @@ -0,0 +1,299 @@ +"""Waveform background computation: colormaps, mel spectrogram, load workers.""" + +from __future__ import annotations + +import threading + +import numpy as np + +from PySide6.QtCore import QThread, Signal +from scipy.signal import stft as scipy_stft + + +# --------------------------------------------------------------------------- +# Spectrogram colormaps +# --------------------------------------------------------------------------- + +SPECTROGRAM_COLORMAPS: dict[str, np.ndarray] = {} # name → (256, 4) uint8 RGBA + + +def _register_colormap(name: str, + controls: list[tuple[float, tuple[int, int, int]]]): + """Build a 256-entry RGBA LUT from control points via linear interpolation.""" + lut = np.zeros((256, 4), dtype=np.uint8) + lut[:, 3] = 255 # fully opaque + positions = np.array([c[0] for c in controls]) + for ch in range(3): + values = np.array([c[1][ch] for c in controls], dtype=np.float64) + lut[:, ch] = np.clip( + np.interp(np.linspace(0, 1, 256), positions, values), 0, 255 + ).astype(np.uint8) + SPECTROGRAM_COLORMAPS[name] = lut + + +_register_colormap("magma", [ + (0.0, (0, 0, 4)), + (0.25, (81, 18, 124)), + (0.5, (183, 55, 121)), + (0.75, (254, 159, 109)), + (1.0, (252, 253, 191)), +]) + +_register_colormap("viridis", [ + (0.0, (68, 1, 84)), + (0.25, (59, 82, 139)), + (0.5, (33, 145, 140)), + (0.75, (94, 201, 98)), + (1.0, (253, 231, 37)), +]) + +_register_colormap("grayscale", [ + (0.0, (0, 0, 0)), + (1.0, (255, 255, 255)), +]) + + +# --------------------------------------------------------------------------- +# Mel filterbank +# --------------------------------------------------------------------------- + +def _hz_to_mel(f: float) -> float: + return 2595.0 * np.log10(1.0 + f / 700.0) + + +def _mel_to_hz(m: float) -> float: + return 700.0 * (10.0 ** (m / 2595.0) - 1.0) + + +def _mel_filterbank(sr: int, n_fft: int, n_mels: int = 128, + f_min: float = 20.0, f_max: float = 22050.0 + ) -> np.ndarray: + """Build a Mel filterbank matrix (n_mels, n_fft // 2 + 1).""" + f_max = min(f_max, sr / 2.0) + n_freqs = n_fft // 2 + 1 + mel_min = _hz_to_mel(f_min) + mel_max = _hz_to_mel(f_max) + mel_points = np.linspace(mel_min, mel_max, n_mels + 2) + hz_points = 700.0 * (10.0 ** (mel_points / 2595.0) - 1.0) + bin_points = np.floor((n_fft + 1) * hz_points / sr).astype(np.intp) + + fb = np.zeros((n_mels, n_freqs), dtype=np.float64) + for i in range(n_mels): + left, center, right = bin_points[i], bin_points[i + 1], bin_points[i + 2] + if center == left: + center = left + 1 + if right == center: + right = center + 1 + for j in range(int(left), int(center)): + if j < n_freqs: + fb[i, j] = (j - left) / (center - left) + for j in range(int(center), int(right)): + if j < n_freqs: + fb[i, j] = (right - j) / (right - center) + return fb + + +# --------------------------------------------------------------------------- +# Spectrogram computation (used by background workers) +# --------------------------------------------------------------------------- + +_SPEC_N_FFT = 2048 +_SPEC_HOP = 512 +_SPEC_N_MELS = 256 +_SPEC_F_MIN = 20.0 +_SPEC_F_MAX = 22050.0 +_SPEC_DB_FLOOR = -80.0 # dB floor for normalization + + +def compute_mel_spectrogram(channels: list[np.ndarray], sr: int, *, + n_fft: int = _SPEC_N_FFT, + hop: int | None = None, + window: str = "hann", + ) -> np.ndarray | None: + """Compute a full-file mel spectrogram from channel data. + + Returns a float32 array of shape (n_mels, n_frames) in dB, or None + if the audio is too short. + """ + if not channels: + return None + if hop is None: + hop = n_fft // 4 + # Mix to mono + if len(channels) == 1: + mono = channels[0].astype(np.float64) + else: + mono = np.mean( + np.column_stack([ch.astype(np.float64) for ch in channels]), + axis=1, + ) + if len(mono) < n_fft: + return None + # STFT + _f, _t, Zxx = scipy_stft( + mono, fs=sr, nperseg=n_fft, + noverlap=n_fft - hop, window=window, boundary=None, + ) + power = np.abs(Zxx) ** 2 + # Mel filterbank + f_max = min(_SPEC_F_MAX, sr / 2.0) + fb = _mel_filterbank(sr, n_fft, _SPEC_N_MELS, _SPEC_F_MIN, f_max) + mel_spec = fb @ power # (n_mels, n_frames) + # To dB + mel_spec = 10.0 * np.log10(np.maximum(mel_spec, 1e-10)) + return mel_spec.astype(np.float32) + + +# --------------------------------------------------------------------------- +# Background workers +# --------------------------------------------------------------------------- + +class WaveformLoadWorker(QThread): + """Background thread for heavy waveform preparation work. + + Splits channels, finds peak position, and computes RMS-max position + so the main thread stays responsive. + """ + + finished = Signal(object) # emits a dict with all computed results + + def __init__(self, audio_data: np.ndarray, samplerate: int, + rms_window_samples: int, *, + spec_n_fft: int = _SPEC_N_FFT, + spec_window: str = "hann", + parent=None): + super().__init__(parent) + self._audio_data = audio_data + self._samplerate = samplerate + self._rms_win = rms_window_samples + self._spec_n_fft = spec_n_fft + self._spec_window = spec_window + self._cancelled = threading.Event() + + def cancel(self): + """Request early termination of the computation.""" + self._cancelled.set() + + def run(self): + data = self._audio_data + sr = self._samplerate + win = self._rms_win + + # --- Channel splitting --- + if data.ndim == 1: + channels = [np.ascontiguousarray(data)] + else: + channels = [ + np.ascontiguousarray(data[:, ch]) + for ch in range(data.shape[1]) + ] + nch = len(channels) + total = len(channels[0]) + + if self._cancelled.is_set(): + return + + # --- Peak finding --- + if nch == 1: + peak_sample = int(np.argmax(np.abs(channels[0]))) + peak_channel = 0 + else: + abs_cols = np.column_stack([np.abs(ch) for ch in channels]) + max_per_sample = np.max(abs_cols, axis=1) + peak_sample = int(np.argmax(max_per_sample)) + peak_channel = int(np.argmax(abs_cols[peak_sample])) + peak_lin = abs(float(channels[peak_channel][peak_sample])) + peak_db = 20.0 * np.log10(peak_lin) if peak_lin > 0 else float('-inf') + peak_amplitude = float(channels[peak_channel][peak_sample]) + + if self._cancelled.is_set(): + return + + # --- RMS cumsum (computed once, reused for envelope drawing) --- + rms_max_sample = -1 + rms_max_db = float('-inf') + rms_max_amplitude = 0.0 + rms_cumsums: list[np.ndarray] = [] + if win > 0: + ch_wms: list[np.ndarray] = [] + for ch_data in channels: + if self._cancelled.is_set(): + return + n = len(ch_data) + if n <= win: + rms_cumsums.append(np.zeros(2, dtype=np.float64)) + ch_wms.append(np.zeros(1, dtype=np.float64)) + continue + sq = ch_data.astype(np.float64) ** 2 + cs = np.empty(n + 1, dtype=np.float64) + cs[0] = 0.0 + np.cumsum(sq, out=cs[1:]) + rms_cumsums.append(cs) + ch_wms.append((cs[win:] - cs[:n - win + 1]) / win) + min_len = min(len(wm) for wm in ch_wms) + if min_len > 0: + combined = np.mean( + np.column_stack([wm[:min_len] for wm in ch_wms]), axis=1 + ) + max_idx = int(np.argmax(combined)) + rms_max_sample = max_idx + win // 2 + rms_lin = float(np.sqrt(combined[max_idx])) + rms_max_db = 20.0 * np.log10(rms_lin) if rms_lin > 0 else float('-inf') + rms_max_amplitude = rms_lin + + if self._cancelled.is_set(): + return + + # --- Spectrogram --- + spec_db = compute_mel_spectrogram( + channels, sr, + n_fft=self._spec_n_fft, window=self._spec_window, + ) + + if self._cancelled.is_set(): + return + + self.finished.emit({ + "channels": channels, + "samplerate": sr, + "total_samples": total, + "peak_sample": peak_sample, + "peak_channel": peak_channel, + "peak_db": peak_db, + "peak_amplitude": peak_amplitude, + "rms_window_samples": win, + "rms_max_sample": rms_max_sample, + "rms_max_db": rms_max_db, + "rms_max_amplitude": rms_max_amplitude, + "rms_cumsums": rms_cumsums, + "spec_db": spec_db, + }) + + +class SpectrogramRecomputeWorker(QThread): + """Lightweight background thread to recompute the mel spectrogram.""" + + finished = Signal(object) # emits np.ndarray | None + + def __init__(self, channels: list[np.ndarray], sr: int, *, + n_fft: int = _SPEC_N_FFT, window: str = "hann", + parent=None): + super().__init__(parent) + self._channels = channels + self._sr = sr + self._n_fft = n_fft + self._window = window + self._cancelled = threading.Event() + + def cancel(self): + """Request early termination.""" + self._cancelled.set() + + def run(self): + result = compute_mel_spectrogram( + self._channels, self._sr, + n_fft=self._n_fft, window=self._window, + ) + if self._cancelled.is_set(): + return + self.finished.emit(result) diff --git a/sessionprepgui/waveform_overlay.py b/sessionprepgui/waveform_overlay.py new file mode 100644 index 0000000..95ae2e3 --- /dev/null +++ b/sessionprepgui/waveform_overlay.py @@ -0,0 +1,189 @@ +"""Stateless overlay drawing helpers: issue overlays and time scale.""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QFont, QPainter, QPen + +from .theme import COLORS +from .waveform_compute import _hz_to_mel + +_SEVERITY_OVERLAY = { + "problem": QColor(255, 68, 68, 55), + "attention": QColor(255, 170, 0, 45), + "information": QColor(68, 153, 255, 40), + "info": QColor(68, 153, 255, 40), +} +_SEVERITY_BORDER = { + "problem": QColor(255, 68, 68, 140), + "attention": QColor(255, 170, 0, 120), + "information": QColor(68, 153, 255, 100), + "info": QColor(68, 153, 255, 100), +} + + +def draw_issue_overlays( + painter: QPainter, + x0: int, + draw_w: int, + draw_h: float, + view_start: int, + view_end: int, + total_samples: int, + issues: list, + enabled_overlays: set, + display_mode: str, + num_channels: int, + mel_view_min: float, + mel_view_max: float, +): + """Draw detector issue overlays. Works in both waveform and spectrogram modes.""" + if not issues or not enabled_overlays: + return + + view_len = view_end - view_start + if view_len <= 0 or total_samples <= 0: + return + + lane_h = draw_h / max(num_channels, 1) + is_spec = display_mode == "spectrogram" + mel_range = (mel_view_max - mel_view_min) if is_spec else 0.0 + + def _sample_to_x(sample: int) -> int: + return x0 + int((sample - view_start) / view_len * draw_w) + + for issue in issues: + if issue.label not in enabled_overlays: + continue + sev_val = issue.severity.value if hasattr(issue.severity, "value") else str(issue.severity) + fill = _SEVERITY_OVERLAY.get(sev_val, QColor(255, 255, 255, 30)) + border = _SEVERITY_BORDER.get(sev_val, QColor(255, 255, 255, 60)) + + ix1 = _sample_to_x(issue.sample_start) + ix2 = (_sample_to_x(issue.sample_end + 1) + if issue.sample_end is not None else ix1) + rx = ix1 + rw = max(ix2 - ix1, 2) + + if is_spec and issue.freq_min_hz is not None and issue.freq_max_hz is not None and mel_range > 0: + mel_lo = _hz_to_mel(issue.freq_min_hz) + mel_hi = _hz_to_mel(issue.freq_max_hz) + frac_top = (mel_hi - mel_view_min) / mel_range + frac_bot = (mel_lo - mel_view_min) / mel_range + y_top = int(draw_h * (1.0 - frac_top)) + y_bot = int(draw_h * (1.0 - frac_bot)) + y_top = max(0, min(y_top, int(draw_h))) + y_bot = max(0, min(y_bot, int(draw_h))) + if y_top >= y_bot: + continue + ry, rh = y_top, y_bot - y_top + elif not is_spec: + if issue.channel is None: + ry, rh = 0, int(draw_h) + else: + ch = issue.channel + if ch < num_channels: + ry = int(ch * lane_h) + rh = int(lane_h) + else: + continue + else: + ry, rh = 0, int(draw_h) + + painter.fillRect(rx, ry, rw, rh, fill) + painter.setPen(QPen(border, 1)) + painter.drawRect(rx, ry, rw, rh) + + +def draw_time_scale( + painter: QPainter, + x0: int, + draw_w: int, + draw_h: float, + view_start: int, + view_end: int, + samplerate: int, +): + """Draw horizontal time axis with adaptive tick labels below the waveform.""" + if samplerate <= 0 or draw_w <= 0: + return + + view_start_sec = view_start / samplerate + view_end_sec = view_end / samplerate + visible_dur = view_end_sec - view_start_sec + if visible_dur <= 0: + return + + _NICE_INTERVALS = [ + 0.001, 0.002, 0.005, + 0.01, 0.02, 0.05, + 0.1, 0.2, 0.5, + 1, 2, 5, 10, 15, 30, + 60, 120, 300, 600, 1800, 3600, + ] + _MIN_TICK_PX = 60 + + interval = _NICE_INTERVALS[-1] + for ni in _NICE_INTERVALS: + if ni / visible_dur * draw_w >= _MIN_TICK_PX: + interval = ni + break + + if interval >= 1.0: + def _fmt(t): + m = int(t) // 60 + s = int(t) % 60 + return f"{m}:{s:02d}" + elif interval >= 0.1: + def _fmt(t): + m = int(t) // 60 + s = t - m * 60 + return f"{m}:{s:04.1f}" + elif interval >= 0.01: + def _fmt(t): + m = int(t) // 60 + s = t - m * 60 + return f"{m}:{s:05.2f}" + else: + def _fmt(t): + m = int(t) // 60 + s = t - m * 60 + return f"{m}:{s:06.3f}" + + scale_font = QFont("Consolas", 7) + painter.setFont(scale_font) + fm = painter.fontMetrics() + + label_color = QColor(COLORS["dim"]) + tick_pen = QPen(label_color, 1) + grid_color = QColor(COLORS["accent"]) + grid_color.setAlpha(25) + grid_pen = QPen(grid_color, 1, Qt.DotLine) + + first_tick = (int(view_start_sec / interval) + 1) * interval + if abs(view_start_sec / interval - round(view_start_sec / interval)) < 1e-9: + first_tick = round(view_start_sec / interval) * interval + + t = first_tick + bottom_y = int(draw_h) + while t <= view_end_sec + interval * 0.01: + frac = (t - view_start_sec) / visible_dur + px = x0 + int(frac * draw_w) + + if px < x0 or px > x0 + draw_w: + t += interval + continue + + painter.setPen(grid_pen) + painter.drawLine(px, 0, px, bottom_y) + + painter.setPen(tick_pen) + painter.drawLine(px, bottom_y, px, bottom_y + 4) + + label = _fmt(t) + tw = fm.horizontalAdvance(label) + lx = px - tw // 2 + ly = bottom_y + 4 + fm.ascent() + painter.drawText(int(lx), int(ly), label) + + t += interval diff --git a/sessionprepgui/waveform_renderer.py b/sessionprepgui/waveform_renderer.py new file mode 100644 index 0000000..d7233b5 --- /dev/null +++ b/sessionprepgui/waveform_renderer.py @@ -0,0 +1,638 @@ +"""Waveform renderer: peaks, RMS envelope, dB scale, and markers.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from PySide6.QtCore import QPointF, Qt +from PySide6.QtGui import (QColor, QFont, QLinearGradient, QPainter, + QPen, QPolygonF) + +from .theme import COLORS + +_CHANNEL_COLORS = [ + "#44aa44", "#44aaaa", "#aa44aa", "#aaaa44", + "#4488cc", "#cc8844", "#88cc44", "#cc4488", +] + + +@dataclass +class WaveformRenderCtx: + """All data WaveformRenderer needs — immutable snapshot from WaveformWidget.""" + x0: int + draw_w: int + draw_h: int + margin_right: int + view_start: int + view_end: int + vscale: float + channels: list + num_channels: int + show_rms_lr: bool + show_rms_avg: bool + show_markers: bool + wf_antialias: bool + wf_line_width: int + + +class WaveformRenderer: + """Draws per-channel audio waveforms with RMS overlays and peak markers.""" + + def __init__(self): + self._peaks_cache: list[tuple[np.ndarray, np.ndarray]] = [] + self._cached_view: tuple[int, int, int] = (0, 0, 0) + self._rms_envelope: list[np.ndarray] = [] + self._rms_combined: np.ndarray | list = [] + self._rms_cache_key: tuple[int, int, int] = (0, 0, 0) + self._rms_cumsums: list[np.ndarray] = [] + self._rms_window_samples: int = 0 + self._channels: list[np.ndarray] = [] + self._peak_sample: int = -1 + self._peak_channel: int = -1 + self._peak_db: float = float('-inf') + self._peak_amplitude: float = 0.0 + self._peak_dirty: bool = False + self._rms_max_sample: int = -1 + self._rms_max_db: float = float('-inf') + self._rms_max_amplitude: float = 0.0 + self._rms_max_dirty: bool = False + + def reset(self): + """Clear all state on track unload / set_loading.""" + self._peaks_cache = [] + self._cached_view = (0, 0, 0) + self._rms_envelope = [] + self._rms_combined = [] + self._rms_cache_key = (0, 0, 0) + self._rms_cumsums = [] + self._rms_window_samples = 0 + self._channels = [] + self._peak_sample = -1 + self._peak_channel = -1 + self._peak_db = float('-inf') + self._peak_amplitude = 0.0 + self._peak_dirty = False + self._rms_max_sample = -1 + self._rms_max_db = float('-inf') + self._rms_max_amplitude = 0.0 + self._rms_max_dirty = False + + def set_track_data(self, channels: list, *, + peak_sample: int = -1, peak_channel: int = -1, + peak_db: float = float('-inf'), + peak_amplitude: float = 0.0, + peak_dirty: bool = False, + rms_cumsums: list | None = None, + rms_window: int = 0, + rms_max_sample: int = -1, + rms_max_db: float = float('-inf'), + rms_max_amplitude: float = 0.0, + rms_max_dirty: bool = False): + """Set per-track data. Resets caches. Called from set_audio / set_precomputed.""" + self._channels = channels + self._peak_sample = peak_sample + self._peak_channel = peak_channel + self._peak_db = peak_db + self._peak_amplitude = peak_amplitude + self._peak_dirty = peak_dirty + self._rms_cumsums = rms_cumsums or [] + self._rms_window_samples = rms_window + self._rms_max_sample = rms_max_sample + self._rms_max_db = rms_max_db + self._rms_max_amplitude = rms_max_amplitude + self._rms_max_dirty = rms_max_dirty + self._peaks_cache = [] + self._cached_view = (0, 0, 0) + self._rms_envelope = [] + self._rms_combined = [] + self._rms_cache_key = (0, 0, 0) + + def set_rms_window(self, window_samples: int): + """Update RMS window size and reset RMS caches + dirty flags.""" + self._rms_window_samples = max(window_samples, 0) + self._rms_envelope = [] + self._rms_combined = [] + self._rms_cache_key = (0, 0, 0) + self._rms_max_sample = -1 + self._rms_max_db = float('-inf') + self._rms_max_amplitude = 0.0 + self._rms_max_dirty = bool(self._channels and window_samples > 0) + + def invalidate(self): + """Invalidate peak and RMS caches (zoom change, resize, large scroll).""" + self._peaks_cache = [] + self._cached_view = (0, 0, 0) + self._rms_envelope = [] + self._rms_combined = [] + self._rms_cache_key = (0, 0, 0) + + def invalidate_rms_only(self): + """Invalidate RMS envelope cache but keep peaks for incremental updates.""" + self._rms_envelope = [] + self._rms_combined = [] + self._rms_cache_key = (0, 0, 0) + + def paint(self, painter: QPainter, ctx: WaveformRenderCtx): + """Full waveform draw pass: envelope + dB scale + RMS + markers.""" + painter.setRenderHint(QPainter.Antialiasing, ctx.wf_antialias) + self._build_peaks(ctx) + if ctx.show_rms_lr or ctx.show_rms_avg: + self._build_rms_envelope(ctx) + nch = ctx.num_channels + lane_h = ctx.draw_h / nch + self._draw_db_scale(painter, ctx, nch, lane_h) + self._draw_waveform_channels(painter, ctx, nch, lane_h) + if ctx.show_rms_lr or ctx.show_rms_avg: + self._draw_rms_overlay(painter, ctx, nch, lane_h) + painter.setRenderHint(QPainter.Antialiasing, True) + if ctx.show_markers: + self._draw_markers(painter, ctx, nch, lane_h) + + def draw_db_guide(self, painter: QPainter, ctx: WaveformRenderCtx, + nch: int, lane_h: float, my: float): + """Draw dBFS readout labels at mouse y position (called from paintEvent).""" + mouse_ch = int(my / lane_h) if lane_h > 0 else 0 + mouse_ch = max(0, min(mouse_ch, nch - 1)) + ch_y_off = mouse_ch * lane_h + ch_mid_y = ch_y_off + lane_h / 2.0 + ch_scale = (lane_h / 2.0) * 0.85 * ctx.vscale + if ch_scale > 0: + amp = abs(ch_mid_y - my) / ch_scale + db_label = f"{20.0 * np.log10(amp):.1f}" if amp > 0 else "-\u221e" + painter.setFont(QFont("Consolas", 7)) + label_color = QColor(180, 180, 180, 120) + painter.setPen(label_color) + fm = painter.fontMetrics() + tw = fm.horizontalAdvance(db_label) + painter.drawText(ctx.x0 - 5 - tw, int(ch_y_off) + fm.ascent() + 1, db_label) + painter.drawText(ctx.x0 + ctx.draw_w + 5, int(ch_y_off) + fm.ascent() + 1, db_label) + + @property + def peak_sample(self) -> int: + return self._peak_sample + + @property + def peak_db(self) -> float: + return self._peak_db + + @property + def rms_max_sample(self) -> int: + return self._rms_max_sample + + @property + def rms_max_db(self) -> float: + return self._rms_max_db + + @property + def rms_max_amplitude(self) -> float: + return self._rms_max_amplitude + + # ── Internal helpers ─────────────────────────────────────────────────── + + def _sample_to_x(self, sample: int, ctx: WaveformRenderCtx) -> int: + view_len = ctx.view_end - ctx.view_start + if view_len <= 0: + return 0 + return int((sample - ctx.view_start) / view_len * ctx.draw_w) + + @staticmethod + def _peaks_for_view(ch_data, vs, ve, width): + """Compute (mins, maxs) arrays for one channel over samples [vs:ve].""" + view_data = ch_data[vs:ve] + n = len(view_data) + if n == 0: + return np.zeros(width, dtype=np.float64), np.zeros(width, dtype=np.float64) + if n >= width: + starts = np.arange(width, dtype=np.int64) * n // width + maxs = np.maximum.reduceat(view_data, starts).astype(np.float64) + mins = np.minimum.reduceat(view_data, starts).astype(np.float64) + else: + mins = np.zeros(width, dtype=np.float64) + maxs = np.zeros(width, dtype=np.float64) + starts = np.arange(width) * n // width + ends = np.minimum((np.arange(width) + 1) * n // width, n) + valid = ends > starts + if valid.any(): + single = valid & ((ends - starts) == 1) + if single.any(): + mins[single] = view_data[starts[single]] + maxs[single] = view_data[starts[single]] + multi = valid & ((ends - starts) > 1) + for i in np.nonzero(multi)[0]: + chunk = view_data[starts[i]:ends[i]] + mins[i] = chunk.min() + maxs[i] = chunk.max() + return mins, maxs + + def _build_peaks(self, ctx: WaveformRenderCtx): + """Downsample audio to peak envelope, with incremental scroll updates.""" + channels = ctx.channels + width = ctx.draw_w + if not channels or width <= 0: + self._peaks_cache = [] + return + cache_key = (width, ctx.view_start, ctx.view_end) + if self._cached_view == cache_key and self._peaks_cache: + return + vs, ve = ctx.view_start, ctx.view_end + view_len = ve - vs + if view_len <= 0: + self._peaks_cache = [] + return + old_w, old_vs, old_ve = self._cached_view + can_inc = ( + self._peaks_cache + and old_w == width + and (old_ve - old_vs) == view_len + and vs != old_vs + and len(self._peaks_cache) == len(channels) + ) + if can_inc: + shift_samples = vs - old_vs + shift_bins = int(round(shift_samples * width / view_len)) + if 0 < abs(shift_bins) < width: + new_cache = [] + for ch_idx, ch_data in enumerate(channels): + old_mins, old_maxs = self._peaks_cache[ch_idx] + mins = np.empty(width, dtype=np.float64) + maxs = np.empty(width, dtype=np.float64) + if shift_bins > 0: + keep = width - shift_bins + mins[:keep] = old_mins[shift_bins:] + maxs[:keep] = old_maxs[shift_bins:] + new_vs = vs + keep * view_len // width + nm, nx = self._peaks_for_view(ch_data, new_vs, ve, width - keep) + mins[keep:] = nm + maxs[keep:] = nx + else: + sb = -shift_bins + keep = width - sb + mins[sb:] = old_mins[:keep] + maxs[sb:] = old_maxs[:keep] + new_ve = vs + sb * view_len // width + nm, nx = self._peaks_for_view(ch_data, vs, new_ve, sb) + mins[:sb] = nm + maxs[:sb] = nx + new_cache.append((mins, maxs)) + self._peaks_cache = new_cache + self._cached_view = cache_key + return + self._peaks_cache = [] + for ch_data in channels: + mins, maxs = self._peaks_for_view(ch_data, vs, ve, width) + self._peaks_cache.append((mins, maxs)) + self._cached_view = cache_key + + def _draw_waveform_channels(self, painter: QPainter, + ctx: WaveformRenderCtx, + nch: int, lane_h: float): + """Draw filled waveform envelopes and centre lines for all channels.""" + widget_w = ctx.x0 + ctx.draw_w + ctx.margin_right + view_len = ctx.view_end - ctx.view_start + spp = view_len / max(ctx.draw_w, 1) + + for ch in range(nch): + y_off = ch * lane_h + mid_y = y_off + lane_h / 2.0 + scale = (lane_h / 2.0) * 0.85 * ctx.vscale + + lane_top = int(y_off) + lane_bot = int(y_off + lane_h) + painter.setClipRect(ctx.x0, lane_top, ctx.draw_w, lane_bot - lane_top) + + color = QColor(_CHANNEL_COLORS[ch % len(_CHANNEL_COLORS)]) + mins, maxs = self._peaks_cache[ch] + + n_pts = len(mins) + xs = np.arange(n_pts, dtype=np.float64) + ctx.x0 + ys_top = mid_y - maxs * scale + ys_bot = mid_y - mins * scale + + env_x = np.concatenate([xs, xs[::-1]]) + env_y = np.concatenate([ys_top, ys_bot[::-1]]) + env_poly = QPolygonF([QPointF(env_x[i], env_y[i]) + for i in range(len(env_x))]) + + grad = QLinearGradient(0, y_off, 0, y_off + lane_h) + color_edge = QColor(color) + color_edge.setAlpha(30) + color_mid = QColor(color) + color_mid.setAlpha(140) + grad.setColorAt(0.0, color_edge) + grad.setColorAt(0.5, color_mid) + grad.setColorAt(1.0, color_edge) + + painter.setPen(Qt.NoPen) + painter.setBrush(QColor(COLORS["bg"])) + painter.drawPolygon(env_poly) + painter.setBrush(grad) + painter.drawPolygon(env_poly) + + outline_alpha = max(100, min(200, int(200 - spp * 0.1))) + outline = QColor(color) + outline.setAlpha(outline_alpha) + painter.setBrush(Qt.NoBrush) + painter.setPen(QPen(outline, ctx.wf_line_width)) + top_poly = QPolygonF([QPointF(xs[i], ys_top[i]) for i in range(n_pts)]) + bot_poly = QPolygonF([QPointF(xs[i], ys_bot[i]) for i in range(n_pts)]) + painter.drawPolyline(top_poly) + painter.drawPolyline(bot_poly) + + center_color = QColor(160, 100, 220, 160) + painter.setPen(QPen(center_color, 2, Qt.DotLine)) + painter.drawLine(ctx.x0, int(mid_y), ctx.x0 + ctx.draw_w, int(mid_y)) + + painter.setClipping(False) + + if ch < nch - 1: + sep_y = int(y_off + lane_h) + painter.setPen(QPen(QColor("#555555"), 1)) + painter.drawLine(0, sep_y, widget_w, sep_y) + + def _draw_rms_overlay(self, painter: QPainter, ctx: WaveformRenderCtx, + nch: int, lane_h: float): + """Draw per-channel and combined RMS envelope lines.""" + if not self._rms_envelope: + return + lw = ctx.wf_line_width + ch_pen = QPen(QColor(255, 220, 60, 200), float(lw)) + comb_pen = QPen(QColor(255, 100, 40, 220), float(lw) * 1.5) + for ch in range(nch): + if ch >= len(self._rms_envelope): + break + y_off = ch * lane_h + mid_y = y_off + lane_h / 2.0 + scale = (lane_h / 2.0) * 0.85 * ctx.vscale + painter.setClipRect(ctx.x0, int(y_off), ctx.draw_w, int(lane_h)) + painter.setBrush(Qt.NoBrush) + if ctx.show_rms_lr: + ch_env = self._rms_envelope[ch] + n_rms = len(ch_env) + rxs = np.arange(n_rms, dtype=np.float64) + ctx.x0 + rys = mid_y - ch_env * scale + painter.setPen(ch_pen) + painter.drawPolyline(QPolygonF( + [QPointF(rxs[i], rys[i]) for i in range(n_rms)])) + if ctx.show_rms_avg and len(self._rms_combined) > 0: + n_comb = len(self._rms_combined) + cxs = np.arange(n_comb, dtype=np.float64) + ctx.x0 + cys = mid_y - self._rms_combined * scale + painter.setPen(comb_pen) + painter.drawPolyline(QPolygonF( + [QPointF(cxs[i], cys[i]) for i in range(n_comb)])) + painter.setClipping(False) + + def _draw_db_scale(self, painter: QPainter, ctx: WaveformRenderCtx, + nch: int, lane_h: float): + """Draw dB measurement scale on left/right margins and grid lines.""" + _DB_TICKS = [0, -3, -6, -12, -18, -24, -36, -48, -60] + _MIN_TICK_SPACING = 18 + scale_font = QFont("Consolas", 7) + painter.setFont(scale_font) + fm = painter.fontMetrics() + text_h = fm.height() + grid_color = QColor(COLORS["accent"]) + grid_color.setAlpha(35) + grid_pen = QPen(grid_color, 1, Qt.DotLine) + label_color = QColor(COLORS["dim"]) + tick_pen = QPen(label_color, 1) + full_right = ctx.x0 + ctx.draw_w + ctx.margin_right + for ch in range(nch): + y_off = ch * lane_h + mid_y = y_off + lane_h / 2.0 + scale = (lane_h / 2.0) * 0.85 * ctx.vscale + lane_top = int(y_off) + lane_bot = int(y_off + lane_h) + painter.setClipRect(0, lane_top, full_right, lane_bot - lane_top) + visible_ticks: list[tuple[int, float, float]] = [] + used_ys: list[float] = [] + for db_val in _DB_TICKS: + amp = 10.0 ** (db_val / 20.0) + pixel_offset = amp * scale + if pixel_offset >= lane_h / 2.0: + continue + y_top = mid_y - pixel_offset + y_bot = mid_y + pixel_offset + if y_top < lane_top + text_h or y_bot > lane_bot - text_h: + continue + too_close = False + for uy in used_ys: + if abs(uy - y_top) < _MIN_TICK_SPACING: + too_close = True + break + if db_val != 0 and abs(uy - y_bot) < _MIN_TICK_SPACING: + too_close = True + break + if too_close: + continue + visible_ticks.append((db_val, y_top, y_bot)) + used_ys.append(y_top) + if db_val != 0: + used_ys.append(y_bot) + for db_val, y_top, y_bot in visible_ticks: + label = str(db_val) + painter.setPen(grid_pen) + painter.drawLine(ctx.x0, int(y_top), ctx.x0 + ctx.draw_w, int(y_top)) + if db_val != 0: + painter.drawLine(ctx.x0, int(y_bot), ctx.x0 + ctx.draw_w, int(y_bot)) + painter.setPen(tick_pen) + text_w = fm.horizontalAdvance(label) + lx = ctx.x0 - 5 - text_w + painter.drawText(int(lx), int(y_top + text_h / 3), label) + if db_val != 0: + painter.drawText(int(lx), int(y_bot + text_h / 3), label) + rx = ctx.x0 + ctx.draw_w + 5 + painter.drawText(int(rx), int(y_top + text_h / 3), label) + if db_val != 0: + painter.drawText(int(rx), int(y_bot + text_h / 3), label) + painter.drawLine(ctx.x0 - 3, int(y_top), ctx.x0, int(y_top)) + painter.drawLine(ctx.x0 + ctx.draw_w, int(y_top), ctx.x0 + ctx.draw_w + 3, int(y_top)) + if db_val != 0: + painter.drawLine(ctx.x0 - 3, int(y_bot), ctx.x0, int(y_bot)) + painter.drawLine(ctx.x0 + ctx.draw_w, int(y_bot), ctx.x0 + ctx.draw_w + 3, int(y_bot)) + conn_color = QColor(45, 45, 45) + painter.setPen(QPen(conn_color, 1)) + painter.drawLine(0, int(y_top), full_right, int(y_top)) + if db_val != 0: + painter.drawLine(0, int(y_bot), full_right, int(y_bot)) + painter.setClipping(False) + + def _ensure_peak_computed(self): + if not self._peak_dirty: + return + self._peak_dirty = False + if not self._channels: + return + if len(self._channels) == 1: + self._peak_sample = int(np.argmax(np.abs(self._channels[0]))) + self._peak_channel = 0 + else: + abs_cols = np.column_stack([np.abs(ch) for ch in self._channels]) + max_per_sample = np.max(abs_cols, axis=1) + self._peak_sample = int(np.argmax(max_per_sample)) + self._peak_channel = int(np.argmax(abs_cols[self._peak_sample])) + peak_lin = abs(float(self._channels[self._peak_channel][self._peak_sample])) + self._peak_db = 20.0 * np.log10(peak_lin) if peak_lin > 0 else float('-inf') + self._peak_amplitude = float(self._channels[self._peak_channel][self._peak_sample]) + + def _ensure_rms_max_computed(self): + if not self._rms_max_dirty: + return + self._rms_max_dirty = False + self._compute_rms_max_sample() + + def _compute_rms_max_sample(self): + """Find the sample position of the maximum momentary RMS window.""" + win = self._rms_window_samples + if not self._channels or win <= 0: + self._rms_max_sample = -1 + return + ch_wms: list[np.ndarray] = [] + have_cumsums = len(self._rms_cumsums) == len(self._channels) + for ch_idx, ch_data in enumerate(self._channels): + n = len(ch_data) + if n <= win: + ch_wms.append(np.zeros(1, dtype=np.float64)) + continue + if have_cumsums: + cs = self._rms_cumsums[ch_idx] + else: + sq = ch_data.astype(np.float64) ** 2 + cs = np.empty(n + 1, dtype=np.float64) + cs[0] = 0.0 + np.cumsum(sq, out=cs[1:]) + ch_wms.append((cs[win:] - cs[:n - win + 1]) / win) + min_len = min(len(wm) for wm in ch_wms) + if min_len == 0: + self._rms_max_sample = -1 + return + combined = np.mean(np.column_stack([wm[:min_len] for wm in ch_wms]), axis=1) + max_idx = int(np.argmax(combined)) + self._rms_max_sample = max_idx + win // 2 + max_rms_lin = float(np.sqrt(combined[max_idx])) + self._rms_max_db = 20.0 * np.log10(max_rms_lin) if max_rms_lin > 0 else float('-inf') + self._rms_max_amplitude = max_rms_lin + + def _draw_markers(self, painter: QPainter, ctx: WaveformRenderCtx, + nch: int, lane_h: float): + """Draw peak and max RMS marker vertical lines.""" + self._ensure_peak_computed() + self._ensure_rms_max_computed() + marker_font = QFont("Consolas", 7, QFont.Bold) + _CROSS_HALF = 6 + if self._peak_sample >= 0: + px = ctx.x0 + self._sample_to_x(self._peak_sample, ctx) + if ctx.x0 <= px <= ctx.x0 + ctx.draw_w: + peak_color = QColor(180, 50, 220, 250) + painter.setPen(QPen(peak_color, 1)) + painter.drawLine(px, 0, px, ctx.draw_h) + painter.setFont(marker_font) + painter.setPen(peak_color) + painter.drawText(px + 3, 12, "P") + if 0 <= self._peak_channel < nch: + ch = self._peak_channel + amp = self._peak_amplitude + y_off = ch * lane_h + mid_y = y_off + lane_h / 2.0 + scale = (lane_h / 2.0) * 0.85 * ctx.vscale + cy = int(mid_y - amp * scale) + painter.setPen(QPen(peak_color, 1)) + painter.drawLine(px - _CROSS_HALF, cy, px + _CROSS_HALF, cy) + if self._rms_max_sample >= 0: + rx = ctx.x0 + self._sample_to_x(self._rms_max_sample, ctx) + if ctx.x0 <= rx <= ctx.x0 + ctx.draw_w: + rms_color = QColor(40, 160, 220, 250) + painter.setPen(QPen(rms_color, 1)) + painter.drawLine(rx, 0, rx, ctx.draw_h) + painter.setFont(marker_font) + painter.setPen(rms_color) + painter.drawText(rx + 3, 24, "R") + amp = self._rms_max_amplitude + if amp > 0: + painter.setPen(QPen(rms_color, 1)) + for ch in range(nch): + y_off = ch * lane_h + mid_y = y_off + lane_h / 2.0 + scale = (lane_h / 2.0) * 0.85 * ctx.vscale + cy = int(mid_y - amp * scale) + painter.drawLine(rx - _CROSS_HALF, cy, rx + _CROSS_HALF, cy) + + def _build_rms_envelope(self, ctx: WaveformRenderCtx): + """Compute per-channel AND combined RMS envelopes for ctx.draw_w pixels.""" + win = self._rms_window_samples + width = ctx.draw_w + if not ctx.channels or width <= 0 or win <= 0: + self._rms_envelope = [] + self._rms_combined = [] + return + cache_key = (width, ctx.view_start, ctx.view_end) + if self._rms_cache_key == cache_key and self._rms_envelope: + return + vs, ve = ctx.view_start, ctx.view_end + view_len = ve - vs + if view_len <= 0: + self._rms_envelope = [] + self._rms_combined = [] + return + half_win = win // 2 + have_cumsums = len(self._rms_cumsums) == len(ctx.channels) + ch_wms: list[np.ndarray] = [] + wm_offset = 0 + for ch_idx, ch_data in enumerate(ctx.channels): + n = len(ch_data) + n_wm_total = n - win + 1 + if n <= win: + ch_wms.append(np.zeros(1, dtype=np.float64)) + continue + if have_cumsums: + cs = self._rms_cumsums[ch_idx] + else: + sq = ch_data.astype(np.float64) ** 2 + cs = np.empty(n + 1, dtype=np.float64) + cs[0] = 0.0 + np.cumsum(sq, out=cs[1:]) + wm_lo = max(0, vs - half_win - win) + wm_hi = min(n_wm_total, ve + half_win + win) + wm_offset = wm_lo + ch_wms.append((cs[wm_lo + win: wm_hi + win] - cs[wm_lo: wm_hi]) / win) + min_len = min(len(wm) for wm in ch_wms) + if min_len > 1 and len(ch_wms) > 1: + combined_wm = np.mean( + np.column_stack([wm[:min_len] for wm in ch_wms]), axis=1) + elif ch_wms: + combined_wm = ch_wms[0][:min_len].copy() + else: + combined_wm = np.zeros(1, dtype=np.float64) + + def _downsample(wm: np.ndarray, offset: int) -> np.ndarray: + n_wm = len(wm) + if n_wm == 0: + return np.zeros(width) + pixel_edges = np.arange(width + 1) + s_edges = vs + pixel_edges * view_len // width + global_wm = np.clip(s_edges - half_win, 0, offset + n_wm) + local_wm = np.clip(global_wm - offset, 0, n_wm) + first = int(local_wm[0]) + last = max(int(local_wm[-1]), first + 1) + last = min(last, n_wm) + wm_slice = wm[first:last] + n_slice = len(wm_slice) + if n_slice >= width: + spb = n_slice // width + n_use = spb * width + reshaped = wm_slice[:n_use].reshape(width, spb) + result = np.sqrt(np.maximum(reshaped.max(axis=1), 0.0)) + if n_use < n_slice: + tail_max = float(wm_slice[n_use:].max()) + result[-1] = np.sqrt(max(float(result[-1]) ** 2, tail_max)) + return result + else: + local = np.clip(local_wm[:-1] - first, 0, + max(n_slice - 1, 0)).astype(np.intp) + return np.sqrt(np.maximum(wm_slice[local], 0.0)) + + self._rms_envelope = [_downsample(wm, wm_offset) for wm in ch_wms] + self._rms_combined = _downsample(combined_wm, wm_offset) + self._rms_cache_key = cache_key diff --git a/sessionprepgui/waveform_spectrogram.py b/sessionprepgui/waveform_spectrogram.py new file mode 100644 index 0000000..bf1c8e9 --- /dev/null +++ b/sessionprepgui/waveform_spectrogram.py @@ -0,0 +1,315 @@ +"""Spectrogram renderer: mel image, frequency scale, freq zoom, recompute worker.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPen + +from .theme import COLORS +from .waveform_compute import ( + SPECTROGRAM_COLORMAPS, SpectrogramRecomputeWorker, + _SPEC_DB_FLOOR, _SPEC_F_MAX, _SPEC_F_MIN, _SPEC_N_FFT, + _hz_to_mel, _mel_to_hz, +) + + +@dataclass +class SpecRenderCtx: + """All data SpectrogramRenderer needs — snapshot from WaveformWidget.""" + x0: int + draw_w: int + draw_h: int + view_start: int + view_end: int + total_samples: int + samplerate: int + + +class SpectrogramRenderer: + """Renders the mel spectrogram image, frequency scale, and frequency guide. + + Owns all spectrogram-specific state: image cache, FFT settings, mel view + range, and the recompute worker. + """ + + def __init__(self): + self._spec_db: np.ndarray | None = None + self._spec_image: QImage | None = None + self._spec_image_data = None # prevent GC of numpy buffer + self._spec_cache_key: tuple = () + self._spec_recompute_worker: SpectrogramRecomputeWorker | None = None + self._spec_n_fft: int = _SPEC_N_FFT + self._spec_window: str = "hann" + self._spec_db_floor: float = _SPEC_DB_FLOOR + self._spec_db_ceil: float = 0.0 + self._colormap: str = "magma" + self._mel_view_min: float = _hz_to_mel(_SPEC_F_MIN) + self._mel_view_max: float = _hz_to_mel(_SPEC_F_MAX) + self._on_done_callback = None + + # ── Public API ────────────────────────────────────────────────────────── + + def reset(self, samplerate: int = 44100): + """Clear spec data on track unload. Resets mel view to full range.""" + if self._spec_recompute_worker is not None: + self._spec_recompute_worker.cancel() + self._spec_recompute_worker.finished.disconnect() + self._spec_recompute_worker = None + self._spec_db = None + self._spec_image = None + self._spec_image_data = None + self._spec_cache_key = () + self._mel_view_min = _hz_to_mel(_SPEC_F_MIN) + self._mel_view_max = _hz_to_mel(min(_SPEC_F_MAX, samplerate / 2.0)) + + def set_spec_data(self, spec_db): + """Set new spectrogram data and invalidate image cache.""" + self._spec_db = spec_db + self._spec_image = None + self._spec_image_data = None + self._spec_cache_key = () + + def invalidate(self): + """Force image rebuild on next paint (e.g. resize).""" + self._spec_image = None + self._spec_image_data = None + self._spec_cache_key = () + + def paint(self, painter: QPainter, ctx: SpecRenderCtx): + """Paint spectrogram image and frequency scale.""" + if self._spec_db is None: + painter.setPen(QPen(QColor(COLORS["dim"]))) + painter.drawText(ctx.x0, int(ctx.draw_h / 2), + "Spectrogram not available (audio too short)") + return + cache_key = (ctx.view_start, ctx.view_end, ctx.draw_w, + int(ctx.draw_h), self._colormap, + self._mel_view_min, self._mel_view_max, + self._spec_db_floor, self._spec_db_ceil) + if self._spec_cache_key != cache_key or self._spec_image is None: + self._build_spec_image(ctx) + self._spec_cache_key = cache_key + if self._spec_image is not None: + painter.drawImage(ctx.x0, 0, self._spec_image) + self._draw_freq_scale(painter, ctx) + + def draw_freq_guide(self, painter: QPainter, ctx: SpecRenderCtx, my: float): + """Draw frequency readout at mouse position (called from paintEvent).""" + if self._spec_db is None or ctx.draw_h <= 0: + return + mel_range = self._mel_view_max - self._mel_view_min + if mel_range <= 0: + return + frac = max(0.0, min(1.0 - my / ctx.draw_h, 1.0)) + freq = _mel_to_hz(self._mel_view_min + frac * mel_range) + freq_label = f"{freq / 1000:.1f} kHz" if freq >= 1000 else f"{freq:.0f} Hz" + painter.setFont(QFont("Consolas", 7)) + label_color = QColor(180, 180, 180, 120) + painter.setPen(label_color) + fm = painter.fontMetrics() + tw = fm.horizontalAdvance(freq_label) + label_y = int(my) + fm.ascent() // 2 + painter.drawText(ctx.x0 + 4, label_y, freq_label) + painter.drawText(ctx.x0 + ctx.draw_w - tw - 4, label_y, freq_label) + + def freq_zoom(self, factor: float, anchor_mel: float | None, + samplerate: int = 44100): + """Zoom the mel frequency range by factor around anchor_mel.""" + mel_range = self._mel_view_max - self._mel_view_min + mel_full_min = _hz_to_mel(_SPEC_F_MIN) + mel_full_max = _hz_to_mel(min(_SPEC_F_MAX, samplerate / 2.0)) + if anchor_mel is not None: + anchor = max(self._mel_view_min, min(anchor_mel, self._mel_view_max)) + frac = (anchor - self._mel_view_min) / mel_range if mel_range > 0 else 0.5 + else: + anchor = (self._mel_view_min + self._mel_view_max) / 2.0 + frac = 0.5 + new_range = max(min(mel_range * factor, mel_full_max - mel_full_min), 50.0) + new_min = anchor - frac * new_range + new_max = anchor + (1.0 - frac) * new_range + if new_min < mel_full_min: + new_min = mel_full_min + new_max = new_min + new_range + if new_max > mel_full_max: + new_max = mel_full_max + new_min = new_max - new_range + self._mel_view_min = max(new_min, mel_full_min) + self._mel_view_max = min(new_max, mel_full_max) + self._spec_image = None + self._spec_cache_key = () + + def scroll_freq(self, delta_mel: float, samplerate: int = 44100): + """Pan the frequency view by delta_mel mels.""" + mel_full_min = _hz_to_mel(_SPEC_F_MIN) + mel_full_max = _hz_to_mel(min(_SPEC_F_MAX, samplerate / 2.0)) + mel_range = self._mel_view_max - self._mel_view_min + new_min = self._mel_view_min + delta_mel + new_max = self._mel_view_max + delta_mel + if new_min < mel_full_min: + new_min = mel_full_min + new_max = new_min + mel_range + if new_max > mel_full_max: + new_max = mel_full_max + new_min = new_max - mel_range + self._mel_view_min = max(new_min, mel_full_min) + self._mel_view_max = min(new_max, mel_full_max) + self._spec_image = None + self._spec_cache_key = () + + def reset_freq_view(self, samplerate: int): + """Reset mel frequency view to full range (used by zoom_fit).""" + self._mel_view_min = _hz_to_mel(_SPEC_F_MIN) + self._mel_view_max = _hz_to_mel(min(_SPEC_F_MAX, samplerate / 2.0)) + self._spec_image = None + self._spec_cache_key = () + + def recompute(self, channels: list, sr: int, *, on_done, parent=None): + """Launch background spectrogram recompute. Calls on_done() when done.""" + if self._spec_recompute_worker is not None: + self._spec_recompute_worker.cancel() + self._spec_recompute_worker.finished.disconnect() + self._spec_recompute_worker = None + self._spec_db = None + self._spec_image = None + self._spec_cache_key = () + self._on_done_callback = on_done + worker = SpectrogramRecomputeWorker( + channels, sr, + n_fft=self._spec_n_fft, window=self._spec_window, + parent=parent, + ) + worker.finished.connect(self._on_spec_recomputed) + self._spec_recompute_worker = worker + worker.start() + + def set_colormap(self, name: str): + if name not in SPECTROGRAM_COLORMAPS: + return + self._colormap = name + self._spec_image = None + self._spec_cache_key = () + + def set_n_fft(self, n_fft: int): + self._spec_n_fft = n_fft + + def set_window(self, window: str): + self._spec_window = window + + def set_db_floor(self, val: float): + self._spec_db_floor = val + self._spec_image = None + self._spec_cache_key = () + + def set_db_ceil(self, val: float): + self._spec_db_ceil = val + self._spec_image = None + self._spec_cache_key = () + + @property + def mel_view_min(self) -> float: + return self._mel_view_min + + @property + def mel_view_max(self) -> float: + return self._mel_view_max + + @property + def spec_n_fft(self) -> int: + return self._spec_n_fft + + @property + def spec_window(self) -> str: + return self._spec_window + + # ── Internal helpers ──────────────────────────────────────────────────── + + def _on_spec_recomputed(self, spec_db): + self._spec_db = spec_db + self._spec_image = None + self._spec_cache_key = () + self._spec_recompute_worker = None + if self._on_done_callback is not None: + self._on_done_callback() + + def _build_spec_image(self, ctx: SpecRenderCtx): + """Render the visible portion of the spectrogram to a cached QImage.""" + spec = self._spec_db + if spec is None or ctx.draw_w <= 0 or ctx.draw_h <= 0: + self._spec_image = None + return + n_mels, n_frames = spec.shape + frame_start = max(0, ctx.view_start * n_frames // ctx.total_samples) + frame_end = min(n_frames, ctx.view_end * n_frames // ctx.total_samples) + if frame_end <= frame_start: + frame_end = min(frame_start + 1, n_frames) + mel_full_min = _hz_to_mel(_SPEC_F_MIN) + mel_full_max = _hz_to_mel(min(_SPEC_F_MAX, ctx.samplerate / 2.0)) + mel_full_range = mel_full_max - mel_full_min + if mel_full_range <= 0: + self._spec_image = None + return + row_lo = int((self._mel_view_min - mel_full_min) / mel_full_range * (n_mels - 1)) + row_hi = int(np.ceil((self._mel_view_max - mel_full_min) / mel_full_range * (n_mels - 1))) + row_lo = max(0, min(row_lo, n_mels - 1)) + row_hi = max(row_lo + 1, min(row_hi + 1, n_mels)) + view_spec = spec[row_lo:row_hi, frame_start:frame_end] + db_floor = self._spec_db_floor + db_ceil = self._spec_db_ceil + norm = np.clip((view_spec - db_floor) / max(db_ceil - db_floor, 1.0), 0.0, 1.0) + norm = norm[::-1, :] # low freq at bottom + lut = SPECTROGRAM_COLORMAPS.get(self._colormap) + if lut is None: + lut = SPECTROGRAM_COLORMAPS.get("magma", np.zeros((256, 4), np.uint8)) + indices = (norm * 255).astype(np.uint8) + rgba = lut[indices] + nat_h, nat_w = rgba.shape[:2] + rgba_c = np.ascontiguousarray(rgba) + self._spec_image_data = rgba_c + native_img = QImage(rgba_c.data, nat_w, nat_h, nat_w * 4, QImage.Format.Format_RGBA8888) + self._spec_image = native_img.scaled(ctx.draw_w, ctx.draw_h, Qt.IgnoreAspectRatio, Qt.SmoothTransformation) + + def _draw_freq_scale(self, painter: QPainter, ctx: SpecRenderCtx): + """Draw frequency scale on left/right margins for spectrogram mode.""" + if self._spec_db is None or ctx.draw_h <= 0: + return + _FREQ_TICKS = [50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000] + _MIN_TICK_SPACING = 20 + mel_min = self._mel_view_min + mel_max = self._mel_view_max + mel_range = mel_max - mel_min + if mel_range <= 0: + return + scale_font = QFont("Consolas", 7) + painter.setFont(scale_font) + fm = painter.fontMetrics() + text_h = fm.height() + label_color = QColor(COLORS["dim"]) + tick_pen = QPen(label_color, 1) + grid_color = QColor(COLORS["accent"]) + grid_color.setAlpha(35) + grid_pen = QPen(grid_color, 1, Qt.DotLine) + used_ys: list[int] = [] + for freq in _FREQ_TICKS: + mel = _hz_to_mel(freq) + if mel < mel_min or mel > mel_max: + continue + frac = (mel - mel_min) / mel_range + y = int(ctx.draw_h * (1.0 - frac)) + if y < text_h or y > ctx.draw_h - text_h: + continue + if any(abs(uy - y) < _MIN_TICK_SPACING for uy in used_ys): + continue + used_ys.append(y) + label = f"{freq // 1000}k" if freq >= 1000 else str(freq) + painter.setPen(grid_pen) + painter.drawLine(ctx.x0, y, ctx.x0 + ctx.draw_w, y) + painter.setPen(tick_pen) + tw = fm.horizontalAdvance(label) + painter.drawText(ctx.x0 - 5 - tw, y + fm.ascent() // 2, label) + painter.drawText(ctx.x0 + ctx.draw_w + 5, y + fm.ascent() // 2, label) + painter.drawLine(ctx.x0 - 3, y, ctx.x0, y) + painter.drawLine(ctx.x0 + ctx.draw_w, y, ctx.x0 + ctx.draw_w + 3, y) From 34423dafb0f92ad529f1459afe694e6c4224b50d Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Thu, 19 Feb 2026 23:43:05 +0100 Subject: [PATCH 17/30] GUI directory restructuring. --- sessionprepgui/analysis/__init__.py | 13 ++++++++++++ .../{analysis_mixin.py => analysis/mixin.py} | 20 +++++++++--------- sessionprepgui/{ => analysis}/worker.py | 0 sessionprepgui/daw/__init__.py | 5 +++++ sessionprepgui/{daw_mixin.py => daw/mixin.py} | 8 +++---- sessionprepgui/detail/__init__.py | 11 ++++++++++ .../{detail_mixin.py => detail/mixin.py} | 10 ++++----- sessionprepgui/{ => detail}/playback.py | 0 sessionprepgui/{ => detail}/report.py | 4 ++-- sessionprepgui/mainwindow.py | 21 +++++++------------ sessionprepgui/prefs/__init__.py | 9 ++++++++ .../{preferences.py => prefs/dialog.py} | 6 +++--- sessionprepgui/{ => prefs}/param_widgets.py | 0 sessionprepgui/session/__init__.py | 5 +++++ .../{session_io.py => session/io.py} | 0 sessionprepgui/tracks/__init__.py | 21 +++++++++++++++++++ .../columns_mixin.py} | 10 ++++----- sessionprepgui/{ => tracks}/groups_mixin.py | 8 +++---- sessionprepgui/{ => tracks}/table_widgets.py | 4 ++-- sessionprepgui/waveform/__init__.py | 6 ++++++ .../compute.py} | 0 .../overlay.py} | 4 ++-- .../renderer.py} | 2 +- .../spectrogram.py} | 4 ++-- .../{waveform.py => waveform/widget.py} | 10 ++++----- 25 files changed, 123 insertions(+), 58 deletions(-) create mode 100644 sessionprepgui/analysis/__init__.py rename sessionprepgui/{analysis_mixin.py => analysis/mixin.py} (98%) rename sessionprepgui/{ => analysis}/worker.py (100%) create mode 100644 sessionprepgui/daw/__init__.py rename sessionprepgui/{daw_mixin.py => daw/mixin.py} (99%) create mode 100644 sessionprepgui/detail/__init__.py rename sessionprepgui/{detail_mixin.py => detail/mixin.py} (98%) rename sessionprepgui/{ => detail}/playback.py (100%) rename sessionprepgui/{ => detail}/report.py (99%) create mode 100644 sessionprepgui/prefs/__init__.py rename sessionprepgui/{preferences.py => prefs/dialog.py} (99%) rename sessionprepgui/{ => prefs}/param_widgets.py (100%) create mode 100644 sessionprepgui/session/__init__.py rename sessionprepgui/{session_io.py => session/io.py} (100%) create mode 100644 sessionprepgui/tracks/__init__.py rename sessionprepgui/{track_columns_mixin.py => tracks/columns_mixin.py} (99%) rename sessionprepgui/{ => tracks}/groups_mixin.py (99%) rename sessionprepgui/{ => tracks}/table_widgets.py (99%) create mode 100644 sessionprepgui/waveform/__init__.py rename sessionprepgui/{waveform_compute.py => waveform/compute.py} (100%) rename sessionprepgui/{waveform_overlay.py => waveform/overlay.py} (98%) rename sessionprepgui/{waveform_renderer.py => waveform/renderer.py} (99%) rename sessionprepgui/{waveform_spectrogram.py => waveform/spectrogram.py} (99%) rename sessionprepgui/{waveform.py => waveform/widget.py} (98%) diff --git a/sessionprepgui/analysis/__init__.py b/sessionprepgui/analysis/__init__.py new file mode 100644 index 0000000..f07dd64 --- /dev/null +++ b/sessionprepgui/analysis/__init__.py @@ -0,0 +1,13 @@ +"""Analysis subpackage: analysis mixin and background workers.""" + +from .mixin import AnalysisMixin +from .worker import ( + AudioLoadWorker, AnalyzeWorker, PrepareWorker, + BatchReanalyzeWorker, DawCheckWorker, DawFetchWorker, DawTransferWorker, +) + +__all__ = [ + "AnalysisMixin", + "AudioLoadWorker", "AnalyzeWorker", "PrepareWorker", + "BatchReanalyzeWorker", "DawCheckWorker", "DawFetchWorker", "DawTransferWorker", +] diff --git a/sessionprepgui/analysis_mixin.py b/sessionprepgui/analysis/mixin.py similarity index 98% rename from sessionprepgui/analysis_mixin.py rename to sessionprepgui/analysis/mixin.py index dc1325c..c2a522c 100644 --- a/sessionprepgui/analysis_mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -27,18 +27,18 @@ from sessionpreplib.processors import default_processors from sessionpreplib.utils import protools_sort_key -from .helpers import track_analysis_label -from .param_widgets import build_config_pages, load_config_widgets, read_config_widgets -from .report import render_track_detail_html -from .session_io import save_session as _save_session_file, load_session as _load_session_file -from .settings import build_defaults, resolve_config_preset -from .table_widgets import ( +from ..helpers import track_analysis_label +from ..prefs.param_widgets import build_config_pages, load_config_widgets, read_config_widgets +from ..detail.report import render_track_detail_html +from ..session.io import save_session as _save_session_file, load_session as _load_session_file +from ..settings import build_defaults, resolve_config_preset +from ..tracks.table_widgets import ( _SortableItem, _make_analysis_cell, _TAB_FILE, _TAB_GROUPS, _TAB_SESSION, _TAB_SUMMARY, _PAGE_PROGRESS, _PAGE_TABS, _PHASE_ANALYSIS, _PHASE_SETUP, ) -from .theme import COLORS, FILE_COLOR_OK, FILE_COLOR_ERROR +from ..theme import COLORS, FILE_COLOR_OK, FILE_COLOR_ERROR from .worker import AnalyzeWorker, PrepareWorker @@ -485,8 +485,8 @@ def _on_track_planned(self, filename: str, track): self._track_table.removeCellWidget(row, 5) from PySide6.QtWidgets import QDoubleSpinBox - from .widgets import BatchComboBox - from .theme import ( + from ..widgets import BatchComboBox + from ..theme import ( FILE_COLOR_SILENT, FILE_COLOR_TRANSIENT, FILE_COLOR_SUSTAINED, ) @@ -616,7 +616,7 @@ def _on_analyze_error(self, message: str): self._analyze_action.setEnabled(True) self._worker = None - from .helpers import esc + from ..helpers import esc self._right_stack.setCurrentIndex(_PAGE_TABS) self._detail_tabs.setCurrentIndex(_TAB_SUMMARY) diff --git a/sessionprepgui/worker.py b/sessionprepgui/analysis/worker.py similarity index 100% rename from sessionprepgui/worker.py rename to sessionprepgui/analysis/worker.py diff --git a/sessionprepgui/daw/__init__.py b/sessionprepgui/daw/__init__.py new file mode 100644 index 0000000..36a1694 --- /dev/null +++ b/sessionprepgui/daw/__init__.py @@ -0,0 +1,5 @@ +"""DAW integration subpackage.""" + +from .mixin import DawMixin + +__all__ = ["DawMixin"] diff --git a/sessionprepgui/daw_mixin.py b/sessionprepgui/daw/mixin.py similarity index 99% rename from sessionprepgui/daw_mixin.py rename to sessionprepgui/daw/mixin.py index a1a30d5..cc93df4 100644 --- a/sessionprepgui/daw_mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -26,13 +26,13 @@ from sessionpreplib.daw_processors import create_runtime_daw_processors -from .table_widgets import ( +from ..tracks.table_widgets import ( _FolderDropTree, _SetupDragTable, _SETUP_RIGHT_PLACEHOLDER, _SETUP_RIGHT_TREE, ) -from .theme import COLORS, PT_DEFAULT_COLORS -from .widgets import ProgressPanel -from .worker import DawCheckWorker, DawFetchWorker, DawTransferWorker +from ..theme import COLORS, PT_DEFAULT_COLORS +from ..widgets import ProgressPanel +from ..analysis.worker import DawCheckWorker, DawFetchWorker, DawTransferWorker class DawMixin: diff --git a/sessionprepgui/detail/__init__.py b/sessionprepgui/detail/__init__.py new file mode 100644 index 0000000..2eeed87 --- /dev/null +++ b/sessionprepgui/detail/__init__.py @@ -0,0 +1,11 @@ +"""Detail view subpackage: report, playback, and detail mixin.""" + +from .mixin import DetailMixin +from .report import render_track_detail_html, render_summary_html +from .playback import PlaybackController + +__all__ = [ + "DetailMixin", + "render_track_detail_html", "render_summary_html", + "PlaybackController", +] diff --git a/sessionprepgui/detail_mixin.py b/sessionprepgui/detail/mixin.py similarity index 98% rename from sessionprepgui/detail_mixin.py rename to sessionprepgui/detail/mixin.py index bb6546e..e658b97 100644 --- a/sessionprepgui/detail_mixin.py +++ b/sessionprepgui/detail/mixin.py @@ -10,12 +10,12 @@ from sessionpreplib.audio import get_window_samples -from .helpers import fmt_time +from ..helpers import fmt_time from .report import render_summary_html, render_track_detail_html -from .table_widgets import _TAB_FILE, _TAB_SUMMARY -from .theme import COLORS -from .worker import AudioLoadWorker -from .waveform_compute import WaveformLoadWorker +from ..tracks.table_widgets import _TAB_FILE, _TAB_SUMMARY +from ..theme import COLORS +from ..analysis.worker import AudioLoadWorker +from ..waveform.compute import WaveformLoadWorker class DetailMixin: diff --git a/sessionprepgui/playback.py b/sessionprepgui/detail/playback.py similarity index 100% rename from sessionprepgui/playback.py rename to sessionprepgui/detail/playback.py diff --git a/sessionprepgui/report.py b/sessionprepgui/detail/report.py similarity index 99% rename from sessionprepgui/report.py rename to sessionprepgui/detail/report.py index a12e08c..2f811d3 100644 --- a/sessionprepgui/report.py +++ b/sessionprepgui/detail/report.py @@ -2,8 +2,8 @@ from __future__ import annotations -from .theme import COLORS, FILE_COLOR_TRANSIENT, FILE_COLOR_SUSTAINED -from .helpers import esc +from ..theme import COLORS, FILE_COLOR_TRANSIENT, FILE_COLOR_SUSTAINED +from ..helpers import esc from sessionpreplib.chunks import read_chunks, STANDARD_CHUNKS, detect_origin diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 2e47d59..999c727 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -48,28 +48,23 @@ ) from .theme import COLORS, apply_dark_theme from .log import dbg -from .preferences import PreferencesDialog -from .report import render_track_detail_html -from .waveform import WaveformWidget -from .waveform_compute import WaveformLoadWorker -from .playback import PlaybackController +from .prefs import PreferencesDialog +from .detail import render_track_detail_html, PlaybackController, DetailMixin +from .waveform import WaveformWidget, WaveformLoadWorker from .widgets import ProgressPanel -from .worker import ( +from .analysis import ( + AnalysisMixin, AudioLoadWorker, BatchReanalyzeWorker, DawCheckWorker, DawFetchWorker, DawTransferWorker, PrepareWorker, ) - -from .table_widgets import ( +from .tracks import ( + TrackColumnsMixin, GroupsMixin, _HelpBrowser, _DraggableTrackTable, _SortableItem, _TAB_SUMMARY, _TAB_FILE, _TAB_GROUPS, _TAB_SESSION, _PAGE_PROGRESS, _PAGE_TABS, _PHASE_ANALYSIS, _PHASE_SETUP, ) -from .analysis_mixin import AnalysisMixin -from .track_columns_mixin import TrackColumnsMixin -from .groups_mixin import GroupsMixin -from .daw_mixin import DawMixin -from .detail_mixin import DetailMixin +from .daw import DawMixin class SessionPrepWindow(QMainWindow, AnalysisMixin, TrackColumnsMixin, diff --git a/sessionprepgui/prefs/__init__.py b/sessionprepgui/prefs/__init__.py new file mode 100644 index 0000000..68996ad --- /dev/null +++ b/sessionprepgui/prefs/__init__.py @@ -0,0 +1,9 @@ +"""Preferences subpackage.""" + +from .dialog import PreferencesDialog, _argb_to_qcolor +from .param_widgets import build_config_pages, load_config_widgets, read_config_widgets + +__all__ = [ + "PreferencesDialog", "_argb_to_qcolor", + "build_config_pages", "load_config_widgets", "read_config_widgets", +] diff --git a/sessionprepgui/preferences.py b/sessionprepgui/prefs/dialog.py similarity index 99% rename from sessionprepgui/preferences.py rename to sessionprepgui/prefs/dialog.py index 768a62d..a834730 100644 --- a/sessionprepgui/preferences.py +++ b/sessionprepgui/prefs/dialog.py @@ -48,13 +48,13 @@ GroupsTableWidget, sanitize_output_folder, ) -from .settings import ( +from ..settings import ( _APP_DEFAULTS, _PRESENTATION_DEFAULTS, _build_default_config_preset, build_defaults, ) -from .theme import PT_DEFAULT_COLORS +from ..theme import PT_DEFAULT_COLORS @@ -737,7 +737,7 @@ def _on_group_preset_reset_default(self): QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply != QMessageBox.Yes: return - from .settings import _DEFAULT_GROUPS + from ..settings import _DEFAULT_GROUPS self._group_presets_data[current] = copy.deepcopy(_DEFAULT_GROUPS) self._load_groups_for_preset(current) diff --git a/sessionprepgui/param_widgets.py b/sessionprepgui/prefs/param_widgets.py similarity index 100% rename from sessionprepgui/param_widgets.py rename to sessionprepgui/prefs/param_widgets.py diff --git a/sessionprepgui/session/__init__.py b/sessionprepgui/session/__init__.py new file mode 100644 index 0000000..93a8c4b --- /dev/null +++ b/sessionprepgui/session/__init__.py @@ -0,0 +1,5 @@ +"""Session I/O subpackage.""" + +from .io import save_session, load_session + +__all__ = ["save_session", "load_session"] diff --git a/sessionprepgui/session_io.py b/sessionprepgui/session/io.py similarity index 100% rename from sessionprepgui/session_io.py rename to sessionprepgui/session/io.py diff --git a/sessionprepgui/tracks/__init__.py b/sessionprepgui/tracks/__init__.py new file mode 100644 index 0000000..3ff7d92 --- /dev/null +++ b/sessionprepgui/tracks/__init__.py @@ -0,0 +1,21 @@ +"""Tracks subpackage: track columns mixin, groups mixin, and table widgets.""" + +from .columns_mixin import TrackColumnsMixin +from .groups_mixin import GroupsMixin +from .table_widgets import ( + _HelpBrowser, _DraggableTrackTable, _SortableItem, _make_analysis_cell, + _TAB_SUMMARY, _TAB_FILE, _TAB_GROUPS, _TAB_SESSION, + _PAGE_PROGRESS, _PAGE_TABS, + _PHASE_ANALYSIS, _PHASE_SETUP, + _FolderDropTree, _SetupDragTable, + _SETUP_RIGHT_PLACEHOLDER, _SETUP_RIGHT_TREE, +) + +__all__ = [ + "TrackColumnsMixin", "GroupsMixin", + "_HelpBrowser", "_DraggableTrackTable", "_SortableItem", "_make_analysis_cell", + "_TAB_SUMMARY", "_TAB_FILE", "_TAB_GROUPS", "_TAB_SESSION", + "_PAGE_PROGRESS", "_PAGE_TABS", "_PHASE_ANALYSIS", "_PHASE_SETUP", + "_FolderDropTree", "_SetupDragTable", + "_SETUP_RIGHT_PLACEHOLDER", "_SETUP_RIGHT_TREE", +] diff --git a/sessionprepgui/track_columns_mixin.py b/sessionprepgui/tracks/columns_mixin.py similarity index 99% rename from sessionprepgui/track_columns_mixin.py rename to sessionprepgui/tracks/columns_mixin.py index f9818ef..37d4ea4 100644 --- a/sessionprepgui/track_columns_mixin.py +++ b/sessionprepgui/tracks/columns_mixin.py @@ -18,10 +18,10 @@ from sessionpreplib.processors import default_processors from sessionpreplib.utils import protools_sort_key -from .helpers import track_analysis_label -from .report import render_track_detail_html +from ..helpers import track_analysis_label +from ..detail.report import render_track_detail_html from .table_widgets import _SortableItem, _make_analysis_cell -from .theme import ( +from ..theme import ( COLORS, FILE_COLOR_OK, FILE_COLOR_ERROR, @@ -29,8 +29,8 @@ FILE_COLOR_TRANSIENT, FILE_COLOR_SUSTAINED, ) -from .widgets import BatchComboBox, BatchToolButton -from .worker import BatchReanalyzeWorker +from ..widgets import BatchComboBox, BatchToolButton +from ..analysis.worker import BatchReanalyzeWorker class TrackColumnsMixin: diff --git a/sessionprepgui/groups_mixin.py b/sessionprepgui/tracks/groups_mixin.py similarity index 99% rename from sessionprepgui/groups_mixin.py rename to sessionprepgui/tracks/groups_mixin.py index 6db25ca..cbcf45d 100644 --- a/sessionprepgui/groups_mixin.py +++ b/sessionprepgui/tracks/groups_mixin.py @@ -24,11 +24,11 @@ QWidget, ) -from .preferences import _argb_to_qcolor -from .settings import build_defaults, save_config +from ..prefs.dialog import _argb_to_qcolor +from ..settings import build_defaults, save_config from .table_widgets import _SortableItem -from .theme import COLORS, PT_DEFAULT_COLORS -from .widgets import BatchComboBox +from ..theme import COLORS, PT_DEFAULT_COLORS +from ..widgets import BatchComboBox class GroupsMixin: diff --git a/sessionprepgui/table_widgets.py b/sessionprepgui/tracks/table_widgets.py similarity index 99% rename from sessionprepgui/table_widgets.py rename to sessionprepgui/tracks/table_widgets.py index 4cd73a1..ec2192e 100644 --- a/sessionprepgui/table_widgets.py +++ b/sessionprepgui/tracks/table_widgets.py @@ -14,8 +14,8 @@ QTreeWidget, ) -from .theme import COLORS -from .widgets import BatchEditTableWidget +from ..theme import COLORS +from ..widgets import BatchEditTableWidget # ── Constants ──────────────────────────────────────────────────────────────── diff --git a/sessionprepgui/waveform/__init__.py b/sessionprepgui/waveform/__init__.py new file mode 100644 index 0000000..84a568c --- /dev/null +++ b/sessionprepgui/waveform/__init__.py @@ -0,0 +1,6 @@ +"""Waveform display subpackage.""" + +from .widget import WaveformWidget +from .compute import WaveformLoadWorker, SPECTROGRAM_COLORMAPS + +__all__ = ["WaveformWidget", "WaveformLoadWorker", "SPECTROGRAM_COLORMAPS"] diff --git a/sessionprepgui/waveform_compute.py b/sessionprepgui/waveform/compute.py similarity index 100% rename from sessionprepgui/waveform_compute.py rename to sessionprepgui/waveform/compute.py diff --git a/sessionprepgui/waveform_overlay.py b/sessionprepgui/waveform/overlay.py similarity index 98% rename from sessionprepgui/waveform_overlay.py rename to sessionprepgui/waveform/overlay.py index 95ae2e3..016089d 100644 --- a/sessionprepgui/waveform_overlay.py +++ b/sessionprepgui/waveform/overlay.py @@ -5,8 +5,8 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QFont, QPainter, QPen -from .theme import COLORS -from .waveform_compute import _hz_to_mel +from ..theme import COLORS +from .compute import _hz_to_mel _SEVERITY_OVERLAY = { "problem": QColor(255, 68, 68, 55), diff --git a/sessionprepgui/waveform_renderer.py b/sessionprepgui/waveform/renderer.py similarity index 99% rename from sessionprepgui/waveform_renderer.py rename to sessionprepgui/waveform/renderer.py index d7233b5..cb9b0d1 100644 --- a/sessionprepgui/waveform_renderer.py +++ b/sessionprepgui/waveform/renderer.py @@ -10,7 +10,7 @@ from PySide6.QtGui import (QColor, QFont, QLinearGradient, QPainter, QPen, QPolygonF) -from .theme import COLORS +from ..theme import COLORS _CHANNEL_COLORS = [ "#44aa44", "#44aaaa", "#aa44aa", "#aaaa44", diff --git a/sessionprepgui/waveform_spectrogram.py b/sessionprepgui/waveform/spectrogram.py similarity index 99% rename from sessionprepgui/waveform_spectrogram.py rename to sessionprepgui/waveform/spectrogram.py index bf1c8e9..55491b5 100644 --- a/sessionprepgui/waveform_spectrogram.py +++ b/sessionprepgui/waveform/spectrogram.py @@ -9,8 +9,8 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPen -from .theme import COLORS -from .waveform_compute import ( +from ..theme import COLORS +from .compute import ( SPECTROGRAM_COLORMAPS, SpectrogramRecomputeWorker, _SPEC_DB_FLOOR, _SPEC_F_MAX, _SPEC_F_MIN, _SPEC_N_FFT, _hz_to_mel, _mel_to_hz, diff --git a/sessionprepgui/waveform.py b/sessionprepgui/waveform/widget.py similarity index 98% rename from sessionprepgui/waveform.py rename to sessionprepgui/waveform/widget.py index bc241ec..87fcb05 100644 --- a/sessionprepgui/waveform.py +++ b/sessionprepgui/waveform/widget.py @@ -8,11 +8,11 @@ from PySide6.QtGui import QColor, QFont, QPainter, QPen from PySide6.QtWidgets import QToolTip, QWidget -from .theme import COLORS -from .waveform_compute import WaveformLoadWorker, _mel_to_hz # noqa: F401 (re-export) -from .waveform_overlay import draw_issue_overlays, draw_time_scale -from .waveform_renderer import WaveformRenderCtx, WaveformRenderer -from .waveform_spectrogram import SpecRenderCtx, SpectrogramRenderer +from ..theme import COLORS +from .compute import WaveformLoadWorker, _mel_to_hz # noqa: F401 (re-export) +from .overlay import draw_issue_overlays, draw_time_scale +from .renderer import WaveformRenderCtx, WaveformRenderer +from .spectrogram import SpecRenderCtx, SpectrogramRenderer class WaveformWidget(QWidget): From 08ef5153384fca784793d1f435a690dc9bb9a24a Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Fri, 20 Feb 2026 23:45:57 +0100 Subject: [PATCH 18/30] preferences refactoring. --- sessionprepgui/prefs/__init__.py | 9 +- sessionprepgui/prefs/config_pages.py | 689 ++++++++++++++++ sessionprepgui/prefs/dialog.py | 1038 +++++------------------ sessionprepgui/prefs/page_colors.py | 190 +++++ sessionprepgui/prefs/page_general.py | 140 ++++ sessionprepgui/prefs/page_groups.py | 153 ++++ sessionprepgui/prefs/param_form.py | 489 +++++++++++ sessionprepgui/prefs/param_widgets.py | 1102 +------------------------ sessionprepgui/prefs/preset_panel.py | 223 +++++ sessionprepgui/theme.py | 44 +- sessionprepgui/tracks/groups_mixin.py | 2 +- 11 files changed, 2122 insertions(+), 1957 deletions(-) create mode 100644 sessionprepgui/prefs/config_pages.py create mode 100644 sessionprepgui/prefs/page_colors.py create mode 100644 sessionprepgui/prefs/page_general.py create mode 100644 sessionprepgui/prefs/page_groups.py create mode 100644 sessionprepgui/prefs/param_form.py create mode 100644 sessionprepgui/prefs/preset_panel.py diff --git a/sessionprepgui/prefs/__init__.py b/sessionprepgui/prefs/__init__.py index 68996ad..9219f10 100644 --- a/sessionprepgui/prefs/__init__.py +++ b/sessionprepgui/prefs/__init__.py @@ -1,9 +1,12 @@ """Preferences subpackage.""" -from .dialog import PreferencesDialog, _argb_to_qcolor -from .param_widgets import build_config_pages, load_config_widgets, read_config_widgets +from .dialog import PreferencesDialog +from .param_form import PathPicker, PathPickerMode, _argb_to_qcolor +from .config_pages import build_config_pages, load_config_widgets, read_config_widgets __all__ = [ - "PreferencesDialog", "_argb_to_qcolor", + "PreferencesDialog", + "PathPicker", "PathPickerMode", + "_argb_to_qcolor", "build_config_pages", "load_config_widgets", "read_config_widgets", ] diff --git a/sessionprepgui/prefs/config_pages.py b/sessionprepgui/prefs/config_pages.py new file mode 100644 index 0000000..660d6f6 --- /dev/null +++ b/sessionprepgui/prefs/config_pages.py @@ -0,0 +1,689 @@ +"""SessionPrep-specific preference widgets and config-page builders. + +Depends on sessionpreplib. Not portable to other apps as-is. +""" + +from __future__ import annotations + +import re +from typing import Any, Callable + +from PySide6.QtCore import QSize, Qt, Signal +from PySide6.QtGui import QColor, QFont, QIcon +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QPushButton, + QTableWidget, + QTableWidgetItem, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from .param_form import ( + _build_param_page, + _color_swatch_icon, + _read_widget, + _set_widget_value, +) + + +# --------------------------------------------------------------------------- +# Type alias +# --------------------------------------------------------------------------- + +# Returns (color_names, argb_lookup_by_name). +ColorProvider = Callable[[], tuple[list[str], Callable[[str], str | None]]] + + +# --------------------------------------------------------------------------- +# GroupsTableWidget +# --------------------------------------------------------------------------- + +class GroupsTableWidget(QWidget): + """Reusable widget for editing a list of track groups. + + The *color_provider* callable must return + ``(color_names, argb_lookup)`` where *color_names* is a list of + available color names and *argb_lookup* maps a name to an ARGB hex + string (or ``None``). + """ + + groups_changed = Signal() + + def __init__(self, color_provider: ColorProvider, parent=None): + super().__init__(parent) + self._color_provider = color_provider + self._init_ui() + + # ── UI setup ────────────────────────────────────────────────────── + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + self._table = QTableWidget() + self._table.setColumnCount(6) + self._table.setHorizontalHeaderLabels( + ["Name", "Color", "Gain-Linked", "DAW Target", + "Match", "Match Pattern"]) + vh = self._table.verticalHeader() + vh.setSectionsMovable(True) + vh.sectionMoved.connect(self._on_row_moved) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + self._table.setSelectionMode(QTableWidget.SingleSelection) + gh = self._table.horizontalHeader() + gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + gh.setSectionResizeMode(0, QHeaderView.Stretch) + gh.setSectionResizeMode(1, QHeaderView.Fixed) + gh.resizeSection(1, 160) + gh.setSectionResizeMode(2, QHeaderView.Fixed) + gh.resizeSection(2, 80) + gh.setSectionResizeMode(3, QHeaderView.Interactive) + gh.resizeSection(3, 140) + gh.setSectionResizeMode(4, QHeaderView.Fixed) + gh.resizeSection(4, 90) + gh.setSectionResizeMode(5, QHeaderView.Interactive) + gh.resizeSection(5, 200) + self._table.cellChanged.connect(self._on_cell_changed) + layout.addWidget(self._table, 1) + + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(6) + add_btn = QPushButton("Add") + add_btn.clicked.connect(self._on_add) + btn_row.addWidget(add_btn) + remove_btn = QPushButton("Remove") + remove_btn.clicked.connect(self._on_remove) + btn_row.addWidget(remove_btn) + btn_row.addStretch() + az_btn = QPushButton("Sort A\u2192Z") + az_btn.clicked.connect(self._on_sort_az) + btn_row.addWidget(az_btn) + layout.addLayout(btn_row) + + # ── Public API ──────────────────────────────────────────────────── + + def set_groups(self, groups: list[dict]): + self._table.blockSignals(True) + self._table.setRowCount(0) + self._table.setRowCount(len(groups)) + for row, g in enumerate(groups): + self._set_row( + row, g.get("name", ""), g.get("color", ""), + g.get("gain_linked", False), g.get("daw_target", ""), + g.get("match_method", "contains"), + g.get("match_pattern", "")) + self._table.blockSignals(False) + + def get_groups(self) -> list[dict]: + return self._read_groups() + + @property + def table(self) -> QTableWidget: + return self._table + + # ── Row helpers ─────────────────────────────────────────────────── + + def _set_row(self, row: int, name: str, color: str, + gain_linked: bool, daw_target: str = "", + match_method: str = "contains", + match_pattern: str = ""): + name_item = QTableWidgetItem(name) + self._table.setItem(row, 0, name_item) + + color_names, argb_lookup = self._color_provider() + color_combo = QComboBox() + color_combo.setIconSize(QSize(16, 16)) + for cn in color_names: + argb = argb_lookup(cn) + icon = _color_swatch_icon(argb) if argb else QIcon() + color_combo.addItem(icon, cn) + ci = color_combo.findText(color) + if ci >= 0: + color_combo.setCurrentIndex(ci) + self._table.setCellWidget(row, 1, color_combo) + + chk = QCheckBox() + chk.setChecked(gain_linked) + chk_container = QWidget() + chk_layout = QHBoxLayout(chk_container) + chk_layout.setContentsMargins(0, 0, 0, 0) + chk_layout.setAlignment(Qt.AlignCenter) + chk_layout.addWidget(chk) + self._table.setCellWidget(row, 2, chk_container) + + self._table.setItem(row, 3, QTableWidgetItem(daw_target)) + + match_combo = QComboBox() + match_combo.addItems(["contains", "regex"]) + mi = match_combo.findText(match_method) + if mi >= 0: + match_combo.setCurrentIndex(mi) + match_combo.currentTextChanged.connect( + lambda _text, r=row: self._validate_pattern_cell(r)) + self._table.setCellWidget(row, 4, match_combo) + + pattern_item = QTableWidgetItem(match_pattern) + self._table.setItem(row, 5, pattern_item) + self._validate_pattern_cell(row) + + def _read_groups(self) -> list[dict]: + groups: list[dict] = [] + for row in range(self._table.rowCount()): + name_item = self._table.item(row, 0) + if not name_item: + continue + name = name_item.text().strip() + if not name: + continue + color_combo = self._table.cellWidget(row, 1) + color = color_combo.currentText() if color_combo else "" + chk_container = self._table.cellWidget(row, 2) + gain_linked = False + if chk_container: + chk = chk_container.findChild(QCheckBox) + if chk: + gain_linked = chk.isChecked() + daw_item = self._table.item(row, 3) + daw_target = daw_item.text().strip() if daw_item else "" + match_combo = self._table.cellWidget(row, 4) + match_method = match_combo.currentText() if match_combo else "contains" + pattern_item = self._table.item(row, 5) + match_pattern = pattern_item.text().strip() if pattern_item else "" + groups.append({ + "name": name, "color": color, "gain_linked": gain_linked, + "daw_target": daw_target, "match_method": match_method, + "match_pattern": match_pattern, + }) + return groups + + def _read_groups_visual_order(self) -> list[dict]: + vh = self._table.verticalHeader() + n = self._table.rowCount() + visual_to_logical = sorted(range(n), key=lambda i: vh.visualIndex(i)) + groups: list[dict] = [] + for logical in visual_to_logical: + name_item = self._table.item(logical, 0) + if not name_item: + continue + name = name_item.text().strip() + if not name: + continue + cc = self._table.cellWidget(logical, 1) + color = cc.currentText() if cc else "" + chk_c = self._table.cellWidget(logical, 2) + gl = False + if chk_c: + chk = chk_c.findChild(QCheckBox) + if chk: + gl = chk.isChecked() + daw_item = self._table.item(logical, 3) + dt = daw_item.text().strip() if daw_item else "" + mc = self._table.cellWidget(logical, 4) + mm = mc.currentText() if mc else "contains" + pi = self._table.item(logical, 5) + mp = pi.text().strip() if pi else "" + groups.append({"name": name, "color": color, + "gain_linked": gl, "daw_target": dt, + "match_method": mm, "match_pattern": mp}) + return groups + + # ── Name dedup ──────────────────────────────────────────────────── + + @staticmethod + def _group_names_in_table(table: QTableWidget, + exclude_row: int = -1) -> set[str]: + names: set[str] = set() + for r in range(table.rowCount()): + if r == exclude_row: + continue + item = table.item(r, 0) + if item: + n = item.text().strip() + if n: + names.add(n) + return names + + def _unique_name(self, base: str = "New Group") -> str: + existing = self._group_names_in_table(self._table) + if base not in existing: + return base + n = 2 + while f"{base} {n}" in existing: + n += 1 + return f"{base} {n}" + + def _on_cell_changed(self, row: int, col: int): + if col == 0: + item = self._table.item(row, 0) + if not item: + return + name = item.text().strip() + others = self._group_names_in_table(self._table, exclude_row=row) + if name in others: + self._table.blockSignals(True) + item.setText(self._unique_name(name)) + self._table.blockSignals(False) + elif col == 5: + self._validate_pattern_cell(row) + self.groups_changed.emit() + + def _validate_pattern_cell(self, row: int): + match_combo = self._table.cellWidget(row, 4) + pattern_item = self._table.item(row, 5) + if not pattern_item: + return + method = match_combo.currentText() if match_combo else "contains" + pattern = pattern_item.text().strip() + if method == "regex" and pattern: + try: + re.compile(pattern) + pattern_item.setForeground(QColor("#4ec94e")) + pattern_item.setToolTip("") + except re.error as e: + pattern_item.setForeground(QColor("#e05050")) + pattern_item.setToolTip(f"Invalid regex: {e}") + else: + pattern_item.setForeground(QColor("#cccccc")) + pattern_item.setToolTip("") + + # ── Row operations ──────────────────────────────────────────────── + + def _on_add(self): + row = self._table.rowCount() + self._table.insertRow(row) + color_names, _ = self._color_provider() + default_color = color_names[0] if color_names else "" + self._set_row(row, self._unique_name(), default_color, False) + self._table.scrollToBottom() + self._table.editItem(self._table.item(row, 0)) + self.groups_changed.emit() + + def _on_remove(self): + row = self._table.currentRow() + if row >= 0: + self._table.removeRow(row) + self.groups_changed.emit() + + def _on_row_moved(self, logical: int, old_visual: int, new_visual: int): + vh = self._table.verticalHeader() + ordered = self._read_groups_visual_order() + vh.blockSignals(True) + self._table.blockSignals(True) + for i in range(self._table.rowCount()): + vh.moveSection(vh.visualIndex(i), i) + self._table.setRowCount(0) + self._table.setRowCount(len(ordered)) + for row, entry in enumerate(ordered): + self._set_row( + row, entry["name"], entry["color"], + entry["gain_linked"], entry.get("daw_target", ""), + entry.get("match_method", "contains"), + entry.get("match_pattern", "")) + self._table.blockSignals(False) + vh.blockSignals(False) + self.groups_changed.emit() + + def _on_sort_az(self): + groups = self._read_groups() + groups.sort(key=lambda g: g["name"].lower()) + self._table.blockSignals(True) + self._table.setRowCount(0) + self._table.setRowCount(len(groups)) + for row, entry in enumerate(groups): + self._set_row( + row, entry["name"], entry["color"], + entry["gain_linked"], entry.get("daw_target", ""), + entry.get("match_method", "contains"), + entry.get("match_pattern", "")) + self._table.blockSignals(False) + self.groups_changed.emit() + + +# --------------------------------------------------------------------------- +# DawProjectTemplatesWidget +# --------------------------------------------------------------------------- + +class DawProjectTemplatesWidget(QWidget): + """Editable table of DAWProject mix templates.""" + + templates_changed = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._init_ui() + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + layout.addWidget(QLabel("Mix Templates")) + + self._table = QTableWidget() + self._table.setColumnCount(3) + self._table.setHorizontalHeaderLabels( + ["Name", "Template Path", "Fader Ceiling (dB)"]) + gh = self._table.horizontalHeader() + gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + gh.setSectionResizeMode(0, QHeaderView.Interactive) + gh.resizeSection(0, 180) + gh.setSectionResizeMode(1, QHeaderView.Stretch) + gh.setSectionResizeMode(2, QHeaderView.Fixed) + gh.resizeSection(2, 120) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + self._table.setSelectionMode(QTableWidget.SingleSelection) + layout.addWidget(self._table, 1) + + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(6) + add_btn = QPushButton("Add") + add_btn.clicked.connect(self._on_add) + btn_row.addWidget(add_btn) + remove_btn = QPushButton("Remove") + remove_btn.clicked.connect(self._on_remove) + btn_row.addWidget(remove_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + def set_templates(self, templates: list[dict]): + self._table.blockSignals(True) + self._table.setRowCount(0) + self._table.setRowCount(len(templates)) + for row, tpl in enumerate(templates): + self._set_row( + row, tpl.get("name", ""), + tpl.get("template_path", ""), + float(tpl.get("fader_ceiling_db", 6.0))) + self._table.blockSignals(False) + + def get_templates(self) -> list[dict]: + templates: list[dict] = [] + for row in range(self._table.rowCount()): + name_item = self._table.item(row, 0) + name = name_item.text().strip() if name_item else "" + path = "" + path_container = self._table.cellWidget(row, 1) + if path_container: + le = path_container.findChild(QLineEdit) + if le: + path = le.text().strip() + ceiling_widget = self._table.cellWidget(row, 2) + ceiling = ceiling_widget.value() if ceiling_widget else 24.0 + if name or path: + templates.append({ + "name": name, + "template_path": path, + "fader_ceiling_db": ceiling, + }) + return templates + + def _set_row(self, row: int, name: str, template_path: str, + fader_ceiling_db: float = 6.0): + self._table.setItem(row, 0, QTableWidgetItem(name)) + + path_container = QWidget() + path_layout = QHBoxLayout(path_container) + path_layout.setContentsMargins(2, 0, 2, 0) + path_layout.setSpacing(4) + path_edit = QLineEdit(template_path) + path_edit.setPlaceholderText("Path to .dawproject file") + path_layout.addWidget(path_edit, 1) + browse_btn = QPushButton("Browse\u2026") + browse_btn.setFixedWidth(80) + browse_btn.clicked.connect( + lambda _checked=False, le=path_edit: self._browse_template(le)) + path_layout.addWidget(browse_btn) + self._table.setCellWidget(row, 1, path_container) + + ceiling_spin = QDoubleSpinBox() + ceiling_spin.setRange(0.0, 48.0) + ceiling_spin.setDecimals(1) + ceiling_spin.setSuffix(" dB") + ceiling_spin.setValue(fader_ceiling_db) + self._table.setCellWidget(row, 2, ceiling_spin) + + def _browse_template(self, line_edit: QLineEdit): + path, _ = QFileDialog.getOpenFileName( + self, "Select DAWProject Template", + line_edit.text(), + "DAWProject Files (*.dawproject);;All Files (*)") + if path: + line_edit.setText(path) + self.templates_changed.emit() + + def _on_add(self): + row = self._table.rowCount() + self._table.setRowCount(row + 1) + self._set_row(row, "", "", 6.0) + self.templates_changed.emit() + + def _on_remove(self): + row = self._table.currentRow() + if row < 0: + return + self._table.removeRow(row) + self.templates_changed.emit() + + +# --------------------------------------------------------------------------- +# Shared config page builder / loader / reader +# --------------------------------------------------------------------------- + +def build_config_pages( + tree, + preset: dict[str, Any], + widgets_dict: dict, + register_page: Callable[[QTreeWidgetItem, QWidget], None], + *, + on_processor_enabled: Callable | None = None, +) -> DawProjectTemplatesWidget | None: + """Build the common config tree pages (Analysis, Detectors, Processors, DAW Processors). + + Returns the DawProjectTemplatesWidget if created, otherwise None. + """ + from sessionpreplib.config import ANALYSIS_PARAMS, PRESENTATION_PARAMS + from sessionpreplib.detectors import default_detectors + from sessionpreplib.processors import default_processors + from sessionpreplib.daw_processors import default_daw_processors + + dawproject_tpl_widget: DawProjectTemplatesWidget | None = None + + item = QTreeWidgetItem(tree, ["Analysis"]) + item.setFont(0, QFont("", -1, QFont.Bold)) + pg, wdg = _build_param_page(ANALYSIS_PARAMS, preset.get("analysis", {})) + widgets_dict["analysis"] = wdg + register_page(item, pg) + + det_parent = QTreeWidgetItem(tree, ["Detectors"]) + det_parent.setFont(0, QFont("", -1, QFont.Bold)) + pg, wdg = _build_param_page(PRESENTATION_PARAMS, preset.get("presentation", {})) + widgets_dict["_presentation"] = wdg + register_page(det_parent, pg) + + det_sections = preset.get("detectors", {}) + for det in default_detectors(): + params = det.config_params() + if not params: + continue + child = QTreeWidgetItem(det_parent, [det.name]) + pg, wdg = _build_param_page(params, det_sections.get(det.id, {})) + widgets_dict[f"detectors.{det.id}"] = wdg + register_page(child, pg) + + proc_parent = QTreeWidgetItem(tree, ["Processors"]) + proc_parent.setFont(0, QFont("", -1, QFont.Bold)) + placeholder = QWidget() + pl = QVBoxLayout(placeholder) + pl.setContentsMargins(12, 12, 12, 12) + pl.addWidget(QLabel("Select a processor from the tree to configure.")) + pl.addStretch() + register_page(proc_parent, placeholder) + + proc_sections = preset.get("processors", {}) + for proc in default_processors(): + params = proc.config_params() + if not params: + continue + child = QTreeWidgetItem(proc_parent, [proc.name]) + pg, wdg = _build_param_page(params, proc_sections.get(proc.id, {})) + widgets_dict[f"processors.{proc.id}"] = wdg + register_page(child, pg) + if on_processor_enabled is not None: + enabled_key = f"{proc.id}_enabled" + for key, widget in wdg: + if key == enabled_key and isinstance(widget, QCheckBox): + widget.toggled.connect(on_processor_enabled) + break + + daw_parent = QTreeWidgetItem(tree, ["DAW Processors"]) + daw_parent.setFont(0, QFont("", -1, QFont.Bold)) + placeholder2 = QWidget() + pl2 = QVBoxLayout(placeholder2) + pl2.setContentsMargins(12, 12, 12, 12) + pl2.addWidget(QLabel("Select a DAW processor from the tree to configure.")) + pl2.addStretch() + register_page(daw_parent, placeholder2) + + dp_sections = preset.get("daw_processors", {}) + for dp in default_daw_processors(): + params = dp.config_params() + if not params: + continue + child = QTreeWidgetItem(daw_parent, [dp.name]) + pg, wdg = _build_param_page(params, dp_sections.get(dp.id, {})) + widgets_dict[f"daw_processors.{dp.id}"] = wdg + if dp.id == "dawproject": + tpl_widget = DawProjectTemplatesWidget() + tpl_widget.set_templates(dp_sections.get(dp.id, {}).get("dawproject_templates", [])) + dawproject_tpl_widget = tpl_widget + pg.layout().insertWidget(pg.layout().count() - 1, tpl_widget) + register_page(child, pg) + + return dawproject_tpl_widget + + +def load_config_widgets( + widgets_dict: dict, + preset: dict[str, Any], + dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, +) -> None: + """Load values from *preset* into widgets stored in *widgets_dict*.""" + from sessionpreplib.detectors import default_detectors + from sessionpreplib.processors import default_processors + from sessionpreplib.daw_processors import default_daw_processors + + for key, widget in widgets_dict.get("analysis", []): + if key in preset.get("analysis", {}): + _set_widget_value(widget, preset["analysis"][key]) + + for key, widget in widgets_dict.get("_presentation", []): + if key in preset.get("presentation", {}): + _set_widget_value(widget, preset["presentation"][key]) + + det_sections = preset.get("detectors", {}) + for det in default_detectors(): + wkey = f"detectors.{det.id}" + if wkey not in widgets_dict: + continue + vals = det_sections.get(det.id, {}) + for key, widget in widgets_dict[wkey]: + if key in vals: + _set_widget_value(widget, vals[key]) + + proc_sections = preset.get("processors", {}) + for proc in default_processors(): + wkey = f"processors.{proc.id}" + if wkey not in widgets_dict: + continue + vals = proc_sections.get(proc.id, {}) + for key, widget in widgets_dict[wkey]: + if key in vals: + _set_widget_value(widget, vals[key]) + + dp_sections = preset.get("daw_processors", {}) + for dp in default_daw_processors(): + wkey = f"daw_processors.{dp.id}" + if wkey not in widgets_dict: + continue + vals = dp_sections.get(dp.id, {}) + for key, widget in widgets_dict[wkey]: + if key in vals: + _set_widget_value(widget, vals[key]) + if dp.id == "dawproject" and dawproject_tpl_widget is not None: + dawproject_tpl_widget.set_templates(vals.get("dawproject_templates", [])) + + +def read_config_widgets( + widgets_dict: dict, + dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, + fallback_daw_sections: dict[str, dict] | None = None, +) -> dict[str, Any]: + """Read current widget values into a structured config dict.""" + from sessionpreplib.detectors import default_detectors + from sessionpreplib.processors import default_processors + from sessionpreplib.daw_processors import default_daw_processors + + cfg: dict[str, Any] = {} + + analysis: dict[str, Any] = {} + for key, widget in widgets_dict.get("analysis", []): + analysis[key] = _read_widget(widget) + cfg["analysis"] = analysis + + presentation: dict[str, Any] = {} + for key, widget in widgets_dict.get("_presentation", []): + presentation[key] = _read_widget(widget) + cfg["presentation"] = presentation + + detectors: dict[str, dict] = {} + for det in default_detectors(): + wkey = f"detectors.{det.id}" + if wkey not in widgets_dict: + continue + section: dict[str, Any] = {} + for key, widget in widgets_dict[wkey]: + section[key] = _read_widget(widget) + detectors[det.id] = section + cfg["detectors"] = detectors + + processors: dict[str, dict] = {} + for proc in default_processors(): + wkey = f"processors.{proc.id}" + if wkey not in widgets_dict: + continue + section = {} + for key, widget in widgets_dict[wkey]: + section[key] = _read_widget(widget) + processors[proc.id] = section + cfg["processors"] = processors + + daw_procs: dict[str, dict] = {} + for dp in default_daw_processors(): + wkey = f"daw_processors.{dp.id}" + if wkey not in widgets_dict: + continue + section = {} + for key, widget in widgets_dict[wkey]: + section[key] = _read_widget(widget) + if dp.id == "dawproject" and dawproject_tpl_widget is not None: + section["dawproject_templates"] = dawproject_tpl_widget.get_templates() + if fallback_daw_sections: + for gk, gv in fallback_daw_sections.get(dp.id, {}).items(): + if gk not in section: + section[gk] = gv + daw_procs[dp.id] = section + cfg["daw_processors"] = daw_procs + + return cfg diff --git a/sessionprepgui/prefs/dialog.py b/sessionprepgui/prefs/dialog.py index a834730..53b7ca9 100644 --- a/sessionprepgui/prefs/dialog.py +++ b/sessionprepgui/prefs/dialog.py @@ -1,4 +1,8 @@ -"""Preferences dialog for SessionPrep GUI.""" +"""Preferences dialog — thin orchestrator. + +Creates the two-tab shell (Global / Config Presets), wires up the +self-contained page classes, and owns config-preset CRUD via NamedPresetPanel. +""" from __future__ import annotations @@ -7,61 +11,29 @@ from typing import Any from PySide6.QtCore import Qt -from PySide6.QtGui import QColor, QFont +from PySide6.QtGui import QFont from PySide6.QtWidgets import ( - QColorDialog, - QComboBox, QDialog, QDialogButtonBox, - QFileDialog, - QHBoxLayout, - QHeaderView, - QInputDialog, - QLabel, - QLineEdit, QMessageBox, - QPushButton, QScrollArea, QSplitter, QStackedWidget, - QStyle, QTabWidget, - QTableWidget, - QTableWidgetItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, ) -from sessionpreplib.config import ParamSpec -from .param_widgets import ( - _argb_to_qcolor, - _build_param_page, - _build_subtext, - _build_tooltip, - _read_widget, - _set_widget_value, - build_config_pages, - load_config_widgets, - read_config_widgets, - GroupsTableWidget, - sanitize_output_folder, -) -from ..settings import ( - _APP_DEFAULTS, - _PRESENTATION_DEFAULTS, - _build_default_config_preset, - build_defaults, -) -from ..theme import PT_DEFAULT_COLORS - +from ..settings import _build_default_config_preset +from .config_pages import build_config_pages, load_config_widgets, read_config_widgets +from .page_colors import ColorsPage +from .page_general import GeneralPage +from .page_groups import GroupsPage +from .preset_panel import NamedPresetPanel -# --------------------------------------------------------------------------- -# Preferences Dialog -# --------------------------------------------------------------------------- - class PreferencesDialog(QDialog): """Hierarchical preferences dialog with tree navigation.""" @@ -70,876 +42,234 @@ def __init__(self, config: dict[str, Any], parent=None): self.setWindowTitle("Preferences") self.resize(1150, 700) self._config = copy.deepcopy(config) - self._widgets: dict[str, list[tuple[str, QWidget]]] = {} - self._general_widgets: list[tuple[str, QWidget]] = [] self._saved = False - # Working copy of config presets {name: structured_dict} + # Config presets working copy {name: structured_dict} self._config_presets_data: dict[str, dict[str, Any]] = copy.deepcopy( self._config.get("config_presets", {})) if "Default" not in self._config_presets_data: self._config_presets_data["Default"] = _build_default_config_preset() + # Pipeline widget registry (built by build_config_pages) + self._cfg_widgets: dict = {} + self._cfg_dawproject_widget = None + + # Pages + self._general_page = GeneralPage() + self._colors_page = ColorsPage() + self._groups_page = GroupsPage( + color_provider=self._colors_page.color_provider) + self._init_ui() + # Load all pages after UI is built + self._general_page.load(self._config) + self._colors_page.load(self._config) + self._groups_page.load(self._config) + + active_cfg = self._config.get("app", {}).get( + "active_config_preset", "Default") + self._cfg_panel.set_current(active_cfg) + self._load_cfg_preset_widgets(active_cfg) + + # ── Public API ──────────────────────────────────────────────────── + @property def saved(self) -> bool: return self._saved def result_config(self) -> dict[str, Any]: - """Return the edited config (only valid after save).""" + """Return the edited config (only valid after Save).""" return self._config - # ── Active config preset helpers ────────────────────────────────── + # ── UI construction ─────────────────────────────────────────────── - def _active_preset(self) -> dict[str, Any]: - """Return the structured dict for the currently selected config preset.""" - name = self._cfg_preset_combo.currentText() - return self._config_presets_data.get( - name, self._config_presets_data.get("Default", {})) + def _init_ui(self) -> None: + root = QVBoxLayout(self) + root.setContentsMargins(8, 8, 8, 8) - # ── UI setup ────────────────────────────────────────────────────── + tabs = QTabWidget() + tabs.setDocumentMode(True) + root.addWidget(tabs, 1) - def _init_ui(self): - root_layout = QVBoxLayout(self) - root_layout.setContentsMargins(8, 8, 8, 8) + tabs.addTab(self._build_global_tab(), "Global") + tabs.addTab(self._build_preset_tab(), "Config Presets") - # ── Top-level tabs: Global / Config Presets ─────────────────── - self._top_tabs = QTabWidget() - self._top_tabs.setDocumentMode(True) - root_layout.addWidget(self._top_tabs, 1) + btn_box = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Save) + btn_box.button(QDialogButtonBox.Save).setDefault(True) + btn_box.accepted.connect(self._on_save) + btn_box.rejected.connect(self.reject) + root.addWidget(btn_box) - # ── Global tab ──────────────────────────────────────────────── - self._global_page_index: dict[int, int] = {} - global_tab = QWidget() - g_layout = QVBoxLayout(global_tab) - g_layout.setContentsMargins(0, 4, 0, 0) - g_splitter = QSplitter(Qt.Horizontal) + def _build_global_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(0, 4, 0, 0) + splitter = QSplitter(Qt.Horizontal) self._global_tree = QTreeWidget() self._global_tree.setHeaderHidden(True) self._global_tree.setMinimumWidth(140) self._global_tree.setMaximumWidth(200) - self._global_tree.currentItemChanged.connect( - self._on_global_tree_selection) - g_splitter.addWidget(self._global_tree) + splitter.addWidget(self._global_tree) self._global_stack = QStackedWidget() - g_splitter.addWidget(self._global_stack) - g_splitter.setStretchFactor(0, 0) - g_splitter.setStretchFactor(1, 1) - g_layout.addWidget(g_splitter, 1) - self._top_tabs.addTab(global_tab, "Global") + splitter.addWidget(self._global_stack) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + layout.addWidget(splitter, 1) - # ── Config Presets tab ──────────────────────────────────────── - self._preset_page_index: dict[int, int] = {} - preset_tab = QWidget() - p_layout = QVBoxLayout(preset_tab) - p_layout.setContentsMargins(0, 4, 0, 0) - - # Config preset toolbar - cfg_preset_row = QHBoxLayout() - cfg_preset_row.setContentsMargins(0, 0, 0, 4) - cfg_preset_row.setSpacing(6) - - cfg_preset_row.addWidget(QLabel("Config Preset:")) - self._cfg_preset_combo = QComboBox() - self._cfg_preset_combo.setMinimumWidth(180) - cfg_preset_row.addWidget(self._cfg_preset_combo, 1) - - add_btn = QPushButton("+") - add_btn.setFixedWidth(36) - add_btn.setToolTip("New config preset") - add_btn.clicked.connect(self._on_cfg_preset_add) - cfg_preset_row.addWidget(add_btn) - - dup_btn = QPushButton("Duplicate") - dup_btn.clicked.connect(self._on_cfg_preset_duplicate) - cfg_preset_row.addWidget(dup_btn) - - self._cfg_rename_btn = QPushButton("Rename") - self._cfg_rename_btn.clicked.connect(self._on_cfg_preset_rename) - cfg_preset_row.addWidget(self._cfg_rename_btn) - - self._cfg_delete_btn = QPushButton("Delete") - self._cfg_delete_btn.clicked.connect(self._on_cfg_preset_delete) - cfg_preset_row.addWidget(self._cfg_delete_btn) - - p_layout.addLayout(cfg_preset_row) - - # Populate config preset combo - self._cfg_preset_combo.blockSignals(True) - for name in self._config_presets_data: - self._cfg_preset_combo.addItem(name) - active = self._config.get("app", {}).get( - "active_config_preset", "Default") - idx = self._cfg_preset_combo.findText(active) - if idx >= 0: - self._cfg_preset_combo.setCurrentIndex(idx) - self._cfg_preset_combo.blockSignals(False) - self._prev_cfg_preset: str | None = self._cfg_preset_combo.currentText() - self._update_cfg_preset_buttons() + self._global_page_index: dict[int, int] = {} + + def add(label: str, page: QWidget) -> QTreeWidgetItem: + item = QTreeWidgetItem(self._global_tree, [label]) + item.setFont(0, QFont("", -1, QFont.Bold)) + self._register_page(item, page, + self._global_stack, self._global_page_index) + return item - p_splitter = QSplitter(Qt.Horizontal) + add("General", self._general_page) + add("Colors", self._colors_page) + add("Groups", self._groups_page) + + self._global_tree.expandAll() + first = self._global_tree.topLevelItem(0) + if first: + self._global_tree.setCurrentItem(first) + self._global_tree.currentItemChanged.connect( + lambda cur, _: self._on_tree_selection( + cur, self._global_stack, self._global_page_index)) + return tab + + def _build_preset_tab(self) -> QWidget: + tab = QWidget() + layout = QVBoxLayout(tab) + layout.setContentsMargins(0, 4, 0, 0) + + self._cfg_panel = NamedPresetPanel( + list(self._config_presets_data), + label="Config Preset:", + protected=frozenset({"Default"}), + ) + self._cfg_panel.preset_switching.connect(self._on_cfg_switching) + self._cfg_panel.preset_added.connect(self._on_cfg_added) + self._cfg_panel.preset_duplicated.connect(self._on_cfg_duplicated) + self._cfg_panel.preset_renamed.connect(self._on_cfg_renamed) + self._cfg_panel.preset_deleted.connect(self._on_cfg_deleted) + layout.addWidget(self._cfg_panel) + + splitter = QSplitter(Qt.Horizontal) self._preset_tree = QTreeWidget() self._preset_tree.setHeaderHidden(True) self._preset_tree.setMinimumWidth(180) self._preset_tree.setMaximumWidth(250) - self._preset_tree.currentItemChanged.connect( - self._on_preset_tree_selection) - p_splitter.addWidget(self._preset_tree) + splitter.addWidget(self._preset_tree) self._preset_stack = QStackedWidget() - p_splitter.addWidget(self._preset_stack) - p_splitter.setStretchFactor(0, 0) - p_splitter.setStretchFactor(1, 1) - - p_layout.addWidget(p_splitter, 1) - self._top_tabs.addTab(preset_tab, "Config Presets") - - # ── Build pages ─────────────────────────────────────────────── - self._build_general_page() - self._build_colors_page() - self._build_groups_page() - - self._build_pipeline_pages() + splitter.addWidget(self._preset_stack) + splitter.setStretchFactor(0, 0) + splitter.setStretchFactor(1, 1) + layout.addWidget(splitter, 1) - # Select first items - self._global_tree.expandAll() - first_g = self._global_tree.topLevelItem(0) - if first_g: - self._global_tree.setCurrentItem(first_g) + self._preset_page_index: dict[int, int] = {} + self._cfg_dawproject_widget = build_config_pages( + self._preset_tree, + self._active_preset(), + self._cfg_widgets, + lambda item, page: self._register_page( + item, page, self._preset_stack, self._preset_page_index), + ) self._preset_tree.expandAll() - first_p = self._preset_tree.topLevelItem(0) - if first_p: - self._preset_tree.setCurrentItem(first_p) - - # Connect config preset switching (after pages are built) - self._cfg_preset_combo.currentTextChanged.connect( - self._on_cfg_preset_switched) + first = self._preset_tree.topLevelItem(0) + if first: + self._preset_tree.setCurrentItem(first) + self._preset_tree.currentItemChanged.connect( + lambda cur, _: self._on_tree_selection( + cur, self._preset_stack, self._preset_page_index)) - # -- Buttons -- - btn_box = QDialogButtonBox( - QDialogButtonBox.Cancel | QDialogButtonBox.Save - ) - btn_box.button(QDialogButtonBox.Save).setDefault(True) - btn_box.accepted.connect(self._on_save) - btn_box.rejected.connect(self.reject) - root_layout.addWidget(btn_box) + return tab - def _add_global_page(self, tree_item: QTreeWidgetItem, page: QWidget): - scroll = QScrollArea() - scroll.setWidgetResizable(True) - scroll.setFrameShape(QScrollArea.NoFrame) - scroll.setWidget(page) - idx = self._global_stack.addWidget(scroll) - self._global_page_index[id(tree_item)] = idx + # ── Tree/stack navigation ───────────────────────────────────────── - def _add_preset_page(self, tree_item: QTreeWidgetItem, page: QWidget): + def _register_page(self, item: QTreeWidgetItem, page: QWidget, + stack: QStackedWidget, + index: dict[int, int]) -> None: scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setFrameShape(QScrollArea.NoFrame) scroll.setWidget(page) - idx = self._preset_stack.addWidget(scroll) - self._preset_page_index[id(tree_item)] = idx - - def _on_global_tree_selection(self, current, _previous): - if current is None: - return - idx = self._global_page_index.get(id(current)) - if idx is not None: - self._global_stack.setCurrentIndex(idx) + idx = stack.addWidget(scroll) + index[id(item)] = idx - def _on_preset_tree_selection(self, current, _previous): + def _on_tree_selection(self, current: QTreeWidgetItem | None, + stack: QStackedWidget, + index: dict[int, int]) -> None: if current is None: return - idx = self._preset_page_index.get(id(current)) + idx = index.get(id(current)) if idx is not None: - self._preset_stack.setCurrentIndex(idx) - - # ── General page ────────────────────────────────────────────────── - - def _build_general_page(self): - item = QTreeWidgetItem(self._global_tree, ["General"]) - item.setFont(0, QFont("", -1, QFont.Bold)) - - app_params = [ - ParamSpec( - key="scale_factor", type=(int, float), default=1.0, - min=0.5, max=4.0, - label="HiDPI scale factor", - description="Scale factor for the application UI. Requires a restart to take effect.", - ), - ParamSpec( - key="report_verbosity", type=str, default="normal", - choices=["normal", "verbose"], - label="Report verbosity", - description=( - "Controls the level of detail shown in track reports. " - "Verbose mode includes additional analytical data such as " - "classification metrics." - ), - ), - ParamSpec( - key="output_folder", type=str, default="processed", - label="Output folder name", - description=( - "Name of the subfolder (relative to the project directory) " - "where processed audio files are written. " - "Must be a simple folder name without path separators." - ), - ), - ParamSpec( - key="spectrogram_colormap", type=str, default="magma", - choices=["magma", "viridis", "grayscale"], - label="Spectrogram color theme", - description="Color palette used for the spectrogram display.", - ), - ParamSpec( - key="invert_scroll", type=str, default="default", - choices=["default", "horizontal", "vertical", "both"], - label="Invert mouse-wheel scrolling", - description=( - "Reverses the scroll direction in the waveform/spectrogram view. " - "'horizontal' inverts Shift+wheel (timeline panning), " - "'vertical' inverts Shift+Alt+wheel (frequency panning), " - "'both' inverts both axes." - ), - ), - ] - values = self._config.get("app", {}) - page, widgets = _build_param_page(app_params, values) - - # --- Default project directory (custom row with browse button) --- - dir_spec = ParamSpec( - key="default_project_dir", type=str, default="", - label="Default project directory", - description=( - "When set, the Open Folder dialog starts in this directory. " - "Leave empty to use the system default." - ), - ) - cur_dir = values.get("default_project_dir", "") - dir_edit = QLineEdit() - dir_edit.setText(str(cur_dir) if cur_dir else "") - dir_edit.setPlaceholderText("(system default)") - dir_edit._param_spec = dir_spec - dir_edit.setToolTip(_build_tooltip(dir_spec)) - - browse_btn = QPushButton("Browse\u2026") - browse_btn.setFixedWidth(80) - browse_btn.clicked.connect( - lambda: self._browse_project_dir(dir_edit)) - - clear_btn = QPushButton() - clear_btn.setIcon(page.style().standardIcon( - QStyle.StandardPixmap.SP_DialogCloseButton)) - clear_btn.setFixedSize(26, 26) - clear_btn.setToolTip("Clear (use system default)") - clear_btn.clicked.connect(lambda: dir_edit.setText("")) - - dir_row = QHBoxLayout() - dir_row.setContentsMargins(0, 0, 0, 0) - dir_row.setSpacing(8) - dir_name_label = QLabel(f"{dir_spec.label}") - dir_name_label.setToolTip(_build_tooltip(dir_spec)) - dir_row.addWidget(dir_name_label, 0) - dir_row.addWidget(dir_edit, 1) - dir_row.addWidget(browse_btn, 0) - dir_row.addWidget(clear_btn, 0) - - dir_box = QVBoxLayout() - dir_box.setContentsMargins(0, 0, 0, 0) - dir_box.setSpacing(2) - dir_box.addLayout(dir_row) - dir_sub = QLabel(_build_subtext(dir_spec)) - dir_sub.setWordWrap(True) - dir_sub.setStyleSheet("color: #888; font-size: 9pt;") - dir_sub.setToolTip(_build_tooltip(dir_spec)) - dir_box.addWidget(dir_sub) - - # Insert before the stretch at the end of the page layout - outer = page.layout() - outer.insertLayout(outer.count() - 1, dir_box) - - widgets.append(("default_project_dir", dir_edit)) - self._general_widgets = widgets - self._add_global_page(item, page) - - # ── Pipeline config pages (shared builder) ────────────────────── - - def _build_pipeline_pages(self): - self._dawproject_templates_widget = build_config_pages( - self._preset_tree, - self._active_preset(), - self._widgets, - self._add_preset_page, - ) - - # ── Colors page ──────────────────────────────────────────────────── - - def _build_colors_page(self): - item = QTreeWidgetItem(self._global_tree, ["Colors"]) - item.setFont(0, QFont("", -1, QFont.Bold)) + stack.setCurrentIndex(idx) - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(8) - - desc = QLabel( - "Color palette used for track groups. " - "Double-click a swatch to edit." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #888; font-size: 9pt;") - layout.addWidget(desc) - - self._colors_table = QTableWidget() - self._colors_table.setColumnCount(3) - self._colors_table.setHorizontalHeaderLabels(["#", "Name", "Color"]) - self._colors_table.verticalHeader().setVisible(False) - self._colors_table.setSelectionBehavior(QTableWidget.SelectRows) - self._colors_table.setSelectionMode(QTableWidget.SingleSelection) - ch = self._colors_table.horizontalHeader() - ch.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) - ch.setSectionResizeMode(0, QHeaderView.Fixed) - ch.resizeSection(0, 36) - ch.setSectionResizeMode(1, QHeaderView.Stretch) - ch.setSectionResizeMode(2, QHeaderView.Fixed) - ch.resizeSection(2, 60) - - self._colors_table.cellDoubleClicked.connect( - self._on_color_swatch_dbl_click) - - # Populate from config - colors = self._config.get("colors", []) - if not colors: - colors = copy.deepcopy(PT_DEFAULT_COLORS) - self._colors_table.setRowCount(len(colors)) - for row, entry in enumerate(colors): - self._set_color_row(row, entry.get("name", ""), entry.get("argb", "#ff888888")) - - layout.addWidget(self._colors_table, 1) - - # Buttons - btn_row = QHBoxLayout() - btn_row.setContentsMargins(0, 0, 0, 0) - btn_row.setSpacing(6) - - add_btn = QPushButton("Add") - add_btn.clicked.connect(self._on_color_add) - btn_row.addWidget(add_btn) - - remove_btn = QPushButton("Remove") - remove_btn.clicked.connect(self._on_color_remove) - btn_row.addWidget(remove_btn) - - reset_btn = QPushButton("Reset to Defaults") - reset_btn.clicked.connect(self._on_colors_reset) - btn_row.addWidget(reset_btn) - - btn_row.addStretch() - layout.addLayout(btn_row) - - self._add_global_page(item, page) - - def _set_color_row(self, row: int, name: str, argb: str): - """Populate a single row in the colors table.""" - idx_item = QTableWidgetItem(str(row + 1)) - idx_item.setFlags(idx_item.flags() & ~Qt.ItemIsEditable) - idx_item.setForeground(QColor("#888888")) - self._colors_table.setItem(row, 0, idx_item) - - name_item = QTableWidgetItem(name) - self._colors_table.setItem(row, 1, name_item) - - swatch_item = QTableWidgetItem() - swatch_item.setFlags(swatch_item.flags() & ~Qt.ItemIsEditable) - swatch_item.setBackground(_argb_to_qcolor(argb)) - swatch_item.setData(Qt.UserRole, argb) - swatch_item.setToolTip(argb) - self._colors_table.setItem(row, 2, swatch_item) - - def _on_color_swatch_dbl_click(self, row: int, col: int): - """Open QColorDialog when the swatch column is double-clicked.""" - if col != 2: - return - item = self._colors_table.item(row, 2) - if not item: - return - current = _argb_to_qcolor(item.data(Qt.UserRole) or "#ff888888") - color = QColorDialog.getColor( - current, self, "Select Color", - QColorDialog.ShowAlphaChannel) - if color.isValid(): - argb = "#{:02x}{:02x}{:02x}{:02x}".format( - color.alpha(), color.red(), color.green(), color.blue()) - item.setBackground(color) - item.setData(Qt.UserRole, argb) - item.setToolTip(argb) - - def _on_color_add(self): - row = self._colors_table.rowCount() - self._colors_table.insertRow(row) - self._set_color_row(row, "New Color", "#ff888888") - self._colors_table.scrollToBottom() - self._colors_table.editItem(self._colors_table.item(row, 1)) - - def _on_color_remove(self): - row = self._colors_table.currentRow() - if row >= 0: - self._colors_table.removeRow(row) - - def _on_colors_reset(self): - self._colors_table.setRowCount(0) - self._colors_table.setRowCount(len(PT_DEFAULT_COLORS)) - for row, entry in enumerate(PT_DEFAULT_COLORS): - self._set_color_row(row, entry["name"], entry["argb"]) - - def _read_colors(self) -> list[dict[str, str]]: - """Read the colors table into a list of {name, argb} dicts.""" - colors = [] - for row in range(self._colors_table.rowCount()): - name_item = self._colors_table.item(row, 1) - swatch_item = self._colors_table.item(row, 2) - if not name_item or not swatch_item: - continue - name = name_item.text().strip() - argb = swatch_item.data(Qt.UserRole) or "#ff888888" - if name: - colors.append({"name": name, "argb": argb}) - return colors - - # ── Groups page ────────────────────────────────────────────────── - - def _build_groups_page(self): - item = QTreeWidgetItem(self._global_tree, ["Groups"]) - item.setFont(0, QFont("", -1, QFont.Bold)) - - page = QWidget() - layout = QVBoxLayout(page) - layout.setContentsMargins(12, 12, 12, 12) - layout.setSpacing(8) - - desc = QLabel( - "Default track groups used when analyzing a session. " - "Groups reference colors from the Colors page." - ) - desc.setWordWrap(True) - desc.setStyleSheet("color: #888; font-size: 9pt;") - layout.addWidget(desc) - - # ── Preset toolbar ──────────────────────────────────────────── - preset_row = QHBoxLayout() - preset_row.setContentsMargins(0, 0, 0, 0) - preset_row.setSpacing(6) - - preset_row.addWidget(QLabel("Preset:")) - self._group_preset_combo = QComboBox() - self._group_preset_combo.setMinimumWidth(160) - preset_row.addWidget(self._group_preset_combo, 1) - - add_preset_btn = QPushButton("+") - add_preset_btn.setFixedWidth(36) - add_preset_btn.setToolTip("New preset") - add_preset_btn.clicked.connect(self._on_group_preset_add) - preset_row.addWidget(add_preset_btn) - - dup_preset_btn = QPushButton("Duplicate") - dup_preset_btn.clicked.connect(self._on_group_preset_duplicate) - preset_row.addWidget(dup_preset_btn) - - self._rename_preset_btn = QPushButton("Rename") - self._rename_preset_btn.clicked.connect(self._on_group_preset_rename) - preset_row.addWidget(self._rename_preset_btn) - - self._delete_preset_btn = QPushButton("Delete") - self._delete_preset_btn.clicked.connect(self._on_group_preset_delete) - preset_row.addWidget(self._delete_preset_btn) - - preset_row.addStretch() - - reset_default_btn = QPushButton("Reset to Default") - reset_default_btn.setToolTip( - "Replace the current preset's groups with the built-in defaults") - reset_default_btn.clicked.connect(self._on_group_preset_reset_default) - preset_row.addWidget(reset_default_btn) - - layout.addLayout(preset_row) - - # ── Groups table (reusable widget) ─────────────────────────── - self._groups_widget = GroupsTableWidget( - color_provider=self._group_color_provider) - layout.addWidget(self._groups_widget, 1) - - self._add_global_page(item, page) - - # ── Initialise preset data ──────────────────────────────────── - defaults = build_defaults() - presets = self._config.get("group_presets", - defaults.get("group_presets", {})) - self._group_presets_data: dict[str, list[dict]] = copy.deepcopy(presets) - active = self._config.get("app", {}).get( - "active_group_preset", "Default") - if active not in self._group_presets_data: - active = "Default" - - self._group_preset_combo.blockSignals(True) - for name in self._group_presets_data: - self._group_preset_combo.addItem(name) - idx = self._group_preset_combo.findText(active) - if idx >= 0: - self._group_preset_combo.setCurrentIndex(idx) - self._group_preset_combo.blockSignals(False) - - self._group_preset_combo.currentTextChanged.connect( - self._on_group_preset_switched) - self._load_groups_for_preset(active) - self._update_group_preset_buttons() - - # ── Preset helpers ───────────────────────────────────────────── - - def _group_color_provider(self): - """Color provider callable for GroupsTableWidget.""" - return self._color_names(), self._color_argb_for_name - - def _load_groups_for_preset(self, preset_name: str): - """Load groups from *preset_name* into the groups table.""" - groups = self._group_presets_data.get(preset_name, []) - self._groups_widget.set_groups(groups) - - def _save_current_preset(self): - """Save the current groups table state back into _group_presets_data.""" - name = self._group_preset_combo.currentText() - if name: - self._group_presets_data[name] = self._groups_widget.get_groups() - - def _update_group_preset_buttons(self): - """Enable/disable Rename and Delete based on current preset.""" - is_default = self._group_preset_combo.currentText() == "Default" - self._rename_preset_btn.setEnabled(not is_default) - self._delete_preset_btn.setEnabled(not is_default) - - def _on_group_preset_switched(self, text: str): - """Save current table state, load newly selected preset.""" - # Save previous preset before switching - prev = getattr(self, "_prev_group_preset", None) - if prev and prev in self._group_presets_data: - self._group_presets_data[prev] = self._groups_widget.get_groups() - self._prev_group_preset = text - self._load_groups_for_preset(text) - self._update_group_preset_buttons() - - def _on_group_preset_add(self): - """Create a new empty preset.""" - name, ok = QInputDialog.getText( - self, "New Group Preset", "Preset name:") - if not ok or not name.strip(): - return - name = name.strip() - if name in self._group_presets_data: - QMessageBox.warning( - self, "Duplicate Name", - f"A preset named \u201c{name}\u201d already exists.") - return - self._save_current_preset() - self._group_presets_data[name] = [] - self._group_preset_combo.blockSignals(True) - self._group_preset_combo.addItem(name) - self._group_preset_combo.setCurrentText(name) - self._group_preset_combo.blockSignals(False) - self._prev_group_preset = name - self._load_groups_for_preset(name) - self._update_group_preset_buttons() - - def _on_group_preset_duplicate(self): - """Duplicate the current preset under a new name.""" - current = self._group_preset_combo.currentText() - name, ok = QInputDialog.getText( - self, "Duplicate Group Preset", "New preset name:", - text=f"{current} Copy") - if not ok or not name.strip(): - return - name = name.strip() - if name in self._group_presets_data: - QMessageBox.warning( - self, "Duplicate Name", - f"A preset named \u201c{name}\u201d already exists.") - return - self._save_current_preset() - self._group_presets_data[name] = copy.deepcopy( - self._group_presets_data.get(current, [])) - self._group_preset_combo.blockSignals(True) - self._group_preset_combo.addItem(name) - self._group_preset_combo.setCurrentText(name) - self._group_preset_combo.blockSignals(False) - self._prev_group_preset = name - self._load_groups_for_preset(name) - self._update_group_preset_buttons() - - def _on_group_preset_rename(self): - """Rename the current preset (not allowed for Default).""" - current = self._group_preset_combo.currentText() - if current == "Default": - return - name, ok = QInputDialog.getText( - self, "Rename Group Preset", "New name:", text=current) - if not ok or not name.strip(): - return - name = name.strip() - if name == current: - return - if name in self._group_presets_data: - QMessageBox.warning( - self, "Duplicate Name", - f"A preset named \u201c{name}\u201d already exists.") - return - self._save_current_preset() - self._group_presets_data[name] = self._group_presets_data.pop(current) - idx = self._group_preset_combo.findText(current) - self._group_preset_combo.blockSignals(True) - self._group_preset_combo.setItemText(idx, name) - self._group_preset_combo.blockSignals(False) - self._prev_group_preset = name - self._update_group_preset_buttons() - - def _on_group_preset_delete(self): - """Delete the current preset (not allowed for Default).""" - current = self._group_preset_combo.currentText() - if current == "Default": - return - reply = QMessageBox.question( - self, "Delete Preset", - f"Delete the preset \u201c{current}\u201d?", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - if reply != QMessageBox.Yes: - return - self._group_presets_data.pop(current, None) - idx = self._group_preset_combo.findText(current) - self._group_preset_combo.blockSignals(True) - self._group_preset_combo.removeItem(idx) - self._group_preset_combo.setCurrentText("Default") - self._group_preset_combo.blockSignals(False) - self._prev_group_preset = "Default" - self._load_groups_for_preset("Default") - self._update_group_preset_buttons() - - def _on_group_preset_reset_default(self): - """Replace the current preset's groups with the built-in defaults.""" - current = self._group_preset_combo.currentText() - reply = QMessageBox.question( - self, "Reset to Default", - f"Replace all groups in \u201c{current}\u201d with the " - f"built-in defaults?\n\nThis cannot be undone.", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - if reply != QMessageBox.Yes: - return - from ..settings import _DEFAULT_GROUPS - self._group_presets_data[current] = copy.deepcopy(_DEFAULT_GROUPS) - self._load_groups_for_preset(current) + # ── Config preset helpers ───────────────────────────────────────── - # ── Config preset helpers ────────────────────────────────────── + def _active_preset(self) -> dict[str, Any]: + name = self._cfg_panel.current_name if hasattr(self, "_cfg_panel") else "Default" + return self._config_presets_data.get( + name, self._config_presets_data.get("Default", {})) - def _save_cfg_preset_widgets(self): - """Save current pipeline widget values into the active config preset.""" - name = self._cfg_preset_combo.currentText() + def _save_cfg_preset_widgets(self, name: str | None = None) -> None: + if name is None: + name = self._cfg_panel.current_name if not name: return preset = self._config_presets_data.setdefault(name, {}) preset.update(read_config_widgets( - self._widgets, self._dawproject_templates_widget)) - - def _load_cfg_preset_widgets(self, preset_name: str): - """Load config preset values into pipeline widgets.""" - preset = self._config_presets_data.get(preset_name, {}) - load_config_widgets( - self._widgets, preset, self._dawproject_templates_widget) - - def _update_cfg_preset_buttons(self): - """Enable/disable Rename and Delete for config presets.""" - is_default = self._cfg_preset_combo.currentText() == "Default" - self._cfg_rename_btn.setEnabled(not is_default) - self._cfg_delete_btn.setEnabled(not is_default) - - def _on_cfg_preset_switched(self, text: str): - """Save current widgets, load newly selected config preset.""" - prev = self._prev_cfg_preset - if prev and prev in self._config_presets_data: - self._save_cfg_preset_widgets() - self._prev_cfg_preset = text - self._load_cfg_preset_widgets(text) - self._update_cfg_preset_buttons() - - def _on_cfg_preset_add(self): - """Create a new config preset from built-in defaults.""" - name, ok = QInputDialog.getText( - self, "New Config Preset", "Preset name:") - if not ok or not name.strip(): - return - name = name.strip() - if name in self._config_presets_data: - QMessageBox.warning( - self, "Duplicate Name", - f"A preset named \u201c{name}\u201d already exists.") - return - self._save_cfg_preset_widgets() + self._cfg_widgets, self._cfg_dawproject_widget)) + + def _load_cfg_preset_widgets(self, name: str) -> None: + preset = self._config_presets_data.get(name, {}) + load_config_widgets(self._cfg_widgets, preset, self._cfg_dawproject_widget) + + # ── Config preset signal handlers ──────────────────────────────── + + def _on_cfg_switching(self, old: str, new: str) -> None: + if old and old in self._config_presets_data: + self._save_cfg_preset_widgets(old) # old name before combo changed + self._load_cfg_preset_widgets(new) + + def _on_cfg_added(self, name: str) -> None: self._config_presets_data[name] = _build_default_config_preset() - self._cfg_preset_combo.blockSignals(True) - self._cfg_preset_combo.addItem(name) - self._cfg_preset_combo.setCurrentText(name) - self._cfg_preset_combo.blockSignals(False) - self._prev_cfg_preset = name self._load_cfg_preset_widgets(name) - self._update_cfg_preset_buttons() - - def _on_cfg_preset_duplicate(self): - """Duplicate the current config preset under a new name.""" - current = self._cfg_preset_combo.currentText() - name, ok = QInputDialog.getText( - self, "Duplicate Config Preset", "New preset name:", - text=f"{current} Copy") - if not ok or not name.strip(): - return - name = name.strip() - if name in self._config_presets_data: - QMessageBox.warning( - self, "Duplicate Name", - f"A preset named \u201c{name}\u201d already exists.") - return - self._save_cfg_preset_widgets() - self._config_presets_data[name] = copy.deepcopy( - self._config_presets_data.get(current, {})) - self._cfg_preset_combo.blockSignals(True) - self._cfg_preset_combo.addItem(name) - self._cfg_preset_combo.setCurrentText(name) - self._cfg_preset_combo.blockSignals(False) - self._prev_cfg_preset = name - self._load_cfg_preset_widgets(name) - self._update_cfg_preset_buttons() - def _on_cfg_preset_rename(self): - """Rename the current config preset (not allowed for Default).""" - current = self._cfg_preset_combo.currentText() - if current == "Default": - return - name, ok = QInputDialog.getText( - self, "Rename Config Preset", "New name:", text=current) - if not ok or not name.strip(): - return - name = name.strip() - if name == current: - return - if name in self._config_presets_data: - QMessageBox.warning( - self, "Duplicate Name", - f"A preset named \u201c{name}\u201d already exists.") - return - self._save_cfg_preset_widgets() - self._config_presets_data[name] = self._config_presets_data.pop(current) - idx = self._cfg_preset_combo.findText(current) - self._cfg_preset_combo.blockSignals(True) - self._cfg_preset_combo.setItemText(idx, name) - self._cfg_preset_combo.blockSignals(False) - self._prev_cfg_preset = name - self._update_cfg_preset_buttons() - - def _on_cfg_preset_delete(self): - """Delete the current config preset (not allowed for Default).""" - current = self._cfg_preset_combo.currentText() - if current == "Default": - return - reply = QMessageBox.question( - self, "Delete Config Preset", - f"Delete the config preset \u201c{current}\u201d?", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) - if reply != QMessageBox.Yes: - return - self._config_presets_data.pop(current, None) - idx = self._cfg_preset_combo.findText(current) - self._cfg_preset_combo.blockSignals(True) - self._cfg_preset_combo.removeItem(idx) - self._cfg_preset_combo.setCurrentText("Default") - self._cfg_preset_combo.blockSignals(False) - self._prev_cfg_preset = "Default" - self._load_cfg_preset_widgets("Default") - self._update_cfg_preset_buttons() - - # ── Color helpers ───────────────────────────────────────────────── - - def _color_names(self) -> list[str]: - """Return the list of color names currently in the colors table.""" - names = [] - for row in range(self._colors_table.rowCount()): - item = self._colors_table.item(row, 1) - if item: - name = item.text().strip() - if name: - names.append(name) - return names - - def _color_argb_for_name(self, name: str) -> str | None: - """Look up an ARGB value by color name from the colors table.""" - for row in range(self._colors_table.rowCount()): - item = self._colors_table.item(row, 1) - if item and item.text().strip() == name: - swatch = self._colors_table.item(row, 2) - if swatch: - return swatch.data(Qt.UserRole) - return None - - # ── Helpers ──────────────────────────────────────────────────────── - - def _browse_project_dir(self, line_edit: QLineEdit): - """Open a directory picker and set the result into *line_edit*.""" - start = line_edit.text().strip() or "" - path = QFileDialog.getExistingDirectory( - self, "Select Default Project Directory", start, - QFileDialog.ShowDirsOnly, - ) - if path: - line_edit.setText(path) - - # ── Save ────────────────────────────────────────────────────────── - - def _on_save(self): - # ── App settings ────────────────────────────────────────────── - app = self._config.setdefault("app", {}) - for key, widget in self._general_widgets: - app[key] = _read_widget(widget) - - # Validate output folder name - raw_folder = app.get("output_folder", "") - clean_folder = sanitize_output_folder(str(raw_folder)) - if clean_folder is None: - QMessageBox.warning( - self, "Invalid output folder", - "The output folder name is invalid.\n\n" - "It must be a simple folder name without path separators, " - "special characters, or reserved names.", - ) - return - app["output_folder"] = clean_folder + def _on_cfg_duplicated(self, source: str, new: str) -> None: + self._save_cfg_preset_widgets(source) # capture any unsaved widget edits + self._config_presets_data[new] = copy.deepcopy( + self._config_presets_data.get(source, {})) + self._load_cfg_preset_widgets(new) - # Remember active preset names - app["active_config_preset"] = self._cfg_preset_combo.currentText() - app["active_group_preset"] = self._group_preset_combo.currentText() + def _on_cfg_renamed(self, old: str, new: str) -> None: + self._config_presets_data[new] = self._config_presets_data.pop(old, {}) - # ── Config presets ──────────────────────────────────────────── - self._save_cfg_preset_widgets() - self._config["config_presets"] = copy.deepcopy( - self._config_presets_data) + def _on_cfg_deleted(self, name: str) -> None: + self._config_presets_data.pop(name, None) + self._load_cfg_preset_widgets(self._cfg_panel.current_name) + + # ── Save ───────────────────────────────────────────────────────── - # ── Colors ──────────────────────────────────────────────────── - self._config["colors"] = self._read_colors() + def _on_save(self) -> None: + err = self._general_page.validate() + if err: + QMessageBox.warning(self, "Invalid Settings", err) + return - # ── Group presets ───────────────────────────────────────────── - self._save_current_preset() + self._general_page.commit(self._config) + self._colors_page.commit(self._config) - # Validate regex patterns in all group presets - for preset_name, groups in self._group_presets_data.items(): + # Validate regex patterns across all group presets before committing + self._groups_page._save_current() + for preset_name, groups in self._groups_page._presets_data.items(): for g in groups: if g.get("match_method") == "regex" and g.get("match_pattern", ""): try: @@ -950,19 +280,19 @@ def _on_save(self): f"Group \u201c{g['name']}\u201d in preset " f"\u201c{preset_name}\u201d has an invalid " f"regular expression:\n\n" - f"{g['match_pattern']}\n\n{e}", - ) + f"{g['match_pattern']}\n\n{e}") return - self._config["group_presets"] = copy.deepcopy( - self._group_presets_data) + self._groups_page.commit(self._config) + + self._save_cfg_preset_widgets() + self._config["config_presets"] = copy.deepcopy(self._config_presets_data) + self._config.setdefault("app", {})[ + "active_config_preset"] = self._cfg_panel.current_name - # Remove legacy keys if present - self._config.pop("gui", None) - self._config.pop("analysis", None) - self._config.pop("detectors", None) - self._config.pop("processors", None) - self._config.pop("daw_processors", None) + # Remove legacy keys + for legacy in ("gui", "analysis", "detectors", "processors", "daw_processors"): + self._config.pop(legacy, None) self._saved = True self.accept() diff --git a/sessionprepgui/prefs/page_colors.py b/sessionprepgui/prefs/page_colors.py new file mode 100644 index 0000000..07787d5 --- /dev/null +++ b/sessionprepgui/prefs/page_colors.py @@ -0,0 +1,190 @@ +"""ColorsPage — editable color palette for track groups.""" + +from __future__ import annotations + +import copy +from typing import Callable + +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QColorDialog, + QHBoxLayout, + QHeaderView, + QLabel, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, +) + +from .param_form import _argb_to_qcolor + + +class ColorsPage(QWidget): + """Editable color palette (name + ARGB swatch per row). + + Implements the standard page interface: + load(config) — populate from config["colors"] + commit(config) — write back to config["colors"] + + Also exposes color_provider() for GroupsPage to reference live data. + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._init_ui() + + # ── Page interface ──────────────────────────────────────────────── + + def load(self, config: dict) -> None: + from ..theme import PT_DEFAULT_COLORS + colors = config.get("colors", []) + if not colors: + colors = copy.deepcopy(PT_DEFAULT_COLORS) + self._table.setRowCount(len(colors)) + for row, entry in enumerate(colors): + self._set_color_row( + row, entry.get("name", ""), entry.get("argb", "#ff888888")) + + def commit(self, config: dict) -> None: + config["colors"] = self._read_colors() + + # ── Color provider (for GroupsPage) ─────────────────────────────── + + def color_provider(self) -> tuple[list[str], Callable[[str], str | None]]: + """Return (color_names, argb_lookup) from the current table state.""" + return self._color_names(), self._color_argb_for_name + + # ── UI setup ───────────────────────────────────────────────────── + + def _init_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + desc = QLabel( + "Color palette used for track groups. " + "Double-click a swatch to edit." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #888; font-size: 9pt;") + layout.addWidget(desc) + + self._table = QTableWidget() + self._table.setColumnCount(3) + self._table.setHorizontalHeaderLabels(["#", "Name", "Color"]) + self._table.verticalHeader().setVisible(False) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + self._table.setSelectionMode(QTableWidget.SingleSelection) + ch = self._table.horizontalHeader() + ch.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + ch.setSectionResizeMode(0, QHeaderView.Fixed) + ch.resizeSection(0, 36) + ch.setSectionResizeMode(1, QHeaderView.Stretch) + ch.setSectionResizeMode(2, QHeaderView.Fixed) + ch.resizeSection(2, 60) + self._table.cellDoubleClicked.connect(self._on_swatch_dbl_click) + layout.addWidget(self._table, 1) + + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(6) + add_btn = QPushButton("Add") + add_btn.clicked.connect(self._on_add) + btn_row.addWidget(add_btn) + remove_btn = QPushButton("Remove") + remove_btn.clicked.connect(self._on_remove) + btn_row.addWidget(remove_btn) + reset_btn = QPushButton("Reset to Defaults") + reset_btn.clicked.connect(self._on_reset) + btn_row.addWidget(reset_btn) + btn_row.addStretch() + layout.addLayout(btn_row) + + # ── Row helpers ─────────────────────────────────────────────────── + + def _set_color_row(self, row: int, name: str, argb: str) -> None: + idx_item = QTableWidgetItem(str(row + 1)) + idx_item.setFlags(idx_item.flags() & ~Qt.ItemIsEditable) + idx_item.setForeground(QColor("#888888")) + self._table.setItem(row, 0, idx_item) + + self._table.setItem(row, 1, QTableWidgetItem(name)) + + swatch_item = QTableWidgetItem() + swatch_item.setFlags(swatch_item.flags() & ~Qt.ItemIsEditable) + swatch_item.setBackground(_argb_to_qcolor(argb)) + swatch_item.setData(Qt.UserRole, argb) + swatch_item.setToolTip(argb) + self._table.setItem(row, 2, swatch_item) + + def _read_colors(self) -> list[dict[str, str]]: + colors = [] + for row in range(self._table.rowCount()): + name_item = self._table.item(row, 1) + swatch_item = self._table.item(row, 2) + if not name_item or not swatch_item: + continue + name = name_item.text().strip() + argb = swatch_item.data(Qt.UserRole) or "#ff888888" + if name: + colors.append({"name": name, "argb": argb}) + return colors + + def _color_names(self) -> list[str]: + names = [] + for row in range(self._table.rowCount()): + item = self._table.item(row, 1) + if item: + name = item.text().strip() + if name: + names.append(name) + return names + + def _color_argb_for_name(self, name: str) -> str | None: + for row in range(self._table.rowCount()): + item = self._table.item(row, 1) + if item and item.text().strip() == name: + swatch = self._table.item(row, 2) + if swatch: + return swatch.data(Qt.UserRole) + return None + + # ── Slot handlers ───────────────────────────────────────────────── + + def _on_swatch_dbl_click(self, row: int, col: int) -> None: + if col != 2: + return + item = self._table.item(row, 2) + if not item: + return + current = _argb_to_qcolor(item.data(Qt.UserRole) or "#ff888888") + color = QColorDialog.getColor( + current, self, "Select Color", QColorDialog.ShowAlphaChannel) + if color.isValid(): + argb = "#{:02x}{:02x}{:02x}{:02x}".format( + color.alpha(), color.red(), color.green(), color.blue()) + item.setBackground(color) + item.setData(Qt.UserRole, argb) + item.setToolTip(argb) + + def _on_add(self) -> None: + row = self._table.rowCount() + self._table.insertRow(row) + self._set_color_row(row, "New Color", "#ff888888") + self._table.scrollToBottom() + self._table.editItem(self._table.item(row, 1)) + + def _on_remove(self) -> None: + row = self._table.currentRow() + if row >= 0: + self._table.removeRow(row) + + def _on_reset(self) -> None: + from ..theme import PT_DEFAULT_COLORS + self._table.setRowCount(0) + self._table.setRowCount(len(PT_DEFAULT_COLORS)) + for row, entry in enumerate(PT_DEFAULT_COLORS): + self._set_color_row(row, entry["name"], entry["argb"]) diff --git a/sessionprepgui/prefs/page_general.py b/sessionprepgui/prefs/page_general.py new file mode 100644 index 0000000..00a2f72 --- /dev/null +++ b/sessionprepgui/prefs/page_general.py @@ -0,0 +1,140 @@ +"""GeneralPage — application-level settings (app + waveform prefs).""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QVBoxLayout, + QWidget, +) + +from sessionpreplib.config import ParamSpec + +from .param_form import ( + PathPicker, + PathPickerMode, + _build_param_page, + _read_widget, + _set_widget_value, + sanitize_output_folder, +) + +# --------------------------------------------------------------------------- +# Page-level param specs (data, not UI) +# --------------------------------------------------------------------------- + +_APP_PARAMS = [ + ParamSpec( + key="scale_factor", type=(int, float), default=1.0, + min=0.5, max=4.0, + label="HiDPI scale factor", + description=( + "Scale factor for the application UI. " + "Requires a restart to take effect." + ), + ), + ParamSpec( + key="report_verbosity", type=str, default="normal", + choices=["normal", "verbose"], + label="Report verbosity", + description=( + "Controls the level of detail shown in track reports. " + "Verbose mode includes additional analytical data such as " + "classification metrics." + ), + ), + ParamSpec( + key="output_folder", type=str, default="processed", + label="Output folder name", + description=( + "Name of the subfolder (relative to the project directory) " + "where processed audio files are written. " + "Must be a simple folder name without path separators." + ), + ), + ParamSpec( + key="spectrogram_colormap", type=str, default="magma", + choices=["magma", "viridis", "grayscale"], + label="Spectrogram color theme", + description="Color palette used for the spectrogram display.", + ), + ParamSpec( + key="invert_scroll", type=str, default="default", + choices=["default", "horizontal", "vertical", "both"], + label="Invert mouse-wheel scrolling", + description=( + "Reverses the scroll direction in the waveform/spectrogram view. " + "'horizontal' inverts Shift+wheel (timeline panning), " + "'vertical' inverts Shift+Alt+wheel (frequency panning), " + "'both' inverts both axes." + ), + ), +] + +_DIR_SPEC = ParamSpec( + key="default_project_dir", type=str, default="", + label="Default project directory", + description=( + "When set, the Open Folder dialog starts in this directory. " + "Leave empty to use the system default." + ), +) + + +class GeneralPage(QWidget): + """App-level preference form. + + Implements the standard page interface: + load(config) — populate from config["app"] + commit(config) — write back to config["app"] + validate() — returns error string or None + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._widgets: list[tuple[str, QWidget]] = [] + self._dir_picker: PathPicker + self._init_ui() + + # ── Page interface ──────────────────────────────────────────────── + + def load(self, config: dict) -> None: + values = config.get("app", {}) + for key, widget in self._widgets: + if key in values: + _set_widget_value(widget, values[key]) + self._dir_picker.set_value(str(values.get("default_project_dir", ""))) + + def commit(self, config: dict) -> None: + app = config.setdefault("app", {}) + for key, widget in self._widgets: + app[key] = _read_widget(widget) + app["default_project_dir"] = self._dir_picker.value() + + def validate(self) -> str | None: + """Return an error message if output_folder is invalid, else None.""" + for key, widget in self._widgets: + if key == "output_folder": + raw = _read_widget(widget) + if sanitize_output_folder(str(raw)) is None: + return ( + "The output folder name is invalid.\n\n" + "It must be a simple folder name without path " + "separators, special characters, or reserved names." + ) + return None + + # ── UI setup ───────────────────────────────────────────────────── + + def _init_ui(self) -> None: + page, widgets = _build_param_page(_APP_PARAMS, {}) + self._widgets = widgets + + self._dir_picker = PathPicker(_DIR_SPEC, mode=PathPickerMode.FOLDER) + + # Insert the directory picker at the top, before the param rows + outer = page.layout() + outer.insertWidget(0, self._dir_picker) + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(page) diff --git a/sessionprepgui/prefs/page_groups.py b/sessionprepgui/prefs/page_groups.py new file mode 100644 index 0000000..a9872c3 --- /dev/null +++ b/sessionprepgui/prefs/page_groups.py @@ -0,0 +1,153 @@ +"""GroupsPage — named group presets with add/duplicate/rename/delete.""" + +from __future__ import annotations + +import copy +from typing import Callable + +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QLabel, + QVBoxLayout, + QWidget, +) + +from .config_pages import GroupsTableWidget +from .preset_panel import NamedPresetPanel + + +class GroupsPage(QWidget): + """Editable group preset list. + + Implements the standard page interface: + load(config) — populate from config["group_presets"] + commit(config) — write back to config["group_presets"] and + config["app"]["active_group_preset"] + + Parameters + ---------- + color_provider: + Callable returning ``(color_names, argb_lookup)`` — delegated + from ColorsPage so the group color dropdowns always reflect the + live color table. + """ + + def __init__(self, color_provider: Callable, parent=None): + super().__init__(parent) + self._color_provider = color_provider + self._presets_data: dict[str, list[dict]] = {} + self._init_ui() + + # ── Page interface ──────────────────────────────────────────────── + + def load(self, config: dict) -> None: + from ..settings import build_defaults + defaults = build_defaults() + presets = config.get("group_presets", defaults.get("group_presets", {})) + self._presets_data = copy.deepcopy(presets) + active = config.get("app", {}).get("active_group_preset", "Default") + if active not in self._presets_data: + active = "Default" + + self._panel.reset(list(self._presets_data), current=active) + self._load_preset(active) + + def commit(self, config: dict) -> None: + self._save_current() + config["group_presets"] = copy.deepcopy(self._presets_data) + config.setdefault("app", {})["active_group_preset"] = ( + self._panel.current_name) + + def active_preset_name(self) -> str: + return self._panel.current_name + + # ── UI setup ───────────────────────────────────────────────────── + + def _init_ui(self) -> None: + layout = QVBoxLayout(self) + layout.setContentsMargins(12, 12, 12, 12) + layout.setSpacing(8) + + desc = QLabel( + "Default track groups used when analyzing a session. " + "Groups reference colors from the Colors page." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #888; font-size: 9pt;") + layout.addWidget(desc) + + self._panel = NamedPresetPanel( + [], + label="Preset:", + protected=frozenset({"Default"}), + ) + self._panel.preset_switching.connect(self._on_switching) + self._panel.preset_added.connect(self._on_added) + self._panel.preset_duplicated.connect(self._on_duplicated) + self._panel.preset_renamed.connect(self._on_renamed) + self._panel.preset_deleted.connect(self._on_deleted) + layout.addWidget(self._panel) + + # Reset-to-default button (groups-specific, not generic) + from PySide6.QtWidgets import QHBoxLayout, QPushButton + reset_row = QHBoxLayout() + reset_row.setContentsMargins(0, 0, 0, 0) + reset_btn = QPushButton("Reset to Default") + reset_btn.setToolTip( + "Replace the current preset's groups with the built-in defaults") + reset_btn.clicked.connect(self._on_reset_default) + reset_row.addStretch() + reset_row.addWidget(reset_btn) + layout.addLayout(reset_row) + + self._groups_widget = GroupsTableWidget( + color_provider=self._color_provider) + layout.addWidget(self._groups_widget, 1) + + # ── Preset helpers ──────────────────────────────────────────────── + + def _load_preset(self, name: str) -> None: + groups = self._presets_data.get(name, []) + self._groups_widget.set_groups(groups) + + def _save_current(self) -> None: + name = self._panel.current_name + if name: + self._presets_data[name] = self._groups_widget.get_groups() + + # ── Signal handlers ─────────────────────────────────────────────── + + def _on_switching(self, old: str, new: str) -> None: + if old and old in self._presets_data: + self._presets_data[old] = self._groups_widget.get_groups() + self._load_preset(new) + + def _on_added(self, name: str) -> None: + self._presets_data[name] = [] + self._load_preset(name) + + def _on_duplicated(self, source: str, new: str) -> None: + self._presets_data[new] = copy.deepcopy( + self._presets_data.get(source, [])) + self._load_preset(new) + + def _on_renamed(self, old: str, new: str) -> None: + self._presets_data[new] = self._presets_data.pop(old, []) + + def _on_deleted(self, name: str) -> None: + self._presets_data.pop(name, None) + self._load_preset(self._panel.current_name) + + def _on_reset_default(self) -> None: + from ..settings import _DEFAULT_GROUPS + from PySide6.QtWidgets import QMessageBox + current = self._panel.current_name + reply = QMessageBox.question( + self, "Reset to Default", + f"Replace all groups in \u201c{current}\u201d with the " + f"built-in defaults?\n\nThis cannot be undone.", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply != QMessageBox.Yes: + return + self._presets_data[current] = copy.deepcopy(_DEFAULT_GROUPS) + self._load_preset(current) diff --git a/sessionprepgui/prefs/param_form.py b/sessionprepgui/prefs/param_form.py new file mode 100644 index 0000000..e474851 --- /dev/null +++ b/sessionprepgui/prefs/param_form.py @@ -0,0 +1,489 @@ +"""Generic PySide6 widget factory for parameter-spec-driven forms. + +No dependency on sessionpreplib. Works with any object that satisfies the +ParamSpec protocol (key, label, type, default, choices, min, max, description). +Portable: copy param_form.py to any PySide6 project. +""" + +from __future__ import annotations + +import enum +import re +from decimal import Decimal +from typing import Any, Protocol, runtime_checkable + +from PySide6.QtCore import Signal +from PySide6.QtGui import QColor, QIcon, QPixmap +from PySide6.QtWidgets import ( + QCheckBox, + QComboBox, + QDoubleSpinBox, + QFileDialog, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + + +# --------------------------------------------------------------------------- +# ParamSpec protocol (duck-typed — sessionpreplib.config.ParamSpec satisfies it) +# --------------------------------------------------------------------------- + +@runtime_checkable +class ParamSpec(Protocol): + key: str + label: str + type: type + default: Any + choices: list | None + min: float | None + max: float | None + description: str | None + + +# --------------------------------------------------------------------------- +# PathPickerMode +# --------------------------------------------------------------------------- + +class PathPickerMode(enum.Enum): + """Controls which QFileDialog variant PathPicker opens.""" + + FOLDER = "folder" # QFileDialog.getExistingDirectory + OPEN_FILE = "open_file" # QFileDialog.getOpenFileName + SAVE_FILE = "save_file" # QFileDialog.getSaveFileName + + +# --------------------------------------------------------------------------- +# ARGB / color helpers +# --------------------------------------------------------------------------- + +def _argb_to_qcolor(argb: str) -> QColor: + """Parse a ``#AARRGGBB`` hex string into a QColor.""" + s = argb.lstrip("#") + if len(s) == 8: + a, r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16) + return QColor(r, g, b, a) + return QColor(argb) + + +def _color_swatch_icon(argb: str, size: int = 16) -> QIcon: + """Create a small square QIcon filled with the given ARGB color.""" + pm = QPixmap(size, size) + pm.fill(_argb_to_qcolor(argb)) + return QIcon(pm) + + +# --------------------------------------------------------------------------- +# Widget factory +# --------------------------------------------------------------------------- + +def _build_widget(spec: Any, value: Any) -> QWidget: + """Create an appropriate input widget for a ParamSpec and set its value.""" + if spec.choices is not None: + w = QComboBox() + for c in spec.choices: + w.addItem(str(c), c) + idx = w.findData(value) + if idx >= 0: + w.setCurrentIndex(idx) + w._param_spec = spec + return w + + if spec.type is bool: + w = QCheckBox() + w.setChecked(bool(value)) + w._param_spec = spec + return w + + if spec.type is int: + w = QSpinBox() + w.setMinimum(int(spec.min) if spec.min is not None else -999999) + w.setMaximum(int(spec.max) if spec.max is not None else 999999) + w.setValue(int(value) if value is not None else int(spec.default)) + w._param_spec = spec + return w + + if spec.type in ((int, float), float): + w = QDoubleSpinBox() + lo = float(spec.min) if spec.min is not None else -999999.0 + hi = float(spec.max) if spec.max is not None else 999999.0 + decimals = 2 + for ref in (spec.default, value, lo if spec.min is not None else None): + if ref is not None and ref != 0: + try: + d = Decimal(str(float(ref))) + exp = -d.as_tuple().exponent + decimals = max(decimals, exp) + except Exception: + pass + w.setDecimals(min(decimals, 10)) + w.setMinimum(lo) + w.setMaximum(hi) + span = hi - lo + if decimals >= 4: + w.setSingleStep(10 ** -decimals) + elif span <= 5: + w.setSingleStep(0.25) + elif span <= 20: + w.setSingleStep(0.5) + elif span <= 200: + w.setSingleStep(1.0) + else: + w.setSingleStep(5.0) + w.setValue(float(value) if value is not None else float(spec.default)) + w._param_spec = spec + return w + + if spec.type is list: + w = QLineEdit() + if isinstance(value, list): + w.setText(", ".join(str(x) for x in value)) + else: + w.setText(str(value) if value else "") + w.setPlaceholderText("comma-separated values") + w._param_spec = spec + return w + + w = QLineEdit() + w.setText(str(value) if value is not None else "") + w._param_spec = spec + return w + + +def _set_widget_value(widget: QWidget, value: Any) -> None: + """Set a widget's value programmatically.""" + if isinstance(widget, PathPicker): # checked before QLineEdit (PathPicker contains one) + widget.set_value(str(value) if value is not None else "") + return + if isinstance(widget, QComboBox): + idx = widget.findData(value) + if idx >= 0: + widget.setCurrentIndex(idx) + elif isinstance(widget, QCheckBox): + widget.setChecked(bool(value)) + elif isinstance(widget, QSpinBox): + widget.setValue(int(value)) + elif isinstance(widget, QDoubleSpinBox): + widget.setValue(float(value)) + elif isinstance(widget, QLineEdit): + if isinstance(value, list): + widget.setText(", ".join(str(x) for x in value)) + else: + widget.setText(str(value) if value is not None else "") + + +def _read_widget(widget: QWidget) -> Any: + """Read the current value from a widget created by _build_widget.""" + if isinstance(widget, PathPicker): # checked before QLineEdit (PathPicker contains one) + return widget.value() + spec = widget._param_spec + if isinstance(widget, QComboBox): + return widget.currentData() + if isinstance(widget, QCheckBox): + return widget.isChecked() + if isinstance(widget, QSpinBox): + return widget.value() + if isinstance(widget, QDoubleSpinBox): + return widget.value() + if isinstance(widget, QLineEdit): + text = widget.text().strip() + if spec.type is list: + if not text: + return [] + return [s.strip() for s in text.split(",") if s.strip()] + return text + return None + + +# --------------------------------------------------------------------------- +# Tooltip / subtext builders +# --------------------------------------------------------------------------- + +def _type_label(t: Any) -> str: + """Human-readable type name.""" + if isinstance(t, tuple): + return " or ".join(x.__name__ for x in t) + return t.__name__ + + +def _build_tooltip(spec: Any) -> str: + """Build a rich tooltip with key, default, and range info.""" + parts = [f"{spec.label}"] + if spec.description: + parts.append(f"
{spec.description}") + parts.append(f"

Config key: {spec.key}") + parts.append(f"
Default: {spec.default}") + if spec.min is not None or spec.max is not None: + lo = str(spec.min) if spec.min is not None else "\u2212\u221e" + hi = str(spec.max) if spec.max is not None else "\u221e" + parts.append(f"
Range: {lo} \u2013 {hi}") + if spec.choices: + parts.append(f"
Choices: {', '.join(str(c) for c in spec.choices)}") + return "".join(parts) + + +def _build_subtext(spec: Any) -> str: + """Build visible subtext with description, type, and range info.""" + parts = [] + if spec.description: + parts.append(spec.description) + meta = [f"Type: {_type_label(spec.type)}"] + if spec.min is not None or spec.max is not None: + lo = str(spec.min) if spec.min is not None else "\u2212\u221e" + hi = str(spec.max) if spec.max is not None else "\u221e" + meta.append(f"Range: {lo} \u2013 {hi}") + if spec.choices: + meta.append(f"Choices: {', '.join(str(c) for c in spec.choices)}") + meta.append(f"Default: {spec.default}") + parts.append(" \u2022 ".join(meta)) + return "\n".join(parts) + + +# --------------------------------------------------------------------------- +# PathPicker — self-contained path / file picker widget +# --------------------------------------------------------------------------- + +class PathPicker(QWidget): + """Generic path/file picker row driven by a ParamSpec. + + Renders a bold label, a ``QLineEdit``, a Browse button, and a reset + button. When *show_recursive* is ``True`` and *mode* is ``FOLDER`` an + "Include subfolders" checkbox is appended below the input row. A grey + description subtext mirrors the style of ``_build_param_page`` rows, + making the widget a visual drop-in for any form built with this module. + + This class has **zero dependency on sessionpreplib** — copy + ``param_form.py`` to any PySide6 project and use it freely. + + Parameters + ---------- + spec: + ``ParamSpec`` providing *key*, *label*, *description*, and *default*. + mode: + Which ``QFileDialog`` variant the Browse button opens. + file_filter: + Qt filter string (e.g. ``"Audio (*.wav *.flac);;All (*.*)"``). + Only meaningful for ``OPEN_FILE`` / ``SAVE_FILE`` modes. + show_recursive: + When ``True`` and *mode* is ``FOLDER``, adds an + "Include subfolders" ``QCheckBox`` below the input row. + """ + + #: Emitted whenever the path text changes (browse, clear, or manual edit). + path_changed = Signal(str) + + def __init__( + self, + spec: Any, + *, + mode: PathPickerMode = PathPickerMode.FOLDER, + file_filter: str = "", + show_recursive: bool = False, + parent: QWidget | None = None, + ) -> None: + super().__init__(parent) + self._spec = spec + self._mode = mode + self._file_filter = file_filter + self._show_recursive = show_recursive + self._line_edit: QLineEdit + self._recursive_cb: QCheckBox | None = None + self._build_ui() + + # ── Public API ──────────────────────────────────────────────────── + + def value(self) -> str: + """Return the current path text (stripped).""" + return self._line_edit.text().strip() + + def set_value(self, path: str) -> None: + """Set the path text and emit :attr:`path_changed`.""" + self._line_edit.setText(path) + self.path_changed.emit(path) + + def recursive(self) -> bool: + """Return the "Include subfolders" checkbox state. + + Always returns ``False`` when *show_recursive* was not set. + """ + return self._recursive_cb.isChecked() if self._recursive_cb else False + + def set_recursive(self, on: bool) -> None: + """Set the "Include subfolders" checkbox state.""" + if self._recursive_cb: + self._recursive_cb.setChecked(on) + + # ── Private ─────────────────────────────────────────────────────── + + def _build_ui(self) -> None: + spec = self._spec + tooltip = _build_tooltip(spec) + placeholder = "" if spec.default else "(system default)" + + # Input row: label | line-edit | Browse… | ↺ + self._line_edit = QLineEdit() + self._line_edit.setPlaceholderText(placeholder) + self._line_edit.setToolTip(tooltip) + self._line_edit.textEdited.connect(self._on_text_edited) + + browse_btn = QPushButton("Browse\u2026") + browse_btn.setFixedWidth(80) + browse_btn.setToolTip("Open file browser") + browse_btn.clicked.connect(self._browse) + + default_repr = repr(spec.default) if spec.default else "empty" + reset_btn = QPushButton() + reset_btn.setIcon( + self.style().standardIcon( + self.style().StandardPixmap.SP_BrowserReload)) + reset_btn.setFixedSize(26, 26) + reset_btn.setToolTip(f"Reset to default ({default_repr})") + reset_btn.clicked.connect(lambda: self.set_value(spec.default)) + + name_lbl = QLabel(f"{spec.label}") + name_lbl.setToolTip(tooltip) + + input_row = QHBoxLayout() + input_row.setContentsMargins(0, 0, 0, 0) + input_row.setSpacing(8) + input_row.addWidget(name_lbl, 0) + input_row.addWidget(self._line_edit, 1) + input_row.addWidget(browse_btn) + input_row.addWidget(reset_btn) + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + outer.addLayout(input_row) + + # Optional recursive checkbox (FOLDER mode only) + if self._show_recursive and self._mode is PathPickerMode.FOLDER: + self._recursive_cb = QCheckBox("Include subfolders") + outer.addWidget(self._recursive_cb) + + # Description subtext — matches _build_param_page visual style + sub = QLabel(_build_subtext(spec)) + sub.setWordWrap(True) + sub.setStyleSheet("color: #888; font-size: 9pt;") + sub.setToolTip(tooltip) + outer.addWidget(sub) + + def _browse(self) -> None: + start = self.value() or "" + path = "" + if self._mode is PathPickerMode.FOLDER: + path = QFileDialog.getExistingDirectory( + self, f"Select {self._spec.label}", start, + QFileDialog.Option.ShowDirsOnly, + ) + elif self._mode is PathPickerMode.OPEN_FILE: + path, _ = QFileDialog.getOpenFileName( + self, f"Select {self._spec.label}", start, self._file_filter, + ) + elif self._mode is PathPickerMode.SAVE_FILE: + path, _ = QFileDialog.getSaveFileName( + self, f"Select {self._spec.label}", start, self._file_filter, + ) + if path: + self.set_value(path) + + def _on_text_edited(self, text: str) -> None: + self.path_changed.emit(text.strip()) + + +# --------------------------------------------------------------------------- +# Page builder +# --------------------------------------------------------------------------- + +def _build_param_page( + params: list[Any], + values: dict[str, Any], +) -> tuple[QWidget, list[tuple[str, QWidget]]]: + """Build a scrollable form page for a list of ParamSpecs. + + Returns ``(page_widget, [(key, widget), ...])`` + """ + page = QWidget() + outer = QVBoxLayout(page) + outer.setContentsMargins(12, 12, 12, 12) + outer.setSpacing(12) + widgets: list[tuple[str, QWidget]] = [] + for spec in params: + val = values.get(spec.key, spec.default) + w = _build_widget(spec, val) + tooltip = _build_tooltip(spec) + w.setToolTip(tooltip) + + row = QHBoxLayout() + row.setContentsMargins(0, 0, 0, 0) + row.setSpacing(8) + name_label = QLabel(f"{spec.label}") + name_label.setToolTip(tooltip) + row.addWidget(name_label, 1) + row.addWidget(w, 0) + + reset_btn = QPushButton() + reset_btn.setIcon( + page.style().standardIcon( + page.style().StandardPixmap.SP_BrowserReload)) + reset_btn.setFixedSize(26, 26) + reset_btn.setToolTip(f"Reset to default ({spec.default})") + reset_btn.clicked.connect( + lambda _checked=False, ww=w, dv=spec.default: _set_widget_value(ww, dv)) + row.addWidget(reset_btn) + + param_box = QVBoxLayout() + param_box.setContentsMargins(0, 0, 0, 0) + param_box.setSpacing(2) + param_box.addLayout(row) + + sub_label = QLabel(_build_subtext(spec)) + sub_label.setWordWrap(True) + sub_label.setStyleSheet("color: #888; font-size: 9pt;") + sub_label.setToolTip(tooltip) + param_box.addWidget(sub_label) + + outer.addLayout(param_box) + widgets.append((spec.key, w)) + + outer.addStretch() + return page, widgets + + +# --------------------------------------------------------------------------- +# Output folder validation +# --------------------------------------------------------------------------- + +_WINDOWS_RESERVED = frozenset( + ["CON", "PRN", "AUX", "NUL"] + + [f"COM{i}" for i in range(1, 10)] + + [f"LPT{i}" for i in range(1, 10)] +) +_ILLEGAL_CHARS = frozenset('<>:"|?*') + + +def sanitize_output_folder(name: str) -> str | None: + """Validate and clean an output folder name. + + Returns the stripped name on success, or ``None`` if invalid. + Rejects empty strings, path traversals, separators, illegal Windows + characters, control characters, and reserved names. + """ + name = name.strip() + if not name: + return None + if ".." in name: + return None + if "/" in name or "\\" in name: + return None + if any(c in _ILLEGAL_CHARS for c in name): + return None + if any(ord(c) < 32 for c in name): + return None + if name.upper() in _WINDOWS_RESERVED: + return None + return name diff --git a/sessionprepgui/prefs/param_widgets.py b/sessionprepgui/prefs/param_widgets.py index b7f3a32..0e0c95f 100644 --- a/sessionprepgui/prefs/param_widgets.py +++ b/sessionprepgui/prefs/param_widgets.py @@ -1,1082 +1,30 @@ -"""Reusable widget builders and GroupsTableWidget for SessionPrep GUI. +"""Backward-compatibility re-export shim. -Extracted from preferences.py to be shared between the Preferences dialog -and the Session Settings tab. +All symbols have moved to param_form.py (generic widget factory) or +config_pages.py (SessionPrep-specific builders). Import from those +modules directly in new code. """ -from __future__ import annotations - -import copy -import re -from decimal import Decimal -from typing import Any, Callable - -from PySide6.QtCore import QSize, Qt, Signal -from PySide6.QtGui import QColor, QFont, QIcon, QPixmap -from PySide6.QtWidgets import ( - QCheckBox, - QComboBox, - QDoubleSpinBox, - QFileDialog, - QHBoxLayout, - QHeaderView, - QLabel, - QLineEdit, - QPushButton, - QSpinBox, - QTableWidget, - QTableWidgetItem, - QTreeWidgetItem, - QVBoxLayout, - QWidget, +# ruff: noqa: F401 +from .param_form import ( + _argb_to_qcolor, + _build_param_page, + _build_subtext, + _build_tooltip, + _build_widget, + _color_swatch_icon, + _read_widget, + _set_widget_value, + _type_label, + _ILLEGAL_CHARS, + _WINDOWS_RESERVED, + sanitize_output_folder, ) - -from sessionpreplib.config import ParamSpec - - -# --------------------------------------------------------------------------- -# ARGB color helper -# --------------------------------------------------------------------------- - -def _argb_to_qcolor(argb: str) -> QColor: - """Parse a ``#AARRGGBB`` hex string into a QColor.""" - s = argb.lstrip("#") - if len(s) == 8: - a, r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16) - return QColor(r, g, b, a) - return QColor(argb) - - -def _color_swatch_icon(argb: str, size: int = 16) -> QIcon: - """Create a small square QIcon filled with the given ARGB color.""" - pm = QPixmap(size, size) - pm.fill(_argb_to_qcolor(argb)) - return QIcon(pm) - - -# --------------------------------------------------------------------------- -# Widget builders for ParamSpec -# --------------------------------------------------------------------------- - -def _build_widget(spec: ParamSpec, value: Any) -> QWidget: - """Create an appropriate input widget for a ParamSpec and set its value.""" - if spec.choices is not None: - w = QComboBox() - for c in spec.choices: - w.addItem(str(c), c) - idx = w.findData(value) - if idx >= 0: - w.setCurrentIndex(idx) - w._param_spec = spec - return w - - if spec.type is bool: - w = QCheckBox() - w.setChecked(bool(value)) - w._param_spec = spec - return w - - if spec.type is int: - w = QSpinBox() - w.setMinimum(int(spec.min) if spec.min is not None else -999999) - w.setMaximum(int(spec.max) if spec.max is not None else 999999) - w.setValue(int(value) if value is not None else int(spec.default)) - w._param_spec = spec - return w - - if spec.type in ((int, float), float): - w = QDoubleSpinBox() - lo = float(spec.min) if spec.min is not None else -999999.0 - hi = float(spec.max) if spec.max is not None else 999999.0 - # Adaptive decimals: enough to represent default and current value - decimals = 2 - for ref in (spec.default, value, lo if spec.min is not None else None): - if ref is not None and ref != 0: - try: - d = Decimal(str(float(ref))) - # Number of decimal places (negative exponent) - exp = -d.as_tuple().exponent - decimals = max(decimals, exp) - except Exception: - pass - w.setDecimals(min(decimals, 10)) - w.setMinimum(lo) - w.setMaximum(hi) - # Smart step size based on range and precision - span = hi - lo - if decimals >= 4: - w.setSingleStep(10 ** -decimals) - elif span <= 5: - w.setSingleStep(0.25) - elif span <= 20: - w.setSingleStep(0.5) - elif span <= 200: - w.setSingleStep(1.0) - else: - w.setSingleStep(5.0) - w.setValue(float(value) if value is not None else float(spec.default)) - w._param_spec = spec - return w - - if spec.type is list: - w = QLineEdit() - if isinstance(value, list): - w.setText(", ".join(str(x) for x in value)) - else: - w.setText(str(value) if value else "") - w.setPlaceholderText("comma-separated values") - w._param_spec = spec - return w - - # Fallback: string - w = QLineEdit() - w.setText(str(value) if value is not None else "") - w._param_spec = spec - return w - - -def _set_widget_value(widget: QWidget, value: Any): - """Set a widget's value programmatically.""" - if isinstance(widget, QComboBox): - idx = widget.findData(value) - if idx >= 0: - widget.setCurrentIndex(idx) - elif isinstance(widget, QCheckBox): - widget.setChecked(bool(value)) - elif isinstance(widget, QSpinBox): - widget.setValue(int(value)) - elif isinstance(widget, QDoubleSpinBox): - widget.setValue(float(value)) - elif isinstance(widget, QLineEdit): - if isinstance(value, list): - widget.setText(", ".join(str(x) for x in value)) - else: - widget.setText(str(value) if value is not None else "") - - -def _build_tooltip(spec: ParamSpec) -> str: - """Build a rich tooltip with key, default, and range info.""" - parts = [] - parts.append(f"{spec.label}") - if spec.description: - parts.append(f"
{spec.description}") - parts.append(f"

Config key: {spec.key}") - parts.append(f"
Default: {spec.default}") - if spec.min is not None or spec.max is not None: - lo = str(spec.min) if spec.min is not None else "−∞" - hi = str(spec.max) if spec.max is not None else "∞" - parts.append(f"
Range: {lo} \u2013 {hi}") - if spec.choices: - parts.append(f"
Choices: {', '.join(str(c) for c in spec.choices)}") - return "".join(parts) - - -def _read_widget(widget: QWidget) -> Any: - """Read the current value from a widget created by _build_widget.""" - spec = widget._param_spec - if isinstance(widget, QComboBox): - return widget.currentData() - if isinstance(widget, QCheckBox): - return widget.isChecked() - if isinstance(widget, QSpinBox): - return widget.value() - if isinstance(widget, QDoubleSpinBox): - return widget.value() - if isinstance(widget, QLineEdit): - text = widget.text().strip() - if spec.type is list: - if not text: - return [] - return [s.strip() for s in text.split(",") if s.strip()] - return text - return None - - -# --------------------------------------------------------------------------- -# Page builders -# --------------------------------------------------------------------------- - -def _type_label(t) -> str: - """Human-readable type name.""" - if isinstance(t, tuple): - return " or ".join(x.__name__ for x in t) - return t.__name__ - - -def _build_subtext(spec: ParamSpec) -> str: - """Build visible subtext with description, type, and range info.""" - parts = [] - if spec.description: - parts.append(spec.description) - meta = [] - meta.append(f"Type: {_type_label(spec.type)}") - if spec.min is not None or spec.max is not None: - lo = str(spec.min) if spec.min is not None else "\u2212\u221e" - hi = str(spec.max) if spec.max is not None else "\u221e" - meta.append(f"Range: {lo} \u2013 {hi}") - if spec.choices: - meta.append(f"Choices: {', '.join(str(c) for c in spec.choices)}") - meta.append(f"Default: {spec.default}") - if parts: - parts.append(" \u2022 ".join(meta)) - else: - parts.append(" \u2022 ".join(meta)) - return "\n".join(parts) - - -def _build_param_page(params: list[ParamSpec], values: dict[str, Any]) -> tuple[QWidget, list[tuple[str, QWidget]]]: - """Build a form page for a list of ParamSpecs. Returns (page_widget, [(key, widget)]).""" - page = QWidget() - outer = QVBoxLayout(page) - outer.setContentsMargins(12, 12, 12, 12) - outer.setSpacing(12) - widgets = [] - for spec in params: - val = values.get(spec.key, spec.default) - w = _build_widget(spec, val) - tooltip = _build_tooltip(spec) - w.setToolTip(tooltip) - - # Row 1: label + widget + reset button - row = QHBoxLayout() - row.setContentsMargins(0, 0, 0, 0) - row.setSpacing(8) - name_label = QLabel(f"{spec.label}") - name_label.setToolTip(tooltip) - row.addWidget(name_label, 1) - row.addWidget(w, 0) - reset_btn = QPushButton() - reset_btn.setIcon(page.style().standardIcon(page.style().StandardPixmap.SP_BrowserReload)) - reset_btn.setFixedSize(26, 26) - reset_btn.setToolTip(f"Reset to default ({spec.default})") - _default = spec.default - _widget = w - reset_btn.clicked.connect(lambda checked=False, ww=_widget, dv=_default: _set_widget_value(ww, dv)) - row.addWidget(reset_btn) - - # Row 2: subtext (description + type + range) - param_box = QVBoxLayout() - param_box.setContentsMargins(0, 0, 0, 0) - param_box.setSpacing(2) - param_box.addLayout(row) - - subtext = _build_subtext(spec) - sub_label = QLabel(subtext) - sub_label.setWordWrap(True) - sub_label.setStyleSheet("color: #888; font-size: 9pt;") - sub_label.setToolTip(tooltip) - param_box.addWidget(sub_label) - - outer.addLayout(param_box) - widgets.append((spec.key, w)) - - outer.addStretch() - return page, widgets - - -# --------------------------------------------------------------------------- -# Output folder validation -# --------------------------------------------------------------------------- - -_WINDOWS_RESERVED = frozenset( - ["CON", "PRN", "AUX", "NUL"] - + [f"COM{i}" for i in range(1, 10)] - + [f"LPT{i}" for i in range(1, 10)] +from .config_pages import ( + build_config_pages, + ColorProvider, + DawProjectTemplatesWidget, + GroupsTableWidget, + load_config_widgets, + read_config_widgets, ) -_ILLEGAL_CHARS = frozenset('<>:"|?*') - - -def sanitize_output_folder(name: str) -> str | None: - """Validate and clean an output folder name. - - Returns the stripped name on success, or ``None`` if the name is - invalid. Rejects empty strings, path traversals, path separators, - illegal Windows characters, control characters, and reserved names. - """ - name = name.strip() - if not name: - return None - if ".." in name: - return None - if "/" in name or "\\" in name: - return None - if any(c in _ILLEGAL_CHARS for c in name): - return None - if any(ord(c) < 32 for c in name): - return None - if name.upper() in _WINDOWS_RESERVED: - return None - return name - - -# --------------------------------------------------------------------------- -# GroupsTableWidget — reusable group table with Add/Remove/Sort -# --------------------------------------------------------------------------- - -# Type alias for the color provider callable. -# Returns (list_of_color_names, lookup_argb_by_name). -ColorProvider = Callable[[], tuple[list[str], Callable[[str], str | None]]] - - -class GroupsTableWidget(QWidget): - """Reusable widget for editing a list of track groups. - - The *color_provider* callable must return - ``(color_names, argb_lookup)`` where *color_names* is a list of - available color names and *argb_lookup* maps a name to an ARGB hex - string (or ``None``). - """ - - groups_changed = Signal() - - def __init__(self, color_provider: ColorProvider, parent=None): - super().__init__(parent) - self._color_provider = color_provider - self._init_ui() - - # ── UI setup ────────────────────────────────────────────────────── - - def _init_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) - - self._table = QTableWidget() - self._table.setColumnCount(6) - self._table.setHorizontalHeaderLabels( - ["Name", "Color", "Gain-Linked", "DAW Target", - "Match", "Match Pattern"]) - vh = self._table.verticalHeader() - vh.setSectionsMovable(True) - vh.sectionMoved.connect(self._on_row_moved) - self._table.setSelectionBehavior(QTableWidget.SelectRows) - self._table.setSelectionMode(QTableWidget.SingleSelection) - gh = self._table.horizontalHeader() - gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) - gh.setSectionResizeMode(0, QHeaderView.Stretch) - gh.setSectionResizeMode(1, QHeaderView.Fixed) - gh.resizeSection(1, 160) - gh.setSectionResizeMode(2, QHeaderView.Fixed) - gh.resizeSection(2, 80) - gh.setSectionResizeMode(3, QHeaderView.Interactive) - gh.resizeSection(3, 140) - gh.setSectionResizeMode(4, QHeaderView.Fixed) - gh.resizeSection(4, 90) - gh.setSectionResizeMode(5, QHeaderView.Interactive) - gh.resizeSection(5, 200) - - self._table.cellChanged.connect(self._on_cell_changed) - - layout.addWidget(self._table, 1) - - # Buttons - btn_row = QHBoxLayout() - btn_row.setContentsMargins(0, 0, 0, 0) - btn_row.setSpacing(6) - - add_btn = QPushButton("Add") - add_btn.clicked.connect(self._on_add) - btn_row.addWidget(add_btn) - - remove_btn = QPushButton("Remove") - remove_btn.clicked.connect(self._on_remove) - btn_row.addWidget(remove_btn) - - btn_row.addStretch() - - az_btn = QPushButton("Sort A\u2192Z") - az_btn.clicked.connect(self._on_sort_az) - btn_row.addWidget(az_btn) - - layout.addLayout(btn_row) - - # ── Public API ──────────────────────────────────────────────────── - - def set_groups(self, groups: list[dict]): - """Populate the table from a list of group dicts.""" - self._table.blockSignals(True) - self._table.setRowCount(0) - self._table.setRowCount(len(groups)) - for row, g in enumerate(groups): - self._set_row( - row, g.get("name", ""), g.get("color", ""), - g.get("gain_linked", False), g.get("daw_target", ""), - g.get("match_method", "contains"), - g.get("match_pattern", "")) - self._table.blockSignals(False) - - def get_groups(self) -> list[dict]: - """Read the table back into a list of group dicts.""" - return self._read_groups() - - @property - def table(self) -> QTableWidget: - """Direct access to the underlying QTableWidget (for selection, etc.).""" - return self._table - - # ── Row helpers ─────────────────────────────────────────────────── - - def _set_row(self, row: int, name: str, color: str, - gain_linked: bool, daw_target: str = "", - match_method: str = "contains", - match_pattern: str = ""): - """Populate one row in the groups table.""" - name_item = QTableWidgetItem(name) - self._table.setItem(row, 0, name_item) - - # Color dropdown with swatch icons - color_names, argb_lookup = self._color_provider() - color_combo = QComboBox() - color_combo.setIconSize(QSize(16, 16)) - for cn in color_names: - argb = argb_lookup(cn) - icon = _color_swatch_icon(argb) if argb else QIcon() - color_combo.addItem(icon, cn) - ci = color_combo.findText(color) - if ci >= 0: - color_combo.setCurrentIndex(ci) - self._table.setCellWidget(row, 1, color_combo) - - # Gain-linked checkbox (centered) - chk = QCheckBox() - chk.setChecked(gain_linked) - chk_container = QWidget() - chk_layout = QHBoxLayout(chk_container) - chk_layout.setContentsMargins(0, 0, 0, 0) - chk_layout.setAlignment(Qt.AlignCenter) - chk_layout.addWidget(chk) - self._table.setCellWidget(row, 2, chk_container) - - # DAW Target name - daw_item = QTableWidgetItem(daw_target) - self._table.setItem(row, 3, daw_item) - - # Match method dropdown - match_combo = QComboBox() - match_combo.addItems(["contains", "regex"]) - mi = match_combo.findText(match_method) - if mi >= 0: - match_combo.setCurrentIndex(mi) - match_combo.setProperty("_row", row) - match_combo.currentTextChanged.connect( - lambda _text, r=row: self._validate_pattern_cell(r)) - self._table.setCellWidget(row, 4, match_combo) - - # Match pattern text - pattern_item = QTableWidgetItem(match_pattern) - self._table.setItem(row, 5, pattern_item) - self._validate_pattern_cell(row) - - def _read_groups(self) -> list[dict]: - """Read all rows (logical order) into a list of group dicts.""" - groups: list[dict] = [] - for row in range(self._table.rowCount()): - name_item = self._table.item(row, 0) - if not name_item: - continue - name = name_item.text().strip() - if not name: - continue - color_combo = self._table.cellWidget(row, 1) - color = color_combo.currentText() if color_combo else "" - chk_container = self._table.cellWidget(row, 2) - gain_linked = False - if chk_container: - chk = chk_container.findChild(QCheckBox) - if chk: - gain_linked = chk.isChecked() - daw_item = self._table.item(row, 3) - daw_target = daw_item.text().strip() if daw_item else "" - match_combo = self._table.cellWidget(row, 4) - match_method = match_combo.currentText() if match_combo else "contains" - pattern_item = self._table.item(row, 5) - match_pattern = pattern_item.text().strip() if pattern_item else "" - groups.append({ - "name": name, - "color": color, - "gain_linked": gain_linked, - "daw_target": daw_target, - "match_method": match_method, - "match_pattern": match_pattern, - }) - return groups - - def _read_groups_visual_order(self) -> list[dict]: - """Read groups in current visual (display) order.""" - vh = self._table.verticalHeader() - n = self._table.rowCount() - visual_to_logical = sorted(range(n), key=lambda i: vh.visualIndex(i)) - groups: list[dict] = [] - for logical in visual_to_logical: - name_item = self._table.item(logical, 0) - if not name_item: - continue - name = name_item.text().strip() - if not name: - continue - cc = self._table.cellWidget(logical, 1) - color = cc.currentText() if cc else "" - chk_c = self._table.cellWidget(logical, 2) - gl = False - if chk_c: - chk = chk_c.findChild(QCheckBox) - if chk: - gl = chk.isChecked() - daw_item = self._table.item(logical, 3) - dt = daw_item.text().strip() if daw_item else "" - mc = self._table.cellWidget(logical, 4) - mm = mc.currentText() if mc else "contains" - pi = self._table.item(logical, 5) - mp = pi.text().strip() if pi else "" - groups.append({"name": name, "color": color, - "gain_linked": gl, "daw_target": dt, - "match_method": mm, "match_pattern": mp}) - return groups - - # ── Name dedup ──────────────────────────────────────────────────── - - @staticmethod - def _group_names_in_table(table: QTableWidget, - exclude_row: int = -1) -> set[str]: - """Collect all group names from a table, optionally excluding one row.""" - names: set[str] = set() - for r in range(table.rowCount()): - if r == exclude_row: - continue - item = table.item(r, 0) - if item: - n = item.text().strip() - if n: - names.add(n) - return names - - def _unique_name(self, base: str = "New Group") -> str: - """Generate a unique group name for the table.""" - existing = self._group_names_in_table(self._table) - if base not in existing: - return base - n = 2 - while f"{base} {n}" in existing: - n += 1 - return f"{base} {n}" - - def _on_cell_changed(self, row: int, col: int): - """Handle cell edits: name dedup (col 0), pattern validation (col 5).""" - if col == 0: - item = self._table.item(row, 0) - if not item: - return - name = item.text().strip() - others = self._group_names_in_table(self._table, exclude_row=row) - if name in others: - self._table.blockSignals(True) - item.setText(self._unique_name(name)) - self._table.blockSignals(False) - elif col == 5: - self._validate_pattern_cell(row) - self.groups_changed.emit() - - def _validate_pattern_cell(self, row: int): - """Validate the match pattern cell and set visual indicator. - - When match_method is "regex", tries to compile the pattern. - Sets the cell foreground to green (valid / empty) or red (invalid). - For "contains" mode, always shows green. - """ - match_combo = self._table.cellWidget(row, 4) - pattern_item = self._table.item(row, 5) - if not pattern_item: - return - method = match_combo.currentText() if match_combo else "contains" - pattern = pattern_item.text().strip() - - if method == "regex" and pattern: - try: - re.compile(pattern) - pattern_item.setForeground(QColor("#4ec94e")) # green - pattern_item.setToolTip("") - except re.error as e: - pattern_item.setForeground(QColor("#e05050")) # red - pattern_item.setToolTip(f"Invalid regex: {e}") - else: - pattern_item.setForeground(QColor("#cccccc")) # default - pattern_item.setToolTip("") - - # ── Row operations ──────────────────────────────────────────────── - - def _on_add(self): - row = self._table.rowCount() - self._table.insertRow(row) - color_names, _ = self._color_provider() - default_color = color_names[0] if color_names else "" - self._set_row(row, self._unique_name(), default_color, False) - self._table.scrollToBottom() - self._table.editItem(self._table.item(row, 0)) - self.groups_changed.emit() - - def _on_remove(self): - row = self._table.currentRow() - if row >= 0: - self._table.removeRow(row) - self.groups_changed.emit() - - def _on_row_moved(self, logical: int, old_visual: int, - new_visual: int): - """Handle drag-and-drop row reorder.""" - vh = self._table.verticalHeader() - ordered = self._read_groups_visual_order() - vh.blockSignals(True) - self._table.blockSignals(True) - for i in range(self._table.rowCount()): - vh.moveSection(vh.visualIndex(i), i) - self._table.setRowCount(0) - self._table.setRowCount(len(ordered)) - for row, entry in enumerate(ordered): - self._set_row( - row, entry["name"], entry["color"], - entry["gain_linked"], entry.get("daw_target", ""), - entry.get("match_method", "contains"), - entry.get("match_pattern", "")) - self._table.blockSignals(False) - vh.blockSignals(False) - self.groups_changed.emit() - - def _on_sort_az(self): - groups = self._read_groups() - groups.sort(key=lambda g: g["name"].lower()) - self._table.blockSignals(True) - self._table.setRowCount(0) - self._table.setRowCount(len(groups)) - for row, entry in enumerate(groups): - self._set_row( - row, entry["name"], entry["color"], - entry["gain_linked"], entry.get("daw_target", ""), - entry.get("match_method", "contains"), - entry.get("match_pattern", "")) - self._table.blockSignals(False) - self.groups_changed.emit() - - -# --------------------------------------------------------------------------- -# DawProjectTemplatesWidget — template list for DAWProject processor -# --------------------------------------------------------------------------- - -class DawProjectTemplatesWidget(QWidget): - """Editable table of DAWProject mix templates. - - Each row has a *Name*, a *Template Path* (with Browse button), - and a *Fader Ceiling (dB)* spinbox. The widget stores its data as - ``[{name, template_path, fader_ceiling_db}, ...]``. - """ - - templates_changed = Signal() - - def __init__(self, parent=None): - super().__init__(parent) - self._init_ui() - - def _init_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(6) - - lbl = QLabel("Mix Templates") - layout.addWidget(lbl) - - self._table = QTableWidget() - self._table.setColumnCount(3) - self._table.setHorizontalHeaderLabels( - ["Name", "Template Path", "Fader Ceiling (dB)"]) - gh = self._table.horizontalHeader() - gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) - gh.setSectionResizeMode(0, QHeaderView.Interactive) - gh.resizeSection(0, 180) - gh.setSectionResizeMode(1, QHeaderView.Stretch) - gh.setSectionResizeMode(2, QHeaderView.Fixed) - gh.resizeSection(2, 120) - self._table.setSelectionBehavior(QTableWidget.SelectRows) - self._table.setSelectionMode(QTableWidget.SingleSelection) - layout.addWidget(self._table, 1) - - btn_row = QHBoxLayout() - btn_row.setContentsMargins(0, 0, 0, 0) - btn_row.setSpacing(6) - add_btn = QPushButton("Add") - add_btn.clicked.connect(self._on_add) - btn_row.addWidget(add_btn) - remove_btn = QPushButton("Remove") - remove_btn.clicked.connect(self._on_remove) - btn_row.addWidget(remove_btn) - btn_row.addStretch() - layout.addLayout(btn_row) - - # ── Public API ──────────────────────────────────────────────────── - - def set_templates(self, templates: list[dict]): - """Populate the table from a list of template dicts.""" - self._table.blockSignals(True) - self._table.setRowCount(0) - self._table.setRowCount(len(templates)) - for row, tpl in enumerate(templates): - self._set_row( - row, - tpl.get("name", ""), - tpl.get("template_path", ""), - float(tpl.get("fader_ceiling_db", 6.0)), - ) - self._table.blockSignals(False) - - def get_templates(self) -> list[dict]: - """Read the table back into a list of template dicts.""" - templates: list[dict] = [] - for row in range(self._table.rowCount()): - name_item = self._table.item(row, 0) - name = name_item.text().strip() if name_item else "" - # Path is inside a container widget (QLineEdit + browse btn) - path = "" - path_container = self._table.cellWidget(row, 1) - if path_container: - le = path_container.findChild(QLineEdit) - if le: - path = le.text().strip() - ceiling_widget = self._table.cellWidget(row, 2) - ceiling = ceiling_widget.value() if ceiling_widget else 24.0 - if name or path: - templates.append({ - "name": name, - "template_path": path, - "fader_ceiling_db": ceiling, - }) - return templates - - # ── Row helpers ─────────────────────────────────────────────────── - - def _set_row(self, row: int, name: str, template_path: str, - fader_ceiling_db: float = 6.0): - self._table.setItem(row, 0, QTableWidgetItem(name)) - - # Path cell: read-only text item + browse button via cell widget - path_container = QWidget() - path_layout = QHBoxLayout(path_container) - path_layout.setContentsMargins(2, 0, 2, 0) - path_layout.setSpacing(4) - path_edit = QLineEdit(template_path) - path_edit.setPlaceholderText("Path to .dawproject file") - path_layout.addWidget(path_edit, 1) - browse_btn = QPushButton("Browse\u2026") - browse_btn.setFixedWidth(80) - browse_btn.setToolTip("Browse for .dawproject template") - browse_btn.clicked.connect( - lambda _checked=False, le=path_edit: self._browse_template(le)) - path_layout.addWidget(browse_btn) - self._table.setCellWidget(row, 1, path_container) - - # Fader ceiling spinbox - ceiling_spin = QDoubleSpinBox() - ceiling_spin.setRange(0.0, 48.0) - ceiling_spin.setDecimals(1) - ceiling_spin.setSuffix(" dB") - ceiling_spin.setValue(fader_ceiling_db) - self._table.setCellWidget(row, 2, ceiling_spin) - - def _browse_template(self, line_edit: QLineEdit): - path, _ = QFileDialog.getOpenFileName( - self, "Select DAWProject Template", - line_edit.text(), - "DAWProject Files (*.dawproject);;All Files (*)") - if path: - line_edit.setText(path) - self.templates_changed.emit() - - # ── Button handlers ────────────────────────────────────────────── - - def _on_add(self): - row = self._table.rowCount() - self._table.setRowCount(row + 1) - self._set_row(row, "", "", 6.0) - self.templates_changed.emit() - - def _on_remove(self): - row = self._table.currentRow() - if row < 0: - return - self._table.removeRow(row) - self.templates_changed.emit() - - -# --------------------------------------------------------------------------- -# Shared config page builder / loader / reader -# --------------------------------------------------------------------------- -# Used by both PreferencesDialog and the session Config tab to avoid -# duplicating tree+page construction, widget loading, and reading logic. - -def build_config_pages( - tree, - preset: dict[str, Any], - widgets_dict: dict, - register_page: Callable[[QTreeWidgetItem, QWidget], None], - *, - on_processor_enabled: Callable | None = None, -) -> DawProjectTemplatesWidget | None: - """Build the common config tree pages. - - Creates Analysis, Detectors (with Presentation parent), Processors, - and DAW Processors sections in *tree*, populating *widgets_dict*. - - Parameters - ---------- - tree: - QTreeWidget to add top-level items to. - preset: - Config preset dict with ``analysis``, ``detectors``, etc. sections. - widgets_dict: - Mutable dict to store ``(key, widget)`` lists per section key. - register_page: - Callback ``(tree_item, page_widget) -> None`` that adds the page - to the host's QStackedWidget (and optionally wraps in QScrollArea). - on_processor_enabled: - Optional slot connected to every processor's *enabled* checkbox. - - Returns - ------- - The :class:`DawProjectTemplatesWidget` instance if a DAWProject page - was created, otherwise ``None``. - """ - from sessionpreplib.config import ANALYSIS_PARAMS, PRESENTATION_PARAMS - from sessionpreplib.detectors import default_detectors - from sessionpreplib.processors import default_processors - from sessionpreplib.daw_processors import default_daw_processors - - dawproject_tpl_widget: DawProjectTemplatesWidget | None = None - - # ── Analysis ────────────────────────────────────────────────── - item = QTreeWidgetItem(tree, ["Analysis"]) - item.setFont(0, QFont("", -1, QFont.Bold)) - values = preset.get("analysis", {}) - pg, wdg = _build_param_page(ANALYSIS_PARAMS, values) - widgets_dict["analysis"] = wdg - register_page(item, pg) - - # ── Detectors (parent shows presentation params) ────────────── - det_parent = QTreeWidgetItem(tree, ["Detectors"]) - det_parent.setFont(0, QFont("", -1, QFont.Bold)) - pres_values = preset.get("presentation", {}) - pg, wdg = _build_param_page(PRESENTATION_PARAMS, pres_values) - widgets_dict["_presentation"] = wdg - register_page(det_parent, pg) - - det_sections = preset.get("detectors", {}) - for det in default_detectors(): - params = det.config_params() - if not params: - continue - child = QTreeWidgetItem(det_parent, [det.name]) - vals = det_sections.get(det.id, {}) - pg, wdg = _build_param_page(params, vals) - widgets_dict[f"detectors.{det.id}"] = wdg - register_page(child, pg) - - # ── Processors ──────────────────────────────────────────────── - proc_parent = QTreeWidgetItem(tree, ["Processors"]) - proc_parent.setFont(0, QFont("", -1, QFont.Bold)) - placeholder = QWidget() - pl = QVBoxLayout(placeholder) - pl.setContentsMargins(12, 12, 12, 12) - pl.addWidget(QLabel("Select a processor from the tree to configure.")) - pl.addStretch() - register_page(proc_parent, placeholder) - - proc_sections = preset.get("processors", {}) - for proc in default_processors(): - params = proc.config_params() - if not params: - continue - child = QTreeWidgetItem(proc_parent, [proc.name]) - vals = proc_sections.get(proc.id, {}) - pg, wdg = _build_param_page(params, vals) - widgets_dict[f"processors.{proc.id}"] = wdg - register_page(child, pg) - - if on_processor_enabled is not None: - enabled_key = f"{proc.id}_enabled" - for key, widget in wdg: - if key == enabled_key and isinstance(widget, QCheckBox): - widget.toggled.connect(on_processor_enabled) - break - - # ── DAW Processors ──────────────────────────────────────────── - daw_parent = QTreeWidgetItem(tree, ["DAW Processors"]) - daw_parent.setFont(0, QFont("", -1, QFont.Bold)) - placeholder2 = QWidget() - pl2 = QVBoxLayout(placeholder2) - pl2.setContentsMargins(12, 12, 12, 12) - pl2.addWidget(QLabel( - "Select a DAW processor from the tree to configure.")) - pl2.addStretch() - register_page(daw_parent, placeholder2) - - dp_sections = preset.get("daw_processors", {}) - for dp in default_daw_processors(): - params = dp.config_params() - if not params: - continue - child = QTreeWidgetItem(daw_parent, [dp.name]) - vals = dp_sections.get(dp.id, {}) - pg, wdg = _build_param_page(params, vals) - widgets_dict[f"daw_processors.{dp.id}"] = wdg - - if dp.id == "dawproject": - tpl_widget = DawProjectTemplatesWidget() - templates = vals.get("dawproject_templates", []) - tpl_widget.set_templates(templates) - dawproject_tpl_widget = tpl_widget - pg.layout().insertWidget( - pg.layout().count() - 1, tpl_widget) - - register_page(child, pg) - - return dawproject_tpl_widget - - -def load_config_widgets( - widgets_dict: dict, - preset: dict[str, Any], - dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, -) -> None: - """Load values from *preset* into widgets stored in *widgets_dict*. - - Shared between PreferencesDialog._load_cfg_preset_widgets and - SessionPrepWindow._load_session_widgets_inner. - """ - from sessionpreplib.detectors import default_detectors - from sessionpreplib.processors import default_processors - from sessionpreplib.daw_processors import default_daw_processors - - # Analysis - analysis = preset.get("analysis", {}) - for key, widget in widgets_dict.get("analysis", []): - if key in analysis: - _set_widget_value(widget, analysis[key]) - - # Presentation - pres = preset.get("presentation", {}) - for key, widget in widgets_dict.get("_presentation", []): - if key in pres: - _set_widget_value(widget, pres[key]) - - # Detectors - det_sections = preset.get("detectors", {}) - for det in default_detectors(): - wkey = f"detectors.{det.id}" - if wkey not in widgets_dict: - continue - vals = det_sections.get(det.id, {}) - for key, widget in widgets_dict[wkey]: - if key in vals: - _set_widget_value(widget, vals[key]) - - # Processors - proc_sections = preset.get("processors", {}) - for proc in default_processors(): - wkey = f"processors.{proc.id}" - if wkey not in widgets_dict: - continue - vals = proc_sections.get(proc.id, {}) - for key, widget in widgets_dict[wkey]: - if key in vals: - _set_widget_value(widget, vals[key]) - - # DAW Processors - dp_sections = preset.get("daw_processors", {}) - for dp in default_daw_processors(): - wkey = f"daw_processors.{dp.id}" - if wkey not in widgets_dict: - continue - vals = dp_sections.get(dp.id, {}) - for key, widget in widgets_dict[wkey]: - if key in vals: - _set_widget_value(widget, vals[key]) - if dp.id == "dawproject" and dawproject_tpl_widget is not None: - dawproject_tpl_widget.set_templates( - vals.get("dawproject_templates", [])) - - -def read_config_widgets( - widgets_dict: dict, - dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, - fallback_daw_sections: dict[str, dict] | None = None, -) -> dict[str, Any]: - """Read current widget values into a structured config dict. - - Shared between PreferencesDialog._save_cfg_preset_widgets and - SessionPrepWindow._read_session_config. - - Parameters - ---------- - fallback_daw_sections: - Optional dict of DAW-processor sections whose non-widget keys - are carried forward (used by session config to inherit global - preset keys that have no widget representation). - """ - from sessionpreplib.detectors import default_detectors - from sessionpreplib.processors import default_processors - from sessionpreplib.daw_processors import default_daw_processors - - cfg: dict[str, Any] = {} - - # Analysis - analysis: dict[str, Any] = {} - for key, widget in widgets_dict.get("analysis", []): - analysis[key] = _read_widget(widget) - cfg["analysis"] = analysis - - # Presentation - presentation: dict[str, Any] = {} - for key, widget in widgets_dict.get("_presentation", []): - presentation[key] = _read_widget(widget) - cfg["presentation"] = presentation - - # Detectors - detectors: dict[str, dict] = {} - for det in default_detectors(): - wkey = f"detectors.{det.id}" - if wkey not in widgets_dict: - continue - section: dict[str, Any] = {} - for key, widget in widgets_dict[wkey]: - section[key] = _read_widget(widget) - detectors[det.id] = section - cfg["detectors"] = detectors - - # Processors - processors: dict[str, dict] = {} - for proc in default_processors(): - wkey = f"processors.{proc.id}" - if wkey not in widgets_dict: - continue - section: dict[str, Any] = {} - for key, widget in widgets_dict[wkey]: - section[key] = _read_widget(widget) - processors[proc.id] = section - cfg["processors"] = processors - - # DAW Processors - daw_procs: dict[str, dict] = {} - for dp in default_daw_processors(): - wkey = f"daw_processors.{dp.id}" - if wkey not in widgets_dict: - continue - section: dict[str, Any] = {} - for key, widget in widgets_dict[wkey]: - section[key] = _read_widget(widget) - if dp.id == "dawproject" and dawproject_tpl_widget is not None: - section["dawproject_templates"] = ( - dawproject_tpl_widget.get_templates()) - if fallback_daw_sections: - for gk, gv in fallback_daw_sections.get(dp.id, {}).items(): - if gk not in section: - section[gk] = gv - daw_procs[dp.id] = section - cfg["daw_processors"] = daw_procs - - return cfg diff --git a/sessionprepgui/prefs/preset_panel.py b/sessionprepgui/prefs/preset_panel.py new file mode 100644 index 0000000..f4c1f21 --- /dev/null +++ b/sessionprepgui/prefs/preset_panel.py @@ -0,0 +1,223 @@ +"""Generic reusable preset-management toolbar widget. + +No app-specific dependency. Portable: copy preset_panel.py to any PySide6 project. +""" + +from __future__ import annotations + +from PySide6.QtCore import Signal +from PySide6.QtWidgets import ( + QComboBox, + QHBoxLayout, + QInputDialog, + QLabel, + QMessageBox, + QPushButton, + QWidget, +) + + +class NamedPresetPanel(QWidget): + """Combo + Add / Duplicate / Rename / Delete toolbar for named presets. + + The panel owns the combo and manages button enable-states. + All data management is delegated to the caller via signals. + + Signals + ------- + preset_switching(old_name, new_name) + Emitted when the user selects a different preset. The caller + should *save* old_name's data and *load* new_name's data. + preset_added(name) + A new empty preset was created. Caller creates the data entry. + preset_duplicated(source_name, new_name) + Caller deep-copies source_name's data to new_name. + preset_renamed(old_name, new_name) + Caller renames the data key. + preset_deleted(name) + The preset was removed from the combo. Panel has already switched + to the first protected (fallback) name. Caller removes data entry. + """ + + preset_switching = Signal(str, str) + preset_added = Signal(str) + preset_duplicated = Signal(str, str) + preset_renamed = Signal(str, str) + preset_deleted = Signal(str) + + def __init__( + self, + initial_names: list[str], + *, + label: str = "", + protected: frozenset[str] = frozenset({"Default"}), + parent=None, + ): + super().__init__(parent) + self._protected = frozenset(protected) + self._current: str = initial_names[0] if initial_names else "" + self._init_ui(initial_names, label) + + # ── Public API ──────────────────────────────────────────────────────── + + @property + def current_name(self) -> str: + return self._combo.currentText() + + def set_current(self, name: str) -> None: + """Select a preset programmatically without emitting preset_switching.""" + self._combo.blockSignals(True) + idx = self._combo.findText(name) + if idx >= 0: + self._combo.setCurrentIndex(idx) + self._current = name + self._combo.blockSignals(False) + self._update_buttons() + + def all_names(self) -> list[str]: + return [self._combo.itemText(i) for i in range(self._combo.count())] + + def reset(self, names: list[str], *, current: str | None = None) -> None: + """Repopulate the combo without emitting any signals.""" + self._combo.blockSignals(True) + self._combo.clear() + for name in names: + self._combo.addItem(name) + self._current = names[0] if names else "" + self._combo.blockSignals(False) + if current: + self.set_current(current) + else: + self._update_buttons() + + # ── UI setup ───────────────────────────────────────────────────────── + + def _init_ui(self, initial_names: list[str], label: str) -> None: + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(6) + + if label: + layout.addWidget(QLabel(label)) + + self._combo = QComboBox() + self._combo.setMinimumWidth(160) + for name in initial_names: + self._combo.addItem(name) + layout.addWidget(self._combo, 1) + + add_btn = QPushButton("+") + add_btn.setFixedWidth(36) + add_btn.setToolTip("New preset") + add_btn.clicked.connect(self._on_add) + layout.addWidget(add_btn) + + dup_btn = QPushButton("Duplicate") + dup_btn.clicked.connect(self._on_duplicate) + layout.addWidget(dup_btn) + + self._rename_btn = QPushButton("Rename") + self._rename_btn.clicked.connect(self._on_rename) + layout.addWidget(self._rename_btn) + + self._delete_btn = QPushButton("Delete") + self._delete_btn.clicked.connect(self._on_delete) + layout.addWidget(self._delete_btn) + + self._combo.currentTextChanged.connect(self._on_combo_changed) + self._update_buttons() + + # ── Slot helpers ───────────────────────────────────────────────────── + + def _existing_names(self) -> set[str]: + return {self._combo.itemText(i) for i in range(self._combo.count())} + + def _prompt_unique_name(self, title: str, prompt: str, + initial: str = "") -> str | None: + name, ok = QInputDialog.getText(self, title, prompt, text=initial) + if not ok or not name.strip(): + return None + name = name.strip() + if name in self._existing_names(): + QMessageBox.warning( + self, "Duplicate Name", + f"A preset named \u201c{name}\u201d already exists.") + return None + return name + + def _on_combo_changed(self, new_name: str) -> None: + old = self._current + self._current = new_name + self.preset_switching.emit(old, new_name) + self._update_buttons() + + def _update_buttons(self) -> None: + protected = self._current in self._protected + self._rename_btn.setEnabled(not protected) + self._delete_btn.setEnabled(not protected) + + def _on_add(self) -> None: + name = self._prompt_unique_name("New Preset", "Preset name:") + if not name: + return + self._combo.blockSignals(True) + self._combo.addItem(name) + self._combo.setCurrentText(name) + self._combo.blockSignals(False) + self._current = name + self._update_buttons() + self.preset_added.emit(name) + + def _on_duplicate(self) -> None: + source = self._current + name = self._prompt_unique_name( + "Duplicate Preset", "New preset name:", + initial=f"{source} Copy") + if not name: + return + self._combo.blockSignals(True) + self._combo.addItem(name) + self._combo.setCurrentText(name) + self._combo.blockSignals(False) + self._current = name + self._update_buttons() + self.preset_duplicated.emit(source, name) + + def _on_rename(self) -> None: + old = self._current + if old in self._protected: + return + name = self._prompt_unique_name( + "Rename Preset", "New name:", initial=old) + if not name or name == old: + return + idx = self._combo.findText(old) + self._combo.blockSignals(True) + self._combo.setItemText(idx, name) + self._combo.blockSignals(False) + self._current = name + self._update_buttons() + self.preset_renamed.emit(old, name) + + def _on_delete(self) -> None: + current = self._current + if current in self._protected: + return + reply = QMessageBox.question( + self, "Delete Preset", + f"Delete the preset \u201c{current}\u201d?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + if reply != QMessageBox.Yes: + return + idx = self._combo.findText(current) + self._combo.blockSignals(True) + self._combo.removeItem(idx) + fallback = next( + (n for n in self._protected if self._combo.findText(n) >= 0), + self._combo.itemText(0) if self._combo.count() else "", + ) + self._combo.setCurrentText(fallback) + self._combo.blockSignals(False) + self._current = fallback + self._update_buttons() + self.preset_deleted.emit(current) diff --git a/sessionprepgui/theme.py b/sessionprepgui/theme.py index 7382e83..704fd69 100644 --- a/sessionprepgui/theme.py +++ b/sessionprepgui/theme.py @@ -40,10 +40,10 @@ PT_DEFAULT_COLORS: list[dict[str, str]] = [ # ── Bright tier (indices 0–22) ───────────────────────────────────────── {"name": "Blue Dark", "argb": "#ff2c00fc"}, - {"name": "Electric Violet Darkest", "argb": "#ff5600fc"}, - {"name": "Electric Violet Dark", "argb": "#ff8800fc"}, - {"name": "Electric Violet Light", "argb": "#ffbf00fc"}, - {"name": "Electric Violet Lightest", "argb": "#ffbe00c0"}, + {"name": "Electric Violet Dark", "argb": "#ff5600fc"}, + {"name": "Electric Violet", "argb": "#ff8800fc"}, + {"name": "Electric Violet Lightest", "argb": "#ffbf00fc"}, + {"name": "Electric Violet Light", "argb": "#ffbe00c0"}, {"name": "Flirt", "argb": "#ffbd0088"}, {"name": "Lipstick", "argb": "#ffbd0054"}, {"name": "Guardsman Red", "argb": "#ffbc000d"}, @@ -51,36 +51,36 @@ {"name": "Tia Maria", "argb": "#ffbd520e"}, {"name": "Pizza", "argb": "#ffbe8911"}, {"name": "La Rioja", "argb": "#ffc0c514"}, - {"name": "Lima Dark", "argb": "#ff89c511"}, - {"name": "Lima Light", "argb": "#ff57c610"}, + {"name": "Lima Light", "argb": "#ff89c511"}, + {"name": "Lima", "argb": "#ff57c610"}, {"name": "Christi", "argb": "#ff2ec60f"}, {"name": "Malachite", "argb": "#ff1cc60e"}, - {"name": "Mountain Meadow Dark", "argb": "#ff1ec654"}, + {"name": "Mountain Meadow", "argb": "#ff1ec654"}, {"name": "Mountain Meadow Light", "argb": "#ff20c488"}, {"name": "Java", "argb": "#ff23c3c1"}, - {"name": "Dodger Blue Dark", "argb": "#ff27c1fd"}, - {"name": "Dodger Blue Light", "argb": "#ff2184fc"}, + {"name": "Dodger Blue Light", "argb": "#ff27c1fd"}, + {"name": "Dodger Blue", "argb": "#ff2184fc"}, {"name": "Blue Ribbon", "argb": "#ff1c4afc"}, {"name": "Blue Light", "argb": "#ff1900fc"}, # ── Medium tier (indices 23–45) ──────────────────────────────────────── {"name": "Navy Blue", "argb": "#ff1e00a3"}, {"name": "Pigment Indigo", "argb": "#ff3700a3"}, - {"name": "Purple Darkest", "argb": "#ff5500a3"}, + {"name": "Purple Dark", "argb": "#ff5500a3"}, {"name": "Purple", "argb": "#ff7400a4"}, - {"name": "Purple Lightest", "argb": "#ff7c0089"}, + {"name": "Purple Light", "argb": "#ff7c0089"}, {"name": "Cardinal Pink", "argb": "#ff7b0066"}, {"name": "Siren", "argb": "#ff7a0046"}, {"name": "Japanese Maple", "argb": "#ff7a000b"}, {"name": "Dark Burgundy", "argb": "#ff7a120b"}, {"name": "Cafe Royale Dark", "argb": "#ff7a310c"}, - {"name": "Cafe Royale Light", "argb": "#ff7b510d"}, + {"name": "Cafe Royale", "argb": "#ff7b510d"}, {"name": "Corn Harvest", "argb": "#ff898010"}, {"name": "Olivetone", "argb": "#ff66800e"}, {"name": "Green Leaf", "argb": "#ff48800d"}, {"name": "Bilbao", "argb": "#ff2d800c"}, {"name": "Japanese Laurel", "argb": "#ff18800c"}, {"name": "Jewel Dark", "argb": "#ff158033"}, - {"name": "Jewel Light", "argb": "#ff167f51"}, + {"name": "Jewel", "argb": "#ff167f51"}, {"name": "Elm", "argb": "#ff1a8c7e"}, {"name": "Eastern Blue", "argb": "#ff1d8da4"}, {"name": "Matisse", "argb": "#ff1969a4"}, @@ -88,28 +88,28 @@ {"name": "Torea Bay", "argb": "#ff1423a3"}, # ── Dark tier (indices 46–65) ────────────────────────────────────────── {"name": "Paua Dark", "argb": "#ff14005f"}, - {"name": "Paua Light", "argb": "#ff21005f"}, - {"name": "Ripe Plum Darkest", "argb": "#ff31005f"}, + {"name": "Paua", "argb": "#ff21005f"}, + {"name": "Ripe Plum Dark", "argb": "#ff31005f"}, {"name": "Ripe Plum", "argb": "#ff41005f"}, - {"name": "Ripe Plum Lightest", "argb": "#ff4b0057"}, + {"name": "Ripe Plum Light", "argb": "#ff4b0057"}, {"name": "Blackberry", "argb": "#ff470042"}, {"name": "Barossa", "argb": "#ff470031"}, {"name": "Temptress", "argb": "#ff47000b"}, {"name": "Van Cleef Dark", "argb": "#ff470c0b"}, - {"name": "Van Cleef Light", "argb": "#ff471c0b"}, + {"name": "Van Cleef", "argb": "#ff471c0b"}, {"name": "Bronze", "argb": "#ff472c0c"}, {"name": "Saratoga", "argb": "#ff574d0f"}, {"name": "Bronze Olive", "argb": "#ff424a0c"}, {"name": "Green House Dark", "argb": "#ff324a0c"}, - {"name": "Green House Light", "argb": "#ff234a0c"}, + {"name": "Green House", "argb": "#ff234a0c"}, {"name": "Dark Fern", "argb": "#ff154b0b"}, {"name": "Parsley", "argb": "#ff0f4a1d"}, {"name": "Bottle Green", "argb": "#ff0f4a2c"}, {"name": "Eden Darkest", "argb": "#ff14594c"}, - {"name": "Eden", "argb": "#ff16595f"}, - {"name": "Eden Lightest", "argb": "#ff14475f"}, - {"name": "Blue Zodiac Dark", "argb": "#ff13355f"}, - {"name": "Blue Zodiac Light", "argb": "#ff11225f"}, + {"name": "Eden Light", "argb": "#ff16595f"}, + {"name": "Eden", "argb": "#ff14475f"}, + {"name": "Blue Zodiac Light", "argb": "#ff13355f"}, + {"name": "Blue Zodiac", "argb": "#ff11225f"}, ] diff --git a/sessionprepgui/tracks/groups_mixin.py b/sessionprepgui/tracks/groups_mixin.py index cbcf45d..2c68bbb 100644 --- a/sessionprepgui/tracks/groups_mixin.py +++ b/sessionprepgui/tracks/groups_mixin.py @@ -24,7 +24,7 @@ QWidget, ) -from ..prefs.dialog import _argb_to_qcolor +from ..prefs.param_form import _argb_to_qcolor from ..settings import build_defaults, save_config from .table_widgets import _SortableItem from ..theme import COLORS, PT_DEFAULT_COLORS From b279ff9b7de704739e4ef0cc4634e1d906f28099 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Fri, 20 Feb 2026 23:57:41 +0100 Subject: [PATCH 19/30] bugfix for generalized pathpicker. --- DEVELOPMENT.md | 65 +++++++++++++++++++++------- sessionprepgui/prefs/page_general.py | 33 +++++--------- sessionprepgui/prefs/param_form.py | 37 +++++++++++++++- sessionpreplib/config.py | 1 + 4 files changed, 97 insertions(+), 39 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 26d9a45..eba8bed 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -456,8 +456,24 @@ class ParamSpec: choices: list | None = None # allowed string values item_type: type | None = None # element type for list fields nullable: bool = False # True if None is valid + presentation_only: bool = False # True → changing this key never requires re-analysis + widget_hint: str | None = None # rendering hint for the GUI widget factory (never read by the library) ``` +`presentation_only` marks keys whose changes never affect analysis results (e.g. +`report_as`), allowing the GUI to skip re-analysis on save. + +`widget_hint` is an *optional* rendering hint consumed exclusively by +`sessionprepgui/prefs/param_form.py`'s `_build_widget()`. The library itself +never reads it. Supported values: + +| Value | Widget produced | +|---|---| +| `"path_picker_folder"` | `PathPicker(mode=FOLDER)` — line edit + Browse dir dialog | +| `"path_picker_file"` | `PathPicker(mode=OPEN_FILE)` — line edit + Open file dialog | +| `"path_picker_save"` | `PathPicker(mode=SAVE_FILE)` — line edit + Save file dialog | +| `None` (default) | Standard widget derived from `type` and `choices` | + Every detector and processor exposes its parameters via a `config_params()` classmethod that returns `list[ParamSpec]`. Shared analysis parameters and global processing defaults are in @@ -1521,8 +1537,14 @@ group). | `report.py` | HTML rendering: `render_summary_html()`, `render_fader_table_html()`, `render_track_detail_html()` | | `waveform.py` | `WaveformWidget` — two display modes (waveform + spectrogram), vectorised NumPy peak/RMS downsampling, mel spectrogram (256 mel bins via `scipy.signal.stft`, configurable FFT/window/dB range/colormap), dB and frequency scales, peak/RMS markers, crosshair mouse guide (dBFS in waveform, Hz in spectrogram), mouse-wheel zoom/pan (Ctrl+wheel h-zoom, Ctrl+Shift+wheel v-zoom, Shift+Alt+wheel freq pan, Shift+wheel scroll), keyboard shortcuts (R/T zoom), detector issue overlays with optional frequency bounds, RMS L/R and RMS AVG envelopes, playback cursor, tooltips | | `playback.py` | `PlaybackController` — sounddevice OutputStream lifecycle, QTimer cursor updates, signal-based API | -| `param_widgets.py` | Reusable ParamSpec-driven widget builders (`_build_param_page`, `_read_widget`, `_set_widget_value`, `_build_tooltip`) + `GroupsTableWidget` (drag-reorderable group editor with color/gain-linked/DAW-target columns) | -| `preferences.py` | `PreferencesDialog` — two-tab layout (Global + Config Presets), config preset CRUD, group preset CRUD, ParamSpec-driven widgets, reset-to-default, HiDPI scaling | +| `prefs/param_form.py` | **Portable** generic widget factory — `ParamSpec` protocol, `PathPickerMode`, `PathPicker`, `_build_widget`, `_build_param_page`, `_set_widget_value`, `_read_widget`, tooltip/subtext builders, `sanitize_output_folder`. Zero sessionpreplib dependency; copy to any PySide6 project. | +| `prefs/preset_panel.py` | **Portable** `NamedPresetPanel` — reusable CRUD widget for named presets with add/duplicate/rename/delete signals. | +| `prefs/config_pages.py` | SessionPrep-specific builders: `GroupsTableWidget`, `DawProjectTemplatesWidget`, `build_config_pages`, `load_config_widgets`, `read_config_widgets`. | +| `prefs/page_general.py` | `GeneralPage` — app-level settings (`_APP_PARAMS` list drives `_build_param_page`; `widget_hint="path_picker_folder"` on `default_project_dir` auto-generates a `PathPicker`). | +| `prefs/page_colors.py` | `ColorsPage` — editable color palette table with `load`/`commit`/`color_provider` interface. | +| `prefs/page_groups.py` | `GroupsPage` — named group presets using `NamedPresetPanel` + `GroupsTableWidget` with `load`/`commit` interface. | +| `prefs/dialog.py` | `PreferencesDialog` — thin ~270-line orchestrator wiring all pages, managing config presets via `NamedPresetPanel`, and handling save with validation. | +| `prefs/param_widgets.py` | Backward-compatible re-export shim — all public names forward to `param_form.py` / `config_pages.py`. | | `mainwindow.py` | `SessionPrepWindow` (QMainWindow) — orchestrator, UI layout, slot handlers, toolbar config/group preset combos, session Config tab | ### 18.3 Dependency Direction @@ -1628,19 +1650,32 @@ leaves. `preferences` reads `ParamSpec` metadata from detectors and processors. for horizontal scroll. - **report.py** contains pure HTML-building functions (no widget references), making them independently testable. -- **PreferencesDialog** uses a two-tab layout: - - **Global** tab — General (HiDPI scale, project dir, etc.), Colors - (palette editor), Groups (group preset CRUD + `GroupsTableWidget`). - Each has its own tree + stacked widget. - - **Config Presets** tab — toolbar with combo + Add/Duplicate/Rename/Delete - buttons. Below it: Analysis, Detectors (with presentation params), - Processors, DAW Processors pages. Each has its own tree + stacked widget. - Switching presets saves current widget values to the old preset and loads - the new one. - Widget type, range, step size, and decimal precision are all derived from - `ParamSpec` (via `param_widgets.py`). Each parameter gets a visible - description subtext, a rich tooltip (with key name, default, range), and a - reset-to-default button using Qt's `SP_BrowserReload` icon. +- **`prefs/` subpackage** implements a layered, portable preferences framework: + - **Portable layer** (`param_form.py`, `preset_panel.py`) has zero + sessionpreplib dependency and can be copied to any PySide6 project. + - **App-specific layer** (`config_pages.py`, `page_*.py`, `dialog.py`) + contains SessionPrep-specific builders, page classes, and the + orchestrator dialog. + - **Widget factory** (`_build_widget`) resolves widgets in priority order: + (1) `widget_hint` override → `PathPicker` or future custom widgets, + (2) `choices` → `QComboBox`, (3) `type` → spinbox/checkbox/line-edit. + Adding a new custom widget type requires only a new branch in + `_build_widget` and a `widget_hint=` on the relevant `ParamSpec`. + - **`PathPicker`** is a self-contained folder/file picker widget (label + + `QLineEdit` + Browse + reset button + optional recursive checkbox). + Activated by `widget_hint="path_picker_folder"` / `"path_picker_file"` / + `"path_picker_save"`. + - **Page interface** — every page class (`GeneralPage`, `ColorsPage`, + `GroupsPage`) implements `load(config)`, `commit(config)`, and + `validate() → str | None`. All read/write goes through `_set_widget_value` + / `_read_widget`, which dispatch uniformly across all widget types + including `PathPicker`. + - **`PreferencesDialog`** is a ~270-line thin orchestrator. Two-tab layout: + - **Global** tab — General, Colors, Groups pages in a tree + stacked widget. + - **Config Presets** tab — `NamedPresetPanel` CRUD + Analysis, Detectors, + Processors, DAW Processors pages. + - Each parameter gets a visible description subtext, a rich tooltip (key + name, default, range), and a reset-to-default button (`SP_BrowserReload`). - **Toolbar config preset chooser** — the analysis toolbar includes a "Config:" combo that shows available config presets. Switching presets with an active session warns the user, then resets session config and diff --git a/sessionprepgui/prefs/page_general.py b/sessionprepgui/prefs/page_general.py index 00a2f72..d63fa3f 100644 --- a/sessionprepgui/prefs/page_general.py +++ b/sessionprepgui/prefs/page_general.py @@ -10,8 +10,6 @@ from sessionpreplib.config import ParamSpec from .param_form import ( - PathPicker, - PathPickerMode, _build_param_page, _read_widget, _set_widget_value, @@ -23,6 +21,15 @@ # --------------------------------------------------------------------------- _APP_PARAMS = [ + ParamSpec( + key="default_project_dir", type=str, default="", + label="Default project directory", + description=( + "When set, the Open Folder dialog starts in this directory. " + "Leave empty to use the system default." + ), + widget_hint="path_picker_folder", + ), ParamSpec( key="scale_factor", type=(int, float), default=1.0, min=0.5, max=4.0, @@ -70,15 +77,6 @@ ), ] -_DIR_SPEC = ParamSpec( - key="default_project_dir", type=str, default="", - label="Default project directory", - description=( - "When set, the Open Folder dialog starts in this directory. " - "Leave empty to use the system default." - ), -) - class GeneralPage(QWidget): """App-level preference form. @@ -92,7 +90,6 @@ class GeneralPage(QWidget): def __init__(self, parent=None): super().__init__(parent) self._widgets: list[tuple[str, QWidget]] = [] - self._dir_picker: PathPicker self._init_ui() # ── Page interface ──────────────────────────────────────────────── @@ -102,13 +99,11 @@ def load(self, config: dict) -> None: for key, widget in self._widgets: if key in values: _set_widget_value(widget, values[key]) - self._dir_picker.set_value(str(values.get("default_project_dir", ""))) def commit(self, config: dict) -> None: app = config.setdefault("app", {}) for key, widget in self._widgets: app[key] = _read_widget(widget) - app["default_project_dir"] = self._dir_picker.value() def validate(self) -> str | None: """Return an error message if output_folder is invalid, else None.""" @@ -126,15 +121,7 @@ def validate(self) -> str | None: # ── UI setup ───────────────────────────────────────────────────── def _init_ui(self) -> None: - page, widgets = _build_param_page(_APP_PARAMS, {}) - self._widgets = widgets - - self._dir_picker = PathPicker(_DIR_SPEC, mode=PathPickerMode.FOLDER) - - # Insert the directory picker at the top, before the param rows - outer = page.layout() - outer.insertWidget(0, self._dir_picker) - + page, self._widgets = _build_param_page(_APP_PARAMS, {}) layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(page) diff --git a/sessionprepgui/prefs/param_form.py b/sessionprepgui/prefs/param_form.py index e474851..998c86c 100644 --- a/sessionprepgui/prefs/param_form.py +++ b/sessionprepgui/prefs/param_form.py @@ -43,6 +43,7 @@ class ParamSpec(Protocol): min: float | None max: float | None description: str | None + widget_hint: str | None # rendering hint consumed by _build_widget; never read by the library # --------------------------------------------------------------------------- @@ -82,7 +83,33 @@ def _color_swatch_icon(argb: str, size: int = 16) -> QIcon: # --------------------------------------------------------------------------- def _build_widget(spec: Any, value: Any) -> QWidget: - """Create an appropriate input widget for a ParamSpec and set its value.""" + """Create an appropriate input widget for a ParamSpec and set its value. + + Resolution order + ---------------- + 1. ``widget_hint`` — explicit override; beats all type-based logic. + 2. ``choices`` — QComboBox when an allowed-values list is provided. + 3. ``type`` — bool → QCheckBox, int → QSpinBox, float → QDoubleSpinBox, + list → QLineEdit (csv), str/fallback → QLineEdit. + + Supported ``widget_hint`` values + --------------------------------- + ``"path_picker_folder"`` → PathPicker(mode=FOLDER) + ``"path_picker_file"`` → PathPicker(mode=OPEN_FILE) + ``"path_picker_save"`` → PathPicker(mode=SAVE_FILE) + """ + # ── 1. widget_hint dispatch ─────────────────────────────────────────────── + # getattr with default keeps third-party ParamSpec implementations working + # even when they pre-date this field. + hint = getattr(spec, "widget_hint", None) + if hint == "path_picker_folder": + return PathPicker(spec, mode=PathPickerMode.FOLDER) + if hint == "path_picker_file": + return PathPicker(spec, mode=PathPickerMode.OPEN_FILE) + if hint == "path_picker_save": + return PathPicker(spec, mode=PathPickerMode.SAVE_FILE) + + # ── 2. choices → QComboBox ──────────────────────────────────────────────── if spec.choices is not None: w = QComboBox() for c in spec.choices: @@ -415,6 +442,14 @@ def _build_param_page( for spec in params: val = values.get(spec.key, spec.default) w = _build_widget(spec, val) + + # Self-contained widgets (e.g. PathPicker) already render their own + # label, subtext, and reset button — add them directly. + if isinstance(w, PathPicker): + outer.addWidget(w) + widgets.append((spec.key, w)) + continue + tooltip = _build_tooltip(spec) w.setToolTip(tooltip) diff --git a/sessionpreplib/config.py b/sessionpreplib/config.py index 07455dd..7259951 100644 --- a/sessionpreplib/config.py +++ b/sessionpreplib/config.py @@ -54,6 +54,7 @@ class ParamSpec: item_type: type | None = None # element type for list fields nullable: bool = False # True if None is valid presentation_only: bool = False # True → changing this key never requires re-analysis + widget_hint: str | None = None # rendering hint for the GUI widget factory (never read by the library) def default_config() -> dict[str, Any]: From ecff2a71aceb21cebe0e6f2fb6a8281a35f9cc48 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 00:33:35 +0100 Subject: [PATCH 20/30] Documentation updates. --- DEVELOPMENT.md | 95 ++++++++--- KANBAN.md | 322 +++++++++++++++++++++++++++++++++++++ README.md | 4 +- REFERENCE.md | 16 +- TECHNICAL.md | 15 +- TODO.md | 419 +++++-------------------------------------------- 6 files changed, 452 insertions(+), 419 deletions(-) create mode 100644 KANBAN.md diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index eba8bed..61de1d5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -284,10 +284,10 @@ sudo dnf install gcc patchelf ccache libatomic-static |---------|------|--------| | `numpy` | Runtime | `sessionpreplib` (DSP, array ops) | | `soundfile` | Runtime | `sessionpreplib/audio.py` (WAV I/O, bundles libsndfile) | -| `scipy` | Runtime | `sessionpreplib/audio.py` (subsonic STFT analysis), `sessionprepgui/waveform.py` (mel spectrogram) | +| `scipy` | Runtime | `sessionpreplib/audio.py` (subsonic STFT analysis), `sessionprepgui/waveform/compute.py` (mel spectrogram) | | `rich` | Runtime | `sessionprep.py` (CLI rendering: tables, panels, progress) | | `PySide6` | Optional (gui) | `sessionprepgui` (Qt widgets, main window, waveform) | -| `sounddevice` | Optional (gui) | `sessionprepgui/playback.py` (audio playback via PortAudio) | +| `sounddevice` | Optional (gui) | `sessionprepgui/detail/playback.py` (audio playback via PortAudio) | | `py-ptsl` | Optional (gui) | `sessionpreplib/daw_processors/protools.py` (Pro Tools Scripting SDK gRPC client) | | `dawproject` | Optional (gui) | `sessionpreplib/daw_processors/dawproject.py` (DAWproject file format library) | | `pytest` | Dev | Test runner | @@ -349,13 +349,44 @@ sessionprepgui/ # GUI package (PySide6) settings.py # Persistent config (load/save/validate, OS paths) theme.py # Colors, FILE_COLOR_* constants, dark theme helpers.py # esc(), track_analysis_label() (severity counts), fmt_time(), severity maps - worker.py # QThread workers: AnalyzeWorker, BatchReanalyzeWorker, DawCheckWorker, DawFetchWorker, DawTransferWorker - report.py # HTML report rendering (summary, fader table, track detail) - waveform.py # WaveformWidget (waveform + spectrogram display, dB/freq scales, markers, overlays, keyboard/mouse nav) - playback.py # PlaybackController (sounddevice lifecycle + signals) - param_widgets.py # Reusable ParamSpec widget builders + GroupsTableWidget - preferences.py # PreferencesDialog (two-tab layout: Global + Config Presets) - mainwindow.py # SessionPrepWindow (QMainWindow) + main() + mainwindow.py # SessionPrepWindow (QMainWindow) + main() — thin orchestrator composing all mixins + analysis/ + mixin.py # AnalysisMixin — open/save session, analyze, prepare, session Config tab + worker.py # QThread workers: AnalyzeWorker, BatchReanalyzeWorker, PrepareWorker, + # DawCheckWorker, DawFetchWorker, DawTransferWorker + daw/ + mixin.py # DawMixin — DAW processor selection, fetch, transfer, folder tree, assignments + detail/ + mixin.py # DetailMixin — file detail view, waveform display, overlays, playback + playback.py # PlaybackController (sounddevice lifecycle + signals) + report.py # HTML rendering: render_summary_html(), render_fader_table_html(), + # render_track_detail_html() + prefs/ + __init__.py # Exports PreferencesDialog, PathPicker, PathPickerMode, build_config_pages + param_form.py # Portable widget factory: ParamSpec protocol, PathPickerMode, PathPicker, + # _build_widget, _build_param_page, tooltip builders, sanitize_output_folder + preset_panel.py # Portable NamedPresetPanel — reusable named-preset CRUD widget + config_pages.py # SessionPrep-specific: GroupsTableWidget, DawProjectTemplatesWidget, + # build/load/read_config_pages + page_general.py # GeneralPage — app settings (widget_hint drives PathPicker for dir field) + page_colors.py # ColorsPage — editable color palette table + page_groups.py # GroupsPage — named group presets via NamedPresetPanel + dialog.py # PreferencesDialog — thin ~270-line orchestrator + param_widgets.py # Backward-compatible re-export shim → param_form + config_pages + session/ + io.py # Session save/load (.spsession JSON) — serialises analysis state + tracks/ + columns_mixin.py # TrackColumnsMixin — track table column setup and sorting + groups_mixin.py # GroupsMixin — group assignment UI and color rendering + table_widgets.py # Track table widget classes + waveform/ + __init__.py # Re-exports WaveformWidget, WaveformLoadWorker, SPECTROGRAM_COLORMAPS + compute.py # Colormaps, mel math, spectrogram computation, QThread workers + renderer.py # WaveformRenderer — peaks, waveform drawing, RMS overlay, dB scale, markers + spectrogram.py # SpectrogramRenderer — mel image, frequency scale, freq zoom/pan + overlay.py # Stateless overlay drawing functions (issue overlays, time scale) + widget.py # WaveformWidget — thin orchestrator coordinating WaveformRenderer + # and SpectrogramRenderer sessionprep.py # Thin CLI: argparse + Rich rendering + glue sessionprep-gui.py # Thin GUI entry point (delegates to sessionprepgui) @@ -1533,10 +1564,22 @@ group). | `helpers.py` | `esc()`, `track_analysis_label(track, detectors=None)` (filters via `is_relevant()`), `fmt_time()`, severity maps | | `widgets.py` | `BatchEditTableWidget`, `BatchComboBox` — reusable batch-edit base classes preserving multi-row selection across cell-widget clicks (zero app imports) | | `log.py` | `dbg(msg)` — lightweight debug logging to stderr, gated by `SP_DEBUG` env var. Timestamped output with caller class name. Used by `pipeline.py`, `dawproject.py`, and other modules via conditional import. | -| `worker.py` | QThread workers: `AnalyzeWorker` (pipeline in background, thread-safe progress, per-track signals), `BatchReanalyzeWorker` (subset re-analysis after batch overrides), `PrepareWorker` (runs `Pipeline.prepare()` in background with progress), `DawCheckWorker` (connectivity check), `DawFetchWorker` (folder fetch), `DawTransferWorker` (transfer with progress + progress_value signals) | -| `report.py` | HTML rendering: `render_summary_html()`, `render_fader_table_html()`, `render_track_detail_html()` | -| `waveform.py` | `WaveformWidget` — two display modes (waveform + spectrogram), vectorised NumPy peak/RMS downsampling, mel spectrogram (256 mel bins via `scipy.signal.stft`, configurable FFT/window/dB range/colormap), dB and frequency scales, peak/RMS markers, crosshair mouse guide (dBFS in waveform, Hz in spectrogram), mouse-wheel zoom/pan (Ctrl+wheel h-zoom, Ctrl+Shift+wheel v-zoom, Shift+Alt+wheel freq pan, Shift+wheel scroll), keyboard shortcuts (R/T zoom), detector issue overlays with optional frequency bounds, RMS L/R and RMS AVG envelopes, playback cursor, tooltips | -| `playback.py` | `PlaybackController` — sounddevice OutputStream lifecycle, QTimer cursor updates, signal-based API | +| `analysis/mixin.py` | `AnalysisMixin` — open/save/load session, analyze, prepare, session Config tab wiring | +| `analysis/worker.py` | QThread workers: `AnalyzeWorker` (pipeline in background, thread-safe progress, per-track signals), `BatchReanalyzeWorker` (subset re-analysis after batch overrides), `PrepareWorker` (runs `Pipeline.prepare()` in background with progress), `DawCheckWorker` (connectivity check), `DawFetchWorker` (folder fetch), `DawTransferWorker` (transfer with progress + progress_value signals) | +| `daw/mixin.py` | `DawMixin` — DAW processor selection, check/fetch/transfer/sync, folder tree, drag-and-drop track assignment | +| `detail/mixin.py` | `DetailMixin` — file detail view, waveform display, detector overlay panel, playback toolbar wiring | +| `detail/playback.py` | `PlaybackController` — sounddevice OutputStream lifecycle, QTimer cursor updates, signal-based API | +| `detail/report.py` | HTML rendering: `render_summary_html()`, `render_fader_table_html()`, `render_track_detail_html()` | +| `session/io.py` | Session save/load — serialises full analysis state (detector + processor results, user edits) to `.spsession` JSON without re-running analysis. Versioned format with forward-compatible migrations. | +| `tracks/columns_mixin.py` | `TrackColumnsMixin` — track table column definitions, cell rendering, sorting | +| `tracks/groups_mixin.py` | `GroupsMixin` — group assignment UI, color rendering in track table | +| `tracks/table_widgets.py` | Track table widget classes (custom cell widgets, batch-edit base classes) | +| `waveform/__init__.py` | Re-exports `WaveformWidget`, `WaveformLoadWorker`, `SPECTROGRAM_COLORMAPS` | +| `waveform/compute.py` | Colormaps (magma/viridis/grayscale LUTs), mel math, spectrogram computation, `WaveformLoadWorker` QThread | +| `waveform/renderer.py` | `WaveformRenderer` — vectorised NumPy peak/RMS downsampling, waveform drawing, RMS L/R and AVG envelopes, dB scale, peak/RMS markers | +| `waveform/spectrogram.py` | `SpectrogramRenderer` — mel spectrogram QImage (256 mel bins via `scipy.signal.stft`), frequency scale, freq zoom/pan, background recompute worker | +| `waveform/overlay.py` | Stateless overlay drawing functions — detector issue overlays (with optional frequency bounds), horizontal time scale | +| `waveform/widget.py` | `WaveformWidget` — thin orchestrator coordinating `WaveformRenderer` and `SpectrogramRenderer`; paintEvent, mouse/keyboard event handlers, zoom/pan API, public setters | | `prefs/param_form.py` | **Portable** generic widget factory — `ParamSpec` protocol, `PathPickerMode`, `PathPicker`, `_build_widget`, `_build_param_page`, `_set_widget_value`, `_read_widget`, tooltip/subtext builders, `sanitize_output_folder`. Zero sessionpreplib dependency; copy to any PySide6 project. | | `prefs/preset_panel.py` | **Portable** `NamedPresetPanel` — reusable CRUD widget for named presets with add/duplicate/rename/delete signals. | | `prefs/config_pages.py` | SessionPrep-specific builders: `GroupsTableWidget`, `DawProjectTemplatesWidget`, `build_config_pages`, `load_config_widgets`, `read_config_widgets`. | @@ -1551,18 +1594,20 @@ group). ``` settings (leaf) <-- mainwindow -theme (leaf) <-- helpers <-- report <-- mainwindow -widgets (leaf) <---------------------------------+ - | - waveform <--------------------+ - playback <--------------------+ - worker <--------------------+ - preferences <--------------------+ +theme (leaf) <-- helpers <-- detail/report <-- mainwindow +widgets (leaf) <-- tracks/ <---------------------------+ + | + waveform/ <----------------------------+ + detail/ <----------------------------+ + analysis/ <----------------------------+ + daw/ <----------------------------+ + session/ <----------------------------+ + prefs/ <----------------------------+ ``` No circular imports. `settings`, `theme`, `helpers`, and `widgets` are pure -leaves. `preferences` reads `ParamSpec` metadata from detectors and processors. -`mainwindow` composes all other modules. +leaves. `prefs/` reads `ParamSpec` metadata from detectors and processors. +`mainwindow` composes all subpackage mixins. ### 18.4 Key Design Decisions @@ -1648,7 +1693,7 @@ leaves. `preferences` reads `ParamSpec` metadata from detectors and processors. for vertical zoom (amplitude in waveform, frequency range in spectrogram), Shift+Alt+wheel for frequency panning (spectrogram only), Shift+wheel for horizontal scroll. -- **report.py** contains pure HTML-building functions (no widget references), +- **`detail/report.py`** contains pure HTML-building functions (no widget references), making them independently testable. - **`prefs/` subpackage** implements a layered, portable preferences framework: - **Portable layer** (`param_form.py`, `preset_panel.py`) has zero @@ -1888,10 +1933,10 @@ The CLI is **not** affected by this file — it continues to use its own |---------|---------| | `numpy` | `sessionpreplib` (core dependency) | | `soundfile` | `sessionpreplib/audio.py` (audio I/O) | -| `scipy` | `sessionpreplib/audio.py` (subsonic STFT), `sessionprepgui/waveform.py` (mel spectrogram) — core dependency | +| `scipy` | `sessionpreplib/audio.py` (subsonic STFT), `sessionprepgui/waveform/compute.py` (mel spectrogram) — core dependency | | `rich` | `sessionprep.py` only — **not** a library dependency | | `PySide6` | `sessionprepgui` only — optional GUI dependency | -| `sounddevice` | `sessionprepgui/playback.py` only — optional GUI dependency | +| `sounddevice` | `sessionprepgui/detail/playback.py` only — optional GUI dependency | ### 19.3 Layer Cake diff --git a/KANBAN.md b/KANBAN.md new file mode 100644 index 0000000..08a19b5 --- /dev/null +++ b/KANBAN.md @@ -0,0 +1,322 @@ +# SessionPrep Board + +## To Do + +### Group Gain as Processor + + - priority: low + ```md + Extract GroupGainProcessor at PRIORITY_POST (200). + Currently implemented as Pipeline._apply_group_levels() post-step. + Moving to a processor makes it disableable/replaceable. + ``` + +### Component-level configure() Validation + + - priority: low + ```md + Each detector/processor should raise ConfigError for invalid config slices. + Currently config is validated globally but not per-component at startup. + ``` + +### Asymmetric Panning Detection + + - priority: low + ```md + Stereo file hard-panned to one side — often a bounce error. + Categorization: ATTENTION + ``` + +### Frequency Content vs. Name Mismatch + + - priority: low + ```md + "Kick.wav" with no energy below 100Hz, "HiHat.wav" with lots of low end. + Approach: spectral centroid or band energy checks against filename keywords. + Categorization: ATTENTION (informational) + ``` + +### Vocal Automation Curve Generation + + - priority: low + ```md + FUTURE: Pre-mix vocal cleanup (plosives, sibilance, peaks, level inconsistencies). + NOT in scope: Macro dynamics, artistic automation, creative mixing. + When: After core features are complete and stable. + Sub-items: phrase-level leveler, plosive tamer, sibilance tamer, peak limiter, + genre presets (pop/rock/jazz/minimal/custom), automation curve generation, + GUI panel, DAWproject/PTSL/JSON export. + ``` + +### Visual Feedback in Setup Table + + - priority: high + ```md + Show processed vs original file status in setup table (badges/tooltips). + Currently no visual indication of which file will be transferred. + ``` + +### Unit Tests — Detectors + + - priority: high + ```md + One test file per detector in tests/test_detectors/. + Covers: silence, clipping, dc_offset, stereo_compat, dual_mono, + one_sided_silence, subsonic, audio_classifier, tail_exceedance, + format_consistency, length_consistency. + ``` + +### Unit Tests — Processors + + - priority: high + ```md + tests/test_processors/test_bimodal_normalize.py + Test gain decisions for transient, sustained, silent, and edge cases. + ``` + +### Pipeline Integration Tests + + - priority: high + ```md + tests/test_pipeline.py: analyze → plan → prepare round-trip. + Covers: topological sort, group levelling, fader offsets, staleness. + ``` + +### Test Factories + + - priority: high + ```md + tests/factories.py: make_audio(), make_track(), make_session(). + Deterministic, file-I/O-free synthetic test objects for use across all test modules. + ``` + +### Undo Execution + + - priority: high + ```md + Rollback last transfer/sync batch using DawCommand.undo_params. + Data model ready; execution not implemented. + ``` + +### ProTools sync() + + - priority: high + ```md + Incremental delta push — currently raises NotImplementedError. + Compare current session state against transfer() snapshot; send only changes. + ``` + +### GUI DAW Tools Panel + + - priority: high + ```md + Color picker, etc. routed through execute_commands(). + Enables ad-hoc DAW operations from the GUI without a full Transfer. + ``` + +### Multi-Mic Phase Coherence Within Groups + + ```md + Kick_In and Kick_Out with opposite polarity cancel when summed. + Grouping preserves gain but doesn't catch this. + Approach: for each group, compute pairwise correlation in low-frequency band (< 500 Hz). + Threshold: warn if correlation < 0. + Categorization: ATTENTION (within group context) + ``` + +### Email-Ready Issue Summary Generator + + ```md + Detection without communication is incomplete. + Goal: copy/paste request for corrected exports. + Implementation: --email-summary flag or automatic sessionprep_issues.txt. + Format: grouped PROBLEMS / ATTENTION with per-file details and a polite request template. + ``` + +### Renderer ABC + + - priority: medium + ```md + Add Renderer ABC to rendering.py: + - render_diagnostic_summary(summary) -> Any + - render_track_table(tracks) -> Any + - render_daw_action_preview(actions) -> Any + Also: DictRenderer (raw dicts for JSON/GUI) and wrap existing functions into PlainTextRenderer. + ``` + +### Session Detector Result Storage + + - priority: medium + ```md + Move session-level detector results out of session.config. + Currently stored as session.config[f"_session_det_{det.id}"] in pipeline.py. + SessionContext should have a dedicated session_detector_results: dict field. + Avoids using config as a grab-bag for runtime state. + ``` + +### Split rendering.py Aggregation from Rendering + + - priority: medium + ```md + build_diagnostic_summary() (465 lines) contains substantial data aggregation logic. + When Renderer ABC is introduced, the builder should move to its own module. + The rendering module should only contain format-specific output (plain text, Rich, etc.) + ``` + +### Loudness Range (LRA) Measurement + + - priority: medium + ```md + Beyond crest — flag heavily compressed vs genuinely dynamic tracks. + Complements over-compression detection. + ``` + +### Spectral Gaps / Aliasing Artifacts + + - priority: medium + ```md + Detect: Notch at 16kHz (MP3 artifact), energy above Nyquist/2 (bad SRC), + missing expected low end (e.g., "Bass" track with nothing below 100Hz). + Categorization: ATTENTION + ``` + +### Reverb / Bleed Estimation + + - priority: medium + ```md + Signal that doesn't drop > 30 dB within 200ms after transients. + Affects processing decisions (gating, compression threshold). + Categorization: ATTENTION (informational) + ``` + +### Start Offset Misalignment + + - priority: medium + ```md + Report time-to-first-content; flag outliers. + Catches "forgot to bounce from session start" errors. + Categorization: ATTENTION + ``` + +### Detector Performance Optimization + + - priority: medium + ```md + Profiled on 27-track session: audio_classifier 577–1470 ms/file (~60% of total), + subsonic 460–1940 ms/file, stereo_compat 470–600 ms/file on stereo. + Ideas: cache shared FFT/STFT data, downsample before classification, vectorize hot loops. + ``` + +### Optional Auto-HPF on Processing + + - priority: medium + ```md + --hpf 30 flag to clean subsonic garbage on processing. + Opt-in only, never default. + ``` + +### Automatic Sample Rate Conversion + + - priority: medium + ```md + --target_sr 48000 with high-quality resampling (libsamplerate). + Destructive operation — needs clear user intent. + ``` + +### Tempo / BPM Metadata Consistency + + - priority: medium + ```md + Warn if mixed or missing BPM metadata across files. + Scope note: only meaningful if source files carry reliable tempo metadata. + Categorization: ATTENTION + ``` + +### Track Duration Sub-second Mismatches + + - priority: medium + ```md + "Length mismatches" detector exists; may need threshold tuning for sub-second differences. + Common export error (e.g., one track is 3:24 and another is 3:25). + ``` + +### LUFS Measurement + + - priority: medium + ```md + Nice context but usually doesn't change decisions. + Display: add to per-file stats, include integrated + short-term. + ``` + +### Config and Queue Tests + + - priority: high + ```md + tests/test_config.py: validate_config, merge_configs, preset load/save. + tests/test_queue.py: SessionQueue priority ordering, job lifecycle. + ``` + +### "Effectively Silent" / Noise-Only Detection + + - priority: high + ```md + Current is_silent only triggers on peak == 0.0 (absolute zero). + Flag when: peak < -40 dBFS AND crest < 6 dB AND content is spectrally flat. + Complements SNR estimation (that's for files WITH content; this is for noise-only files). + Categorization: ATTENTION + ``` + +### Click / Pop Detection + + - priority: high + ```md + Isolated transients that are anomalously loud — editing errors, plugin glitches, mouth clicks. + Flag when a single sample or very short window (< 5ms) exceeds local RMS by > 20 dB. + Categorization: ATTENTION + ``` + +## P0 + +### Over-Compression / Brick-Wall Limiting Detection + + ```md + A track with crest < 6 dB, peak > -1 dBFS, and RMS > -8 dBFS has been crushed. + Dynamics may be unrecoverable — changes how the mix engineer approaches the track. + Heuristic: if crest < 6 and peak > -1 and rms > -8: warn(...) + Categorization: ATTENTION + ``` + +### Noise Floor / SNR Estimation + + ```md + Quiet vocal comp with audible hiss/hum — current silent-file detection misses this. + Approach: find silent sections (RMS < threshold for > 500ms), measure noise floor RMS, + compute SNR = signal_rms_db - noise_floor_db. Warn if SNR < 30 dB. + Categorization: ATTENTION + ``` + +### Stereo Narrowness Detection + + - priority: low + ```md + Stereo file that's effectively mono (not identical, but < 5% width). + Could save disk space / simplify processing. + Categorization: ATTENTION (informational) + ``` + +### DC Offset Removal + + - priority: high + ```md + Why detect it if we won't fix it? + --fix_dc flag or automatic in execute mode. + ``` + +## In Development + +### InnoSetup Installer + + - defaultExpanded: false + +## Done + diff --git a/README.md b/README.md index a5d6b15..6b259a8 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ sessionprep /path/to/tracks # analyze (safe, read-only) sessionprep /path/to/tracks -x # analyze + process (writes to processed/) ``` -**4. Import** processed tracks into your DAW, apply fader offsets from `sessionprep.txt`. +**4. Import** processed tracks into your DAW. Use **DAW Transfer** in the GUI to apply fader offsets automatically (Pro Tools via PTSL, DAWproject via file generation). --- @@ -151,7 +151,7 @@ SessionPrep operates in three stages: |-------|------|-------------|------| | **1** | Analysis & Preparation | Format checks, clipping, DC offset, stereo compatibility, silence, subsonic, peak/RMS measurement, classification, tail exceedance | Always | | **2** | Processing | Bimodal normalization (clip gain adjustment) | GUI Prepare / CLI execute (`-x`) | -| **3** | DAW Integration | Transfer tracks into DAW session, apply fader offsets to restore rough mix balance | GUI Transfer | +| **3** | DAW Integration | Transfer tracks into DAW session; fader offsets applied automatically (Pro Tools via PTSL, DAWproject via file generation) to restore rough mix balance | GUI Transfer | ### Diagnostic categories diff --git a/REFERENCE.md b/REFERENCE.md index 4055535..cca2a41 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -399,7 +399,7 @@ DAWs, but done with sample-accurate precision across 100+ files very quickly. ## 5. Fader Restoration (Stage D) The script calculates the inverse fader offsets for the gain applied in Stage C. -Applying those offsets happens in the DAW (manually or via automation). +Applying those offsets restores the producer's rough-mix balance exactly. Example: - Input: A quiet synth pad (-24 dB). @@ -414,10 +414,16 @@ This ensures that when you hit play, the mix balance is 100% identical to the producer's rough mix, your files are error-checked, and your starting levels are more consistent. Per-insert gain staging is still part of mixing. -> **Planned:** Fader offset application is currently manual or via third-party -> tools (e.g., SoundFlow). A future version will automate this directly via DAW -> scripting APIs such as the -> [Pro Tools Scripting SDK (PTSL)](https://developer.avid.com/). +**Fader offset application:** The GUI's **DAW Transfer** action applies offsets +automatically: +- **Pro Tools** — faders are set via `CId_SetTrackVolume` through the + [Pro Tools Scripting SDK (PTSL)](https://developer.avid.com/) (requires + bimodal normalization enabled and processed files in use). +- **DAWproject** — fader volumes are written directly into the generated + `.dawproject` file. + +The `sessionprep.json` export remains available for custom workflows and +third-party tools. --- diff --git a/TECHNICAL.md b/TECHNICAL.md index 45a7c27..9a8fabc 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -111,9 +111,9 @@ The Script's Solution: In execute mode, it sets a consistent starting level into the first insert (Physics) and calculates the inverse fader offsets to preserve the original - balance (Art). It also exports - a machine-readable `sessionprep.json` intended for a follow-up automation tool - (e.g., SoundFlow) to set faders in the DAW automatically. + balance (Art). The GUI's DAW Transfer action then applies fader offsets automatically + (Pro Tools via PTSL; DAWproject via file generation). A machine-readable + `sessionprep.json` is also exported for custom workflows. --- @@ -215,8 +215,9 @@ The signal path is not a single volume knob. It is a chain: [FADER] <--- STEP 3: RESTORATION (THE REPORT) | We counteract the gain change using the Fader. | This restores the Producer's intended balance. - | The exported sessionprep.json is designed for automation - | tools (e.g., SoundFlow) to apply these fader offsets. + | The GUI's DAW Transfer applies these fader offsets + | automatically (Pro Tools via PTSL, DAWproject via file + | generation). sessionprep.json remains for custom workflows. | [MIX BUS] ``` @@ -400,8 +401,8 @@ that typical ISP overshoots are not a practical problem in the session context. The default mode is analysis-first: it prints a session overview so you can fix format issues, clipping, DC offset, correlation concerns, and other problems before creating a DAW session. Execute mode (`-x`) is optional: it writes processed -tracks and exports `sessionprep.json`, intended for a follow-up automation tool -(e.g., SoundFlow) to apply fader offsets in Pro Tools. +tracks and exports `sessionprep.json`. In the GUI, fader offsets are applied +automatically via DAW Transfer (Pro Tools via PTSL, DAWproject via file generation). ### Heuristic limitation diff --git a/TODO.md b/TODO.md index 7be550f..a1630b4 100644 --- a/TODO.md +++ b/TODO.md @@ -12,72 +12,13 @@ ### P1: DAW Scripting Layer -- [x] **`daw_processor.py` — DawProcessor ABC** - - `config_params()`, `configure()`, `check_connectivity()` - - `fetch(session)`, `transfer(session)`, `sync(session)` - - `execute_commands(session, commands)` — ad-hoc commands from GUI tools - - One processor per DAW (ProTools, DAWProject, etc.) - - Orchestrated by GUI/CLI directly, not Pipeline - -- [x] **`daw_processors/` package — factory** - - `default_daw_processors()` (empty, ready for concrete processors) - -- [x] **DawCommand / DawCommandResult models** - - Plain data + undo_params; processor executes via internal dispatch - -- [x] **Config/Settings/Preferences integration** — four-section config - (`app`, `colors`, `config_presets`, `group_presets`), Preferences two-tab - layout (Global + Config Presets), config preset CRUD, group preset CRUD, - toolbar "Config:" and "Group:" combos, session Config tab with per-session - overrides, legacy config migration - -- [x] **Concrete: ProToolsProcessor** (PTSL) — `ProToolsDawProcessor` in `daw_processors/protools.py`. - `check_connectivity()`, `fetch()` (folder hierarchy), `transfer()` (audio import + CIE L*a*b* - perceptual color matching). `sync()` not yet implemented. Configurable command delay. -- [x] **Concrete: DAWProjectProcessor** (.dawproject files) — `DawProjectDawProcessor` in - `daw_processors/dawproject.py`. Template-based `.dawproject` file generation with track/clip - creation, fader volumes, group colors. Expression gain (clip gain) automation partially - implemented (TODO: XML structure issue with dawproject-py library). -- [x] **GUI toolbar dropdown** for active DAW processor selection — combo box + Check/Fetch/Transfer/Sync actions in Session Setup toolbar - [ ] **GUI DAW Tools panel** (color picker, etc. → execute_commands) - [ ] **Undo execution** (rollback last transfer/sync batch) +- [ ] **`sync()`** for ProToolsDawProcessor (incremental delta push; currently raises `NotImplementedError`) +- [ ] **DAWproject expression gain fix** (XML structure issue with dawproject-py library) ### P1: File-Based Processing Pipeline -- [x] **AudioProcessor enabled toggle** — base `config_params()` returns - `{id}_enabled` ParamSpec, `configure()` reads `_enabled`, `enabled` property. - Subclasses chain via `super().config_params() + [...]`. Pipeline configures - all processors first, then filters to enabled before sorting by priority. - -- [x] **Model changes** — `TrackContext` gained `processed_filepath`, - `applied_processors`, `processor_skip`. `SessionContext` gained - `prepare_state` (`"none"` / `"ready"` / `"stale"`). - -- [x] **`Pipeline.prepare()` method** — wipes output dir, chains enabled - processors per track (respecting `processor_skip`), writes processed files, - updates track metadata, sets `prepare_state = "ready"`. - -- [x] **`PrepareWorker` QThread** — runs `Pipeline.prepare()` in background - with progress/finished/error signals. - -- [x] **Prepare button** (analysis toolbar) — right-aligned, staleness - indicators: "Prepare" / "Prepare ✓" / "Prepare (!)". Enabled after analysis. - -- [x] **Processing column** (analysis table, col 7) — per-track multiselect - `QToolButton` + checkable `QMenu`. Labels: "Default" (all active), "None" - (all skipped), comma-separated (partial). Disabled "None" when no processors - enabled globally. Editable only in analysis phase. - -- [x] **Use Processed toggle** (setup toolbar) — checkable action with stale - indicator. Controls `session.config["_use_processed"]`. - `ProToolsDawProcessor.transfer()` uses `processed_filepath` when enabled. - -- [x] **Staleness triggers** — gain, classification, RMS anchor, processor - selection, and re-analysis changes transition `"ready"` → `"stale"`. - -- [x] **MonoDownmixProcessor** (stub) — `PRIORITY_POST` (200), `apply()` - returns audio unchanged. Tests multi-processor UI behaviour. - - [ ] **Batch-edit support for Processing column** (deferred) - [ ] **Visual feedback in setup table** (processed vs original file badges/tooltips) @@ -99,14 +40,6 @@ - [ ] **Config and queue tests** - `tests/test_config.py`, `tests/test_queue.py` -### P2: Session Snapshots - -- [ ] **`snapshot.py` — Save/load session analysis state** - - `save_snapshot(session, path)` — serialize metadata + detector/processor results (no audio) - - `load_snapshot(path, source_dir) -> SessionContext` — restore state, lazy-load audio - - JSON format with `schema_version` - - Enables "analyze once, iterate on settings" workflow - ### P2: Rendering Abstraction - [ ] **Renderer ABC in `rendering.py`** @@ -118,14 +51,6 @@ - [ ] **Wrap existing functions into PlainTextRenderer class** -### P3: PreferencePage Superclass Refactor - -- [ ] **Refactor preferences into `PreferencePage` base class with generic Reset to Defaults** - - Each page subclasses `PreferencePage` with `populate(config)`, `read() -> dict`, `reset_defaults()` - - Dialog auto-wires Reset button per page - - Reduces duplication, provides consistent UX across all preference pages - - Currently Colors and Groups have manual Reset; General/Analysis/Detectors/Processors do not - ### P3: Validation Polish - [ ] **Component-level `configure()` validation** @@ -146,13 +71,6 @@ - When the Renderer ABC is introduced (see above), the builder should move to its own module - The rendering module should only contain format-specific output (plain text, Rich, etc.) -### P3: Make `rich` an Optional Dependency - -- [x] **Move `rich` from core to optional/CLI dependency** (Status: ✅ Done) - - `rich` is now in `[project.optional-dependencies].cli` - - `sessionprep.py` guards imports with a helpful error message - - GUI builds (Nuitka) explicitly exclude `rich` to save space - ### P3: Group Gain as Processor - [ ] **Extract `GroupGainProcessor` at `PRIORITY_POST` (200)** @@ -192,11 +110,6 @@ ### UX / Output Quality -- [x] **Reduce normalization hint noise** (Status: ✅ Done) - - Removed normalization hints entirely — the three-metric classifier - (crest + decay + density) makes single-metric "near threshold" warnings - obsolete. - - [ ] **Auto-generate email-ready issue summary** (Status: ❌) - Why: Detection without communication is incomplete. This is workflow gold. - Goal: Copy/paste a short request for corrected exports. @@ -240,150 +153,55 @@ - Approach: Flag when a single sample or very short window (< 5ms) exceeds local RMS by > 20 dB. - Categorization: ATTENTION -### Audio Cleanup & Processing - -- [ ] **DC offset removal** (Status: ❌) - - Why: Why detect it if you won't fix it? - - Implementation: `--fix_dc` flag or automatic in execute mode - --- ## P2: Medium Value ### Classification Robustness -- [x] **Audio classifier upgrade (crest + decay + density)** (Status: ✅ Done) - - Replaced single crest-factor threshold with a three-metric classifier: - crest factor, envelope decay rate (10 ms short-window energy envelope), - and content density (fraction of active RMS windows). - - Resolves compressed drums (low crest, fast decay → Transient), - plucked instruments (high crest, slow decay → Sustained), and - sparse percussion like toms/crashes (sparse + dynamic metric agreement). - - Sparse tracks require at least one dynamic metric (crest or decay) to - agree before being classified as Transient, preventing false positives - on sparse sustained content (e.g., guitar only in the outro). - - New configurable params: `decay_lookahead_ms` (default 200), - `decay_db_threshold` (default 12.0), `sparse_density_threshold` (default 0.25). - - Detector renamed: `crest_factor` → `audio_classifier` - (`CrestFactorDetector` → `AudioClassifierDetector`). - - File renamed: `crest_factor.py` → `audio_classifier.py`. - - All consumers updated (tail_exceedance, bimodal_normalize, rendering). - -- [ ] **Loudness Range (LRA) / dynamics measurement** (Status: ❌) - - Why: Beyond crest—flag heavily compressed vs genuinely dynamic tracks. - - Complements over-compression detection. +- [ ] **Loudness Range (LRA) / dynamics measurement** + - Why: Beyond crest — flag heavily compressed vs genuinely dynamic tracks. ### Detection & Diagnostics -- [ ] **Spectral gaps / aliasing artifacts** (Status: ❌) `NEW` - - Why: Declining problem, but still useful if the source was transcoded or poorly sample-rate-converted. - - Detect: - - Notch at 16kHz (MP3 encoding artifact) - - Energy above Nyquist/2 (aliasing from bad SRC) - - Missing expected low end (e.g., "Bass" track with nothing below 100Hz) +- [ ] **Spectral gaps / aliasing artifacts** + - Detect: Notch at 16kHz (MP3 artifact), energy above Nyquist/2 (bad SRC), + missing expected low end (e.g., "Bass" track with nothing below 100Hz) - Categorization: ATTENTION -- [x] **Subsonic detection: per-channel analysis** (Status: ✅ Done) - - Each channel is analyzed independently for stereo/multi-channel files. - - If only one channel triggers, the issue is reported per-channel with the - specific channel index. Both channels triggering → whole-file issue. - - Combined ratio = max of per-channel ratios (no phase-cancellation masking). - - Per-channel ratios stored in `data["per_channel"]`. - - `subsonic_stft_analysis()` in `audio.py` (scipy STFT, replaces three old functions). - -- [x] **Subsonic detection: windowed analysis option** (Status: ✅ Done) - - Default on via `subsonic_windowed` config (default: `true`). - - Splits each channel into windows (`subsonic_window_ms`, default 500 ms). - - Contiguous exceeding windows merged into regions with precise sample ranges. - - Regions reported as `IssueLocation` objects (visible on waveform overlays). - - Capped by `subsonic_max_regions` (default 20). - - Per-window ratios derived from single STFT pass (vectorised, no Python loop). - - Whole-file analysis always runs regardless of windowed setting. - - Four safeguards for accurate windowed results: - 1. Absolute subsonic power gate (window_rms_db + ratio_db < −40 dBFS → skip; - prevents amp hum/noise in quiet gaps from false positives). - 2. Threshold relaxation (windowed threshold = configured − 6 dB, compensates for reduced frequency resolution in short windows). - 3. Active-signal fallback (if no ratio-based regions found, marks windows - within 20 dB of the loudest window — matches where signal is active). - 4. Whole-file fallback (if even active-signal finds nothing, a full-file - overlay is shown so ATTENTION always has a visible issue). - -- [x] **Stereo compatibility: merged detector with windowed analysis** (Status: ✅ Done) - - Merged `StereoCorrelationDetector` + `MonoFolddownDetector` into unified - `StereoCompatDetector` (id `stereo_compat`). - - `windowed_stereo_correlation()` in `audio.py`: vectorised numpy with per-window - DC removal, silence gating, whole-file aggregation from cumulative dot products. - - Both Pearson correlation and mono folddown loss computed per window from the same - dot products (L·L, R·R, L·R) at zero extra cost. - - Contiguous windows exceeding `corr_warn` or `mono_loss_warn_db` merged into regions. - - Regions reported as `IssueLocation` objects with waveform overlays. - - Severity upgrades INFO → ATTENTION when localized regions are found. - - Config: `corr_warn`, `mono_loss_warn_db`, `corr_windowed`, `corr_window_ms`, - `corr_max_regions`. - - Files deleted: `stereo_correlation.py`, `mono_folddown.py`. - -- [ ] **Reverb/bleed estimation** (Status: ❌) `NEW` - - Why: A "dry" vocal with 2 seconds of reverb tail affects processing decisions. A "kick" track with hi-hat bleed means I can't gate it cleanly. - - Approach: Analyze decay characteristics. Signal that doesn't drop > 30 dB within 200ms after transients likely has reverb/bleed. + +- [ ] **Reverb/bleed estimation** + - Approach: Signal that doesn't drop > 30 dB within 200ms after transients. - Categorization: ATTENTION (informational) -- [ ] **Start offset misalignment** (Status: ❌) `NEW` - - Why: Some tracks start with content immediately, others have 10s of silence. Catches "forgot to bounce from session start" errors. - - Approach: Report time-to-first-content for each file; flag outliers. - - Note: Related to existing "Track duration mismatches" but different failure mode. +- [ ] **Start offset misalignment** + - Approach: Report time-to-first-content; flag outliers. - Categorization: ATTENTION -- [ ] **Detector performance optimization** (Status: ❌) `NEW` - - Profiled with `SP_DEBUG=1` on 27-track session (8.17s total analyze, 302.6 ms/track avg). - - Top targets by per-file cost: - 1. **`audio_classifier`**: 577–1470 ms/file — ~60% of total time, biggest win by far - 2. **`subsonic`**: 460–1940 ms/file — worst on long stereo files (OH/Room ~1.9s) - 3. **`stereo_compat`**: 470–600 ms/file on stereo (0 ms on mono — OK) - - Minor: `silence` (27–152 ms), `clipping` (24–168 ms), `dc_offset` (25–86 ms) - - Negligible: `dual_mono`, `one_sided_silence`, `tail_exceedance` (<60 ms) - - Processors are negligible (<1 ms each, plan phase ~65 ms total for 27 tracks) - - Ideas: cache shared FFT/STFT data across detectors, downsample before classification, vectorize hot loops +- [ ] **Detector performance optimization** + - Profiled on 27-track session: `audio_classifier` 577–1470 ms/file (~60% of total), + `subsonic` 460–1940 ms/file, `stereo_compat` 470–600 ms/file on stereo. + - Ideas: cache shared FFT/STFT data, downsample before classification, vectorize hot loops. ### Audio Cleanup & Processing -- [ ] **Optional auto-HPF on processing** (Status: ❌) - - Idea: `--hpf 30` to clean subsonic garbage. - - Caution: Should be opt-in only, never default. +- [ ] **Optional auto-HPF on processing** — `--hpf 30`; opt-in only, never default. -- [ ] **Automatic SRC** (Status: ❌) - - Why: Same issue as DC—flag it and make me open another tool? - - Implementation: `--target_sr 48000` with high-quality resampling (libsamplerate) - - Caution: Destructive operation, needs clear user intent. +- [ ] **Automatic SRC** — `--target_sr 48000` via libsamplerate; destructive, needs explicit intent. ### Session Integrity Checks -- [ ] **Tempo/BPM metadata consistency** (Status: ❌) `NEW` - - Why: When tracks come with conflicting or missing tempo info, aligning to click and time-based effects becomes slower. - - Scope note: Might be out of scope if the source files don't carry reliable tempo metadata. - - Check: - - Consistent BPM/tempo metadata across files, if present - - Warn if mixed/missing +- [ ] **Tempo/BPM metadata consistency** + - Warn if mixed or missing BPM metadata across files (scope note: only if files carry it). - Categorization: ATTENTION -- [ ] **Track duration mismatches** (Status: ❌) - - Why: Common export error (e.g., one track is 3:24 and another is 3:25). - - Note: Already partially implemented as "Length mismatches" detector. May just need threshold tuning for sub-second differences. +- [ ] **Track duration mismatches** + - "Length mismatches" detector exists; may need threshold tuning for sub-second differences. ### Metering & Loudness Context -- [ ] **True-peak / ISP warnings** (Status: ❌) - - Why: Matters more for mastering. At mix stage with a -6 dBFS ceiling, not critical. - -- [ ] **LUFS measurement** (Status: ❌) - - Why: Nice context but usually doesn't change decisions. - - Display: Add to per-file stats, include integrated + short-term +- [ ] **True-peak / ISP warnings** — more relevant at mastering; low priority at mix stage. -### Documentation - -- [x] **Document subsonic detection methodology** (Status: ✅ Done) - - Documented in REFERENCE.md §2.10: STFT-based per-channel analysis via - `scipy.signal.stft`, max-of-channels combined ratio, vectorised windowed - analysis, absolute power gate, threshold relaxation, active-signal and - whole-file fallbacks, frequency-bounded issue overlays. +- [ ] **LUFS measurement** — nice context, per-file integrated + short-term display. --- @@ -391,99 +209,22 @@ ### Detection & Diagnostics -- [ ] **Stereo narrowness detection** (Status: ❌) `NEW` - - What: Stereo file that's effectively mono (not identical, but < 5% width) - - Why: Could save disk space / simplify processing - - Categorization: ATTENTION (informational) +- [ ] **Stereo narrowness detection** — effectively mono stereo (< 5% width); ATTENTION (informational). -- [ ] **Asymmetric panning detection** (Status: ❌) `NEW` - - What: Stereo file hard-panned to one side - - Why: Often a bounce error - - Categorization: ATTENTION +- [ ] **Asymmetric panning detection** — stereo hard-panned to one side; often a bounce error. -- [ ] **Frequency content vs. name mismatch** (Status: ❌) `NEW` - - What: "Kick.wav" with no energy below 100Hz, "HiHat.wav" with lots of low end - - Why: Sanity check against mislabeling - - Approach: Simple spectral centroid or band energy checks against filename keywords - - Categorization: ATTENTION (informational) +- [ ] **Frequency content vs. name mismatch** — "Kick.wav" with no energy below 100Hz, etc. + Approach: spectral centroid or band energy checks against filename keywords. ### Vocal Automation (Future Feature) -- [ ] **Vocal automation curve generation** (Status: ❌) `FUTURE` - - **Scope:** Pre-mix vocal cleanup to make vocals compressor-ready - - **Goal:** Remove mechanical problems (plosives, sibilance, peaks, level inconsistencies) that waste time and sabotage compressor - - **NOT in scope:** Macro dynamics (verse/chorus balance), artistic automation, creative mixing decisions - - **When:** After core features are complete and stable - -#### Core Processors - -- [ ] **Phrase-level leveler** - - Purpose: Smooth out loudness variations between phrases (2-4 second windows) - - Algorithm: Bring outlier phrases toward median ±3-6 dB (genre-dependent) - - Settings: `window_ms`, `target_range_db`, `smoothing` (aggressive/moderate/gentle) - - Not normalization: Preserve intentional dynamics, only fix extremes - -- [ ] **Plosive tamer** - - Purpose: Remove low-frequency thumps from P, B consonants - - Detection: 50-200 Hz bursts, <50ms duration - - Action: Momentary 6-12 dB reduction (genre-dependent threshold) - - Settings: `threshold` (0.0-1.0 energy ratio), `reduction_db`, `freq_range` - -- [ ] **Sibilance tamer** - - Purpose: Reduce harsh high-frequency spikes from S, T consonants - - Detection: 5-10 kHz spikes, <100ms duration - - Action: Momentary 3-8 dB reduction (frequency-specific or broadband) - - Settings: `threshold` (ratio vs. mid-freq), `reduction_db`, `freq_range` - -- [ ] **Peak limiter** - - Purpose: Catch random peaks that would overload compressor (mouth clicks, breath pops) - - Detection: Peaks >6 dB above local RMS (500ms window) - - Action: Fast reduction over 10-50ms (5ms attack, 30ms release) - - Settings: `threshold_db`, `target_db`, `attack_ms`, `release_ms` - -#### Genre Preset System - -- [ ] **Preset configurations** - - Pop: Aggressive control (±3 dB range, heavy plosive/sibilance reduction) - - Rock: Moderate control (±6 dB range, preserve energy) - - Jazz: Minimal control (±10 dB range, preserve natural dynamics) - - Minimal: Only obvious problems (disable leveler, catch extremes only) - - Custom: User-configurable thresholds - -#### Implementation Details - -- [ ] **Automation curve generation** - - Smoothing: 50ms attack, 200ms release (avoid zipper noise, pumping) - - Thinning: Reduce to <5000 points for DAW compatibility - - Global smoothing: Apply ballistics (compressor-style attack/release) - - Preserve intentional peaks: Don't squash belts/screams (>10 dB above target) - -- [ ] **GUI integration** - - Vocal automation panel (waveform + automation overlay) - - Preset dropdown (pop/rock/jazz/minimal/custom) - - Processor checkboxes + threshold sliders - - Real-time curve regeneration (adjust settings, preview curve) - - Statistics display (plosives detected, sibilance regions, peaks reduced) - - Visualization only (no interactive editing—refinement done in DAW) - -- [ ] **Export formats** - - DAWproject: Native volume automation lane - - Pro Tools PTSL: Native automation (when PTSL integration ready) - - JSON: For custom workflows / manual import - -### Documentation - -- [x] **Reorganize docs** (Status: ✅ Done) - - README.md — overview, installation, quick start, usage examples - - TECHNICAL.md — audio engineering background, normalization theory, signal chain - - REFERENCE.md — detector reference, analysis metrics, processing details - - DEVELOPMENT.md — development setup, building (PyInstaller + Nuitka), library architecture - -- [x] **Build System Harmonization** (Status: ✅ Done) - - Centralized `build_conf.py` for shared metadata. - - Symmetric `build_pyinstaller.py` and `build_nuitka.py`. - - Consistent `dist_*/` directory structure. - - GitHub Actions for automated artifacts. +- [ ] **Vocal automation curve generation** `FUTURE` + - **Scope:** Pre-mix vocal cleanup (plosives, sibilance, peaks, level inconsistencies). + - **NOT in scope:** Macro dynamics, artistic automation, creative mixing. + - **When:** After core features are complete and stable. + - Sub-items: phrase-level leveler, plosive tamer, sibilance tamer, peak limiter, + genre presets (pop/rock/jazz/minimal/custom), automation curve generation, + GUI panel, DAWproject/PTSL/JSON export. --- @@ -492,92 +233,10 @@ | Sprint | Focus | Items | |--------|-------|-------| | **0** | Testing & architecture | Test factories, unit tests, session detector storage, rendering split | -| **1** | Detection quality | Email summary generator, Over-compression, Noise floor/SNR, "Effectively silent" detection (rare), Reduce hint noise | +| **1** | Detection quality | Email summary generator, Over-compression, Noise floor/SNR, "Effectively silent" detection (rare) | | **2** | Group intelligence | Multi-mic phase coherence | | **3** | Workflow polish | Click/pop detection | | **4** | Metering depth | LRA, LUFS (P2), True-peak/ISP (P2) | -| ~~**5**~~ | ~~Subsonic improvements~~ | ~~Per-channel analysis, Windowed option, Documentation~~ → ✅ Done (STFT speedup, per-channel, windowed, docs) | -| **6** | Auto-fix capabilities | DC removal, SRC | -| ~~**7**~~ | ~~Classification v2~~ | ~~Crest improvements~~ → ✅ Done (audio classifier with decay metric) | -| ~~**7b**~~ | ~~Simplify CLI grouping~~ | ~~Overlap policies, anonymous IDs~~ → ✅ Done (named groups, first-match-wins, no overlap policy) | -| **8** | DAW scripting | ~~DawProcessor ABC~~, ~~PTSL integration (check/fetch/transfer)~~, ~~PT batch import~~, ~~PT fader levels~~, ~~DAWProject backend~~, sync, DAWProject expression gain fix | -| ~~**9**~~ | ~~File-based processing~~ | ~~AudioProcessor enabled toggle, Pipeline.prepare(), Prepare button, Processing column, Use Processed toggle, staleness, MonoDownmix stub~~ → ✅ Done | -| ~~**10**~~ | ~~Stereo compatibility~~ | ~~Merge StereoCorrelation + MonoFolddown, windowed analysis, waveform overlays~~ → ✅ Done | -| **Ongoing** | Low-hanging fruit | Stereo narrowness, Start offset, Name mismatch, `rich` optional | - ---- - -## Quick Reference: New Items Added This Session - -| Item | Priority | Source | -|------|----------|--------| -| Over-compression / brick-wall limiting | P0 | Mix engineer feedback | -| Noise floor / SNR estimation | P0 | Mix engineer feedback | -| Multi-mic phase coherence | P0 | Mix engineer feedback | -| ~~Reduce normalization hint noise~~ | P0 | ✅ Resolved (normalization hints removed — obsolete with three-metric classifier) | -| Email summary generator | P0 | Workflow requirement | -| "Effectively silent" / noise-only detection (rare) | P1 | User note (close to silence) | -| Click/pop detection | P1 | Mix engineer feedback | -| Session detector result storage | P2 | Architectural review | -| Rendering/aggregation split | P2 | Architectural review | -| Spectral gaps / aliasing artifacts | P2 | Mix engineer feedback | -| Subsonic per-channel analysis | P2 | User note (L/R handling) | -| Subsonic windowed analysis option | P2 | User note (parts vs whole file) | -| Document subsonic methodology | P2 | User note (documentation) | -| Tempo/BPM metadata consistency | P2 | User note (workflow) | -| Reverb/bleed estimation | P2 | Mix engineer feedback | -| Start offset misalignment | P2 | Mix engineer feedback | -| Make `rich` optional dependency | P3 | Architectural review | -| Stereo narrowness detection | P3 | Mix engineer feedback | -| Asymmetric panning detection | P3 | Mix engineer feedback | -| Frequency vs. name mismatch | P3 | Mix engineer feedback | -| ~~Hard-coded processor ID in Pipeline~~ | — | ✅ Resolved | -| ~~Report/JSON generation in CLI~~ | — | ✅ Resolved (moved to `sessionpreplib/reports.py`) | -| ~~Docs reorganization~~ | — | ✅ Resolved (README, TECHNICAL, REFERENCE) | -| ~~Preferences dialog~~ | — | ✅ Resolved (two-tab layout: Global + Config Presets; config preset CRUD; group preset CRUD; ParamSpec-driven pages; reset-to-default) | -| ~~Config presets + session config~~ | — | ✅ Resolved (four-section config structure, named config presets, toolbar Config: combo, session Config tab, per-session overrides, legacy migration, group preservation on re-analysis) | -| ~~HiDPI scaling~~ | — | ✅ Resolved (QT_SCALE_FACTOR, persisted in app.scale_factor) | -| ~~Detector/processor help text~~ | — | ✅ Resolved (visible subtext + rich tooltips) | -| ~~About dialog~~ | — | ✅ Resolved (version from importlib.metadata) | -| ~~Waveform overlay controls~~ | — | ✅ Resolved (Detector Overlays dropdown with per-detector checkable items, filtered by is_relevant) | -| ~~Peak/RMS marker toggle~~ | — | ✅ Resolved (Peak / RMS Max toggle button, dark violet/teal-blue colors) | -| ~~RMS L/R and RMS AVG split~~ | — | ✅ Resolved (separate toggle buttons replacing single RMS toggle) | -| ~~Show clean detector results pref~~ | — | ✅ Resolved (show_clean_detectors in Detectors section, default off) | -| ~~Default project directory pref~~ | — | ✅ Resolved (directory picker in General prefs, Open Folder starts there) | -| ~~Real progress bar~~ | — | ✅ Resolved (determinate bar from EventBus events, async table row updates) | -| ~~Tail exceedance relevance~~ | — | ✅ Resolved (is_relevant on TrackDetector, suppressed for peak/peak-limited methods) | -| ~~Severity label vs is_relevant mismatch~~ | — | ✅ Resolved (track_analysis_label now accepts detectors list, checks is_relevant; re-evaluated in _on_track_planned) | -| ~~Waveform keyboard shortcuts~~ | — | ✅ Resolved (Ctrl+wheel h-zoom, Ctrl+Shift+wheel v-zoom, Shift+wheel scroll, R/T zoom at guide position) | -| ~~Vectorised waveform downsampling~~ | — | ✅ Resolved (_build_peaks and _build_rms_envelope use NumPy reshape + vectorised min/max/sqrt) | -| ~~AIFF/AIF file support~~ | — | ✅ Resolved (AUDIO_EXTENSIONS constant in audio.py; pipeline, GUI, CLI all scan .wav/.aif/.aiff) | -| ~~Channel count column~~ | — | ✅ Resolved (Ch column in track table, populated from TrackContext.channels) | -| ~~WAV/AIFF chunk I/O~~ | — | ✅ Resolved (chunks.py: read_chunks, write_chunks, remove_chunks, chunk_ids; chunk metadata in file detail report) | -| ~~Spectrogram display mode~~ | — | ✅ Resolved (mel spectrogram via scipy STFT, magma/viridis/grayscale colormaps, frequency scale, configurable FFT/window/dB range) | -| ~~Frequency-bounded detector overlays~~ | — | ✅ Resolved (IssueLocation.freq_min_hz/freq_max_hz, mel-mapped rectangles in spectrogram mode) | -| ~~Spectrogram navigation~~ | — | ✅ Resolved (Ctrl+Shift+wheel freq zoom, Shift+Alt+wheel freq pan, dB floor/ceiling presets) | -| ~~Horizontal time scale~~ | — | ✅ Resolved (time axis in waveform display) | -| ~~Output folder preference~~ | — | ✅ Resolved (directory picker in General prefs) | -| ~~Skip reanalysis on GUI-only changes~~ | — | ✅ Resolved (Preferences dialog detects gui-vs-analysis changes) | -| ~~Subsonic STFT speedup~~ | — | ✅ Resolved (scipy.signal.stft replaces per-window Python FFT loop; scipy promoted to core dep) | -| ~~Scipy as core dependency~~ | — | ✅ Resolved (scipy>=1.12 promoted from gui optional to core dependencies; used by subsonic STFT + spectrogram) | -| ~~Batch RMS anchor / classification override~~ | — | ✅ Resolved (BatchEditTableWidget + BatchComboBox in widgets.py, selectionCommand override preserves multi-selection, Alt+Shift batch apply, async BatchReanalyzeWorker with progress) | -| ~~CLI grouping simplification~~ | — | ✅ Resolved (named groups via `Name:pattern` syntax, first-match-wins, overlap warnings, removed `--group_overlap`/union-find/merge) | -| ~~Group levelling terminology~~ | — | ✅ Resolved ("equalize" → "group level" throughout codebase; `_equalize_group_gains` → `_apply_group_levels`) | -| ~~Stereo compat windowed analysis~~ | — | ✅ Resolved (merged StereoCorrelation + MonoFolddown → StereoCompatDetector; windowed Pearson correlation + mono folddown loss; IssueLocation overlays) | -| ~~Stereo compat false positive fix~~ | — | ✅ Resolved (`_windowed_analysis` fallback only runs when `any_whole_warn` is True; prevents unconditional active-region marking) | -| ~~Stereo compat window default~~ | — | ✅ Resolved (default `corr_window_ms` changed from 500 ms to 250 ms for better localization) | -| ~~Summary report\_as routing fix~~ | — | ✅ Resolved (`_buckets` dict in `rendering.py` was missing `"info"` key; `report_as` config choice `"info"` now routes correctly to info bucket) | -| ~~Prepare error reporting~~ | — | ✅ Resolved (per-track write failures collected in `_prepare_errors`, displayed via `QMessageBox.warning` with file-locking guidance) | -| ~~Mono playback button~~ | — | ✅ Resolved (checkable **M** button in playback controls; `PlaybackController.play(mono=True)` folds stereo to mono via (L+R)/2; orange when active) | -| ~~Analysis column severity counts~~ | — | ✅ Resolved (replaced single worst-severity label with colored per-severity counts: `2P 1A 5I` format; QLabel cell widget with HTML rich text + hidden `_SortableItem` for sorting) | -| ~~Peak/RMS Max markers default off~~ | — | ✅ Resolved (`_show_markers` and toggle default changed to `False`) | -| ~~Auto-Group files by keywords~~ | — | ✅ Resolved (Auto-Group button in analysis toolbar; assigns tracks to groups via `matches_keywords()` pattern matching; confirmation dialog, refreshes tables + report) | -| ~~Pro Tools quicker imports~~ | — | ✅ Resolved (batch import: single `CId_ImportData` call for all files instead of one per track; PTSL batch job wraps entire transfer for modal progress) | -| ~~Pro Tools automatic fader levels~~ | — | ✅ Resolved (`CId_SetTrackVolume` applies fader offsets from bimodal normalization when processed files are used) | -| ~~Fader headroom rebalancing~~ | — | ✅ Resolved (`_compute_fader_offsets` ensures max fader ≤ ceiling − headroom; uniform downshift stored in `fader_rebalance_shift`; per-DAW ceiling via `_fader_ceiling_db`) | -| ~~Detector/processor profiling~~ | — | ✅ Resolved (per-component `time.perf_counter` timing via `dbg()` in pipeline.py; per-detector, per-processor, per-phase totals with averages; gated by `SP_DEBUG` env var) | -| ~~DAWProject processor~~ | — | ✅ Resolved (template-based `.dawproject` generation with tracks, clips, fader volumes, group colors; expression gain TODO) | -| ~~Mix Templates widget~~ | — | ✅ Resolved (session Config tab widget for configuring `.dawproject` template files with name, path, fader ceiling) | -| ~~Fetch error dialog~~ | — | ✅ Resolved (QMessageBox.warning on connectivity failure; status label width constrained; toolbar stays functional) | -| ~~Prepare preserves .dawproject~~ | — | ✅ Resolved (prepare phase removes only audio files from output dir, preserving `.dawproject` and other non-audio artefacts) | -| ~~Config refresh before transfer/prepare~~ | — | ✅ Resolved (`session.config.update(_flat_config())` in both `_do_daw_transfer` and `_on_prepare` ensures widget changes take effect) | \ No newline at end of file +| **5** | Auto-fix capabilities | DC removal, SRC | +| **6** | DAW scripting | sync (ProTools), DAWproject expression gain fix, DAW Tools panel, Undo | +| **Ongoing** | Low-hanging fruit | Stereo narrowness, Start offset, Name mismatch, Spectral gaps | \ No newline at end of file From 5a5b9349dabcfb2e65990d16a5b11d589fa266f4 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 06:47:36 +0100 Subject: [PATCH 21/30] windows innosetup --- .github/workflows/build-nuitka.yml | 11 ++ .github/workflows/build-pyinstaller.yml | 11 ++ packaging/windows/sessionprep.iss | 187 ++++++++++++++++++++++++ 3 files changed, 209 insertions(+) create mode 100644 packaging/windows/sessionprep.iss diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 96d925f..592bf9b 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -62,6 +62,16 @@ jobs: - name: Build with Nuitka run: uv run python build_nuitka.py all + - name: Build InnoSetup Installer + if: runner.os == 'Windows' + shell: bash + run: | + VER=$(python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)") + "C:/Program Files (x86)/Inno Setup 6/ISCC.exe" \ + /DAPP_VERSION="$VER" \ + /DDIST_DIR=dist_nuitka \ + packaging/windows/sessionprep.iss + - name: Package macOS .app bundles as DMG if: runner.os == 'macOS' run: | @@ -85,6 +95,7 @@ jobs: dist_nuitka/*.dmg dist_nuitka/sessionprep-cli-* dist_nuitka/sessionprep-gui-* + dist_nuitka/SessionPrep-*-setup.exe !dist_nuitka/*.build !dist_nuitka/*.dist !dist_nuitka/*.onefile-build diff --git a/.github/workflows/build-pyinstaller.yml b/.github/workflows/build-pyinstaller.yml index cf57af9..97cfc76 100644 --- a/.github/workflows/build-pyinstaller.yml +++ b/.github/workflows/build-pyinstaller.yml @@ -47,6 +47,16 @@ jobs: # Using --onefile to produce single binaries where possible (standard distribution format) run: uv run python build_pyinstaller.py --onefile all + - name: Build InnoSetup Installer + if: runner.os == 'Windows' + shell: bash + run: | + VER=$(python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)") + "C:/Program Files (x86)/Inno Setup 6/ISCC.exe" \ + /DAPP_VERSION="$VER" \ + /DDIST_DIR=dist_pyinstaller \ + packaging/windows/sessionprep.iss + - name: Package macOS .app bundles as DMG if: runner.os == 'macOS' run: | @@ -70,6 +80,7 @@ jobs: dist_pyinstaller/*.dmg dist_pyinstaller/sessionprep-cli-* dist_pyinstaller/sessionprep-gui-* + dist_pyinstaller/SessionPrep-*-setup.exe !dist_pyinstaller/*.build !dist_pyinstaller/*.spec !dist_pyinstaller/*.app diff --git a/packaging/windows/sessionprep.iss b/packaging/windows/sessionprep.iss new file mode 100644 index 0000000..0d496cd --- /dev/null +++ b/packaging/windows/sessionprep.iss @@ -0,0 +1,187 @@ +; SessionPrep Windows Installer (Inno Setup 6) +; +; Build from repo root: +; ISCC /DAPP_VERSION=x.y.z /DDIST_DIR=dist_nuitka packaging\windows\sessionprep.iss + +; --------------------------------------------------------------------------- +; Defines +; --------------------------------------------------------------------------- + +#ifndef APP_VERSION + #define APP_VERSION "0.0.0" +#endif +#ifndef DIST_DIR + #define DIST_DIR "dist_nuitka" +#endif + +#define AppName "SessionPrep" +#define AppPublisher "SessionPrep" +#define AppExe "sessionprep-gui-win-x64.exe" +#define AppCli "sessionprep-cli-win-x64.exe" +#define AppIconSrc "..\..\sessionprepgui\res\icon.ico" + +; --------------------------------------------------------------------------- +; Setup +; --------------------------------------------------------------------------- + +[Setup] +AppId={{A9F4C2E1-7B3D-4A6E-8C1F-5D2E0B9A3C78} +AppName={#AppName} +AppVersion={#APP_VERSION} +AppPublisher={#AppPublisher} +AppVerName={#AppName} {#APP_VERSION} + +DefaultDirName={autopf}\{#AppName} +DefaultGroupName={#AppName} + +OutputDir=..\..\{#DIST_DIR} +OutputBaseFilename={#AppName}-{#APP_VERSION}-setup + +SetupIconFile={#AppIconSrc} +UninstallDisplayIcon={app}\icon.ico + +Compression=lzma +SolidCompression=yes +WizardStyle=modern + +PrivilegesRequired=admin +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible + +; --------------------------------------------------------------------------- +; Languages +; --------------------------------------------------------------------------- + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +; --------------------------------------------------------------------------- +; Tasks +; --------------------------------------------------------------------------- + +[Tasks] +Name: "startmenu"; \ + Description: "Create a Start Menu shortcut for SessionPrep GUI"; \ + GroupDescription: "Shortcuts:"; \ + Flags: checked +Name: "addtopath"; \ + Description: "Add installation directory to PATH (enables 'sessionprep' CLI in any terminal)"; \ + GroupDescription: "System:"; \ + Flags: checked + +; --------------------------------------------------------------------------- +; Files +; --------------------------------------------------------------------------- + +[Files] +; GUI executable +Source: "..\..\{#DIST_DIR}\{#AppExe}"; \ + DestDir: "{app}"; \ + Flags: ignoreversion + +; CLI executable +Source: "..\..\{#DIST_DIR}\{#AppCli}"; \ + DestDir: "{app}"; \ + Flags: ignoreversion + +; Icon (used by the uninstaller entry and shortcuts) +Source: "{#AppIconSrc}"; \ + DestDir: "{app}"; \ + DestName: "icon.ico"; \ + Flags: ignoreversion + +; --------------------------------------------------------------------------- +; Shortcuts +; --------------------------------------------------------------------------- + +[Icons] +Name: "{group}\{#AppName}"; \ + Filename: "{app}\{#AppExe}"; \ + IconFilename: "{app}\icon.ico"; \ + Tasks: startmenu + +; --------------------------------------------------------------------------- +; Code — PATH add/remove +; --------------------------------------------------------------------------- + +[Code] + +const + SysEnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + +{ Read the current system PATH from the registry. } +function GetSystemPath: string; +var + Path: string; +begin + if not RegQueryStringValue(HKEY_LOCAL_MACHINE, SysEnvKey, 'Path', Path) then + Path := ''; + Result := Path; +end; + +{ Write back to the registry using REG_EXPAND_SZ so %SystemRoot% etc. survive. } +procedure SetSystemPath(const Path: string); +begin + RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, SysEnvKey, 'Path', Path); +end; + +{ Case-insensitive check: is Dir already present in PathList? } +function DirInPath(const Dir, PathList: string): Boolean; +var + Needle, Haystack: string; +begin + Needle := Lowercase(RemoveBackslash(Dir)); + Haystack := ';' + Lowercase(PathList) + ';'; + Result := (Pos(';' + Needle + ';', Haystack) > 0) or + (Pos(';' + Needle + '\;', Haystack) > 0); +end; + +{ Add Dir to the system PATH only if it is not already present. } +procedure AddDirToPath(const Dir: string); +var + OldPath: string; +begin + OldPath := GetSystemPath; + if DirInPath(Dir, OldPath) then + Exit; { already there — nothing to do } + if OldPath = '' then + SetSystemPath(Dir) + else + SetSystemPath(OldPath + ';' + Dir); + RefreshEnvironment; +end; + +{ Remove Dir from the system PATH (handles trailing backslash variants). } +procedure RemoveDirFromPath(const Dir: string); +var + OldPath, D, P: string; +begin + OldPath := GetSystemPath; + D := RemoveBackslash(Dir); + P := OldPath; + StringChangeEx(P, ';' + D + '\', ';', False); + StringChangeEx(P, ';' + D, '', False); + StringChangeEx(P, D + ';\', '', False); + StringChangeEx(P, D + ';', '', False); + StringChangeEx(P, D, '', False); + if P <> OldPath then + begin + SetSystemPath(P); + RefreshEnvironment; + end; +end; + +{ Called by the installer after files are laid down. } +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + if WizardIsTaskSelected('addtopath') then + AddDirToPath(ExpandConstant('{app}')); +end; + +{ Called by the uninstaller after files are removed. } +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +begin + if CurUninstallStep = usPostUninstall then + RemoveDirFromPath(ExpandConstant('{app}')); +end; From 89827238a25c9885874a07187a013accdc3c8d9c Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 07:06:27 +0100 Subject: [PATCH 22/30] dawproject fix. --- .github/workflows/build-nuitka.yml | 19 +------------------ .github/workflows/build-pyinstaller.yml | 19 +------------------ pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 37 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 592bf9b..97dc71f 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -40,24 +40,7 @@ jobs: ${{ runner.os }}-nuitka- - name: Install Dependencies - shell: bash - run: | - # Install base + dev + cli deps (skip gui extra to avoid dawproject build failure) - uv sync --extra cli - # Workaround: dawproject-py declares non-existent build-backend (setuptools.backends._legacy). - # Clone, patch to setuptools.build_meta, and install into the venv. - git clone --depth 1 https://github.com/roex-audio/dawproject-py.git "$RUNNER_TEMP/dawproject-py" - cd "$RUNNER_TEMP/dawproject-py" - git checkout e848bd06b00408018ff97dfb54942f3fa303a6a6 - python -c " - import pathlib - p = pathlib.Path('pyproject.toml') - p.write_text(p.read_text().replace('setuptools.backends._legacy:_Backend', 'setuptools.build_meta')) - " - cd "$GITHUB_WORKSPACE" - uv pip install --no-deps "$RUNNER_TEMP/dawproject-py" - # Install remaining GUI dependencies - uv pip install "PySide6>=6.10.2" "sounddevice>=0.5.5" "py-ptsl>=600.2.0" + run: uv sync --extra cli --extra gui - name: Build with Nuitka run: uv run python build_nuitka.py all diff --git a/.github/workflows/build-pyinstaller.yml b/.github/workflows/build-pyinstaller.yml index 97cfc76..1f0e8bb 100644 --- a/.github/workflows/build-pyinstaller.yml +++ b/.github/workflows/build-pyinstaller.yml @@ -24,24 +24,7 @@ jobs: python-version-file: "pyproject.toml" - name: Install Dependencies - shell: bash - run: | - # Install base + dev + cli deps (skip gui extra to avoid dawproject build failure) - uv sync --extra cli - # Workaround: dawproject-py declares non-existent build-backend (setuptools.backends._legacy). - # Clone, patch to setuptools.build_meta, and install into the venv. - git clone --depth 1 https://github.com/roex-audio/dawproject-py.git "$RUNNER_TEMP/dawproject-py" - cd "$RUNNER_TEMP/dawproject-py" - git checkout e848bd06b00408018ff97dfb54942f3fa303a6a6 - python -c " - import pathlib - p = pathlib.Path('pyproject.toml') - p.write_text(p.read_text().replace('setuptools.backends._legacy:_Backend', 'setuptools.build_meta')) - " - cd "$GITHUB_WORKSPACE" - uv pip install --no-deps "$RUNNER_TEMP/dawproject-py" - # Install remaining GUI dependencies - uv pip install "PySide6>=6.10.2" "sounddevice>=0.5.5" "py-ptsl>=600.2.0" + run: uv sync --extra cli --extra gui - name: Build with PyInstaller # Using --onefile to produce single binaries where possible (standard distribution format) diff --git a/pyproject.toml b/pyproject.toml index 58b187d..0d0bc35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ gui = [ "PySide6>=6.10.2", "sounddevice>=0.5.5", "py-ptsl>=600.2.0", - "dawproject @ git+https://github.com/roex-audio/dawproject-py.git@e848bd06b00408018ff97dfb54942f3fa303a6a6", + "dawproject @ git+https://github.com/roex-audio/dawproject-py.git@70e65aeb7b260cfec3871ca89ca8d80022c44496", ] [dependency-groups] From 8b1d12d604e31008b9ba6d5ae11be53dadef3cfa Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 08:42:01 +0100 Subject: [PATCH 23/30] innosetup fix + nfpm packaging. --- .github/workflows/build-nuitka.yml | 78 ++++++++++++++---- .github/workflows/build-pyinstaller.yml | 77 +++++++++++++---- build_conf.py | 6 +- packaging/linux/install-sessionprep.sh | 43 ++++++++++ packaging/linux/nfpm.yaml | 34 ++++++++ packaging/linux/sessionprep.desktop | 9 ++ packaging/windows/sessionprep.iss | 8 +- sessionprepgui/mainwindow.py | 4 +- .../res/{icon.icns => sessionprep.icns} | Bin .../res/{icon.ico => sessionprep.ico} | Bin .../res/{icon.png => sessionprep.png} | Bin .../res/{icon.svg => sessionprep.svg} | 0 12 files changed, 221 insertions(+), 38 deletions(-) create mode 100644 packaging/linux/install-sessionprep.sh create mode 100644 packaging/linux/nfpm.yaml create mode 100644 packaging/linux/sessionprep.desktop rename sessionprepgui/res/{icon.icns => sessionprep.icns} (100%) rename sessionprepgui/res/{icon.ico => sessionprep.ico} (100%) rename sessionprepgui/res/{icon.png => sessionprep.png} (100%) rename sessionprepgui/res/{icon.svg => sessionprep.svg} (100%) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 97dc71f..0abb356 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -42,18 +42,52 @@ jobs: - name: Install Dependencies run: uv sync --extra cli --extra gui + - name: Disable Windows Defender for build directory + if: runner.os == 'Windows' + shell: pwsh + run: Add-MpPreference -ExclusionPath $PWD + - name: Build with Nuitka run: uv run python build_nuitka.py all + - name: Package Linux Distributions + if: runner.os == 'Linux' + shell: bash + run: | + echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' \ + | sudo tee /etc/apt/sources.list.d/goreleaser.list + sudo apt-get update -q && sudo apt-get install -y nfpm + + export VERSION=$(python -c \ + "exec(open('sessionpreplib/_version.py').read()); print(__version__)") + export DIST_DIR=dist_nuitka + + nfpm package --config packaging/linux/nfpm.yaml \ + --packager deb --target dist_nuitka/ + nfpm package --config packaging/linux/nfpm.yaml \ + --packager rpm --target dist_nuitka/ + + STAGING=$(mktemp -d) + cp dist_nuitka/sessionprep-linux-x64 "$STAGING/sessionprep" + cp dist_nuitka/sessionprep-gui-linux-x64 "$STAGING/sessionprep-gui" + cp sessionprepgui/res/sessionprep.png "$STAGING/sessionprep.png" + cp packaging/linux/sessionprep.desktop "$STAGING/sessionprep.desktop" + cp packaging/linux/install-sessionprep.sh "$STAGING/install-sessionprep.sh" + chmod +x "$STAGING/install-sessionprep.sh" + tar -czf "dist_nuitka/sessionprep-${VERSION}-linux-x64.tar.gz" \ + -C "$STAGING" \ + sessionprep sessionprep-gui sessionprep.png \ + sessionprep.desktop install-sessionprep.sh + - name: Build InnoSetup Installer if: runner.os == 'Windows' - shell: bash + shell: pwsh run: | - VER=$(python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)") - "C:/Program Files (x86)/Inno Setup 6/ISCC.exe" \ - /DAPP_VERSION="$VER" \ - /DDIST_DIR=dist_nuitka \ - packaging/windows/sessionprep.iss + $ver = (python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)").Trim() + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ` + "/DAPP_VERSION=$ver" ` + "/DDIST_DIR=dist_nuitka" ` + "packaging\windows\sessionprep.iss" - name: Package macOS .app bundles as DMG if: runner.os == 'macOS' @@ -70,18 +104,32 @@ jobs: "$app" done - - name: Upload Artifacts + - name: Upload Artifacts (Windows) + if: runner.os == 'Windows' uses: actions/upload-artifact@v4 with: name: sessionprep-${{ matrix.os }} path: | - dist_nuitka/*.dmg - dist_nuitka/sessionprep-cli-* - dist_nuitka/sessionprep-gui-* + dist_nuitka/sessionprep-win-x64.exe + dist_nuitka/sessionprep-gui-win-x64.exe dist_nuitka/SessionPrep-*-setup.exe - !dist_nuitka/*.build - !dist_nuitka/*.dist - !dist_nuitka/*.onefile-build - !dist_nuitka/*.app - !dist_nuitka/*/ + if-no-files-found: error + + - name: Upload Artifacts (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: sessionprep-${{ matrix.os }} + path: dist_nuitka/*.dmg + if-no-files-found: error + + - name: Upload Artifacts (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: sessionprep-${{ matrix.os }} + path: | + dist_nuitka/*.deb + dist_nuitka/*.rpm + dist_nuitka/*.tar.gz if-no-files-found: error \ No newline at end of file diff --git a/.github/workflows/build-pyinstaller.yml b/.github/workflows/build-pyinstaller.yml index 1f0e8bb..4fe81d1 100644 --- a/.github/workflows/build-pyinstaller.yml +++ b/.github/workflows/build-pyinstaller.yml @@ -26,19 +26,53 @@ jobs: - name: Install Dependencies run: uv sync --extra cli --extra gui + - name: Disable Windows Defender for build directory + if: runner.os == 'Windows' + shell: pwsh + run: Add-MpPreference -ExclusionPath $PWD + - name: Build with PyInstaller # Using --onefile to produce single binaries where possible (standard distribution format) run: uv run python build_pyinstaller.py --onefile all + - name: Package Linux Distributions + if: runner.os == 'Linux' + shell: bash + run: | + echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' \ + | sudo tee /etc/apt/sources.list.d/goreleaser.list + sudo apt-get update -q && sudo apt-get install -y nfpm + + export VERSION=$(python -c \ + "exec(open('sessionpreplib/_version.py').read()); print(__version__)") + export DIST_DIR=dist_pyinstaller + + nfpm package --config packaging/linux/nfpm.yaml \ + --packager deb --target dist_pyinstaller/ + nfpm package --config packaging/linux/nfpm.yaml \ + --packager rpm --target dist_pyinstaller/ + + STAGING=$(mktemp -d) + cp dist_pyinstaller/sessionprep-linux-x64 "$STAGING/sessionprep" + cp dist_pyinstaller/sessionprep-gui-linux-x64 "$STAGING/sessionprep-gui" + cp sessionprepgui/res/sessionprep.png "$STAGING/sessionprep.png" + cp packaging/linux/sessionprep.desktop "$STAGING/sessionprep.desktop" + cp packaging/linux/install-sessionprep.sh "$STAGING/install-sessionprep.sh" + chmod +x "$STAGING/install-sessionprep.sh" + tar -czf "dist_pyinstaller/sessionprep-${VERSION}-linux-x64.tar.gz" \ + -C "$STAGING" \ + sessionprep sessionprep-gui sessionprep.png \ + sessionprep.desktop install-sessionprep.sh + - name: Build InnoSetup Installer if: runner.os == 'Windows' - shell: bash + shell: pwsh run: | - VER=$(python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)") - "C:/Program Files (x86)/Inno Setup 6/ISCC.exe" \ - /DAPP_VERSION="$VER" \ - /DDIST_DIR=dist_pyinstaller \ - packaging/windows/sessionprep.iss + $ver = (python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)").Trim() + & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ` + "/DAPP_VERSION=$ver" ` + "/DDIST_DIR=dist_pyinstaller" ` + "packaging\windows\sessionprep.iss" - name: Package macOS .app bundles as DMG if: runner.os == 'macOS' @@ -55,17 +89,32 @@ jobs: "$app" done - - name: Upload Artifacts + - name: Upload Artifacts (Windows) + if: runner.os == 'Windows' uses: actions/upload-artifact@v4 with: name: sessionprep-${{ matrix.os }} path: | - dist_pyinstaller/*.dmg - dist_pyinstaller/sessionprep-cli-* - dist_pyinstaller/sessionprep-gui-* + dist_pyinstaller/sessionprep-win-x64.exe + dist_pyinstaller/sessionprep-gui-win-x64.exe dist_pyinstaller/SessionPrep-*-setup.exe - !dist_pyinstaller/*.build - !dist_pyinstaller/*.spec - !dist_pyinstaller/*.app - !dist_pyinstaller/*/ + if-no-files-found: error + + - name: Upload Artifacts (macOS) + if: runner.os == 'macOS' + uses: actions/upload-artifact@v4 + with: + name: sessionprep-${{ matrix.os }} + path: dist_pyinstaller/*.dmg + if-no-files-found: error + + - name: Upload Artifacts (Linux) + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: sessionprep-${{ matrix.os }} + path: | + dist_pyinstaller/*.deb + dist_pyinstaller/*.rpm + dist_pyinstaller/*.tar.gz if-no-files-found: error diff --git a/build_conf.py b/build_conf.py index 8f6f1bb..7530388 100644 --- a/build_conf.py +++ b/build_conf.py @@ -46,11 +46,11 @@ def _resolve_icon() -> str | None: res_dir = os.path.join(BASE_DIR, "sessionprepgui", "res") system = platform.system() if system == "Windows": - candidates = ["icon.ico", "icon.png"] + candidates = ["sessionprep.ico", "sessionprep.png"] elif system == "Darwin": - candidates = ["icon.icns", "icon.png"] + candidates = ["sessionprep.icns", "sessionprep.png"] else: - candidates = ["icon.png"] + candidates = ["sessionprep.png"] for name in candidates: path = os.path.join(res_dir, name) if os.path.isfile(path): diff --git a/packaging/linux/install-sessionprep.sh b/packaging/linux/install-sessionprep.sh new file mode 100644 index 0000000..5894e9c --- /dev/null +++ b/packaging/linux/install-sessionprep.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# install-sessionprep.sh — Install SessionPrep from the tar.gz bundle +# +# Usage: +# ./install-sessionprep.sh # installs to ~/.local (no sudo needed) +# sudo ./install-sessionprep.sh /usr/local # system-wide install +# +# What this script does: +# 1. Copies the CLI and GUI binaries to /bin/ +# 2. Copies the icon to /share/pixmaps/ +# 3. Writes a .desktop launcher to /share/applications/ +# (substitutes the actual binary path into the Exec= line) + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INSTALL_DIR="${1:-$HOME/.local}" +BIN_DIR="$INSTALL_DIR/bin" +SHARE_DIR="$INSTALL_DIR/share" + +echo "Installing SessionPrep to $INSTALL_DIR ..." + +mkdir -p "$BIN_DIR" "$SHARE_DIR/applications" "$SHARE_DIR/pixmaps" + +install -m 755 "$SCRIPT_DIR/sessionprep" "$BIN_DIR/sessionprep" +install -m 755 "$SCRIPT_DIR/sessionprep-gui" "$BIN_DIR/sessionprep-gui" +install -m 644 "$SCRIPT_DIR/sessionprep.png" "$SHARE_DIR/pixmaps/sessionprep.png" + +sed "s|Exec=/usr/local/bin/sessionprep-gui|Exec=$BIN_DIR/sessionprep-gui|g" \ + "$SCRIPT_DIR/sessionprep.desktop" \ + > "$SHARE_DIR/applications/sessionprep.desktop" +chmod 644 "$SHARE_DIR/applications/sessionprep.desktop" + +echo "" +echo "Done." +echo " CLI: $BIN_DIR/sessionprep" +echo " GUI: $BIN_DIR/sessionprep-gui" +echo "" +if [ "$INSTALL_DIR" = "$HOME/.local" ]; then + echo "Make sure $BIN_DIR is in your PATH." + echo "Add the following to your ~/.bashrc or ~/.profile if needed:" + echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" +fi diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml new file mode 100644 index 0000000..180287d --- /dev/null +++ b/packaging/linux/nfpm.yaml @@ -0,0 +1,34 @@ +# nfpm package configuration for SessionPrep Linux packages (.deb, .rpm) +# +# Build from repo root: +# VERSION=x.y.z DIST_DIR=dist_nuitka nfpm package \ +# --config packaging/linux/nfpm.yaml --packager deb --target dist_nuitka/ + +name: sessionprep +version: "${VERSION}" +arch: amd64 +maintainer: Benjamin Zeiss +homepage: https://github.com/bzeiss/sessionprep +description: Batch audio analyzer and normalizer for mix session preparation +license: GPL-3.0-or-later + +contents: + - src: "${DIST_DIR}/sessionprep-linux-x64" + dst: /usr/local/bin/sessionprep + file_info: + mode: 0755 + + - src: "${DIST_DIR}/sessionprep-gui-linux-x64" + dst: /usr/local/bin/sessionprep-gui + file_info: + mode: 0755 + + - src: sessionprepgui/res/sessionprep.png + dst: /usr/local/share/pixmaps/sessionprep.png + file_info: + mode: 0644 + + - src: packaging/linux/sessionprep.desktop + dst: /usr/local/share/applications/sessionprep.desktop + file_info: + mode: 0644 diff --git a/packaging/linux/sessionprep.desktop b/packaging/linux/sessionprep.desktop new file mode 100644 index 0000000..e329d7d --- /dev/null +++ b/packaging/linux/sessionprep.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=SessionPrep +Comment=Batch audio analyzer and normalizer for mix session preparation +Exec=/usr/local/bin/sessionprep-gui +Icon=sessionprep +Terminal=false +Categories=Audio;AudioVideo; diff --git a/packaging/windows/sessionprep.iss b/packaging/windows/sessionprep.iss index 0d496cd..c659a7a 100644 --- a/packaging/windows/sessionprep.iss +++ b/packaging/windows/sessionprep.iss @@ -18,7 +18,7 @@ #define AppPublisher "SessionPrep" #define AppExe "sessionprep-gui-win-x64.exe" #define AppCli "sessionprep-cli-win-x64.exe" -#define AppIconSrc "..\..\sessionprepgui\res\icon.ico" +#define AppIconSrc "..\..\sessionprepgui\res\sessionprep.ico" ; --------------------------------------------------------------------------- ; Setup @@ -38,7 +38,7 @@ OutputDir=..\..\{#DIST_DIR} OutputBaseFilename={#AppName}-{#APP_VERSION}-setup SetupIconFile={#AppIconSrc} -UninstallDisplayIcon={app}\icon.ico +UninstallDisplayIcon={app}\sessionprep.ico Compression=lzma SolidCompression=yes @@ -87,7 +87,7 @@ Source: "..\..\{#DIST_DIR}\{#AppCli}"; \ ; Icon (used by the uninstaller entry and shortcuts) Source: "{#AppIconSrc}"; \ DestDir: "{app}"; \ - DestName: "icon.ico"; \ + DestName: "sessionprep.ico"; \ Flags: ignoreversion ; --------------------------------------------------------------------------- @@ -97,7 +97,7 @@ Source: "{#AppIconSrc}"; \ [Icons] Name: "{group}\{#AppName}"; \ Filename: "{app}\{#AppExe}"; \ - IconFilename: "{app}\icon.ico"; \ + IconFilename: "{app}\sessionprep.ico"; \ Tasks: startmenu ; --------------------------------------------------------------------------- diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 999c727..fedbe73 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -908,8 +908,8 @@ def _app_icon() -> QIcon: """Load the application icon from the res/ directory.""" res_dir = os.path.join(os.path.dirname(__file__), "res") icon = QIcon() - svg = os.path.join(res_dir, "icon.svg") - png = os.path.join(res_dir, "icon.png") + svg = os.path.join(res_dir, "sessionprep.svg") + png = os.path.join(res_dir, "sessionprep.png") if os.path.isfile(svg): icon = QIcon(svg) if os.path.isfile(png): diff --git a/sessionprepgui/res/icon.icns b/sessionprepgui/res/sessionprep.icns similarity index 100% rename from sessionprepgui/res/icon.icns rename to sessionprepgui/res/sessionprep.icns diff --git a/sessionprepgui/res/icon.ico b/sessionprepgui/res/sessionprep.ico similarity index 100% rename from sessionprepgui/res/icon.ico rename to sessionprepgui/res/sessionprep.ico diff --git a/sessionprepgui/res/icon.png b/sessionprepgui/res/sessionprep.png similarity index 100% rename from sessionprepgui/res/icon.png rename to sessionprepgui/res/sessionprep.png diff --git a/sessionprepgui/res/icon.svg b/sessionprepgui/res/sessionprep.svg similarity index 100% rename from sessionprepgui/res/icon.svg rename to sessionprepgui/res/sessionprep.svg From fafea53d82f06ac61787948f568a7608a5de0d16 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 09:03:43 +0100 Subject: [PATCH 24/30] linux packaging fix. --- .github/workflows/build-nuitka.yml | 9 +++++---- .github/workflows/build-pyinstaller.yml | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 0abb356..a1a71f5 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -62,10 +62,11 @@ jobs: "exec(open('sessionpreplib/_version.py').read()); print(__version__)") export DIST_DIR=dist_nuitka - nfpm package --config packaging/linux/nfpm.yaml \ - --packager deb --target dist_nuitka/ - nfpm package --config packaging/linux/nfpm.yaml \ - --packager rpm --target dist_nuitka/ + TMPCONFIG=$(mktemp --suffix=.yaml) + envsubst < packaging/linux/nfpm.yaml > "$TMPCONFIG" + nfpm package --config "$TMPCONFIG" --packager deb --target dist_nuitka/ + nfpm package --config "$TMPCONFIG" --packager rpm --target dist_nuitka/ + rm "$TMPCONFIG" STAGING=$(mktemp -d) cp dist_nuitka/sessionprep-linux-x64 "$STAGING/sessionprep" diff --git a/.github/workflows/build-pyinstaller.yml b/.github/workflows/build-pyinstaller.yml index 4fe81d1..aa64a23 100644 --- a/.github/workflows/build-pyinstaller.yml +++ b/.github/workflows/build-pyinstaller.yml @@ -47,10 +47,11 @@ jobs: "exec(open('sessionpreplib/_version.py').read()); print(__version__)") export DIST_DIR=dist_pyinstaller - nfpm package --config packaging/linux/nfpm.yaml \ - --packager deb --target dist_pyinstaller/ - nfpm package --config packaging/linux/nfpm.yaml \ - --packager rpm --target dist_pyinstaller/ + TMPCONFIG=$(mktemp --suffix=.yaml) + envsubst < packaging/linux/nfpm.yaml > "$TMPCONFIG" + nfpm package --config "$TMPCONFIG" --packager deb --target dist_pyinstaller/ + nfpm package --config "$TMPCONFIG" --packager rpm --target dist_pyinstaller/ + rm "$TMPCONFIG" STAGING=$(mktemp -d) cp dist_pyinstaller/sessionprep-linux-x64 "$STAGING/sessionprep" From e5daa5e691df93650f58a00074f7828757eb33f0 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 09:06:43 +0100 Subject: [PATCH 25/30] windows packaging: embedding ico --- build_nuitka.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/build_nuitka.py b/build_nuitka.py index 1699027..9d32abb 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -58,6 +58,9 @@ def run_nuitka(target_key, clean=False): if not target["console"]: if sys.platform == "win32": cmd.append("--windows-disable-console") + icon_path = target.get("icon") + if icon_path and os.path.isfile(icon_path): + cmd.append(f"--windows-icon-from-ico={icon_path}") elif sys.platform == "darwin": # GUI on macOS: produce a proper .app bundle instead of a bare onefile binary cmd.remove("--onefile") From e6495fa8cb448e8482ffbc2007226ce2395d59ed Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 10:26:24 +0100 Subject: [PATCH 26/30] improved linux installer script. --- packaging/linux/install-sessionprep.sh | 195 ++++++++++++++++++++----- 1 file changed, 162 insertions(+), 33 deletions(-) diff --git a/packaging/linux/install-sessionprep.sh b/packaging/linux/install-sessionprep.sh index 5894e9c..f1dd6a0 100644 --- a/packaging/linux/install-sessionprep.sh +++ b/packaging/linux/install-sessionprep.sh @@ -2,42 +2,171 @@ # install-sessionprep.sh — Install SessionPrep from the tar.gz bundle # # Usage: -# ./install-sessionprep.sh # installs to ~/.local (no sudo needed) -# sudo ./install-sessionprep.sh /usr/local # system-wide install +# ./install-sessionprep.sh [--help] +# ./install-sessionprep.sh [PREFIX] # default: ~/.local +# sudo ./install-sessionprep.sh /usr/local # system-wide +# ./install-sessionprep.sh --uninstall [PREFIX] +# sudo ./install-sessionprep.sh --uninstall /usr/local # -# What this script does: -# 1. Copies the CLI and GUI binaries to /bin/ -# 2. Copies the icon to /share/pixmaps/ -# 3. Writes a .desktop launcher to /share/applications/ -# (substitutes the actual binary path into the Exec= line) +# PREFIX and --uninstall may appear in any order. -set -e +set -euo pipefail +# --------------------------------------------------------------------------- +# Constants — filenames and the placeholder in the bundled .desktop template +# --------------------------------------------------------------------------- + +readonly CLI_BIN="sessionprep" +readonly GUI_BIN="sessionprep-gui" +readonly ICON_FILE="sessionprep.png" +readonly DESKTOP_FILE="sessionprep.desktop" +readonly DESKTOP_EXEC_PLACEHOLDER="Exec=/usr/local/bin/sessionprep-gui" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +usage() { + grep '^#' "$0" | sed 's/^# \{0,1\}//' + exit 0 +} + +die() { + echo "Error: $*" >&2 + exit 1 +} + +# Check that all source files are present next to this script. +validate_sources() { + local missing=0 + for f in "$CLI_BIN" "$GUI_BIN" "$ICON_FILE" "$DESKTOP_FILE"; do + if [ ! -f "$SCRIPT_DIR/$f" ]; then + echo " Missing source file: $SCRIPT_DIR/$f" >&2 + missing=1 + fi + done + [ "$missing" -eq 0 ] || die "Source files missing. Run this script from inside the extracted archive." +} + +# Verify we can write to the target prefix (fail before touching anything). +check_write_access() { + if [ -d "$INSTALL_DIR" ] && [ ! -w "$INSTALL_DIR" ]; then + die "No write permission for '$INSTALL_DIR'. Try: sudo $0 $INSTALL_DIR" + fi +} + +# Warn if BIN_DIR is not on PATH (applies to any prefix, not just ~/.local). +check_path() { + case ":${PATH}:" in + *":$BIN_DIR:"*) ;; + *) echo "Note: $BIN_DIR is not in your PATH." + echo " Add to your shell profile: export PATH=\"$BIN_DIR:\$PATH\"" + ;; + esac +} + +# --------------------------------------------------------------------------- +# Argument parsing +# --------------------------------------------------------------------------- + +UNINSTALL=0 +INSTALL_DIR="" + +for arg in "$@"; do + case "$arg" in + --help|-h) usage ;; + --uninstall) UNINSTALL=1 ;; + -*) die "Unknown option: $arg" ;; + *) [ -z "$INSTALL_DIR" ] || die "Unexpected argument: $arg" + INSTALL_DIR="$arg" ;; + esac +done + +INSTALL_DIR="${INSTALL_DIR:-$HOME/.local}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INSTALL_DIR="${1:-$HOME/.local}" + BIN_DIR="$INSTALL_DIR/bin" -SHARE_DIR="$INSTALL_DIR/share" - -echo "Installing SessionPrep to $INSTALL_DIR ..." - -mkdir -p "$BIN_DIR" "$SHARE_DIR/applications" "$SHARE_DIR/pixmaps" - -install -m 755 "$SCRIPT_DIR/sessionprep" "$BIN_DIR/sessionprep" -install -m 755 "$SCRIPT_DIR/sessionprep-gui" "$BIN_DIR/sessionprep-gui" -install -m 644 "$SCRIPT_DIR/sessionprep.png" "$SHARE_DIR/pixmaps/sessionprep.png" - -sed "s|Exec=/usr/local/bin/sessionprep-gui|Exec=$BIN_DIR/sessionprep-gui|g" \ - "$SCRIPT_DIR/sessionprep.desktop" \ - > "$SHARE_DIR/applications/sessionprep.desktop" -chmod 644 "$SHARE_DIR/applications/sessionprep.desktop" - -echo "" -echo "Done." -echo " CLI: $BIN_DIR/sessionprep" -echo " GUI: $BIN_DIR/sessionprep-gui" -echo "" -if [ "$INSTALL_DIR" = "$HOME/.local" ]; then - echo "Make sure $BIN_DIR is in your PATH." - echo "Add the following to your ~/.bashrc or ~/.profile if needed:" - echo " export PATH=\"\$HOME/.local/bin:\$PATH\"" +PIXMAPS_DIR="$INSTALL_DIR/share/pixmaps" +APPS_DIR="$INSTALL_DIR/share/applications" + +# --------------------------------------------------------------------------- +# Install +# --------------------------------------------------------------------------- + +do_install() { + validate_sources + check_write_access + + echo "Installing SessionPrep to $INSTALL_DIR ..." + + mkdir -p "$BIN_DIR" "$APPS_DIR" "$PIXMAPS_DIR" + + install -m 755 "$SCRIPT_DIR/$CLI_BIN" "$BIN_DIR/$CLI_BIN" + install -m 755 "$SCRIPT_DIR/$GUI_BIN" "$BIN_DIR/$GUI_BIN" + install -m 644 "$SCRIPT_DIR/$ICON_FILE" "$PIXMAPS_DIR/$ICON_FILE" + + # Write .desktop atomically: generate into a temp file, then move into place. + local tmp_desktop + tmp_desktop="$(mktemp "$APPS_DIR/.sessionprep.desktop.XXXXXX")" + sed "s|$DESKTOP_EXEC_PLACEHOLDER|Exec=$BIN_DIR/$GUI_BIN|g" \ + "$SCRIPT_DIR/$DESKTOP_FILE" > "$tmp_desktop" + chmod 644 "$tmp_desktop" + mv "$tmp_desktop" "$APPS_DIR/$DESKTOP_FILE" + + # Notify the desktop environment if the tool is available. + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$APPS_DIR" 2>/dev/null || true + fi + + echo "" + echo "Done." + echo " CLI: $BIN_DIR/$CLI_BIN" + echo " GUI: $BIN_DIR/$GUI_BIN" + echo "" + check_path +} + +# --------------------------------------------------------------------------- +# Uninstall +# --------------------------------------------------------------------------- + +do_uninstall() { + check_write_access + + echo "Uninstalling SessionPrep from $INSTALL_DIR ..." + + local found + found=0 + for f in \ + "$BIN_DIR/$CLI_BIN" \ + "$BIN_DIR/$GUI_BIN" \ + "$PIXMAPS_DIR/$ICON_FILE" \ + "$APPS_DIR/$DESKTOP_FILE" + do + if [ -f "$f" ]; then + rm -f "$f" + echo " Removed: $f" + found=1 + fi + done + + if [ "$found" -eq 0 ]; then + echo " Nothing found to remove in $INSTALL_DIR." + else + if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database "$APPS_DIR" 2>/dev/null || true + fi + echo "" + echo "Done." + fi +} + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +if [ "$UNINSTALL" -eq 1 ]; then + do_uninstall +else + do_install fi From 203146d9a220ee64c8b91d141bbbcf05479b3669 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 10:34:09 +0100 Subject: [PATCH 27/30] innosetup rework. --- packaging/windows/sessionprep.iss | 88 ++++++++++++++++++++----------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/packaging/windows/sessionprep.iss b/packaging/windows/sessionprep.iss index c659a7a..ccc42c1 100644 --- a/packaging/windows/sessionprep.iss +++ b/packaging/windows/sessionprep.iss @@ -14,11 +14,12 @@ #define DIST_DIR "dist_nuitka" #endif -#define AppName "SessionPrep" -#define AppPublisher "SessionPrep" -#define AppExe "sessionprep-gui-win-x64.exe" -#define AppCli "sessionprep-cli-win-x64.exe" -#define AppIconSrc "..\..\sessionprepgui\res\sessionprep.ico" +#define AppName "SessionPrep" +#define AppPublisher "Benjamin Zeiss" +#define AppPublisherURL "https://github.com/bzeiss/sessionprep" +#define AppExe "sessionprep-gui-win-x64.exe" +#define AppCli "sessionprep-win-x64.exe" +#define AppIconSrc "..\..\sessionprepgui\res\sessionprep.ico" ; --------------------------------------------------------------------------- ; Setup @@ -29,6 +30,7 @@ AppId={{A9F4C2E1-7B3D-4A6E-8C1F-5D2E0B9A3C78} AppName={#AppName} AppVersion={#APP_VERSION} AppPublisher={#AppPublisher} +AppPublisherURL={#AppPublisherURL} AppVerName={#AppName} {#APP_VERSION} DefaultDirName={autopf}\{#AppName} @@ -44,7 +46,9 @@ Compression=lzma SolidCompression=yes WizardStyle=modern -PrivilegesRequired=admin +; lowest = per-user by default; the dialog lets the user switch to all-users (admin). +PrivilegesRequired=lowest +PrivilegesRequiredOverridesAllowed=dialog ArchitecturesAllowed=x64compatible ArchitecturesInstallIn64BitMode=x64compatible @@ -62,12 +66,10 @@ Name: "english"; MessagesFile: "compiler:Default.isl" [Tasks] Name: "startmenu"; \ Description: "Create a Start Menu shortcut for SessionPrep GUI"; \ - GroupDescription: "Shortcuts:"; \ - Flags: checked + GroupDescription: "Shortcuts:" Name: "addtopath"; \ Description: "Add installation directory to PATH (enables 'sessionprep' CLI in any terminal)"; \ - GroupDescription: "System:"; \ - Flags: checked + GroupDescription: "System:" ; --------------------------------------------------------------------------- ; Files @@ -107,22 +109,45 @@ Name: "{group}\{#AppName}"; \ [Code] const - SysEnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + { Registry sub-keys for the two PATH locations. } + AdminEnvKey = 'SYSTEM\CurrentControlSet\Control\Session Manager\Environment'; + UserEnvKey = 'Environment'; -{ Read the current system PATH from the registry. } -function GetSystemPath: string; +{ Resolve the correct registry root and sub-key for the active install mode. } +procedure GetEnvKey(out RootKey: Integer; out SubKey: string); +begin + if IsAdminInstallMode then + begin + RootKey := HKEY_LOCAL_MACHINE; + SubKey := AdminEnvKey; + end + else + begin + RootKey := HKEY_CURRENT_USER; + SubKey := UserEnvKey; + end; +end; + +{ Read PATH for the current install mode. Returns empty string on failure. } +function GetPath: string; var - Path: string; + RootKey: Integer; + SubKey, Val: string; begin - if not RegQueryStringValue(HKEY_LOCAL_MACHINE, SysEnvKey, 'Path', Path) then - Path := ''; - Result := Path; + GetEnvKey(RootKey, SubKey); + if not RegQueryStringValue(RootKey, SubKey, 'Path', Val) then + Val := ''; + Result := Val; end; -{ Write back to the registry using REG_EXPAND_SZ so %SystemRoot% etc. survive. } -procedure SetSystemPath(const Path: string); +{ Write PATH using REG_EXPAND_SZ so %SystemRoot% etc. survive. } +procedure SetPath(const Path: string); +var + RootKey: Integer; + SubKey: string; begin - RegWriteExpandStringValue(HKEY_LOCAL_MACHINE, SysEnvKey, 'Path', Path); + GetEnvKey(RootKey, SubKey); + RegWriteExpandStringValue(RootKey, SubKey, 'Path', Path); end; { Case-insensitive check: is Dir already present in PathList? } @@ -132,41 +157,44 @@ var begin Needle := Lowercase(RemoveBackslash(Dir)); Haystack := ';' + Lowercase(PathList) + ';'; - Result := (Pos(';' + Needle + ';', Haystack) > 0) or - (Pos(';' + Needle + '\;', Haystack) > 0); + Result := (Pos(';' + Needle + ';', Haystack) > 0) or + (Pos(';' + Needle + '\;', Haystack) > 0); end; -{ Add Dir to the system PATH only if it is not already present. } +{ Append Dir to PATH only if it is not already present. } procedure AddDirToPath(const Dir: string); var OldPath: string; begin - OldPath := GetSystemPath; + OldPath := GetPath; if DirInPath(Dir, OldPath) then - Exit; { already there — nothing to do } + Exit; { idempotent — already present } if OldPath = '' then - SetSystemPath(Dir) + SetPath(Dir) else - SetSystemPath(OldPath + ';' + Dir); + SetPath(OldPath + ';' + Dir); RefreshEnvironment; end; -{ Remove Dir from the system PATH (handles trailing backslash variants). } +{ Remove Dir from PATH, handling all trailing-backslash variants. } procedure RemoveDirFromPath(const Dir: string); var OldPath, D, P: string; begin - OldPath := GetSystemPath; + OldPath := GetPath; D := RemoveBackslash(Dir); P := OldPath; + { middle of PATH: ;DIR\ -> ; and ;DIR -> (empty — merges with next ;) } StringChangeEx(P, ';' + D + '\', ';', False); StringChangeEx(P, ';' + D, '', False); + { start of PATH: DIR\; -> and DIR; -> (empty) } StringChangeEx(P, D + ';\', '', False); StringChangeEx(P, D + ';', '', False); + { PATH contained only DIR } StringChangeEx(P, D, '', False); if P <> OldPath then begin - SetSystemPath(P); + SetPath(P); RefreshEnvironment; end; end; From 5d3207d5ffe63492c97ca8409fb56bb918adb03d Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 11:48:11 +0100 Subject: [PATCH 28/30] switch to mingw on windows by default. --- .github/workflows/build-nuitka.yml | 5 +++++ build_nuitka.py | 7 +++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index a1a71f5..1b71011 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -47,6 +47,11 @@ jobs: shell: pwsh run: Add-MpPreference -ExclusionPath $PWD + - name: Add MSYS2 MinGW64 to PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Build with Nuitka run: uv run python build_nuitka.py all diff --git a/build_nuitka.py b/build_nuitka.py index 9d32abb..9c4ca3b 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -51,10 +51,9 @@ def run_nuitka(target_key, clean=False): ] # Platform specific flags - # if sys.platform == "linux": - # # Let Nuitka decide for Python 3.13 - # pass - + if sys.platform == "win32": + cmd.append("--mingw64") # Use MinGW64+ccache; faster than MSVC+clcache + if not target["console"]: if sys.platform == "win32": cmd.append("--windows-disable-console") From a6381cdc96522e162e5cb1323878e193148a069c Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 11:51:56 +0100 Subject: [PATCH 29/30] another innosetup fix. --- packaging/windows/sessionprep.iss | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packaging/windows/sessionprep.iss b/packaging/windows/sessionprep.iss index ccc42c1..3a93f41 100644 --- a/packaging/windows/sessionprep.iss +++ b/packaging/windows/sessionprep.iss @@ -46,6 +46,9 @@ Compression=lzma SolidCompression=yes WizardStyle=modern +; Tell InnoSetup to broadcast WM_SETTINGCHANGE so Explorer picks up PATH changes +ChangesEnvironment=yes + ; lowest = per-user by default; the dialog lets the user switch to all-users (admin). PrivilegesRequired=lowest PrivilegesRequiredOverridesAllowed=dialog @@ -173,7 +176,6 @@ begin SetPath(Dir) else SetPath(OldPath + ';' + Dir); - RefreshEnvironment; end; { Remove Dir from PATH, handling all trailing-backslash variants. } @@ -195,7 +197,6 @@ begin if P <> OldPath then begin SetPath(P); - RefreshEnvironment; end; end; From 578ad3eae66cbbdcd19f1bdbaa5418efb4b02899 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Sat, 21 Feb 2026 12:07:51 +0100 Subject: [PATCH 30/30] revert to msvc --- .github/workflows/build-nuitka.yml | 5 ----- build_nuitka.py | 3 --- 2 files changed, 8 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 1b71011..a1a71f5 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -47,11 +47,6 @@ jobs: shell: pwsh run: Add-MpPreference -ExclusionPath $PWD - - name: Add MSYS2 MinGW64 to PATH (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - name: Build with Nuitka run: uv run python build_nuitka.py all diff --git a/build_nuitka.py b/build_nuitka.py index 9c4ca3b..a1770d5 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -51,9 +51,6 @@ def run_nuitka(target_key, clean=False): ] # Platform specific flags - if sys.platform == "win32": - cmd.append("--mingw64") # Use MinGW64+ccache; faster than MSVC+clcache - if not target["console"]: if sys.platform == "win32": cmd.append("--windows-disable-console")