From a1e280fdd8c65a1b3bf4e012b4b0e5e144cdf458 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 09:17:16 +0100 Subject: [PATCH 01/56] build caching improvements. --- .github/workflows/build-nuitka.yml | 18 +++++++++++------- build_nuitka.py | 20 ++++++++++++-------- packaging/windows/sessionprep.iss | 12 ++++-------- sessionpreplib/_version.py | 2 +- 4 files changed, 28 insertions(+), 24 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index d6464fd..c7fa7fa 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -27,16 +27,23 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y gcc patchelf ccache - - name: Cache Nuitka Build + - name: Install CCache (macOS) + if: runner.os == 'macOS' + run: brew install ccache + + - name: Cache Nuitka & CCache uses: actions/cache@v4 with: path: | - dist_nuitka/*.build ~/.cache/Nuitka ~/AppData/Local/Nuitka/Nuitka/Cache ~/Library/Caches/Nuitka - key: ${{ runner.os }}-nuitka-${{ hashFiles('**/pyproject.toml', '**/uv.lock') }} + ~/.cache/ccache + ~/Library/Caches/ccache + ~/AppData/Local/ccache + key: ${{ runner.os }}-nuitka-${{ hashFiles('**/uv.lock') }}-${{ github.run_id }} restore-keys: | + ${{ runner.os }}-nuitka-${{ hashFiles('**/uv.lock') }}- ${{ runner.os }}-nuitka- - name: Install Dependencies @@ -119,10 +126,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: sessionprep-${{ matrix.os }} - path: | - dist_nuitka/sessionprep-win-x64.exe - dist_nuitka/sessionprep-gui-win-x64.exe - dist_nuitka/SessionPrep-*-setup.exe + path: dist_nuitka/SessionPrep-*-setup.exe if-no-files-found: error - name: Upload Artifacts (macOS) diff --git a/build_nuitka.py b/build_nuitka.py index 5cc1e1c..ad9b239 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -15,7 +15,7 @@ def _check_dependencies(target_key): """Ensure required packages for the target are installed.""" from importlib.util import find_spec - + # Check explicitly for PySide6 if it's the GUI target if target_key == "gui": if find_spec("PySide6") is None: @@ -27,7 +27,7 @@ def run_nuitka(target_key, clean=False): target = TARGETS[target_key] script_path = os.path.join(BASE_DIR, target["script"]) dist_dir = os.path.join(BASE_DIR, DIST_NUITKA) - + # Clean previous output or build artifacts if requested if clean: if os.path.exists(dist_dir): @@ -37,19 +37,23 @@ def run_nuitka(target_key, clean=False): print(f"\n[BUILD] Building target: {target_key.upper()}") print(f" Script: {script_path}") print(f" Output: {target['name']}") - + # Base Nuitka command cmd = [ sys.executable, "-m", "nuitka", "--standalone", - "--onefile", f"--output-filename={target['name']}", f"--output-dir={dist_dir}", "--assume-yes-for-downloads", # Optimizations "--lto=no", ] - + + # We use onefile on Linux and macOS (for CLI). + # On Windows, we use directory mode (standalone) for faster startup. + if sys.platform != "win32": + cmd.append("--onefile") + # Platform specific flags if not target["console"]: if sys.platform == "win32": @@ -95,7 +99,7 @@ def run_nuitka(target_key, clean=False): # Run print(f" Command: {' '.join(cmd)}") subprocess.check_call(cmd) - + # Nuitka on Linux adds .bin suffix to avoid name collisions with source files. # We rename it back to the target name to match PyInstaller behavior. if sys.platform == "linux": @@ -103,7 +107,7 @@ def run_nuitka(target_key, clean=False): if os.path.exists(bin_path) and not os.path.exists(output_exe): print(f" Renaming {bin_path} -> {output_exe}") os.rename(bin_path, output_exe) - + # On macOS GUI, output is a .app bundle (directory), not a single file. # Nuitka names the bundle from the script name, not --output-filename. # Rename it to MACOS_APP_NAME for a clean user-facing name. @@ -144,4 +148,4 @@ def main(): sys.exit(1) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/packaging/windows/sessionprep.iss b/packaging/windows/sessionprep.iss index 3a93f41..c6f0b88 100644 --- a/packaging/windows/sessionprep.iss +++ b/packaging/windows/sessionprep.iss @@ -79,15 +79,11 @@ Name: "addtopath"; \ ; --------------------------------------------------------------------------- [Files] -; GUI executable -Source: "..\..\{#DIST_DIR}\{#AppExe}"; \ - DestDir: "{app}"; \ - Flags: ignoreversion +; GUI standalone directory +Source: "..\..\{#DIST_DIR}\sessionprep-gui.dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs -; CLI executable -Source: "..\..\{#DIST_DIR}\{#AppCli}"; \ - DestDir: "{app}"; \ - Flags: ignoreversion +; CLI standalone directory +Source: "..\..\{#DIST_DIR}\sessionprep.dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs ; Icon (used by the uninstaller entry and shortcuts) Source: "{#AppIconSrc}"; \ diff --git a/sessionpreplib/_version.py b/sessionpreplib/_version.py index bc9bdae..8ad80b3 100644 --- a/sessionpreplib/_version.py +++ b/sessionpreplib/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the SessionPrep version number.""" -__version__ = "0.3.0" +__version__ = "0.3.2" From 3f78d0a99467af078d57982a2077f96691514ffd Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 13:33:30 +0100 Subject: [PATCH 02/56] gitignore updated --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1b73a0e..70d6562 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ sessionpreplib/daw_processors/ptsl/PTSL_2025.10.md sessionpreplib/daw_processors/ptsl/PTSL_2025.10.proto sessionpreplib/daw_processors/ptsl/DAWProject-Reference.html +_private/ +_private/* + # Temporary build directories _dawproject_build/ _dawproject_build/* From 0836249176bd7344743001ffcb0109512ea77ddd Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 14:14:00 +0100 Subject: [PATCH 03/56] updated Pro Tools template preferences --- sessionprepgui/analysis/mixin.py | 6 +- sessionprepgui/prefs/config_pages.py | 119 ++++++++++++++++++++-- sessionprepgui/prefs/dialog.py | 8 +- sessionpreplib/config.py | 5 + sessionpreplib/daw_processors/__init__.py | 14 +-- sessionpreplib/daw_processors/protools.py | 53 ++++++++++ 6 files changed, 180 insertions(+), 25 deletions(-) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 3374a06..8dd6c4a 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -112,7 +112,7 @@ 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_daw_custom_widgets = build_config_pages( self._session_tree, self._active_preset(), self._session_widgets, @@ -152,13 +152,13 @@ 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) + self._session_daw_custom_widgets) 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, + self._session_daw_custom_widgets, fallback_daw_sections=self._active_preset().get( "daw_processors", {}), ) diff --git a/sessionprepgui/prefs/config_pages.py b/sessionprepgui/prefs/config_pages.py index 8b7be5c..16007ec 100644 --- a/sessionprepgui/prefs/config_pages.py +++ b/sessionprepgui/prefs/config_pages.py @@ -477,6 +477,81 @@ def _on_remove(self): self.templates_changed.emit() +# --------------------------------------------------------------------------- +# ProToolsTemplatesWidget +# --------------------------------------------------------------------------- + +class ProToolsTemplatesWidget(QWidget): + """Editable table of Pro Tools 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("Pro Tools Templates")) + + self._table = QTableWidget() + self._table.setColumnCount(1) + self._table.setHorizontalHeaderLabels(["Name"]) + gh = self._table.horizontalHeader() + gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + gh.setSectionResizeMode(0, QHeaderView.Stretch) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + self._table.setSelectionMode(QTableWidget.SingleSelection) + self._table.cellChanged.connect(lambda r, c: self.templates_changed.emit()) + 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._table.setItem(row, 0, QTableWidgetItem(tpl.get("name", ""))) + 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 "" + if name: + templates.append({"name": name}) + return templates + + def _on_add(self): + row = self._table.rowCount() + self._table.setRowCount(row + 1) + self._table.setItem(row, 0, QTableWidgetItem("")) + self._table.editItem(self._table.item(row, 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 # --------------------------------------------------------------------------- @@ -489,17 +564,17 @@ def build_config_pages( *, on_processor_enabled: Callable | None = None, on_daw_config_changed: Callable | None = None, -) -> DawProjectTemplatesWidget | None: +) -> dict[str, QWidget]: """Build the common config tree pages (Analysis, Detectors, Processors, DAW Processors). - Returns the DawProjectTemplatesWidget if created, otherwise None. + Returns a dict mapping processor IDs to their custom widgets (e.g. dawproject, protools). """ 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 + daw_custom_widgets: dict[str, QWidget] = {} item = QTreeWidgetItem(tree, ["Analysis"]) item.setFont(0, QFont("", -1, QFont.Bold)) @@ -571,28 +646,40 @@ def build_config_pages( if key == enabled_key and isinstance(widget, QCheckBox): widget.toggled.connect(on_daw_config_changed) break + if dp.id == "dawproject": tpl_widget = DawProjectTemplatesWidget() tpl_widget.set_templates(dp_sections.get(dp.id, {}).get("dawproject_templates", [])) - dawproject_tpl_widget = tpl_widget + daw_custom_widgets["dawproject"] = tpl_widget if on_daw_config_changed is not None: tpl_widget.templates_changed.connect(on_daw_config_changed) pg.layout().insertWidget(pg.layout().count() - 1, tpl_widget) + elif dp.id == "protools": + pt_widget = ProToolsTemplatesWidget() + pt_widget.set_templates(dp_sections.get(dp.id, {}).get("protools_templates", [])) + daw_custom_widgets["protools"] = pt_widget + if on_daw_config_changed is not None: + pt_widget.templates_changed.connect(on_daw_config_changed) + pg.layout().insertWidget(2, pt_widget) + register_page(child, pg) - return dawproject_tpl_widget + return daw_custom_widgets def load_config_widgets( widgets_dict: dict, preset: dict[str, Any], - dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, + daw_custom_widgets: dict[str, QWidget] | 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 + if daw_custom_widgets is None: + daw_custom_widgets = {} + for key, widget in widgets_dict.get("analysis", []): if key in preset.get("analysis", {}): _set_widget_value(widget, preset["analysis"][key]) @@ -630,13 +717,16 @@ def load_config_widgets( 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", [])) + + if dp.id == "dawproject" and "dawproject" in daw_custom_widgets: + daw_custom_widgets["dawproject"].set_templates(vals.get("dawproject_templates", [])) + elif dp.id == "protools" and "protools" in daw_custom_widgets: + daw_custom_widgets["protools"].set_templates(vals.get("protools_templates", [])) def read_config_widgets( widgets_dict: dict, - dawproject_tpl_widget: DawProjectTemplatesWidget | None = None, + daw_custom_widgets: dict[str, QWidget] | None = None, fallback_daw_sections: dict[str, dict] | None = None, ) -> dict[str, Any]: """Read current widget values into a structured config dict.""" @@ -644,6 +734,9 @@ def read_config_widgets( from sessionpreplib.processors import default_processors from sessionpreplib.daw_processors import default_daw_processors + if daw_custom_widgets is None: + daw_custom_widgets = {} + cfg: dict[str, Any] = {} analysis: dict[str, Any] = {} @@ -686,8 +779,12 @@ def read_config_widgets( 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 dp.id == "dawproject" and "dawproject" in daw_custom_widgets: + section["dawproject_templates"] = daw_custom_widgets["dawproject"].get_templates() + elif dp.id == "protools" and "protools" in daw_custom_widgets: + section["protools_templates"] = daw_custom_widgets["protools"].get_templates() + if fallback_daw_sections: for gk, gv in fallback_daw_sections.get(dp.id, {}).items(): if gk not in section: diff --git a/sessionprepgui/prefs/dialog.py b/sessionprepgui/prefs/dialog.py index 53b7ca9..5a3906d 100644 --- a/sessionprepgui/prefs/dialog.py +++ b/sessionprepgui/prefs/dialog.py @@ -52,7 +52,7 @@ def __init__(self, config: dict[str, Any], parent=None): # Pipeline widget registry (built by build_config_pages) self._cfg_widgets: dict = {} - self._cfg_dawproject_widget = None + self._cfg_daw_custom_widgets: dict[str, QWidget] = {} # Pages self._general_page = GeneralPage() @@ -173,7 +173,7 @@ def _build_preset_tab(self) -> QWidget: layout.addWidget(splitter, 1) self._preset_page_index: dict[int, int] = {} - self._cfg_dawproject_widget = build_config_pages( + self._cfg_daw_custom_widgets = build_config_pages( self._preset_tree, self._active_preset(), self._cfg_widgets, @@ -226,11 +226,11 @@ def _save_cfg_preset_widgets(self, name: str | None = None) -> None: return preset = self._config_presets_data.setdefault(name, {}) preset.update(read_config_widgets( - self._cfg_widgets, self._cfg_dawproject_widget)) + self._cfg_widgets, self._cfg_daw_custom_widgets)) 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) + load_config_widgets(self._cfg_widgets, preset, self._cfg_daw_custom_widgets) # ── Config preset signal handlers ──────────────────────────────── diff --git a/sessionpreplib/config.py b/sessionpreplib/config.py index 7259951..9054783 100644 --- a/sessionpreplib/config.py +++ b/sessionpreplib/config.py @@ -450,6 +450,11 @@ def build_structured_defaults() -> dict[str, Any]: structured["daw_processors"].setdefault(dp.id, {}) structured["daw_processors"][dp.id].setdefault( "dawproject_templates", []) + # Pro Tools: include templates list default + elif dp.id == "protools": + structured["daw_processors"].setdefault(dp.id, {}) + structured["daw_processors"][dp.id].setdefault( + "protools_templates", []) return structured diff --git a/sessionpreplib/daw_processors/__init__.py b/sessionpreplib/daw_processors/__init__.py index 6c489f9..562b1c5 100644 --- a/sessionpreplib/daw_processors/__init__.py +++ b/sessionpreplib/daw_processors/__init__.py @@ -20,16 +20,16 @@ def create_runtime_daw_processors( ) -> 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. + Both Pro Tools and DAWProject expand 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 ProToolsDawProcessor.create_instances(flat_config): + inst.configure(flat_config) + if inst.enabled: + processors.append(inst) for inst in DawProjectDawProcessor.create_instances(flat_config): inst.configure(flat_config) diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 8f1d10d..af02685 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -93,9 +93,58 @@ class ProToolsDawProcessor(DawProcessor): id = "protools" name = "Pro Tools" + def __init__( + self, + *, + instance_index: int | None = None, + instance_name: str = "", + ): + self._instance_index = instance_index + self._instance_name = instance_name + if instance_index is not None: + self.id = f"protools_{instance_index}" + self.name = f"Pro Tools \u2013 {instance_name}" + + @classmethod + def create_instances( + cls, flat_config: dict[str, Any], + ) -> list[ProToolsDawProcessor]: + """Create one processor instance per configured template. + + Reads ``protools_templates`` from *flat_config*. Each entry + is a dict with key ``name``. Returns an empty list when no templates + are configured. + """ + templates = flat_config.get("protools_templates", []) + if not isinstance(templates, list): + return [] + instances: list[ProToolsDawProcessor] = [] + for idx, tpl in enumerate(templates): + if not isinstance(tpl, dict): + continue + name = tpl.get("name", "").strip() + if not name: + continue + instances.append(cls( + instance_index=idx, + instance_name=name, + )) + return instances + @classmethod def config_params(cls) -> list[ParamSpec]: return super().config_params() + [ + ParamSpec( + key="protools_temp_dir", + type=str, + default="", + label="Temporary project directory", + description=( + "Directory where temporary Pro Tools projects are created " + "from the referenced templates. Leave empty to use the system temp directory." + ), + widget_hint="path_picker_folder", + ), ParamSpec( key="protools_company_name", type=str, @@ -141,7 +190,11 @@ def config_params(cls) -> list[ParamSpec]: ] def configure(self, config: dict[str, Any]) -> None: + saved = config.get(f"{self.id}_enabled") + if saved is None: + config[f"{self.id}_enabled"] = config.get("protools_enabled", True) super().configure(config) + self._temp_dir: str = config.get("protools_temp_dir", "") self._company_name: str = config.get("protools_company_name", "github.com") self._application_name: str = config.get("protools_application_name", "sessionprep") self._host: str = config.get("protools_host", "localhost") From 8ab093585ac4ff1ceb92941cf4e8fc67927f9a03 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 14:31:31 +0100 Subject: [PATCH 04/56] Pro Tools session open check on fetch. --- sessionprepgui/daw/mixin.py | 13 +++++++++++++ sessionpreplib/daw_processors/protools.py | 4 ++++ sessionpreplib/daw_processors/ptsl_helpers.py | 13 +++++++++++++ 3 files changed, 30 insertions(+) diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 502432a..3520746 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -318,6 +318,19 @@ def _do_daw_fetch(self): def _on_daw_fetch_result(self, ok: bool, message: str, session): self._daw_fetch_worker = None self._fetch_action.setEnabled(True) + + if "PRO_TOOLS_SESSION_OPEN" in message: + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning( + self, + "Pro Tools Session Open", + "A Pro Tools session is currently open.\n\n" + "Please save and close the open session in Pro Tools, then try again." + ) + self._status_bar.showMessage("Fetch aborted: Pro Tools session is open.") + self._update_daw_lifecycle_buttons() + return + if ok and session is not None: self._session = session self._populate_folder_tree() diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index af02685..c4f5c17 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -247,6 +247,10 @@ def fetch(self, session: SessionContext) -> SessionContext: application_name=self._application_name, address=address, ) + + if ptslh.is_session_open(engine): + raise RuntimeError("PRO_TOOLS_SESSION_OPEN") + all_tracks = engine.track_list() folders: list[dict[str, Any]] = [] for track in all_tracks: diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index 2a08363..6c6c597 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -131,6 +131,19 @@ def extract_track_id(resp: dict) -> str: # ── Session queries ────────────────────────────────────────────────── +def is_session_open(engine) -> bool: + """Check if a session is currently open in Pro Tools. + + Returns True if a session is open, False otherwise. + """ + try: + # If a session is open, session_name() will return a non-empty string. + name = engine.session_name() + return bool(name) + except Exception: + # PTSL commands typically fail if no session is open. + return False + def get_color_palette(engine, target: str = "CPTarget_Tracks") -> list[str]: """Fetch the Pro Tools color palette. Returns ``[]`` on failure.""" from ptsl import PTSL_pb2 as pt From 9e0c56e7ff68894d3b1f09cfccd6599936f155de Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 17:24:36 +0100 Subject: [PATCH 05/56] new pro tools daw processor parameters. --- sessionprepgui/analysis/mixin.py | 8 ++ sessionprepgui/analysis/worker.py | 13 ++- sessionprepgui/daw/mixin.py | 33 ++++++- sessionprepgui/prefs/config_pages.py | 26 ++++-- sessionpreplib/daw_processors/protools.py | 91 ++++++++++++++++++- sessionpreplib/daw_processors/ptsl_helpers.py | 57 ++++++++++++ 6 files changed, 214 insertions(+), 14 deletions(-) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 8dd6c4a..3175970 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -282,6 +282,11 @@ def _on_save_session(self): ) if not path: return + + # Ensure we capture all active edits from the session settings widgets + if not self._loading_session_widgets: + self._session_config = self._read_session_config() + try: _save_session_file(path, { "source_dir": self._source_dir, @@ -390,6 +395,9 @@ def _on_load_session(self): self._active_config_preset_name = preset_name self._session_config = data.get("session_config") self._session_groups = data.get("session_groups", []) + + if self._session_config: + self._load_session_widgets(self._session_config) # ── Reconstruct SessionContext from saved tracks ────────────────────── from sessionpreplib.models import SessionContext diff --git a/sessionprepgui/analysis/worker.py b/sessionprepgui/analysis/worker.py index 784c01a..59417f9 100644 --- a/sessionprepgui/analysis/worker.py +++ b/sessionprepgui/analysis/worker.py @@ -35,6 +35,8 @@ def run(self): class DawFetchWorker(QThread): """Runs DawProcessor.fetch() off the main thread.""" + progress = Signal(str) # status text + progress_value = Signal(int, int) # (current, total) result = Signal(bool, str, object) # (ok, message, session_or_none) def __init__(self, processor: DawProcessor, session): @@ -42,9 +44,18 @@ def __init__(self, processor: DawProcessor, session): self._processor = processor self._session = session + def _on_progress(self, current: int, total: int, message: str): + self.progress.emit(message) + self.progress_value.emit(current, total) + def run(self): try: - session = self._processor.fetch(self._session) + # Provide the progress callback if the processor supports it + try: + session = self._processor.fetch(self._session, progress_cb=self._on_progress) + except TypeError: + # Fallback for processors that don't support progress_cb yet + session = self._processor.fetch(self._session) self.result.emit(True, "Fetch complete", session) except Exception as e: self.result.emit(False, str(e), None) diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 3520746..18ae76d 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -103,7 +103,7 @@ def _build_setup_page(self) -> QWidget: 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 = QAction("Create", self) self._transfer_action.setEnabled(False) self._transfer_action.triggered.connect(self._on_daw_transfer) self._setup_toolbar.addAction(self._transfer_action) @@ -241,6 +241,18 @@ def _populate_daw_combo(self): def _update_daw_lifecycle_buttons(self): """Enable/disable Fetch/Transfer/Sync based on active processor state.""" + # Check if any async operation is currently running + is_working = getattr(self, "_daw_check_worker", None) is not None or \ + getattr(self, "_daw_fetch_worker", None) is not None or \ + getattr(self, "_daw_transfer_worker", None) is not None + + if is_working: + self._fetch_action.setEnabled(False) + self._auto_assign_action.setEnabled(False) + self._transfer_action.setEnabled(False) + self._reset_manifest_action.setEnabled(False) + return + 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 @@ -273,6 +285,7 @@ def _run_daw_check_then(self, on_success): self._pending_after_check = on_success self._daw_check_label.setText("Connecting\u2026") self._daw_check_label.setStyleSheet(f"color: {COLORS['dim']};") + self._update_daw_lifecycle_buttons() 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() @@ -309,10 +322,18 @@ def _on_daw_fetch(self): def _do_daw_fetch(self): """Actually start the fetch (called after successful connectivity check).""" self._status_bar.showMessage("Fetching folder structure\u2026") + # Ensure the progress panel is visible by switching the stack and clearing the tree + self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_TREE) + self._folder_tree.clear() + self._transfer_progress.start("Fetching folder structure\u2026") + self._daw_fetch_worker = DawFetchWorker( self._active_daw_processor, self._session) + self._daw_fetch_worker.progress.connect(self._on_transfer_progress) + self._daw_fetch_worker.progress_value.connect(self._on_transfer_progress_value) self._daw_fetch_worker.result.connect(self._on_daw_fetch_result) self._daw_fetch_worker.start() + self._update_daw_lifecycle_buttons() @Slot(bool, str, object) def _on_daw_fetch_result(self, ok: bool, message: str, session): @@ -320,6 +341,7 @@ def _on_daw_fetch_result(self, ok: bool, message: str, session): self._fetch_action.setEnabled(True) if "PRO_TOOLS_SESSION_OPEN" in message: + self._transfer_progress.fail("Fetch aborted: Pro Tools session is open.") from PySide6.QtWidgets import QMessageBox QMessageBox.warning( self, @@ -336,8 +358,17 @@ def _on_daw_fetch_result(self, ok: bool, message: str, session): self._populate_folder_tree() self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_TREE) self._populate_setup_table() + self._transfer_progress.finish(message) self._status_bar.showMessage(message) else: + self._transfer_progress.fail(message) + from PySide6.QtWidgets import QMessageBox + QMessageBox.critical( + self, + "Fetch Failed", + f"Could not fetch folder structure from {self._active_daw_processor.name}.\n\n" + f"{message}" + ) self._status_bar.showMessage(f"Fetch failed: {message}") self._update_daw_lifecycle_buttons() diff --git a/sessionprepgui/prefs/config_pages.py b/sessionprepgui/prefs/config_pages.py index 16007ec..ddd075d 100644 --- a/sessionprepgui/prefs/config_pages.py +++ b/sessionprepgui/prefs/config_pages.py @@ -498,14 +498,20 @@ def _init_ui(self): layout.addWidget(QLabel("Pro Tools Templates")) self._table = QTableWidget() - self._table.setColumnCount(1) - self._table.setHorizontalHeaderLabels(["Name"]) + self._table.setColumnCount(2) + self._table.setHorizontalHeaderLabels(["Template Group", "Template Name"]) gh = self._table.horizontalHeader() gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) - gh.setSectionResizeMode(0, QHeaderView.Stretch) + gh.setSectionResizeMode(0, QHeaderView.Interactive) + gh.resizeSection(0, 150) + gh.setSectionResizeMode(1, QHeaderView.Stretch) self._table.setSelectionBehavior(QTableWidget.SelectRows) self._table.setSelectionMode(QTableWidget.SingleSelection) self._table.cellChanged.connect(lambda r, c: self.templates_changed.emit()) + + # Ensure the table is tall enough to show ~3 rows comfortably + self._table.setMinimumHeight(130) + layout.addWidget(self._table, 1) btn_row = QHBoxLayout() @@ -525,22 +531,26 @@ def set_templates(self, templates: list[dict]): self._table.setRowCount(0) self._table.setRowCount(len(templates)) for row, tpl in enumerate(templates): - self._table.setItem(row, 0, QTableWidgetItem(tpl.get("name", ""))) + self._table.setItem(row, 0, QTableWidgetItem(tpl.get("group", ""))) + self._table.setItem(row, 1, QTableWidgetItem(tpl.get("name", ""))) 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) + group_item = self._table.item(row, 0) + group = group_item.text().strip() if group_item else "" + name_item = self._table.item(row, 1) name = name_item.text().strip() if name_item else "" - if name: - templates.append({"name": name}) + if name or group: + templates.append({"group": group, "name": name}) return templates def _on_add(self): row = self._table.rowCount() self._table.setRowCount(row + 1) self._table.setItem(row, 0, QTableWidgetItem("")) + self._table.setItem(row, 1, QTableWidgetItem("")) self._table.editItem(self._table.item(row, 0)) self.templates_changed.emit() @@ -660,7 +670,7 @@ def build_config_pages( daw_custom_widgets["protools"] = pt_widget if on_daw_config_changed is not None: pt_widget.templates_changed.connect(on_daw_config_changed) - pg.layout().insertWidget(2, pt_widget) + pg.layout().insertWidget(3, pt_widget) register_page(child, pg) diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index c4f5c17..aa09ca9 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -97,13 +97,18 @@ def __init__( self, *, instance_index: int | None = None, + instance_group: str = "", instance_name: str = "", ): self._instance_index = instance_index + self._instance_group = instance_group self._instance_name = instance_name if instance_index is not None: self.id = f"protools_{instance_index}" - self.name = f"Pro Tools \u2013 {instance_name}" + if instance_group: + self.name = f"Pro Tools \u2013 {instance_group} / {instance_name}" + else: + self.name = f"Pro Tools \u2013 {instance_name}" @classmethod def create_instances( @@ -112,7 +117,7 @@ def create_instances( """Create one processor instance per configured template. Reads ``protools_templates`` from *flat_config*. Each entry - is a dict with key ``name``. Returns an empty list when no templates + is a dict with key ``name`` and ``group``. Returns an empty list when no templates are configured. """ templates = flat_config.get("protools_templates", []) @@ -122,11 +127,13 @@ def create_instances( for idx, tpl in enumerate(templates): if not isinstance(tpl, dict): continue + group = tpl.get("group", "").strip() name = tpl.get("name", "").strip() - if not name: + if not name or not group: continue instances.append(cls( instance_index=idx, + instance_group=group, instance_name=name, )) return instances @@ -134,6 +141,17 @@ def create_instances( @classmethod def config_params(cls) -> list[ParamSpec]: return super().config_params() + [ + ParamSpec( + key="protools_project_dir", + type=str, + default="", + label="Project directory", + description=( + "Directory where newly created Pro Tools projects are saved. " + "Leave empty to prompt for a location each time." + ), + widget_hint="path_picker_folder", + ), ParamSpec( key="protools_temp_dir", type=str, @@ -194,6 +212,7 @@ def configure(self, config: dict[str, Any]) -> None: if saved is None: config[f"{self.id}_enabled"] = config.get("protools_enabled", True) super().configure(config) + self._project_dir: str = config.get("protools_project_dir", "") self._temp_dir: str = config.get("protools_temp_dir", "") self._company_name: str = config.get("protools_company_name", "github.com") self._application_name: str = config.get("protools_application_name", "sessionprep") @@ -232,7 +251,12 @@ def check_connectivity(self) -> tuple[bool, str]: except Exception: pass - def fetch(self, session: SessionContext) -> SessionContext: + def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: + if not self._temp_dir: + raise RuntimeError("The 'Temporary project directory' is not configured in Preferences.") + if not os.path.isdir(self._temp_dir): + raise RuntimeError(f"The configured temporary project directory does not exist: {self._temp_dir}") + try: from ptsl import Engine from ptsl import PTSL_pb2 as pt @@ -240,7 +264,11 @@ def fetch(self, session: SessionContext) -> SessionContext: return session engine = None + temp_session_name = None try: + if progress_cb: + progress_cb(10, 100, "Connecting to Pro Tools...") + address = f"{self._host}:{self._port}" engine = Engine( company_name=self._company_name, @@ -251,6 +279,24 @@ def fetch(self, session: SessionContext) -> SessionContext: if ptslh.is_session_open(engine): raise RuntimeError("PRO_TOOLS_SESSION_OPEN") + import uuid + temp_session_name = f"SessionPrep_Temp_{uuid.uuid4().hex[:8]}" + + if progress_cb: + progress_cb(30, 100, f"Creating temporary session from template '{self._instance_group} / {self._instance_name}'...") + + # Create the temporary session from the template + ptslh.create_session_from_template( + engine, + temp_session_name, + self._temp_dir, + self._instance_group, + self._instance_name + ) + + if progress_cb: + progress_cb(70, 100, "Reading track folder structure...") + all_tracks = engine.track_list() folders: list[dict[str, Any]] = [] for track in all_tracks: @@ -267,6 +313,9 @@ def fetch(self, session: SessionContext) -> SessionContext: "parent_id": track.parent_folder_id or None, }) + if progress_cb: + progress_cb(90, 100, "Cleaning up temporary session...") + # 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", {}) @@ -284,10 +333,44 @@ def fetch(self, session: SessionContext) -> SessionContext: raise finally: if engine is not None: + if temp_session_name: + try: + ptslh.close_session(engine) + except Exception as e: + dbg(f"Failed to close temp session: {e}") try: engine.close() except Exception: pass + + if temp_session_name: + # Defensive deletion of the temporary session folder + target_dir = os.path.join(self._temp_dir, temp_session_name) + ptx_file = os.path.join(target_dir, f"{temp_session_name}.ptx") + + # Extreme safety checks to ensure we only delete what we created: + # 1. Ensure target_dir actually exists + # 2. Ensure target_dir is exactly a direct child of the configured temp dir + # 3. Ensure target_dir contains our specific UUID .ptx file + if (os.path.isdir(target_dir) and + os.path.dirname(os.path.abspath(target_dir)) == os.path.abspath(self._temp_dir) and + os.path.isfile(ptx_file)): + + import shutil + import time + # Retry loop to handle delayed file locks on Windows from Pro Tools closing + for _ in range(10): # Try for up to 5 seconds + try: + shutil.rmtree(target_dir, ignore_errors=True) + if not os.path.exists(target_dir): + break + except Exception: + pass + time.sleep(0.5) + + if progress_cb: + progress_cb(100, 100, "Fetch complete") + return session def _resolve_group_color( diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index 6c6c597..a568b26 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -162,6 +162,63 @@ def get_session_audio_dir(engine) -> str: return os.path.join(os.path.dirname(session_ptx), "Audio Files") +# ── Session lifecycle ──────────────────────────────────────────────── + +def create_session_from_template( + engine, session_name: str, session_location: str, + template_group: str, template_name: str, +) -> None: + """Create a new Pro Tools session from a template. + + Paths use native OS separators. CId_CreateSession automatically opens the session. + """ + from ptsl import PTSL_pb2 as pt + + location = os.path.abspath(session_location) + os.makedirs(location, exist_ok=True) + + body = { + "session_name": session_name, + "session_location": location, + "create_from_template": True, + "template_group": template_group, + "template_name": template_name, + "file_type": "FT_WAVE", + "sample_rate": "SR_48000", + "bit_depth": "Bit24", + "input_output_settings": "IO_Last", + "is_interleaved": True, + "is_cloud_project": False, + } + + # 1. Create the session (Pro Tools automatically opens it as well) + run_command(engine, pt.CommandId.CId_CreateSession, body) + + # Wait until Pro Tools actually loads the template and writes the PTX file. + # It can take a few seconds for the background creation to finish. + import time + session_dir = os.path.join(location, session_name) + session_path = os.path.join(session_dir, f"{session_name}.ptx") + + success = False + for _ in range(15): # Wait up to 7.5 seconds + if os.path.isfile(session_path): + success = True + break + time.sleep(0.5) + + if not success: + raise RuntimeError( + f"Pro Tools failed to create the session. Please check if the " + f"template '{template_group} / {template_name}' actually exists." + ) + +def close_session(engine, save_on_close: bool = False) -> None: + """Close the current Pro Tools session.""" + from ptsl import PTSL_pb2 as pt + run_command(engine, pt.CommandId.CId_CloseSession, {"save_on_close": save_on_close}) + + # ── Batch job lifecycle ────────────────────────────────────────────── def create_batch_job(engine, name: str, description: str, From b8be75c1fe96468130363df684e8b35dcf848722 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 17:50:13 +0100 Subject: [PATCH 06/56] project name implemented --- sessionprepgui/analysis/mixin.py | 17 +++++++++++++++++ sessionprepgui/daw/mixin.py | 31 +++++++++++++++++++++++++++++++ sessionprepgui/session/io.py | 10 +++++++++- sessionpreplib/models.py | 1 + 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 3175970..9cd811c 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -220,6 +220,7 @@ def _on_open_path(self): self._groups_tab_table.setRowCount(0) self._folder_tree.clear() self._setup_right_stack.setCurrentIndex(0) # placeholder page + self._project_name_edit.clear() app_cfg = self._config.get("app", {}) skip_folders = { @@ -287,6 +288,10 @@ def _on_save_session(self): if not self._loading_session_widgets: self._session_config = self._read_session_config() + # Ensure project name is synced from widget + if self._session: + self._session.project_name = self._project_name_edit.text().strip() + try: _save_session_file(path, { "source_dir": self._source_dir, @@ -302,6 +307,7 @@ def _on_save_session(self): "base_transfer_manifest": self._session.base_transfer_manifest, "use_processed": self._use_processed_cb.isChecked(), "recursive_scan": self._recursive_scan, + "project_name": self._session.project_name, }) self._status_bar.showMessage(f"Session saved to {path}") except Exception as exc: @@ -446,9 +452,16 @@ def _on_load_session(self): prepare_state=data.get("prepare_state", "none"), transfer_manifest=data.get("transfer_manifest", []), base_transfer_manifest=data.get("base_transfer_manifest", []), + project_name=data.get("project_name", ""), ) self._session = session + + # Restore project name widget + self._project_name_edit.blockSignals(True) + self._project_name_edit.setText(session.project_name) + self._project_name_edit.blockSignals(False) + self._summary = build_diagnostic_summary(session) # ── Populate file list in track table ───────────────────────────────── @@ -758,6 +771,10 @@ def _on_analyze_done(self, session, summary): if self._session and self._session.topology and not self._topo_topology: self._topo_topology = self._session.topology + # Preserve project name across analysis runs + current_project_name = self._project_name_edit.text().strip() + session.project_name = current_project_name + self._session = session self._summary = summary self._analyze_action.setEnabled(True) diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 18ae76d..1e95c1c 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -13,6 +13,7 @@ QHeaderView, QInputDialog, QLabel, + QLineEdit, QMenu, QMessageBox, QSizePolicy, @@ -183,6 +184,19 @@ def _build_setup_page(self) -> QWidget: tree_page_layout.setContentsMargins(0, 0, 0, 0) tree_page_layout.setSpacing(0) + # Project Name input + proj_name_container = QWidget() + proj_name_layout = QHBoxLayout(proj_name_container) + proj_name_layout.setContentsMargins(8, 8, 8, 4) + proj_name_layout.setSpacing(8) + proj_name_label = QLabel("Project Name:") + self._project_name_edit = QLineEdit() + self._project_name_edit.setPlaceholderText("Enter project name...") + self._project_name_edit.textChanged.connect(self._on_project_name_changed) + proj_name_layout.addWidget(proj_name_label) + proj_name_layout.addWidget(self._project_name_edit, 1) + tree_page_layout.addWidget(proj_name_container) + self._folder_tree = _FolderDropTree() self._folder_tree.setHeaderLabels(["Folder / Track"]) self._folder_tree.setSelectionMode(QTreeWidget.ExtendedSelection) @@ -355,6 +369,10 @@ def _on_daw_fetch_result(self, ok: bool, message: str, session): if ok and session is not None: self._session = session + # Restore project name from session context if it was loaded + self._project_name_edit.blockSignals(True) + self._project_name_edit.setText(session.project_name) + self._project_name_edit.blockSignals(False) self._populate_folder_tree() self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_TREE) self._populate_setup_table() @@ -372,6 +390,10 @@ def _on_daw_fetch_result(self, ok: bool, message: str, session): self._status_bar.showMessage(f"Fetch failed: {message}") self._update_daw_lifecycle_buttons() + def _on_project_name_changed(self, text: str): + if self._session: + self._session.project_name = text.strip() + # ── Use Processed checkbox ────────────────────────────────────────── @Slot(bool) @@ -402,6 +424,15 @@ def _update_use_processed_action(self): def _on_daw_transfer(self): if not self._active_daw_processor or not self._session: return + + # Validate Project Name + if not self._project_name_edit.text().strip(): + QMessageBox.warning( + self, "Project Name Required", + "Please enter a Project Name before clicking Create." + ) + return + self._transfer_action.setEnabled(False) self._fetch_action.setEnabled(False) self._run_daw_check_then(self._do_daw_transfer) diff --git a/sessionprepgui/session/io.py b/sessionprepgui/session/io.py index d37cdd8..a13120c 100644 --- a/sessionprepgui/session/io.py +++ b/sessionprepgui/session/io.py @@ -36,7 +36,7 @@ # Version & migration table # --------------------------------------------------------------------------- -CURRENT_VERSION: int = 4 +CURRENT_VERSION: int = 5 # Each entry upgrades from key-version to key+1. _MIGRATIONS: dict[int, Callable[[dict], dict]] = { @@ -56,6 +56,11 @@ "recursive_scan": False, "version": 4, }, + 4: lambda d: { + **d, + "project_name": "", + "version": 5, + }, } @@ -356,6 +361,7 @@ def save_session(path: str, data: dict) -> None: ], "use_processed": data.get("use_processed", False), "recursive_scan": data.get("recursive_scan", False), + "project_name": data.get("project_name", ""), } with open(path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2, ensure_ascii=False) @@ -371,6 +377,7 @@ def load_session(path: str) -> dict: - ``session_groups`` (list) - ``daw_state`` (dict) - ``tracks`` (list[TrackContext]) — audio_data is None; filepath validated + - ``project_name`` (str) Raises ``ValueError`` on version mismatch or missing required fields. Raises ``json.JSONDecodeError`` / ``OSError`` on file errors. @@ -412,4 +419,5 @@ def load_session(path: str) -> dict: ], "use_processed": raw.get("use_processed", False), "recursive_scan": raw.get("recursive_scan", False), + "project_name": raw.get("project_name", ""), } diff --git a/sessionpreplib/models.py b/sessionpreplib/models.py index 78660c0..36cbe3b 100644 --- a/sessionpreplib/models.py +++ b/sessionpreplib/models.py @@ -146,6 +146,7 @@ class SessionContext: output_tracks: list[TrackContext] = field(default_factory=list) transfer_manifest: list[TransferEntry] = field(default_factory=list) base_transfer_manifest: list[TransferEntry] = field(default_factory=list) + project_name: str = "" @dataclass From 592a0a2a55a88e4b311f233d94be811103c8d2e3 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 18:12:35 +0100 Subject: [PATCH 07/56] complete session creation --- sessionprepgui/daw/mixin.py | 48 ++- sessionpreplib/daw_processors/protools.py | 405 +++++++----------- sessionpreplib/daw_processors/ptsl_helpers.py | 6 +- 3 files changed, 202 insertions(+), 257 deletions(-) diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 1e95c1c..eafa691 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import Any from PySide6.QtCore import Qt, Slot, QSize, QTimer @@ -391,8 +392,17 @@ def _on_daw_fetch_result(self, ok: bool, message: str, session): self._update_daw_lifecycle_buttons() def _on_project_name_changed(self, text: str): + # Basic sanitization: remove characters invalid for Windows filenames + sanitized = "".join(c for c in text if c not in '<>:"/\\|?*').strip() + if sanitized != text: + self._project_name_edit.blockSignals(True) + cursor_pos = self._project_name_edit.cursorPosition() + self._project_name_edit.setText(sanitized) + self._project_name_edit.setCursorPosition(max(0, cursor_pos - 1)) + self._project_name_edit.blockSignals(False) + if self._session: - self._session.project_name = text.strip() + self._session.project_name = sanitized # ── Use Processed checkbox ────────────────────────────────────────── @@ -422,17 +432,49 @@ def _update_use_processed_action(self): @Slot() def _on_daw_transfer(self): + import os if not self._active_daw_processor or not self._session: return - # Validate Project Name - if not self._project_name_edit.text().strip(): + # 1. Project Name Validation + project_name = self._project_name_edit.text().strip() + if not project_name: QMessageBox.warning( self, "Project Name Required", "Please enter a Project Name before clicking Create." ) return + # 2. Target Directory Validation (Pro Tools specific) + if self._active_daw_processor.id.startswith("protools"): + flat_config = self._flat_config() + project_dir = flat_config.get("protools_project_dir", "").strip() + + if not project_dir: + QMessageBox.critical( + self, "Project Directory Not Set", + "A 'Project directory' must be configured in Pro Tools preferences before creating a project." + ) + return + + if not os.path.isdir(project_dir): + QMessageBox.critical( + self, "Project Directory Not Found", + f"The configured project directory does not exist:\n\n{project_dir}\n\n" + "Please create it or specify a different directory in Preferences." + ) + return + + # 3. Collision Check + target_path = os.path.join(project_dir, project_name) + if os.path.exists(target_path): + QMessageBox.warning( + self, "Project Already Exists", + f"A folder named '{project_name}' already exists in the project directory.\n\n" + "Please choose a different project name." + ) + return + self._transfer_action.setEnabled(False) self._fetch_action.setEnabled(False) self._run_daw_check_then(self._do_daw_transfer) diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index aa09ca9..7da132b 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -5,6 +5,9 @@ import math import os import time +import uuid +import shutil +from collections import Counter from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any @@ -403,31 +406,56 @@ def _open_engine(self): address=address, ) + def _get_optimal_session_specs(self, session: SessionContext) -> tuple[str, str]: + """Determine most common sample rate and bit depth from output tracks. + + Returns (sample_rate_enum, bit_depth_enum). + """ + from collections import Counter + + rates = [t.samplerate for t in session.output_tracks if t.samplerate > 0] + # bitdepth is string, e.g. "PCM_24". Try to extract numeric part. + depths = [] + for t in session.output_tracks: + bd = str(t.bitdepth).upper() + if "32" in bd: depths.append(32) + elif "24" in bd: depths.append(24) + elif "16" in bd: depths.append(16) + + # Default fallback + common_rate = Counter(rates).most_common(1)[0][0] if rates else 48000 + common_depth = Counter(depths).most_common(1)[0][0] if depths else 24 + + rate_map = { + 44100: "SR_44100", 48000: "SR_48000", 88200: "SR_88200", + 96000: "SR_96000", 176400: "SR_176400", 192000: "SR_192000" + } + depth_map = {16: "Bit16", 24: "Bit24", 32: "Bit32Float"} + + return (rate_map.get(common_rate, "SR_48000"), + depth_map.get(common_depth, "Bit24")) + 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 - modal progress dialog in Pro Tools and preventing user - interaction during the transfer. + """Create a new Pro Tools session from a template and import audio. The transfer is structured in phases: - 0. Setup (palette, session path) — before batch job - 1. Create batch job + 0. Connect and verify empty workspace + 1. Determine optimal specs and create new session 2. Batch import all audio files in one call 3. Per-track: create track + spot clip (parallel, 6 workers) 4. Batch colorize by group - 4.5. Set fader offsets (when using processed files) - 5. Complete batch job + 5. Set fader offsets + 6. Complete and save session Args: session: The current session context. - progress_cb: Optional callable(current, total, message) for - progress reporting. + output_path: Not used for Pro Tools (it uses internal prefs). + progress_cb: Optional callable(current, total, message). Returns: List of DawCommandResult for each operation attempted. @@ -446,8 +474,7 @@ def transfer( assignments: dict[str, str] = pt_state.get("assignments", {}) folders = pt_state.get("folders", []) track_order = pt_state.get("track_order", {}) - dbg(f"assignments={len(assignments)}, " - f"folders={len(folders)}, track_order={len(track_order)}") + if not assignments: dbg("No assignments, returning early") return [] @@ -471,23 +498,58 @@ def transfer( if eid not in seen: work.append((eid, fid)) - total = len(work) - dbg(f"work list: {total} items") results: list[DawCommandResult] = [] engine = None delay = self._command_delay batch_job_id: str | None = None try: - dbg("Opening engine...") + if progress_cb: + progress_cb(0, 100, "Connecting to Pro Tools...") engine = self._open_engine() - dbg("Engine opened") - # ── Setup (before batch job) ───────────────────────── + # ── 0. Setup & Safety Checks ───────────────────────── - pt_palette = ptslh.get_color_palette(engine) + if not self._project_dir: + raise RuntimeError("Pro Tools 'Project directory' is not configured.") + if not os.path.isdir(self._project_dir): + raise RuntimeError(f"Pro Tools 'Project directory' does not exist: {self._project_dir}") + if not session.project_name: + raise RuntimeError("Project name is empty.") + + if ptslh.is_session_open(engine): + raise RuntimeError("PRO_TOOLS_SESSION_OPEN") + + if progress_cb: + progress_cb(5, 100, "Calculating audio specifications...") + + rate_enum, depth_enum = self._get_optimal_session_specs(session) + + # ── 1. Create New Session ──────────────────────────── + + if progress_cb: + progress_cb(10, 100, f"Creating session '{session.project_name}'...") - # Pre-compute group → palette index + try: + ptslh.create_session_from_template( + engine, + session.project_name, + self._project_dir, + self._instance_group, + self._instance_name, + sample_rate=rate_enum, + bit_depth=depth_enum + ) + results.append(DawCommandResult( + command=DawCommand("create_session", session.project_name, {}), + success=True)) + except Exception as e: + return [DawCommandResult( + command=DawCommand("create_session", session.project_name, {}), + success=False, error=str(e))] + + # Re-fetch color palette from the new session + pt_palette = ptslh.get_color_palette(engine) group_palette_idx: dict[str, int] = {} if pt_palette: for entry in session.transfer_manifest: @@ -500,84 +562,44 @@ def transfer( group_palette_idx[entry.group] = idx audio_files_dir = ptslh.get_session_audio_dir(engine) - dbg(f"Setup done: palette={len(pt_palette)}, " - f"audio_dir={audio_files_dir}") - # Validate work items and collect filepaths for batch import - # valid_work: [(entry_id, fid, filepath, track_stem, track_format, out_tc)] + # Validate work items and collect filepaths valid_work: list[tuple[str, str, str, str, str, Any]] = [] for eid, fid in work: folder = folder_map.get(fid) - if not folder: - results.append(DawCommandResult( - command=DawCommand("import_to_clip_list", eid, - {"folder_id": fid}), - success=False, error=f"Folder {fid} not found")) - continue + if not folder: continue entry = manifest_map.get(eid) - if not entry: - results.append(DawCommandResult( - command=DawCommand("import_to_clip_list", eid, - {"folder_name": folder["name"]}), - success=False, - error=f"Manifest entry {eid} not found")) - continue + if not entry: continue out_tc = out_track_map.get(entry.output_filename) - audio_path = ( - out_tc.processed_filepath or out_tc.filepath - ) if out_tc else None - if not out_tc or not audio_path: - results.append(DawCommandResult( - command=DawCommand("import_to_clip_list", eid, - {"folder_name": folder["name"]}), - success=False, - error=f"Output track not found for {entry.output_filename}")) - continue + audio_path = (out_tc.processed_filepath or out_tc.filepath) if out_tc else None + if not out_tc or not audio_path: continue + filepath = os.path.abspath(audio_path) track_stem = os.path.splitext(entry.daw_track_name)[0] - track_format = ( - "TF_Mono" if out_tc.channels == 1 else "TF_Stereo") - valid_work.append( - (eid, fid, filepath, track_stem, track_format, out_tc)) + track_format = ("TF_Mono" if out_tc.channels == 1 else "TF_Stereo") + valid_work.append((eid, fid, filepath, track_stem, track_format, out_tc)) if not valid_work: - dbg("No valid work items, returning early") + dbg("No valid work items") return results - total = len(valid_work) - dbg(f"{total} valid work items") - - # ── Create batch job ─────────────────────────────── + # ── 2. Batch Import ────────────────────────────────── batch_job_id = ptslh.create_batch_job( - engine, "SessionPrep Transfer", - f"Importing {total} tracks") - - # ── Batch import all files ───────────────────────── + engine, "SessionPrep Create", f"Importing {len(valid_work)} tracks") if progress_cb: - progress_cb(0, total, "Importing audio to clip list…") + progress_cb(20, 100, "Importing audio to clip list...") - # Deduplicate: multiple manifest entries may share the same file - all_filepaths = list(dict.fromkeys( - fp for _, _, fp, _, _, _ in valid_work)) - clip_cmd = DawCommand( - "batch_import_to_clip_list", "", - {"file_count": len(all_filepaths), - "destination": audio_files_dir}) - - # filepath → list[clip_id] + all_filepaths = list(dict.fromkeys(fp for _, _, fp, _, _, _ in valid_work)) clip_id_map: dict[str, list[str]] = {} import_failures: set[str] = set() - dbg(f"Batch importing {len(all_filepaths)} files...") + try: import_resp = ptslh.batch_import_audio( - engine, all_filepaths, - batch_job_id=batch_job_id, progress=5) - dbg(f"Import response: {import_resp}") + engine, all_filepaths, batch_job_id=batch_job_id, progress=25) time.sleep(delay) - # Map response entries back by original_input_path if import_resp: for entry in import_resp.get("file_list", []): orig = entry.get("original_input_path", "") @@ -585,220 +607,99 @@ def transfer( if dest_list: ids = dest_list[0].get("clip_id_list", []) if ids: - # Normalize path case — PT returns - # lowercase drive letters on Windows - clip_id_map[os.path.normcase(orig)] = \ - list(ids) - + clip_id_map[os.path.normcase(orig)] = list(ids) for fail in import_resp.get("failure_list", []): fail_path = fail.get("original_input_path", "") import_failures.add(os.path.normcase(fail_path)) - - dbg(f"clip_id_map: {len(clip_id_map)} entries, " - f"failures: {len(import_failures)}") + results.append(DawCommandResult( - command=clip_cmd, success=True)) + command=DawCommand("batch_import", "", {}), success=True)) except Exception as e: - dbg(f"Batch import FAILED: {e}") - results.append(DawCommandResult( - command=clip_cmd, success=False, error=str(e))) - # Cannot continue without clip IDs - if batch_job_id: - ptslh.cancel_batch_job(engine, batch_job_id) - batch_job_id = None - session.daw_command_log.extend(results) - return results + if batch_job_id: ptslh.cancel_batch_job(engine, batch_job_id) + return [DawCommandResult(command=DawCommand("batch_import", "", {}), + success=False, error=str(e))] - # ── Per-track create + spot (parallel) ────────────────── + # ── 3. Parallel Track Creation + Spot ──────────────── - # Collect created track stems by color_index for batch colorize color_groups: dict[int, list[str]] = {} - # Collect (track_stem, track_id, tc) for fader setting created_tracks: list[tuple[str, str, Any]] = [] - - # Filter out tracks whose import failed before submitting - spot_work: list[tuple[int, str, str, str, str, str, Any, list[str]]] = [] - for step, (fname, fid, filepath, track_stem, track_format, - tc) in enumerate(valid_work): + spot_work = [] + for step, (fname, fid, filepath, track_stem, track_format, tc) in enumerate(valid_work): clip_ids = clip_id_map.get(os.path.normcase(filepath)) if not clip_ids or os.path.normcase(filepath) in import_failures: - results.append(DawCommandResult( - command=DawCommand("create_track", fname, - {"track_name": track_stem}), - success=False, - error=f"Import failed for {fname}")) continue - spot_work.append( - (step, fname, fid, filepath, track_stem, - track_format, tc, clip_ids)) - - def _create_and_spot( - item: tuple[int, str, str, str, str, str, Any, list[str]], - ) -> tuple[ - list[DawCommandResult], - tuple[str, str, Any] | None, - tuple[int, str] | None, - ]: - """Create one track and spot its clip. Thread-safe.""" - (step, fname, fid, filepath, track_stem, - track_format, tc, clip_ids) = item + spot_work.append((step, fname, fid, filepath, track_stem, track_format, tc, clip_ids)) + + def _create_and_spot(item): + (step, fname, fid, filepath, track_stem, track_format, tc, clip_ids) = item folder_name = folder_map[fid]["name"] - pct = 10 + int(80 * step / max(total, 1)) - step_results: list[DawCommandResult] = [] - - dbg(f"[{step+1}/{total}] create+spot " - f"{track_stem} -> {folder_name}") - - # --- Create new track inside target folder --- - create_cmd = DawCommand( - "create_track", fname, - {"track_name": track_stem, - "folder_name": folder_name, - "format": track_format}) - try: - new_track_id = ptslh.create_track( - engine, track_stem, track_format, - folder_name=folder_name, - batch_job_id=batch_job_id, progress=pct) - dbg(f" Created track: {new_track_id}") - step_results.append(DawCommandResult( - command=create_cmd, success=True)) - except Exception as e: - dbg(f" Create FAILED: {e}") - step_results.append(DawCommandResult( - command=create_cmd, success=False, - error=str(e))) - return step_results, None, None - - # --- Spot clip on the new track at session start --- - spot_cmd = DawCommand( - "spot_clip", fname, - {"clip_ids": clip_ids, "track_id": new_track_id}) + pct = 30 + int(50 * step / max(len(valid_work), 1)) + try: - ptslh.spot_clips( - engine, clip_ids, new_track_id, - batch_job_id=batch_job_id, progress=pct) - dbg(" Spotted clip OK") - step_results.append(DawCommandResult( - command=spot_cmd, success=True)) + tid = ptslh.create_track(engine, track_stem, track_format, + folder_name=folder_name, batch_job_id=batch_job_id, progress=pct) + ptslh.spot_clips(engine, clip_ids, tid, batch_job_id=batch_job_id, progress=pct) + + cinfo = (group_palette_idx[tc.group], track_stem) if tc.group in group_palette_idx else None + return True, (track_stem, tid, tc), cinfo, None except Exception as e: - dbg(f" Spot FAILED: {e}") - step_results.append(DawCommandResult( - command=spot_cmd, success=False, error=str(e))) - return step_results, None, None - - track_info = (track_stem, new_track_id, tc) - color_info: tuple[int, str] | None = None - if tc.group in group_palette_idx: - color_info = (group_palette_idx[tc.group], track_stem) - return step_results, track_info, color_info - - dbg(f"Submitting {len(spot_work)} create+spot tasks " - f"to pool (max_workers=6)") - completed = 0 + return False, None, None, str(e) + with ThreadPoolExecutor(max_workers=6) as pool: - futures = { - pool.submit(_create_and_spot, item): item - for item in spot_work - } - for fut in as_completed(futures): - step_results, track_info, color_info = fut.result() - results.extend(step_results) - if track_info: - created_tracks.append(track_info) - if color_info: - cidx, t_stem = color_info - color_groups.setdefault(cidx, []).append(t_stem) - completed += 1 + futures = [pool.submit(_create_and_spot, item) for item in spot_work] + for i, fut in enumerate(as_completed(futures)): + ok, tinfo, cinfo, err = fut.result() + if ok: + created_tracks.append(tinfo) + if cinfo: color_groups.setdefault(cinfo[0], []).append(cinfo[1]) if progress_cb: - progress_cb( - completed, len(spot_work), - f"Created {completed}/{len(spot_work)} tracks") - - # ── Batch colorize by group ──────────────────────── - - dbg(f"Colorizing {len(color_groups)} groups") - for color_idx, track_names in color_groups.items(): - color_cmd = DawCommand( - "set_track_color", "", - {"color_index": color_idx, - "track_names": track_names}) + progress_cb(30 + int(50 * i / len(spot_work)), 100, f"Created {i+1}/{len(spot_work)} tracks") + + # ── 4. Colorize ────────────────────────────────────── + + for cidx, names in color_groups.items(): try: - ptslh.colorize_tracks( - engine, track_names, color_idx, - batch_job_id=batch_job_id, progress=95) - results.append(DawCommandResult( - command=color_cmd, success=True)) - except Exception as e: - results.append(DawCommandResult( - command=color_cmd, success=False, error=str(e))) + ptslh.colorize_tracks(engine, names, cidx, batch_job_id=batch_job_id, progress=90) + except: pass - # ── Set fader offsets ────────────────────────────────── + # ── 5. Faders ──────────────────────────────────────── proc_id = "bimodal_normalize" bn_enabled = session.config.get(f"{proc_id}_enabled", True) if bn_enabled: - fader_count = 0 for t_stem, t_id, tc in created_tracks: - if proc_id in tc.processor_skip: - continue + if proc_id in tc.processor_skip: continue pr = tc.processor_results.get(proc_id) - if not pr or pr.classification in ("Silent", "Skip"): - continue + if not pr or pr.classification in ("Silent", "Skip"): continue fader_db = pr.data.get("fader_offset", 0.0) - if fader_db == 0.0: - continue - fader_cmd = DawCommand( - "set_fader", t_stem, - {"track_id": t_id, "value": fader_db}) + if fader_db == 0.0: continue try: - ptslh.set_track_volume( - engine, t_id, fader_db, - batch_job_id=batch_job_id, progress=97) - dbg(f" Fader {t_stem}: {fader_db:+.1f} dB") - results.append(DawCommandResult( - command=fader_cmd, success=True)) - fader_count += 1 - except Exception as e: - dbg(f" Fader {t_stem} FAILED: {e}") - results.append(DawCommandResult( - command=fader_cmd, success=False, - error=str(e))) - dbg(f"Fader offsets set on {fader_count} tracks") + ptslh.set_track_volume(engine, t_id, fader_db, batch_job_id=batch_job_id, progress=95) + except: pass - # ── Complete batch job ────────────────────────────── + # ── 6. Save & Close ────────────────────────────────── + if progress_cb: + progress_cb(98, 100, "Saving and closing session...") + if batch_job_id: ptslh.complete_batch_job(engine, batch_job_id) batch_job_id = None - # Store transfer snapshot for future sync() - pt_state["last_transfer"] = { - "assignments": dict(assignments), - "track_order": {k: list(v) - for k, v in track_order.items()}, - } - session.daw_command_log.extend(results) + try: + ptslh.close_session(engine, save_on_close=True) + results.append(DawCommandResult(command=DawCommand("close_session", "", {}), success=True)) + except Exception as e: + results.append(DawCommandResult(command=DawCommand("close_session", "", {}), success=False, error=str(e))) except Exception as e: - dbg(f"UNCAUGHT EXCEPTION: {e}") - import traceback - traceback.print_exc() - results.append(DawCommandResult( - command=DawCommand("transfer", "", {}), - success=False, error=str(e), - )) + results.append(DawCommandResult(command=DawCommand("create_project", "", {}), success=False, error=str(e))) finally: - # Cancel batch job if still open (e.g. due to exception) - if batch_job_id and engine is not None: - ptslh.cancel_batch_job(engine, batch_job_id) - if engine is not None: - try: - engine.close() - except Exception: - pass + if batch_job_id and engine: ptslh.cancel_batch_job(engine, batch_job_id) + if engine: engine.close() - dbg(f"transfer() done, {len(results)} results") + if progress_cb: + progress_cb(100, 100, "Project creation complete") return results def sync(self, session: SessionContext) -> list[DawCommandResult]: diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index a568b26..fa96604 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -167,6 +167,8 @@ def get_session_audio_dir(engine) -> str: def create_session_from_template( engine, session_name: str, session_location: str, template_group: str, template_name: str, + sample_rate: str = "SR_48000", + bit_depth: str = "Bit24", ) -> None: """Create a new Pro Tools session from a template. @@ -184,8 +186,8 @@ def create_session_from_template( "template_group": template_group, "template_name": template_name, "file_type": "FT_WAVE", - "sample_rate": "SR_48000", - "bit_depth": "Bit24", + "sample_rate": sample_rate, + "bit_depth": bit_depth, "input_output_settings": "IO_Last", "is_interleaved": True, "is_cloud_project": False, From b38be5baceef10c8bf2bf8b6a104a25c0fe9218f Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 18:22:36 +0100 Subject: [PATCH 08/56] cosmetic updates. --- .gitignore | 2 + TODO.md | 242 ----------------------------------------------------- 2 files changed, 2 insertions(+), 242 deletions(-) delete mode 100644 TODO.md diff --git a/.gitignore b/.gitignore index 70d6562..83e7a15 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ sessionpreplib/daw_processors/ptsl/DAWProject-Reference.html _private/ _private/* +GEMINI.md + # Temporary build directories _dawproject_build/ _dawproject_build/* diff --git a/TODO.md b/TODO.md deleted file mode 100644 index a1630b4..0000000 --- a/TODO.md +++ /dev/null @@ -1,242 +0,0 @@ -# TODO / Backlog (Prioritized) - -## Priority Legend -- **P0**: Critical path — do first -- **P1**: High value — do soon -- **P2**: Medium value — when time permits -- **P3**: Nice to have — backlog - ---- - -## Library Architecture (from DEVELOPMENT.md gaps) - -### P1: DAW Scripting Layer - -- [ ] **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 - -- [ ] **Batch-edit support for Processing column** (deferred) -- [ ] **Visual feedback in setup table** (processed vs original file badges/tooltips) - -### P1: Testing Infrastructure - -- [ ] **`tests/factories.py` — Test factories** - - `make_audio()`, `make_track()`, `make_session()` - - Deterministic, file-I/O-free synthetic test objects - -- [ ] **Unit tests for all detectors** - - One test file per detector in `tests/test_detectors/` - -- [ ] **Unit tests for processors** - - `tests/test_processors/test_bimodal_normalize.py` - -- [ ] **Pipeline integration tests** - - `tests/test_pipeline.py` - -- [ ] **Config and queue tests** - - `tests/test_config.py`, `tests/test_queue.py` - -### P2: Rendering Abstraction - -- [ ] **Renderer ABC in `rendering.py`** - - `render_diagnostic_summary(summary) -> Any` - - `render_track_table(tracks) -> Any` - - `render_daw_action_preview(actions) -> Any` - -- [ ] **DictRenderer** — returns raw dicts for JSON export / GUI binding - -- [ ] **Wrap existing functions into PlainTextRenderer class** - -### P3: Validation Polish - -- [ ] **Component-level `configure()` validation** - - Each detector/processor should raise `ConfigError` for invalid config slices - - Currently config is validated globally but not per-component at startup - -### P2: Session Detector Result Storage - -- [ ] **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 - -### P2: Separate Aggregation from Rendering - -- [ ] **Split `rendering.py` into builder + renderer modules** - - `build_diagnostic_summary()` (465 lines) contains substantial data aggregation logic - - 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: Group Gain as Processor - -- [ ] **Extract `GroupGainProcessor` at `PRIORITY_POST` (200)** - - Currently implemented as `Pipeline._apply_group_levels()` post-step - - Moving to a processor makes it disableable/replaceable - ---- - -## P0: Critical Path - -### Detection & Diagnostics - -- [ ] **Over-compression / brick-wall limiting detection** (Status: ❌) `NEW` - - Why: A track with crest < 6 dB, peak > -1 dBFS, and RMS > -8 dBFS has been crushed before it reached me. I can't un-limit it. This fundamentally changes how I approach the track. - - Heuristic: - ```python - if crest < 6 and peak > -1 and rms > -8: - warn("Possible over-limiting: dynamics may be unrecoverable") - ``` - - Categorization: ATTENTION - -- [ ] **Noise floor / SNR estimation** (Status: ❌) `NEW` - - Why: A quiet vocal comp with audible hiss/hum. Current "silent file" detection misses this—the file has content, but also has a bad noise floor. - - Approach: - - Find gaps/silent sections (RMS below threshold for >500ms) - - Measure RMS of those regions as noise floor - - Compare to signal RMS: $\text{SNR} = \text{signal\_rms\_db} - \text{noise\_floor\_db}$ - - Threshold: Warn if SNR < 30 dB - - Categorization: ATTENTION - -- [ ] **Multi-mic phase coherence within groups** (Status: ❌) `EXPANDED` - - Why: `Kick_In` and `Kick_Out` with opposite polarity will cancel when summed. Grouping preserves gain but doesn't catch this. - - Approach: For each `--group`, compute pairwise correlation between members in low-frequency band (< 500 Hz where phase matters most). - - Threshold: Warn if correlation < 0 - - Note: Expands existing "Phase coherence between related tracks" item with concrete implementation. - - Categorization: ATTENTION (within group context) - -### UX / Output Quality - -- [ ] **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. - - Implementation: `--email-summary` or automatic `sessionprep_issues.txt` - - Example: - ``` - Hi [Name], - - Session diagnostics found the following issues: - - PROBLEMS (require corrected exports): - - Lead Vox_01.wav: digital clipping detected (3 ranges) - - 808_01.wav: sample rate mismatch (44.1kHz, session is 48kHz) - - ATTENTION (please confirm if intentional): - - Pad_01.wav: dual-mono stereo file - - Please send corrected files at your earliest convenience. - - Thanks, - [Name] - ``` - ---- - -## P1: High Value - -### Detection & Diagnostics - -- [ ] **"Effectively silent" / noise-only file detection** (Status: ❌) `NEW` (rare) - - Why: Current `is_silent` only triggers on `peak_linear == 0.0` (absolute zero). A file containing only noise floor with no musical content passes as valid. - - Approach: - - Compute crest factor and RMS distribution - - If peak is low (< -40 dBFS) AND crest is very low (< 6 dB) AND content is spectrally flat (noise-like), flag as "effectively silent / noise only" - - Alternative: If file RMS is entirely below a threshold (e.g., -60 dBFS) with no transients, flag it - - Relationship: Complements SNR estimation (that's for files WITH content; this is for files that ARE noise) - - Categorization: ATTENTION - -- [ ] **Click/pop detection (non-clipping transients)** (Status: ❌) `NEW` - - Why: Isolated transients that are anomalously loud—not clipping, but likely editing errors, plugin glitches, or mouth clicks. - - Approach: Flag when a single sample or very short window (< 5ms) exceeds local RMS by > 20 dB. - - Categorization: ATTENTION - ---- - -## P2: Medium Value - -### Classification Robustness - -- [ ] **Loudness Range (LRA) / dynamics measurement** - - Why: Beyond crest — flag heavily compressed vs genuinely dynamic tracks. - -### Detection & Diagnostics - -- [ ] **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 - -- [ ] **Reverb/bleed estimation** - - Approach: Signal that doesn't drop > 30 dB within 200ms after transients. - - Categorization: ATTENTION (informational) - -- [ ] **Start offset misalignment** - - Approach: Report time-to-first-content; flag outliers. - - Categorization: ATTENTION - -- [ ] **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** — `--hpf 30`; opt-in only, never default. - -- [ ] **Automatic SRC** — `--target_sr 48000` via libsamplerate; destructive, needs explicit intent. - -### Session Integrity Checks - -- [ ] **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** - - "Length mismatches" detector exists; may need threshold tuning for sub-second differences. - -### Metering & Loudness Context - -- [ ] **True-peak / ISP warnings** — more relevant at mastering; low priority at mix stage. - -- [ ] **LUFS measurement** — nice context, per-file integrated + short-term display. - ---- - -## P3: Nice to Have - -### Detection & Diagnostics - -- [ ] **Stereo narrowness detection** — effectively mono stereo (< 5% width); ATTENTION (informational). - -- [ ] **Asymmetric panning detection** — stereo hard-panned to one side; often a bounce error. - -- [ ] **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** `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. - ---- - -## Summary: Recommended Sprint Order - -| 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) | -| **2** | Group intelligence | Multi-mic phase coherence | -| **3** | Workflow polish | Click/pop detection | -| **4** | Metering depth | LRA, LUFS (P2), True-peak/ISP (P2) | -| **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 7931024fa3401a96793c41562999a1e9c707f71a Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 19:16:36 +0100 Subject: [PATCH 09/56] batch mode --- sessionprepgui/analysis/mixin.py | 134 +++++++++--------- sessionprepgui/batch/__init__.py | 4 + sessionprepgui/batch/manager.py | 231 +++++++++++++++++++++++++++++++ sessionprepgui/batch/panel.py | 192 +++++++++++++++++++++++++ sessionprepgui/daw/mixin.py | 45 ++++++ sessionprepgui/mainwindow.py | 54 ++++++++ 6 files changed, 597 insertions(+), 63 deletions(-) create mode 100644 sessionprepgui/batch/__init__.py create mode 100644 sessionprepgui/batch/manager.py create mode 100644 sessionprepgui/batch/panel.py diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 9cd811c..a7e80b9 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -181,25 +181,31 @@ def _on_session_config_reset(self): # ── 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 - + def _clear_workspace(self): + """Clear the UI and reset session state.""" self._on_stop() - self._source_dir = path - self._topology_dir = None - self._track_table.set_source_dir(path) self._session = None self._summary = None self._current_track = None + self._topology_dir = None + self._source_dir = None + self._topo_source_tracks = [] + self._topo_topology = None + + if getattr(self, "_topo_input_tree", None) is not None: + self._topo_input_tree.clear() + if getattr(self, "_topo_output_tree", None) is not None: + self._topo_output_tree.clear() + if getattr(self, "_topo_status_label", None) is not None: + self._topo_status_label.clear() + if getattr(self, "_topo_apply_action", None) is not None: + self._topo_apply_action.setEnabled(False) + + if getattr(self, "_analyze_action", None) is not None: + self._analyze_action.setEnabled(False) + if getattr(self, "_save_session_action", None) is not None: + self._save_session_action.setEnabled(False) - # Reset UI self._phase_tabs.setCurrentIndex(_PHASE_TOPOLOGY) self._phase_tabs.setTabEnabled(_PHASE_ANALYSIS, False) self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) @@ -215,12 +221,27 @@ def _on_open_path(self): 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_config = None self._session_groups = [] self._groups_tab_table.setRowCount(0) self._folder_tree.clear() - self._setup_right_stack.setCurrentIndex(0) # placeholder page + self._setup_right_stack.setCurrentIndex(0) self._project_name_edit.clear() + self.setWindowTitle("SessionPrep") + + @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._clear_workspace() + self._source_dir = path + self._track_table.set_source_dir(path) app_cfg = self._config.get("app", {}) skip_folders = { @@ -284,31 +305,9 @@ def _on_save_session(self): if not path: return - # Ensure we capture all active edits from the session settings widgets - if not self._loading_session_widgets: - self._session_config = self._read_session_config() - - # Ensure project name is synced from widget - if self._session: - self._session.project_name = self._project_name_edit.text().strip() - 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, - "topology": self._topo_topology, - "transfer_manifest": self._session.transfer_manifest, - "topology_applied": self._topology_dir is not None, - "prepare_state": self._session.prepare_state, - "base_transfer_manifest": self._session.base_transfer_manifest, - "use_processed": self._use_processed_cb.isChecked(), - "recursive_scan": self._recursive_scan, - "project_name": self._session.project_name, - }) + state = self._capture_session_state() + _save_session_file(path, state) self._status_bar.showMessage(f"Session saved to {path}") except Exception as exc: QMessageBox.critical( @@ -316,6 +315,33 @@ def _on_save_session(self): f"Could not save session:\n\n{exc}", ) + def _capture_session_state(self) -> dict: + """Capture the current session state into a dictionary.""" + # Ensure we capture all active edits from the session settings widgets + if not getattr(self, "_loading_session_widgets", False): + self._session_config = self._read_session_config() + + # Ensure project name is synced from widget + if self._session: + self._session.project_name = self._project_name_edit.text().strip() + + return { + "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 if self._session else {}, + "tracks": self._session.tracks if self._session else [], + "topology": self._topo_topology, + "transfer_manifest": self._session.transfer_manifest if self._session else [], + "topology_applied": self._topology_dir is not None, + "prepare_state": self._session.prepare_state if self._session else "none", + "base_transfer_manifest": self._session.base_transfer_manifest if self._session else [], + "use_processed": self._use_processed_cb.isChecked(), + "recursive_scan": self._recursive_scan, + "project_name": self._session.project_name if self._session else "", + } + @Slot() def _on_load_session(self): """Load a .spsession file and restore the full session state.""" @@ -337,6 +363,10 @@ def _on_load_session(self): ) return + self._restore_session_state(data) + + def _restore_session_state(self, data: dict): + """Restore session state from a dictionary.""" source_dir = data["source_dir"] if not os.path.isdir(source_dir): QMessageBox.warning( @@ -347,9 +377,8 @@ def _on_load_session(self): return # ── Reset UI (same as _on_open_path but without auto-analyze) ──────── - self._on_stop() + self._clear_workspace() self._source_dir = source_dir - self._topology_dir = None self._track_table.set_source_dir(source_dir) # Re-discover original source tracks for Phase 1 topology input table @@ -375,27 +404,6 @@ def _on_load_session(self): self._topo_source_tracks = source_tracks self._topo_topology = data.get("topology") - self._session = None - self._summary = None - self._current_track = None - - self._phase_tabs.setCurrentIndex(_PHASE_TOPOLOGY) - self._phase_tabs.setTabEnabled(_PHASE_ANALYSIS, False) - 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 diff --git a/sessionprepgui/batch/__init__.py b/sessionprepgui/batch/__init__.py new file mode 100644 index 0000000..006a5fe --- /dev/null +++ b/sessionprepgui/batch/__init__.py @@ -0,0 +1,4 @@ +from .panel import BatchQueueDock, BatchItem +from .manager import BatchManager + +__all__ = ["BatchQueueDock", "BatchItem", "BatchManager"] diff --git a/sessionprepgui/batch/manager.py b/sessionprepgui/batch/manager.py new file mode 100644 index 0000000..f128f75 --- /dev/null +++ b/sessionprepgui/batch/manager.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +from typing import Any + +from PySide6.QtCore import QObject, Signal, Slot +from sessionpreplib.models import SessionContext +from sessionpreplib.daw_processor import DawProcessor +from sessionpreplib.daw_processors import create_runtime_daw_processors +from sessionprepgui.analysis.worker import DawCheckWorker, DawTransferWorker + +from .panel import BatchItem + +class BatchManager(QObject): + """Orchestrates DAW check and sequential transfer workers for batch processing.""" + + # Emitted when all jobs are complete (or single job finishes) + finished = Signal() + # Emitted when a single item finishes (item_id, status, result_text) + item_finished = Signal(str, str, str) + + def __init__(self, main_window: Any): + super().__init__() + self._main_window = main_window + self._queue: list[BatchItem] = [] + self._current_index: int = 0 + self._running: bool = False + + # Workers + self._check_worker: DawCheckWorker | None = None + self._transfer_worker: DawTransferWorker | None = None + self._current_dp: DawProcessor | None = None + self._current_session: SessionContext | None = None + self._is_single_job: bool = False + + def start_batch(self, items: list[BatchItem]): + if self._running or not items: + return + + self._queue = items + self._current_index = 0 + self._running = True + self._is_single_job = False + + self._process_next() + + def start_single(self, item: BatchItem): + if self._running: + return + + self._queue = [item] + self._current_index = 0 + self._running = True + self._is_single_job = True + + self._process_next() + + def _process_next(self): + if self._current_index >= len(self._queue): + self._finish_batch() + return + + item = self._queue[self._current_index] + item.status = "Running" + item.result_text = "Checking DAW..." + self.item_finished.emit(item.id, item.status, item.result_text) + + # 1. Rehydrate Session and Processor + try: + self._current_session = self._rehydrate_session(item.session_state) + self._current_dp = self._get_daw_processor(item.daw_processor_id, self._current_session.config) + except Exception as e: + self._handle_item_failure(item, f"Failed to prepare job: {e}") + return + + if not self._current_dp: + self._handle_item_failure(item, f"DAW Processor '{item.daw_processor_id}' not found.") + return + + # 2. Run Pre-flight Check (connectivity & open session) + self._check_worker = DawCheckWorker(self._current_dp) + self._check_worker.result.connect(lambda ok, msg: self._on_check_result(item, ok, msg)) + self._check_worker.start() + + @Slot(object, bool, str) + def _on_check_result(self, item: BatchItem, ok: bool, message: str): + self._check_worker = None + + if not ok: + self._handle_item_failure(item, f"DAW Check Failed: {message}") + return + + # Optional: Further checks against the DAW state could be placed here if needed. + # e.g., if message indicates a session is already open and shouldn't be. + # We rely on the fetch/check logic for now. + if "PRO_TOOLS_SESSION_OPEN" in message: + self._handle_item_failure(item, "DAW Check Failed: Pro Tools session is open. Close it first.") + return + + # 3. Start Transfer + item.result_text = "Transferring..." + self.item_finished.emit(item.id, item.status, item.result_text) + + self._transfer_worker = DawTransferWorker( + self._current_dp, self._current_session, item.output_path) + self._transfer_worker.result.connect(lambda ok, msg, results: self._on_transfer_result(item, ok, msg)) + self._transfer_worker.start() + + @Slot(object, bool, str) + def _on_transfer_result(self, item: BatchItem, ok: bool, message: str): + self._transfer_worker = None + + if ok: + item.status = "Success" + item.result_text = "Success" + else: + item.status = "Failed" + item.result_text = message + + self.item_finished.emit(item.id, item.status, item.result_text) + + self._current_index += 1 + self._process_next() + + def _handle_item_failure(self, item: BatchItem, error_msg: str): + item.status = "Failed" + item.result_text = error_msg + self.item_finished.emit(item.id, item.status, item.result_text) + + # In a batch, we proceed to next item even if one fails + self._current_index += 1 + self._process_next() + + def _finish_batch(self): + self._running = False + self._current_index = 0 + self._queue = [] + self._current_dp = None + self._current_session = None + self.finished.emit() + + def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: + """Create a SessionContext instance from the captured dictionary state.""" + from sessionpreplib.models import SessionContext + from sessionpreplib.detectors import default_detectors + from sessionpreplib.processors import default_processors + from sessionpreplib.config import default_config + + tracks = state_dict.get("tracks", []) + source_dir = state_dict.get("source_dir", "") + + flat_config = dict(default_config()) + if state_dict.get("session_config"): + from sessionpreplib.config import flatten_structured_config + flat_config.update(flatten_structured_config(state_dict["session_config"])) + flat_config["_source_dir"] = source_dir + + all_detectors = default_detectors() + for d in all_detectors: + d.configure(flat_config) + + all_processors = [] + for proc in default_processors(): + proc.configure(flat_config) + if proc.enabled: + all_processors.append(proc) + all_processors.sort(key=lambda p: p.priority) + + session = SessionContext( + tracks=tracks, + config=flat_config, + groups={}, + detectors=all_detectors, + processors=all_processors, + daw_state=state_dict.get("daw_state", {}), + prepare_state=state_dict.get("prepare_state", "none"), + transfer_manifest=state_dict.get("transfer_manifest", []), + base_transfer_manifest=state_dict.get("base_transfer_manifest", []), + project_name=state_dict.get("project_name", ""), + ) + # Assuming topology and topology_applied are needed we could restore them too, + # but for transfer, `transfer_manifest` and `output_tracks` (which we rebuilt during load) are key. + # Wait, output_tracks are not in state_dict. + # But prepare step created the files, so we might need output_tracks. + # We can let DawTransferWorker handle it or rebuild it if needed. + # We need output_tracks for the transfer process to know file names. + if state_dict.get("topology_applied", False) and state_dict.get("topology"): + import os + import soundfile as sf + from sessionpreplib.models import TrackContext + topo_folder = flat_config.get("app", {}).get("phase1_output_folder", "sp_01_tracklayout") + prep_folder = flat_config.get("app", {}).get("phase2_output_folder", "sp_02_prepared") + topo_dir = os.path.join(source_dir, topo_folder) + prep_dir = os.path.join(source_dir, prep_folder) + + manifest_group = {e.output_filename: e.group for e in session.transfer_manifest} + rebuilt = [] + topology = state_dict.get("topology") + if topology: + for entry in topology.entries: + topo_path = os.path.join(topo_dir, entry.output_filename) + if not os.path.isfile(topo_path): + continue + try: + info = sf.info(topo_path) + proc_path = os.path.join(prep_dir, entry.output_filename) + out_tc = TrackContext( + filename=entry.output_filename, + filepath=topo_path, + audio_data=None, + samplerate=info.samplerate, + channels=info.channels, + total_samples=info.frames, + bitdepth=str(info.subtype_info) if hasattr(info, 'subtype_info') else "", + subtype=info.subtype, + duration_sec=info.duration, + processed_filepath=(proc_path if os.path.isfile(proc_path) else None) + ) + out_tc.group = manifest_group.get(entry.output_filename) + rebuilt.append(out_tc) + except Exception: + pass + session.output_tracks = rebuilt + + return session + + def _get_daw_processor(self, dp_id: str, flat_config: dict[str, Any]) -> DawProcessor | None: + processors = create_runtime_daw_processors(flat_config) + for dp in processors: + if dp.id == dp_id: + return dp + return None diff --git a/sessionprepgui/batch/panel.py b/sessionprepgui/batch/panel.py new file mode 100644 index 0000000..210b034 --- /dev/null +++ b/sessionprepgui/batch/panel.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +import uuid + +from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtWidgets import ( + QDockWidget, + QHBoxLayout, + QLabel, + QMenu, + QMessageBox, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, + QHeaderView, +) + +from ..theme import COLORS + + +@dataclass +class BatchItem: + id: str + project_name: str + daw_processor_id: str + output_path: str + session_state: dict[str, Any] + status: str = "Pending" + result_text: str = "" + + +class BatchQueueDock(QDockWidget): + """A dock widget that holds the queue of configured sessions for batch transfer.""" + + # Signals + load_requested = Signal(object) # Emits BatchItem + run_batch_requested = Signal(list) # Emits list[BatchItem] + run_single_requested = Signal(object) # Emits BatchItem + + def __init__(self, parent=None): + super().__init__("Batch Queue", parent) + self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) + self.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable) + + self._items: list[BatchItem] = [] + + self._build_ui() + + def _build_ui(self): + container = QWidget() + layout = QVBoxLayout(container) + layout.setContentsMargins(4, 4, 4, 4) + + # Table + self._table = QTableWidget() + self._table.setColumnCount(4) + self._table.setHorizontalHeaderLabels(["Project Name", "DAW", "Status", "Details"]) + self._table.setSelectionBehavior(QTableWidget.SelectRows) + self._table.setSelectionMode(QTableWidget.SingleSelection) + self._table.setEditTriggers(QTableWidget.NoEditTriggers) + self._table.setContextMenuPolicy(Qt.CustomContextMenu) + self._table.customContextMenuRequested.connect(self._on_context_menu) + self._table.setAlternatingRowColors(True) + self._table.setShowGrid(True) + self._table.verticalHeader().setVisible(False) + + header = self._table.horizontalHeader() + header.setSectionResizeMode(0, QHeaderView.Interactive) + header.setSectionResizeMode(1, QHeaderView.ResizeToContents) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + header.setSectionResizeMode(3, QHeaderView.Stretch) + + layout.addWidget(self._table) + + # Bottom bar + bottom_layout = QHBoxLayout() + self._status_label = QLabel("0 sessions queued") + bottom_layout.addWidget(self._status_label) + + bottom_layout.addStretch() + + self._clear_btn = QPushButton("Clear All") + self._clear_btn.clicked.connect(self.clear_all) + bottom_layout.addWidget(self._clear_btn) + + self._run_btn = QPushButton("Run Batch") + self._run_btn.clicked.connect(self._on_run_batch) + self._run_btn.setStyleSheet(f"background-color: {COLORS['accent']}; color: white; font-weight: bold;") + bottom_layout.addWidget(self._run_btn) + + layout.addLayout(bottom_layout) + self.setWidget(container) + + def add_item(self, item: BatchItem) -> bool: + """Add a job to the queue. Returns False if duplicate project name.""" + for existing in self._items: + if existing.project_name.lower() == item.project_name.lower(): + QMessageBox.warning(self, "Duplicate Project Name", f"A session named '{item.project_name}' is already in the queue.") + return False + + self._items.append(item) + self._refresh_table() + return True + + def remove_item(self, item_id: str): + self._items = [i for i in self._items if i.id != item_id] + self._refresh_table() + + def clear_all(self): + self._items.clear() + self._refresh_table() + + def update_item(self, item_id: str, status: str, result_text: str = ""): + """Update the status and result text of a specific item.""" + for item in self._items: + if item.id == item_id: + item.status = status + item.result_text = result_text + break + self._refresh_table() + + def get_pending_items(self) -> list[BatchItem]: + return [i for i in self._items if i.status == "Pending" or i.status == "Failed"] + + def _refresh_table(self): + self._table.setRowCount(0) + for i, item in enumerate(self._items): + self._table.insertRow(i) + + name_item = QTableWidgetItem(item.project_name) + name_item.setData(Qt.UserRole, item.id) + self._table.setItem(i, 0, name_item) + + self._table.setItem(i, 1, QTableWidgetItem(item.daw_processor_id)) + + status_item = QTableWidgetItem(item.status) + if item.status == "Success": + status_item.setForeground(Qt.green) # Use a generic green, or from theme if needed + elif item.status == "Failed": + status_item.setForeground(Qt.red) + elif item.status == "Running": + status_item.setForeground(Qt.blue) + + self._table.setItem(i, 2, status_item) + + details_item = QTableWidgetItem(item.result_text) + details_item.setToolTip(item.result_text) + self._table.setItem(i, 3, details_item) + + pending_count = len(self.get_pending_items()) + self._status_label.setText(f"{len(self._items)} queued ({pending_count} pending)") + self._run_btn.setEnabled(pending_count > 0) + + @Slot() + def _on_run_batch(self): + pending = self.get_pending_items() + if pending: + self.run_batch_requested.emit(pending) + + @Slot(object) + def _on_context_menu(self, pos): + row = self._table.rowAt(pos.y()) + if row < 0: + return + + item_id = self._table.item(row, 0).data(Qt.UserRole) + item = next((i for i in self._items if i.id == item_id), None) + if not item: + return + + menu = QMenu(self) + + load_act = menu.addAction("Load Session (Edit)") + load_act.triggered.connect(lambda: self.load_requested.emit(item)) + + run_act = menu.addAction("Run Individually") + run_act.triggered.connect(lambda: self.run_single_requested.emit(item)) + + menu.addSeparator() + + del_act = menu.addAction("Remove from Queue") + del_act.triggered.connect(lambda: self.remove_item(item_id)) + + menu.exec(self._table.viewport().mapToGlobal(pos)) + + @property + def has_items(self) -> bool: + return len(self._items) > 0 diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index eafa691..763e799 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -279,6 +279,13 @@ def _update_daw_lifecycle_buttons(self): has_assignments = bool(dp_state.get("assignments")) self._auto_assign_action.setEnabled(has_folders) self._transfer_action.setEnabled(has_processor and has_assignments) + + batch_mode = getattr(self, "_batch_mode_action", None) + if batch_mode and batch_mode.isChecked(): + self._transfer_action.setText("Enqueue >>") + else: + self._transfer_action.setText("Create") + has_manifest = bool( self._session and self._session.transfer_manifest) self._reset_manifest_action.setEnabled(has_manifest) @@ -475,6 +482,44 @@ def _on_daw_transfer(self): ) return + # 4. Enqueue or Transfer + batch_mode = getattr(self, "_batch_mode_action", None) + if batch_mode and batch_mode.isChecked(): + import uuid + from ..batch import BatchItem + from ..theme import PT_DEFAULT_COLORS + + output_folder = self._config.get("app", {}).get( + "phase2_output_folder", "sp_02_prepared") + + # Update config like we do in _do_daw_transfer + self._session.config.update(self._flat_config()) + self._session.config.setdefault("gui", {})["groups"] = list(self._session_groups) + colors = self._config.get("colors", PT_DEFAULT_COLORS) + self._session.config["gui"]["colors"] = colors + self._session.config["_source_dir"] = self._source_dir + self._session.config["_output_folder"] = output_folder + + # Resolve output path here so it doesn't prompt during batch execution + output_path = self._active_daw_processor.resolve_output_path( + self._session, self) + if output_path is None: + return + + # Capture state and enqueue + state = self._capture_session_state() + item = BatchItem( + id=uuid.uuid4().hex, + project_name=project_name, + daw_processor_id=self._active_daw_processor.id, + output_path=output_path, + session_state=state, + ) + if self._batch_dock.add_item(item): + self._clear_workspace() + self._status_bar.showMessage("Session added to batch queue.") + return + self._transfer_action.setEnabled(False) self._fetch_action.setEnabled(False) self._run_daw_check_then(self._do_daw_transfer) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index bfa78b2..6568bfe 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -62,6 +62,7 @@ ) from .daw import DawMixin from .topology import TopologyMixin +from .batch import BatchQueueDock, BatchManager class SessionPrepWindow(QMainWindow, AnalysisMixin, TrackColumnsMixin, @@ -114,6 +115,10 @@ def __init__(self): self._daw_fetch_worker: DawFetchWorker | None = None self._daw_transfer_worker: DawTransferWorker | None = None self._prepare_worker: PrepareWorker | None = None + + self._batch_manager = BatchManager(self) + self._batch_manager.finished.connect(self._on_batch_finished) + self._batch_manager.item_finished.connect(self._on_batch_item_finished) # Load persistent GUI configuration (four-section structure) t0 = time.perf_counter() @@ -143,6 +148,13 @@ def __init__(self): t0 = time.perf_counter() self._init_ui() dbg(f"_init_ui: {(time.perf_counter() - t0) * 1000:.1f} ms") + + self._batch_dock = BatchQueueDock(self) + self._batch_dock.load_requested.connect(self._on_load_batch_item) + self._batch_dock.run_batch_requested.connect(self._batch_manager.start_batch) + self._batch_dock.run_single_requested.connect(self._batch_manager.start_single) + self.addDockWidget(Qt.RightDockWidgetArea, self._batch_dock) + self._batch_dock.hide() # hidden by default t0 = time.perf_counter() apply_dark_theme(self) @@ -249,6 +261,13 @@ def _init_menus(self): self._save_session_action.triggered.connect(self._on_save_session) file_menu.addAction(self._save_session_action) + file_menu.addSeparator() + + self._batch_mode_action = QAction("Batch Processing Mode", self) + self._batch_mode_action.setCheckable(True) + self._batch_mode_action.toggled.connect(self._on_batch_mode_toggled) + file_menu.addAction(self._batch_mode_action) + file_menu.addSeparator() prefs_action = QAction("&Preferences...", self) @@ -269,6 +288,32 @@ def _init_menus(self): quit_action.triggered.connect(self.close) file_menu.addAction(quit_action) + @Slot(bool) + def _on_batch_mode_toggled(self, checked: bool): + self._batch_dock.setVisible(checked) + self._update_daw_lifecycle_buttons() + + @Slot(object) + def _on_load_batch_item(self, item): + if self._session: + reply = QMessageBox.question( + self, "Load Session", + "You have an active session in the workspace. Discard current workspace and load from queue?", + QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + self._restore_session_state(item.session_state) + self._batch_dock.remove_item(item.id) + + @Slot() + def _on_batch_finished(self): + self._status_bar.showMessage("Batch processing complete.") + + @Slot(str, str, str) + def _on_batch_item_finished(self, item_id: str, status: str, result_text: str): + self._batch_dock.update_item(item_id, status, result_text) + def _init_analysis_toolbar(self): self._analysis_toolbar = QToolBar("Analysis") self._analysis_toolbar.setIconSize(QSize(16, 16)) @@ -677,6 +722,15 @@ def _on_about(self): ) def closeEvent(self, event): + if self._batch_dock.has_items: + reply = QMessageBox.warning( + self, "Pending Batch Items", + f"You have pending sessions in the batch queue. Are you sure you want to quit?", + QMessageBox.Yes | QMessageBox.No + ) + if reply != QMessageBox.Yes: + event.ignore() + return self._playback.stop() super().closeEvent(event) From 19e8d68e0fa32e8fb3f0005198e747ef4fcfa6cd Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 19:25:02 +0100 Subject: [PATCH 10/56] dawproject silent enqueue --- sessionpreplib/daw_processors/dawproject.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index 47f24f4..2d128e2 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -203,17 +203,30 @@ def resolve_output_path( Returns the chosen path, or ``None`` if the user cancelled. """ from PySide6.QtWidgets import QFileDialog + import os source_dir = session.config.get("_source_dir", "") output_folder = session.config.get("_output_folder", "sp_02_prepared") output_dir = os.path.join(source_dir, output_folder) if source_dir else "" - safe_name = self._template_name or self.name or "dawproject" + # Use project name if set, otherwise fallback to template name + safe_name = session.project_name if getattr(session, "project_name", "") else (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" + # If in batch mode, we do NOT want a blocking dialog + batch_mode = False + if parent_widget: + batch_action = getattr(parent_widget, "_batch_mode_action", None) + if batch_action and batch_action.isChecked(): + batch_mode = True + + if batch_mode: + # Just return the computed path directly, do not block with a dialog. + return default_path + path, _ = QFileDialog.getSaveFileName( parent_widget, "Save DAWproject", default_path, "DAWproject (*.dawproject);;All Files (*)", From 0d0e1e3a028ccaf20deb088724c8f3c5490557c0 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 19:36:12 +0100 Subject: [PATCH 11/56] updated README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 13d45ed..8100d88 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ SessionPrep operates in three phases: |-------|------|-------------|------| | **1** | Track Layout | Define how source tracks map to output files: channel routing, reordering, splitting, merging. Drag-and-drop between input/output trees with visual insert-position indicator. Optional recursive subfolder scanning. Output written to `sp_01_tracklayout/`. | GUI Phase 1 (always available) | | **2** | Analysis & Preparation | Format checks, clipping, DC offset, stereo compatibility, silence, subsonic, peak/RMS measurement, classification, tail exceedance. Bimodal normalization (clip gain adjustment) via Prepare. Output written to `sp_02_prepared/`. | GUI Phase 2 / CLI | -| **3** | DAW Transfer | Transfer tracks into DAW session with per-track naming and folder assignment. Duplicate entries for multi-track scenarios (same clip on different tracks). Fader offsets applied automatically (Pro Tools via PTSL, DAWproject via file generation). | GUI Phase 3 | +| **3** | DAW Transfer | Transfer tracks into DAW session with per-track naming and folder assignment. Duplicate entries for multi-track scenarios (same clip on different tracks). Fader offsets applied automatically (Pro Tools via PTSL, DAWproject via file generation). Support for unattended batch processing of multiple songs. | GUI Phase 3 | ### Diagnostic categories From 64e037b5a361990a848580a09e8c980ce6c9dd70 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 20:00:14 +0100 Subject: [PATCH 12/56] batch progress bars. --- sessionprepgui/batch/manager.py | 33 +++++++++++++++++++++++++++++++++ sessionprepgui/batch/panel.py | 29 ++++++++++++++++++++++++++++- sessionprepgui/mainwindow.py | 9 +++++++++ 3 files changed, 70 insertions(+), 1 deletion(-) diff --git a/sessionprepgui/batch/manager.py b/sessionprepgui/batch/manager.py index f128f75..35d84f7 100644 --- a/sessionprepgui/batch/manager.py +++ b/sessionprepgui/batch/manager.py @@ -17,6 +17,12 @@ class BatchManager(QObject): finished = Signal() # Emitted when a single item finishes (item_id, status, result_text) item_finished = Signal(str, str, str) + # Emitted when a batch (or single item) starts + started = Signal() + # Emitted to update overall progress (current, total) + batch_progress_value = Signal(int, int) + # Emitted to update status bar message + batch_progress_message = Signal(str) def __init__(self, main_window: Any): super().__init__() @@ -41,6 +47,7 @@ def start_batch(self, items: list[BatchItem]): self._running = True self._is_single_job = False + self.started.emit() self._process_next() def start_single(self, item: BatchItem): @@ -52,6 +59,7 @@ def start_single(self, item: BatchItem): self._running = True self._is_single_job = True + self.started.emit() self._process_next() def _process_next(self): @@ -59,10 +67,14 @@ def _process_next(self): self._finish_batch() return + # Emit baseline progress for this job + self._on_transfer_progress_value(0, 1) + item = self._queue[self._current_index] item.status = "Running" item.result_text = "Checking DAW..." self.item_finished.emit(item.id, item.status, item.result_text) + self.batch_progress_message.emit(f"[{item.project_name}] Checking DAW...") # 1. Rehydrate Session and Processor try: @@ -99,12 +111,33 @@ def _on_check_result(self, item: BatchItem, ok: bool, message: str): # 3. Start Transfer item.result_text = "Transferring..." self.item_finished.emit(item.id, item.status, item.result_text) + self.batch_progress_message.emit(f"[{item.project_name}] Transferring...") self._transfer_worker = DawTransferWorker( self._current_dp, self._current_session, item.output_path) + self._transfer_worker.progress.connect(self._on_transfer_progress) + self._transfer_worker.progress_value.connect(self._on_transfer_progress_value) self._transfer_worker.result.connect(lambda ok, msg, results: self._on_transfer_result(item, ok, msg)) self._transfer_worker.start() + @Slot(str) + def _on_transfer_progress(self, message: str): + if self._current_index < len(self._queue): + item = self._queue[self._current_index] + self.batch_progress_message.emit(f"[{item.project_name}] {message}") + + @Slot(int, int) + def _on_transfer_progress_value(self, current: int, total: int): + fraction = current / total if total > 0 else 0 + if not self._is_single_job: + overall_total = len(self._queue) * 100 + overall_current = int((self._current_index * 100) + (fraction * 100)) + else: + overall_total = 100 + overall_current = int(fraction * 100) + + self.batch_progress_value.emit(overall_current, overall_total) + @Slot(object, bool, str) def _on_transfer_result(self, item: BatchItem, ok: bool, message: str): self._transfer_worker = None diff --git a/sessionprepgui/batch/panel.py b/sessionprepgui/batch/panel.py index 210b034..0095759 100644 --- a/sessionprepgui/batch/panel.py +++ b/sessionprepgui/batch/panel.py @@ -11,6 +11,7 @@ QLabel, QMenu, QMessageBox, + QProgressBar, QPushButton, QTableWidget, QTableWidgetItem, @@ -47,11 +48,13 @@ def __init__(self, parent=None): self.setFeatures(QDockWidget.DockWidgetMovable | QDockWidget.DockWidgetFloatable) self._items: list[BatchItem] = [] + self._is_running = False self._build_ui() def _build_ui(self): container = QWidget() + container.setMinimumWidth(450) layout = QVBoxLayout(container) layout.setContentsMargins(4, 4, 4, 4) @@ -76,6 +79,13 @@ def _build_ui(self): layout.addWidget(self._table) + # Progress bar + self._progress_bar = QProgressBar() + self._progress_bar.setRange(0, 100) + self._progress_bar.setValue(0) + self._progress_bar.setVisible(False) + layout.addWidget(self._progress_bar) + # Bottom bar bottom_layout = QHBoxLayout() self._status_label = QLabel("0 sessions queued") @@ -126,6 +136,18 @@ def update_item(self, item_id: str, status: str, result_text: str = ""): def get_pending_items(self) -> list[BatchItem]: return [i for i in self._items if i.status == "Pending" or i.status == "Failed"] + def set_running_state(self, is_running: bool): + self._is_running = is_running + self._progress_bar.setVisible(is_running) + if not is_running: + self._progress_bar.setValue(0) + self._clear_btn.setEnabled(not is_running) + self._run_btn.setEnabled(not is_running and len(self.get_pending_items()) > 0) + + def update_progress(self, current: int, total: int): + self._progress_bar.setRange(0, total) + self._progress_bar.setValue(current) + def _refresh_table(self): self._table.setRowCount(0) for i, item in enumerate(self._items): @@ -153,7 +175,9 @@ def _refresh_table(self): pending_count = len(self.get_pending_items()) self._status_label.setText(f"{len(self._items)} queued ({pending_count} pending)") - self._run_btn.setEnabled(pending_count > 0) + + if not self._is_running: + self._run_btn.setEnabled(pending_count > 0) @Slot() def _on_run_batch(self): @@ -163,6 +187,9 @@ def _on_run_batch(self): @Slot(object) def _on_context_menu(self, pos): + if self._is_running: + return + row = self._table.rowAt(pos.y()) if row < 0: return diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 6568bfe..91e3d58 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -155,6 +155,10 @@ def __init__(self): self._batch_dock.run_single_requested.connect(self._batch_manager.start_single) self.addDockWidget(Qt.RightDockWidgetArea, self._batch_dock) self._batch_dock.hide() # hidden by default + + self._batch_manager.started.connect(self._on_batch_started) + self._batch_manager.batch_progress_value.connect(self._batch_dock.update_progress) + self._batch_manager.batch_progress_message.connect(self._status_bar.showMessage) t0 = time.perf_counter() apply_dark_theme(self) @@ -306,8 +310,13 @@ def _on_load_batch_item(self, item): self._restore_session_state(item.session_state) self._batch_dock.remove_item(item.id) + @Slot() + def _on_batch_started(self): + self._batch_dock.set_running_state(True) + @Slot() def _on_batch_finished(self): + self._batch_dock.set_running_state(False) self._status_bar.showMessage("Batch processing complete.") @Slot(str, str, str) From 1095a7a89831800d95f532c8465804fde6305858 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Sun, 1 Mar 2026 20:48:14 +0100 Subject: [PATCH 13/56] recoloring --- sessionprepgui/batch/manager.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/sessionprepgui/batch/manager.py b/sessionprepgui/batch/manager.py index 35d84f7..012ae42 100644 --- a/sessionprepgui/batch/manager.py +++ b/sessionprepgui/batch/manager.py @@ -185,6 +185,20 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: if state_dict.get("session_config"): from sessionpreplib.config import flatten_structured_config flat_config.update(flatten_structured_config(state_dict["session_config"])) + + # Re-inject the saved groups and colors for the DAW processor to use + # (This matches what _do_daw_transfer does before calling the worker) + flat_config.setdefault("gui", {})["groups"] = state_dict.get("session_groups", []) + + # Colors must come from the global config. The manager receives the main window reference, + # so we can fetch the active global colors from it. + from sessionprepgui.theme import PT_DEFAULT_COLORS + if self._main_window and hasattr(self._main_window, "_config"): + colors = self._main_window._config.get("colors", PT_DEFAULT_COLORS) + else: + colors = PT_DEFAULT_COLORS + flat_config["gui"]["colors"] = colors + flat_config["_source_dir"] = source_dir all_detectors = default_detectors() From 9edcf00557003edd34d50cc9fb14207571181edd Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 00:25:53 +0100 Subject: [PATCH 14/56] hidpi fix, old waveform shown, config merge fixes, batch queue drag & drop, daw processor name in batch queue table, active daw_processor saved to session. --- sessionprepgui/analysis/mixin.py | 22 ++++ sessionprepgui/batch/manager.py | 13 +++ sessionprepgui/batch/panel.py | 172 +++++++++++++++++++++++++++++-- sessionprepgui/daw/mixin.py | 1 + sessionprepgui/mainwindow.py | 2 +- sessionprepgui/session/io.py | 2 + 6 files changed, 205 insertions(+), 7 deletions(-) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index a7e80b9..18ec646 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -206,6 +206,9 @@ def _clear_workspace(self): if getattr(self, "_save_session_action", None) is not None: self._save_session_action.setEnabled(False) + if getattr(self, "_waveform", None) is not None: + self._waveform.set_audio(None, 44100) + self._phase_tabs.setCurrentIndex(_PHASE_TOPOLOGY) self._phase_tabs.setTabEnabled(_PHASE_ANALYSIS, False) self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) @@ -325,12 +328,16 @@ def _capture_session_state(self) -> dict: if self._session: self._session.project_name = self._project_name_edit.text().strip() + active_dp_id = getattr(self, "_active_daw_processor", None) + active_dp_id = active_dp_id.id if active_dp_id else None + return { "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 if self._session else {}, + "active_daw_processor_id": active_dp_id, "tracks": self._session.tracks if self._session else [], "topology": self._topo_topology, "transfer_manifest": self._session.transfer_manifest if self._session else [], @@ -412,6 +419,10 @@ def _restore_session_state(self, data: dict): if self._session_config: self._load_session_widgets(self._session_config) + + # Ensure the DAW processors and combo reflect the newly loaded session config + self._configure_daw_processors() + self._populate_daw_combo() # ── Reconstruct SessionContext from saved tracks ────────────────────── from sessionpreplib.models import SessionContext @@ -563,6 +574,17 @@ def _restore_session_state(self, data: dict): session.config["_use_processed"] = True self._use_processed_cb.setChecked(True) self._update_use_processed_action() + + # ── Restore active DAW processor ────────────────────────────────────── + active_daw_id = data.get("active_daw_processor_id") + if active_daw_id: + for i in range(self._daw_combo.count()): + idx = self._daw_combo.itemData(i) + if idx is not None and idx < len(self._daw_processors): + if self._daw_processors[idx].id == active_daw_id: + self._daw_combo.setCurrentIndex(i) + break + self._update_daw_lifecycle_buttons() # Show folder tree if daw_state already has assignments if has_manifest and self._active_daw_processor: diff --git a/sessionprepgui/batch/manager.py b/sessionprepgui/batch/manager.py index 012ae42..1d2d54f 100644 --- a/sessionprepgui/batch/manager.py +++ b/sessionprepgui/batch/manager.py @@ -176,12 +176,25 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: from sessionpreplib.models import SessionContext from sessionpreplib.detectors import default_detectors from sessionpreplib.processors import default_processors + from sessionpreplib.daw_processors import default_daw_processors from sessionpreplib.config import default_config tracks = state_dict.get("tracks", []) source_dir = state_dict.get("source_dir", "") flat_config = dict(default_config()) + + # Inject defaults for all components so that toggles like protools_enabled exist + for det in default_detectors(): + for param in getattr(det.__class__, "config_params", lambda: [])(): + flat_config[param.key] = param.default + for proc in default_processors(): + for param in getattr(proc.__class__, "config_params", lambda: [])(): + flat_config[param.key] = param.default + for dp in default_daw_processors(): + for param in getattr(dp.__class__, "config_params", lambda: [])(): + flat_config[param.key] = param.default + if state_dict.get("session_config"): from sessionpreplib.config import flatten_structured_config flat_config.update(flatten_structured_config(state_dict["session_config"])) diff --git a/sessionprepgui/batch/panel.py b/sessionprepgui/batch/panel.py index 0095759..6737440 100644 --- a/sessionprepgui/batch/panel.py +++ b/sessionprepgui/batch/panel.py @@ -4,8 +4,10 @@ from typing import Any import uuid -from PySide6.QtCore import Qt, Signal, Slot +from PySide6.QtCore import Qt, Signal, Slot, QPoint +from PySide6.QtGui import QPainter, QPen, QColor, QDropEvent, QDrag, QPixmap from PySide6.QtWidgets import ( + QAbstractItemView, QDockWidget, QHBoxLayout, QLabel, @@ -28,12 +30,160 @@ class BatchItem: id: str project_name: str daw_processor_id: str + daw_processor_name: str output_path: str session_state: dict[str, Any] status: str = "Pending" result_text: str = "" +class _BatchTable(QTableWidget): + """Table widget with internal drag-and-drop row reordering.""" + + reordered = Signal(int, int) # source_row, target_row + + def __init__(self, parent=None): + super().__init__(parent) + self.setSelectionBehavior(QTableWidget.SelectRows) + self.setSelectionMode(QTableWidget.SingleSelection) + self.setEditTriggers(QTableWidget.NoEditTriggers) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.viewport().setAcceptDrops(True) + self.setDragDropMode(QAbstractItemView.DragDrop) + self.setDefaultDropAction(Qt.MoveAction) + self.setDropIndicatorShown(False) + + self._insert_line_y: int | None = None + + def startDrag(self, supportedActions): + selected = self.selectedItems() + if not selected: + return + + row = selected[0].row() + + # Calculate the bounding rect of the entire row + rect = self.visualRect(self.model().index(row, 0)) + for col in range(1, self.columnCount()): + rect = rect.united(self.visualRect(self.model().index(row, col))) + + # Render the row into a pixmap + pixmap = QPixmap(rect.size()) + pixmap.fill(Qt.transparent) + self.viewport().render(pixmap, QPoint(0, 0), rect) + + # Create a new pixmap with 50% opacity + transparent_pixmap = QPixmap(pixmap.size()) + transparent_pixmap.fill(Qt.transparent) + + painter = QPainter(transparent_pixmap) + painter.setOpacity(0.5) + painter.drawPixmap(0, 0, pixmap) + painter.end() + + # Start the drag operation + drag = QDrag(self) + mime = self.model().mimeData(self.selectedIndexes()) + drag.setMimeData(mime) + drag.setPixmap(transparent_pixmap) + + # Get mouse position relative to the row's top-left so the drag image aligns correctly + mouse_pos = self.viewport().mapFromGlobal(self.cursor().pos()) + hotspot = mouse_pos - rect.topLeft() + drag.setHotSpot(hotspot) + + drag.exec_(supportedActions) + + def dragMoveEvent(self, event): + if event.source() != self: + event.ignore() + return + + event.setDropAction(Qt.MoveAction) + event.accept() + + pos = event.position().toPoint() + row = self.rowAt(pos.y()) + + if row == -1: + # Hovering below the last row + last_row = self.rowCount() - 1 + if last_row >= 0: + rect = self.visualRect(self.model().index(last_row, 0)) + self._insert_line_y = rect.bottom() + else: + self._insert_line_y = None + else: + rect = self.visualRect(self.model().index(row, 0)) + mid = rect.top() + rect.height() // 2 + if pos.y() < mid: + self._insert_line_y = rect.top() + else: + self._insert_line_y = rect.bottom() + + self.viewport().update() + + def dragEnterEvent(self, event): + if event.source() == self: + event.setDropAction(Qt.MoveAction) + event.accept() + else: + event.ignore() + + def dragLeaveEvent(self, event): + self._insert_line_y = None + self.viewport().update() + super().dragLeaveEvent(event) + + def dropEvent(self, event: QDropEvent): + self._insert_line_y = None + self.viewport().update() + + if event.source() != self: + event.ignore() + return + + selected = self.selectedItems() + if not selected: + event.ignore() + return + + source_row = selected[0].row() + pos = event.position().toPoint() + target_row = self.rowAt(pos.y()) + + if target_row == -1: + target_row = self.rowCount() + else: + rect = self.visualRect(self.model().index(target_row, 0)) + mid = rect.top() + rect.height() // 2 + if pos.y() >= mid: + target_row += 1 + + # Adjust target if moving downwards because removing the source shifts everything up + if target_row > source_row: + target_row -= 1 + + # Ignore at the Qt level to prevent the default QTableWidget item deletion, + # but accept the event to stop propagation. + event.setDropAction(Qt.IgnoreAction) + event.accept() + + if source_row != target_row: + self.reordered.emit(source_row, target_row) + + def paintEvent(self, event): + super().paintEvent(event) + if self._insert_line_y is not None: + painter = QPainter(self.viewport()) + pen = QPen(QColor(255, 255, 255, 200), 2) + painter.setPen(pen) + w = self.viewport().width() + painter.drawLine(0, self._insert_line_y, w, self._insert_line_y) + painter.end() + + class BatchQueueDock(QDockWidget): """A dock widget that holds the queue of configured sessions for batch transfer.""" @@ -59,14 +209,12 @@ def _build_ui(self): layout.setContentsMargins(4, 4, 4, 4) # Table - self._table = QTableWidget() + self._table = _BatchTable() self._table.setColumnCount(4) self._table.setHorizontalHeaderLabels(["Project Name", "DAW", "Status", "Details"]) - self._table.setSelectionBehavior(QTableWidget.SelectRows) - self._table.setSelectionMode(QTableWidget.SingleSelection) - self._table.setEditTriggers(QTableWidget.NoEditTriggers) self._table.setContextMenuPolicy(Qt.CustomContextMenu) self._table.customContextMenuRequested.connect(self._on_context_menu) + self._table.reordered.connect(self._on_table_reordered) self._table.setAlternatingRowColors(True) self._table.setShowGrid(True) self._table.verticalHeader().setVisible(False) @@ -157,7 +305,7 @@ def _refresh_table(self): name_item.setData(Qt.UserRole, item.id) self._table.setItem(i, 0, name_item) - self._table.setItem(i, 1, QTableWidgetItem(item.daw_processor_id)) + self._table.setItem(i, 1, QTableWidgetItem(item.daw_processor_name)) status_item = QTableWidgetItem(item.status) if item.status == "Success": @@ -179,6 +327,18 @@ def _refresh_table(self): if not self._is_running: self._run_btn.setEnabled(pending_count > 0) + @Slot(int, int) + def _on_table_reordered(self, source_row: int, target_row: int): + if self._is_running: + return # Prevent reordering while batch is executing + + item = self._items.pop(source_row) + self._items.insert(target_row, item) + self._refresh_table() + + # Reselect the moved item + self._table.selectRow(target_row) + @Slot() def _on_run_batch(self): pending = self.get_pending_items() diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 763e799..865a065 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -512,6 +512,7 @@ def _on_daw_transfer(self): id=uuid.uuid4().hex, project_name=project_name, daw_processor_id=self._active_daw_processor.id, + daw_processor_name=self._active_daw_processor.name, output_path=output_path, session_state=state, ) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 91e3d58..da5be55 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -772,7 +772,7 @@ def main(): try: with open(_cfg_path(), "r", encoding="utf-8") as _f: _raw = _json.load(_f) - scale = _raw.get("gui", {}).get("scale_factor") + scale = _raw.get("app", {}).get("scale_factor") if scale is not None and float(scale) != 1.0: os.environ["QT_SCALE_FACTOR"] = str(float(scale)) except Exception: diff --git a/sessionprepgui/session/io.py b/sessionprepgui/session/io.py index a13120c..6172ea1 100644 --- a/sessionprepgui/session/io.py +++ b/sessionprepgui/session/io.py @@ -362,6 +362,7 @@ def save_session(path: str, data: dict) -> None: "use_processed": data.get("use_processed", False), "recursive_scan": data.get("recursive_scan", False), "project_name": data.get("project_name", ""), + "active_daw_processor_id": data.get("active_daw_processor_id"), } with open(path, "w", encoding="utf-8") as fh: json.dump(payload, fh, indent=2, ensure_ascii=False) @@ -420,4 +421,5 @@ def load_session(path: str) -> dict: "use_processed": raw.get("use_processed", False), "recursive_scan": raw.get("recursive_scan", False), "project_name": raw.get("project_name", ""), + "active_daw_processor_id": raw.get("active_daw_processor_id"), } From 3388c4076da38c31d95684ee9f1af91f04f668bf Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 14:03:50 +0100 Subject: [PATCH 15/56] load/save batch queue --- sessionprepgui/batch/panel.py | 39 ++++++++++++++++++ sessionprepgui/mainwindow.py | 76 +++++++++++++++++++++++++++++++++++ sessionprepgui/session/io.py | 73 ++++++++++++++++++--------------- 3 files changed, 156 insertions(+), 32 deletions(-) diff --git a/sessionprepgui/batch/panel.py b/sessionprepgui/batch/panel.py index 6737440..fa7950f 100644 --- a/sessionprepgui/batch/panel.py +++ b/sessionprepgui/batch/panel.py @@ -23,6 +23,7 @@ ) from ..theme import COLORS +from ..session.io import serialize_session_state, deserialize_session_state @dataclass @@ -36,6 +37,31 @@ class BatchItem: status: str = "Pending" result_text: str = "" + def to_dict(self) -> dict: + return { + "id": self.id, + "project_name": self.project_name, + "daw_processor_id": self.daw_processor_id, + "daw_processor_name": self.daw_processor_name, + "output_path": self.output_path, + "session_state": serialize_session_state(self.session_state), + "status": self.status, + "result_text": self.result_text, + } + + @classmethod + def from_dict(cls, data: dict) -> BatchItem: + return cls( + id=data.get("id", str(uuid.uuid4())), + project_name=data.get("project_name", ""), + daw_processor_id=data.get("daw_processor_id", ""), + daw_processor_name=data.get("daw_processor_name", ""), + output_path=data.get("output_path", ""), + session_state=deserialize_session_state(data.get("session_state", {})), + status=data.get("status", "Pending"), + result_text=data.get("result_text", ""), + ) + class _BatchTable(QTableWidget): """Table widget with internal drag-and-drop row reordering.""" @@ -377,3 +403,16 @@ def _on_context_menu(self, pos): @property def has_items(self) -> bool: return len(self._items) > 0 + + def get_state(self) -> list[dict]: + return [item.to_dict() for item in self._items] + + def load_state(self, state: list[dict], append: bool = False): + if not append: + self._items.clear() + self._refresh_table() + + for item_data in state: + new_item = BatchItem.from_dict(item_data) + # add_item handles duplicates and refreshing the table + self.add_item(new_item) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index da5be55..47c7b8a 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -3,6 +3,7 @@ from __future__ import annotations import copy +import json import os import sys import time @@ -15,6 +16,7 @@ from PySide6.QtWidgets import ( QApplication, QComboBox, + QFileDialog, QHBoxLayout, QHeaderView, QLabel, @@ -257,6 +259,10 @@ def _init_menus(self): load_session_action.triggered.connect(self._on_load_session) file_menu.addAction(load_session_action) + load_batch_action = QAction("Load Batch Queue...", self) + load_batch_action.triggered.connect(self._on_load_batch_queue) + file_menu.addAction(load_batch_action) + file_menu.addSeparator() self._save_session_action = QAction("&Save Session...", self) @@ -265,6 +271,10 @@ def _init_menus(self): self._save_session_action.triggered.connect(self._on_save_session) file_menu.addAction(self._save_session_action) + self._save_batch_action = QAction("Save Batch Queue...", self) + self._save_batch_action.triggered.connect(self._on_save_batch_queue) + file_menu.addAction(self._save_batch_action) + file_menu.addSeparator() self._batch_mode_action = QAction("Batch Processing Mode", self) @@ -292,6 +302,72 @@ def _init_menus(self): quit_action.triggered.connect(self.close) file_menu.addAction(quit_action) + @Slot() + def _on_save_batch_queue(self): + if not self._batch_dock.has_items: + QMessageBox.information(self, "Save Batch Queue", "The batch queue is empty.") + return + + start_dir = self._config.get("app", {}).get("default_project_dir", "") or "" + path, _ = QFileDialog.getSaveFileName( + self, "Save Batch Queue", start_dir, + "Batch Queue Files (*.spbatch);;All Files (*)" + ) + if not path: + return + + try: + state = self._batch_dock.get_state() + with open(path, "w", encoding="utf-8") as f: + json.dump(state, f, indent=2) + self._status_bar.showMessage(f"Batch queue saved to {path}") + except Exception as exc: + QMessageBox.critical(self, "Save Batch Queue Failed", f"Could not save batch queue:\n\n{exc}") + + @Slot() + def _on_load_batch_queue(self): + start_dir = self._config.get("app", {}).get("default_project_dir", "") or "" + path, _ = QFileDialog.getOpenFileName( + self, "Load Batch Queue", start_dir, + "Batch Queue Files (*.spbatch);;All Files (*)" + ) + if not path: + return + + try: + with open(path, "r", encoding="utf-8") as f: + state = json.load(f) + except Exception as exc: + QMessageBox.critical(self, "Load Batch Queue Failed", f"Could not load batch queue:\n\n{exc}") + return + + if not isinstance(state, list): + QMessageBox.critical(self, "Invalid File", "The selected file is not a valid batch queue format.") + return + + append = False + if self._batch_dock.has_items: + msg = QMessageBox(self) + msg.setWindowTitle("Load Batch Queue") + msg.setText("How would you like to load the batch queue?") + + replace_btn = msg.addButton("Replace", QMessageBox.AcceptRole) + append_btn = msg.addButton("Append", QMessageBox.AcceptRole) + cancel_btn = msg.addButton("Cancel", QMessageBox.RejectRole) + + msg.exec() + + if msg.clickedButton() == cancel_btn: + return + elif msg.clickedButton() == append_btn: + append = True + + self._batch_dock.load_state(state, append=append) + + self._batch_mode_action.setChecked(True) + self._clear_workspace() + self._status_bar.showMessage(f"Batch queue loaded from {path}") + @Slot(bool) def _on_batch_mode_toggled(self, checked: bool): self._batch_dock.setVisible(checked) diff --git a/sessionprepgui/session/io.py b/sessionprepgui/session/io.py index 6172ea1..6a3841d 100644 --- a/sessionprepgui/session/io.py +++ b/sessionprepgui/session/io.py @@ -331,15 +331,11 @@ def _deser_transfer_entry(d: dict) -> TransferEntry: # 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] = { +def serialize_session_state(data: dict) -> dict: + """Serialise a raw session state dict to a JSON-safe dict.""" + return { "version": CURRENT_VERSION, - "source_dir": data["source_dir"], + "source_dir": data.get("source_dir", ""), "active_config_preset": data.get("active_config_preset", "Default"), "session_config": data.get("session_config"), "session_groups": data.get("session_groups", []), @@ -364,34 +360,13 @@ def save_session(path: str, data: dict) -> None: "project_name": data.get("project_name", ""), "active_daw_processor_id": data.get("active_daw_processor_id"), } - 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 - - ``project_name`` (str) - - 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) +def deserialize_session_state(raw: dict) -> dict: + """Deserialize a JSON-safe dict back into a session state dict.""" 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() @@ -423,3 +398,37 @@ def load_session(path: str) -> dict: "project_name": raw.get("project_name", ""), "active_daw_processor_id": raw.get("active_daw_processor_id"), } + +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 = serialize_session_state(data) + 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 + - ``project_name`` (str) + + 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) + + if not raw.get("source_dir"): + raise ValueError("Session file is missing 'source_dir'.") + + return deserialize_session_state(raw) From 2136f800a020f9b04f9f2f95a265638b9dfb3f70 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 14:37:14 +0100 Subject: [PATCH 16/56] open project folder --- sessionprepgui/batch/panel.py | 25 ++++++++ sessionprepgui/daw/mixin.py | 70 +++++++++++++++++------ sessionprepgui/mainwindow.py | 48 ++++++++++++++++ sessionpreplib/daw_processor.py | 19 +++++- sessionpreplib/daw_processors/protools.py | 11 ---- 5 files changed, 144 insertions(+), 29 deletions(-) diff --git a/sessionprepgui/batch/panel.py b/sessionprepgui/batch/panel.py index fa7950f..191a654 100644 --- a/sessionprepgui/batch/panel.py +++ b/sessionprepgui/batch/panel.py @@ -217,6 +217,7 @@ class BatchQueueDock(QDockWidget): load_requested = Signal(object) # Emits BatchItem run_batch_requested = Signal(list) # Emits list[BatchItem] run_single_requested = Signal(object) # Emits BatchItem + open_project_requested = Signal(object) # Emits BatchItem def __init__(self, parent=None): super().__init__("Batch Queue", parent) @@ -267,6 +268,11 @@ def _build_ui(self): bottom_layout.addStretch() + self._open_project_btn = QPushButton("Open Project Folder") + self._open_project_btn.setEnabled(False) + self._open_project_btn.clicked.connect(self._on_open_batch_project_folder) + bottom_layout.addWidget(self._open_project_btn) + self._clear_btn = QPushButton("Clear All") self._clear_btn.clicked.connect(self.clear_all) bottom_layout.addWidget(self._clear_btn) @@ -278,6 +284,25 @@ def _build_ui(self): layout.addLayout(bottom_layout) self.setWidget(container) + + self._table.itemSelectionChanged.connect(self._on_table_selection_changed) + + @Slot() + def _on_table_selection_changed(self): + selected = self._table.selectedItems() + self._open_project_btn.setEnabled(len(selected) > 0 and not self._is_running) + + @Slot() + def _on_open_batch_project_folder(self): + selected = self._table.selectedItems() + if not selected: + return + + row = selected[0].row() + item_id = self._table.item(row, 0).data(Qt.UserRole) + item = next((i for i in self._items if i.id == item_id), None) + if item: + self.open_project_requested.emit(item) def add_item(self, item: BatchItem) -> bool: """Add a job to the queue. Returns False if duplicate project name.""" diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 865a065..cec7f53 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -17,6 +17,7 @@ QLineEdit, QMenu, QMessageBox, + QPushButton, QSizePolicy, QSplitter, QStackedWidget, @@ -196,6 +197,11 @@ def _build_setup_page(self) -> QWidget: self._project_name_edit.textChanged.connect(self._on_project_name_changed) proj_name_layout.addWidget(proj_name_label) proj_name_layout.addWidget(self._project_name_edit, 1) + + self._open_project_btn = QPushButton("Open Project Folder") + self._open_project_btn.clicked.connect(self._on_open_active_project_folder) + proj_name_layout.addWidget(self._open_project_btn) + tree_page_layout.addWidget(proj_name_container) self._folder_tree = _FolderDropTree() @@ -452,18 +458,17 @@ def _on_daw_transfer(self): ) return - # 2. Target Directory Validation (Pro Tools specific) - if self._active_daw_processor.id.startswith("protools"): - flat_config = self._flat_config() - project_dir = flat_config.get("protools_project_dir", "").strip() - + # 2. Target Directory Validation + if hasattr(self._active_daw_processor, "project_dir"): + project_dir = self._active_daw_processor.project_dir.strip() + if not project_dir: QMessageBox.critical( self, "Project Directory Not Set", - "A 'Project directory' must be configured in Pro Tools preferences before creating a project." + f"A 'Project directory' must be configured in {self._active_daw_processor.name} preferences before creating a project." ) return - + if not os.path.isdir(project_dir): QMessageBox.critical( self, "Project Directory Not Found", @@ -472,16 +477,16 @@ def _on_daw_transfer(self): ) return - # 3. Collision Check - target_path = os.path.join(project_dir, project_name) - if os.path.exists(target_path): - QMessageBox.warning( - self, "Project Already Exists", - f"A folder named '{project_name}' already exists in the project directory.\n\n" - "Please choose a different project name." - ) - return - + # 3. Collision Check (Pro Tools specific) + if self._active_daw_processor.id.startswith("protools"): + target_path = os.path.join(project_dir, project_name) + if os.path.exists(target_path): + QMessageBox.warning( + self, "Project Already Exists", + f"A folder named '{project_name}' already exists in the project directory.\n\n" + "Please choose a different project name." + ) + return # 4. Enqueue or Transfer batch_mode = getattr(self, "_batch_mode_action", None) if batch_mode and batch_mode.isChecked(): @@ -1026,3 +1031,34 @@ def _remove_transfer_entry(self, entry_id: str): self._status_bar.showMessage( f"Removed duplicate '{e.daw_track_name}'") break + + @Slot() + def _on_open_active_project_folder(self): + if not self._active_daw_processor: + return + + project_name = self._project_name_edit.text().strip() + project_dir = getattr(self._active_daw_processor, "project_dir", "").strip() + + if not project_dir: + QMessageBox.information( + self, "Open Project Folder", + f"No project directory configured for {self._active_daw_processor.name}." + ) + return + + import os + from PySide6.QtGui import QDesktopServices + from PySide6.QtCore import QUrl + + target_path = os.path.join(project_dir, project_name) if project_name else project_dir + + if os.path.isdir(target_path): + QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) + elif os.path.isdir(project_dir): + QDesktopServices.openUrl(QUrl.fromLocalFile(project_dir)) + else: + QMessageBox.warning( + self, "Open Project Folder", + f"The directory does not exist:\n\n{project_dir}" + ) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 47c7b8a..65a800f 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -155,6 +155,7 @@ def __init__(self): self._batch_dock.load_requested.connect(self._on_load_batch_item) self._batch_dock.run_batch_requested.connect(self._batch_manager.start_batch) self._batch_dock.run_single_requested.connect(self._batch_manager.start_single) + self._batch_dock.open_project_requested.connect(self._on_open_batch_project_folder) self.addDockWidget(Qt.RightDockWidgetArea, self._batch_dock) self._batch_dock.hide() # hidden by default @@ -386,6 +387,53 @@ def _on_load_batch_item(self, item): self._restore_session_state(item.session_state) self._batch_dock.remove_item(item.id) + @Slot(object) + def _on_open_batch_project_folder(self, item): + daw_processor = None + for dp in self._daw_processors: + if dp.id == item.daw_processor_id: + daw_processor = dp + break + + if not daw_processor: + QMessageBox.warning( + self, "Open Project Folder", + f"The configured DAW processor '{item.daw_processor_name}' is not available." + ) + return + + project_dir = getattr(daw_processor, "project_dir", "").strip() + + if not project_dir: + QMessageBox.information( + self, "Open Project Folder", + f"No project directory configured for {daw_processor.name}." + ) + return + + import os + from PySide6.QtGui import QDesktopServices + from PySide6.QtCore import QUrl + + # For DAWs like DAWproject that specify a full output path (e.g. .dawproject file) + # we try to just open the directory containing it if the output path exists. + if item.output_path and os.path.exists(os.path.dirname(item.output_path)): + QDesktopServices.openUrl(QUrl.fromLocalFile(os.path.dirname(item.output_path))) + return + + # Otherwise, try the conventional project directory approach + target_path = os.path.join(project_dir, item.project_name) if item.project_name else project_dir + + if os.path.isdir(target_path): + QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) + elif os.path.isdir(project_dir): + QDesktopServices.openUrl(QUrl.fromLocalFile(project_dir)) + else: + QMessageBox.warning( + self, "Open Project Folder", + f"The directory does not exist:\n\n{project_dir}" + ) + @Slot() def _on_batch_started(self): self._batch_dock.set_running_state(True) diff --git a/sessionpreplib/daw_processor.py b/sessionpreplib/daw_processor.py index 919a9a8..157cac8 100644 --- a/sessionpreplib/daw_processor.py +++ b/sessionpreplib/daw_processor.py @@ -39,7 +39,7 @@ class DawProcessor(ABC): @classmethod def config_params(cls) -> list[ParamSpec]: - """Base returns the enabled toggle. Subclasses call super() + [...].""" + """Base returns the enabled toggle and project dir. Subclasses call super() + [...].""" return [ ParamSpec( key=f"{cls.id}_enabled", @@ -51,18 +51,35 @@ def config_params(cls) -> list[ParamSpec]: "in the toolbar. Disable if you never use this DAW." ), ), + ParamSpec( + key=f"{cls.id}_project_dir", + type=str, + default="", + label="Project directory", + description=( + f"Directory where newly created {cls.name} projects are saved. " + "Leave empty to prompt for a location each time." + ), + widget_hint="path_picker_folder", + ), ] 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._connected: bool = False + self._project_dir: str = config.get(f"{type(self).id}_project_dir", "") @property def enabled(self) -> bool: """Whether this processor is available for selection.""" return self._enabled + @property + def project_dir(self) -> str: + """The directory where new projects should be created.""" + return self._project_dir + @property def connected(self) -> bool: """Whether the last connectivity check succeeded.""" diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 7da132b..c4492dd 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -144,17 +144,6 @@ def create_instances( @classmethod def config_params(cls) -> list[ParamSpec]: return super().config_params() + [ - ParamSpec( - key="protools_project_dir", - type=str, - default="", - label="Project directory", - description=( - "Directory where newly created Pro Tools projects are saved. " - "Leave empty to prompt for a location each time." - ), - widget_hint="path_picker_folder", - ), ParamSpec( key="protools_temp_dir", type=str, From 68ff32b5f03a8e4f4b3a8769f66bca18576962c3 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 15:19:05 +0100 Subject: [PATCH 17/56] ptsl realiabity improvements --- sessionpreplib/daw_processors/protools.py | 20 ++++++++++- sessionpreplib/daw_processors/ptsl_helpers.py | 33 +++++++++++++++++-- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index c4492dd..6830b0e 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -231,6 +231,12 @@ def check_connectivity(self) -> tuple[bool, str]: if version < 2025: self._connected = False return False, "Protocol 2025 or newer required" + + from . import ptsl_helpers as ptslh + if not ptslh.wait_for_host_ready(engine, timeout=25.0, sleep_time=self._command_delay): + self._connected = False + return False, "Connected, but Pro Tools is busy or not ready. Please bring its window to the front." + self._connected = True return True, f"Protocol: {version}" except Exception as e: @@ -268,6 +274,12 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: address=address, ) + if progress_cb: + progress_cb(15, 100, "Waiting for Pro Tools to become ready...") + + if not ptslh.wait_for_host_ready(engine, timeout=25.0, sleep_time=self._command_delay): + raise RuntimeError("Pro Tools is busy or not ready. Please bring its window to the front to wake it.") + if ptslh.is_session_open(engine): raise RuntimeError("PRO_TOOLS_SESSION_OPEN") @@ -497,6 +509,12 @@ def transfer( progress_cb(0, 100, "Connecting to Pro Tools...") engine = self._open_engine() + if progress_cb: + progress_cb(2, 100, "Waiting for Pro Tools to become ready...") + + if not ptslh.wait_for_host_ready(engine, timeout=25.0, sleep_time=self._command_delay): + raise RuntimeError("Pro Tools is busy or not ready. Please bring its window to the front to wake it.") + # ── 0. Setup & Safety Checks ───────────────────────── if not self._project_dir: @@ -676,7 +694,7 @@ def _create_and_spot(item): batch_job_id = None try: - ptslh.close_session(engine, save_on_close=True) + ptslh.close_session(engine, save_on_close=True, delay=delay) results.append(DawCommandResult(command=DawCommand("close_session", "", {}), success=True)) except Exception as e: results.append(DawCommandResult(command=DawCommand("close_session", "", {}), success=False, error=str(e))) diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index fa96604..2acce98 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -132,8 +132,8 @@ def extract_track_id(resp: dict) -> str: # ── Session queries ────────────────────────────────────────────────── def is_session_open(engine) -> bool: - """Check if a session is currently open in Pro Tools. - + """Check if Pro Tools currently has a session open. + Returns True if a session is open, False otherwise. """ try: @@ -144,6 +144,30 @@ def is_session_open(engine) -> bool: # PTSL commands typically fail if no session is open. return False +def wait_for_host_ready(engine, timeout: float = 25.0, sleep_time: float = 0.5) -> bool: + """ + Poll the Pro Tools HostReadyCheck endpoint. + Returns True if the host is ready, False if the timeout is reached. + """ + import time + from ptsl import ops + + start_time = time.time() + while time.time() - start_time < timeout: + op = ops.HostReadyCheck() + try: + # We run the operation directly through the client so we can inspect the response + engine.client.run(op) + if op.response and getattr(op.response, "is_host_ready", False): + return True + except Exception: + # Ignore temporary gRPC errors, timeout, or parsing failures while waking up + pass + + time.sleep(sleep_time) + + return False + def get_color_palette(engine, target: str = "CPTarget_Tracks") -> list[str]: """Fetch the Pro Tools color palette. Returns ``[]`` on failure.""" from ptsl import PTSL_pb2 as pt @@ -215,10 +239,13 @@ def create_session_from_template( f"template '{template_group} / {template_name}' actually exists." ) -def close_session(engine, save_on_close: bool = False) -> None: +def close_session(engine, save_on_close: bool = False, delay: float = 0.5) -> None: """Close the current Pro Tools session.""" from ptsl import PTSL_pb2 as pt + import time run_command(engine, pt.CommandId.CId_CloseSession, {"save_on_close": save_on_close}) + # Give the host a breather to physically close the document + time.sleep(delay) # ── Batch job lifecycle ────────────────────────────────────────────── From eb62b666d3db016448f6e0eb8971c19b004dc129 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 17:14:36 +0100 Subject: [PATCH 18/56] pylint configuration --- .pylintrc | 193 ++++++++++++++++++++++++++++++++++++++++++ .vscode/settings.json | 6 ++ pyproject.toml | 1 + uv.lock | 65 ++++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 .pylintrc create mode 100644 .vscode/settings.json diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..09fef78 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,193 @@ +[MAIN] +analyse-fallback-blocks = no +clear-cache-post-run = no +extension-pkg-allow-list = PySide6 +extension-pkg-whitelist = +fail-on = +fail-under = 10 +ignore = CVS +ignore-paths = +ignore-patterns = ^\.# +ignored-modules = +jobs = 1 +limit-inference-results = 100 +load-plugins = +persistent = yes +prefer-stubs = no +py-version = 3.14 +recursive = no +source-roots = +unsafe-load-any-extension = no + +[BASIC] +argument-naming-style = snake_case +attr-naming-style = snake_case +bad-names = foo, + bar, + baz, + toto, + tutu, + tata +bad-names-rgxs = +class-attribute-naming-style = any +class-const-naming-style = UPPER_CASE +class-naming-style = PascalCase +const-naming-style = UPPER_CASE +docstring-min-length = -1 +function-naming-style = snake_case +good-names = i,j,k,ex,Run,_,x,y,fs,ch,db,tc,pt,dp,sz,df,ok + j, + k, + ex, + Run, + _ +good-names-rgxs = +include-naming-hint = no +inlinevar-naming-style = any +method-naming-style = snake_case +module-naming-style = snake_case +name-group = +no-docstring-rgx = ^_ +property-classes = abc.abstractproperty +variable-naming-style = snake_case + +[CLASSES] +check-protected-access-in-special-methods = no +defining-attr-methods = __init__, + __new__, + setUp, + asyncSetUp, + __post_init__ +exclude-protected = _asdict,_fields,_replace,_source,_make,os._exit +valid-classmethod-first-arg = cls +valid-metaclass-classmethod-first-arg = mcs + +[DESIGN] +exclude-too-few-public-methods = +ignored-parents = +max-args = 5 +max-attributes = 7 +max-bool-expr = 5 +max-branches = 12 +max-locals = 15 +max-parents = 7 +max-positional-arguments = 5 +max-public-methods = 20 +max-returns = 6 +max-statements = 50 +min-public-methods = 2 + +[EXCEPTIONS] +overgeneral-exceptions = builtins.BaseException,builtins.Exception + +[FORMAT] +expected-line-ending-format = +ignore-long-lines = ^\s*(# )??$ +indent-after-paren = 4 +indent-string = ' ' +max-line-length = 120 +max-module-lines = 1000 +single-line-class-stmt = no +single-line-if-stmt = no + +[IMPORTS] +allow-any-import-level = +allow-reexport-from-package = no +allow-wildcard-with-all = no +deprecated-modules = +ext-import-graph = +import-graph = +int-import-graph = +known-standard-library = +known-third-party = enchant +preferred-modules = + +[LOGGING] +logging-format-style = old +logging-modules = logging + +[MESSAGES CONTROL] +confidence = HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED +disable = raw-checker-failed,,missing-docstring,too-many-instance-attributes,too-many-public-methods,too-many-statements,too-many-arguments,too-many-locals,too-many-branches,too-many-return-statements,protected-access,duplicate-code,fixme,broad-exception-caught,no-member,invalid-name, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + use-implicit-booleaness-not-comparison-to-string, + use-implicit-booleaness-not-comparison-to-zero +enable = + +[METHOD_ARGS] +timeout-methods = requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + +[MISCELLANEOUS] +check-fixme-in-docstring = no +notes = FIXME, + XXX, + TODO +notes-rgx = + +[REFACTORING] +max-nested-blocks = 5 +never-returning-functions = sys.exit,argparse.parse_error +suggest-join-with-non-empty-separator = yes + +[REPORTS] +evaluation = max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) +msg-template = +reports = no +score = yes + +[SIMILARITIES] +ignore-comments = yes +ignore-docstrings = yes +ignore-imports = yes +ignore-signatures = yes +min-similarity-lines = 4 + +[SPELLING] +max-spelling-suggestions = 4 +spelling-dict = +spelling-ignore-comment-directives = fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: +spelling-ignore-words = +spelling-private-dict-file = +spelling-store-unknown-words = no + +[STRING] +check-quote-consistency = no +check-str-concat-over-line-jumps = no + +[TYPECHECK] +contextmanager-decorators = contextlib.contextmanager +generated-members = +ignore-none = yes +ignore-on-opaque-inference = yes +ignored-checks-for-mixins = no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init +ignored-classes = optparse.Values,thread._local,_thread._local,argparse.Namespace +missing-member-hint = yes +missing-member-hint-distance = 1 +missing-member-max-choices = 1 +mixin-class-rgx = .*[Mm]ixin +signature-mutators = + +[VARIABLES] +additional-builtins = +allow-global-unused-variables = yes +allowed-redefined-builtins = +callbacks = cb_, + _cb +dummy-variables-rgx = _+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ +ignored-argument-names = _.*|^ignored_|^unused_ +init-import = no +redefining-builtins-modules = six.moves,past.builtins,future.builtins,builtins,io + diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..db16750 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "pylint.args": [ + "--rcfile=.pylintrc" + ], + "pylint.importStrategy": "fromEnvironment" +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0d0bc35..ed79731 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,4 +53,5 @@ dev = [ "zstandard>=0.25.0", "rich>=13.0", "patchelf>=0.17.2.4; sys_platform != 'win32'", + "pylint>=4.0.5", ] diff --git a/uv.lock b/uv.lock index f82fc3f..e524722 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a9/ba/000a1996d4308bc65120167c21241a3b205464a2e0b58deda26ae8ac21d1/altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597", size = 21228, upload-time = "2025-11-21T20:35:49.444Z" }, ] +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + [[package]] name = "cffi" version = "2.0.0" @@ -90,6 +99,15 @@ dependencies = [ { name = "lxml" }, ] +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "flake8" version = "7.3.0" @@ -134,6 +152,15 @@ 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 = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + [[package]] name = "lxml" version = "6.0.2" @@ -304,6 +331,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -419,6 +455,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/b1/9da6ec3e88696018ee7bb9dc4a7310c2cfaebf32923a19598cd342767c10/pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5", size = 452318, upload-time = "2026-01-20T00:15:21.88Z" }, ] +[[package]] +name = "pylint" +version = "4.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, +] + [[package]] name = "pyside6" version = "6.10.2" @@ -576,6 +630,7 @@ dev = [ { name = "patchelf", marker = "sys_platform != 'win32'" }, { name = "pillow" }, { name = "pyinstaller" }, + { name = "pylint" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "rich" }, @@ -601,6 +656,7 @@ dev = [ { name = "patchelf", marker = "sys_platform != 'win32'", specifier = ">=0.17.2.4" }, { name = "pillow", specifier = ">=10.0" }, { name = "pyinstaller", specifier = ">=6.0" }, + { name = "pylint", specifier = ">=4.0.5" }, { name = "pytest", specifier = ">=8.0" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "rich", specifier = ">=13.0" }, @@ -663,6 +719,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" }, ] +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 3e99fa574e6782cc31bf3d06884d4896ad156041 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 17:36:47 +0100 Subject: [PATCH 19/56] pylint fixes. --- .pylintrc | 211 +++------------------- sessionpreplib/daw_processors/protools.py | 116 ++++++------ 2 files changed, 86 insertions(+), 241 deletions(-) diff --git a/.pylintrc b/.pylintrc index 09fef78..cde8d29 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,193 +1,30 @@ -[MAIN] -analyse-fallback-blocks = no -clear-cache-post-run = no -extension-pkg-allow-list = PySide6 -extension-pkg-whitelist = -fail-on = -fail-under = 10 -ignore = CVS -ignore-paths = -ignore-patterns = ^\.# -ignored-modules = -jobs = 1 -limit-inference-results = 100 -load-plugins = -persistent = yes -prefer-stubs = no -py-version = 3.14 -recursive = no -source-roots = -unsafe-load-any-extension = no - -[BASIC] -argument-naming-style = snake_case -attr-naming-style = snake_case -bad-names = foo, - bar, - baz, - toto, - tutu, - tata -bad-names-rgxs = -class-attribute-naming-style = any -class-const-naming-style = UPPER_CASE -class-naming-style = PascalCase -const-naming-style = UPPER_CASE -docstring-min-length = -1 -function-naming-style = snake_case -good-names = i,j,k,ex,Run,_,x,y,fs,ch,db,tc,pt,dp,sz,df,ok - j, - k, - ex, - Run, - _ -good-names-rgxs = -include-naming-hint = no -inlinevar-naming-style = any -method-naming-style = snake_case -module-naming-style = snake_case -name-group = -no-docstring-rgx = ^_ -property-classes = abc.abstractproperty -variable-naming-style = snake_case - -[CLASSES] -check-protected-access-in-special-methods = no -defining-attr-methods = __init__, - __new__, - setUp, - asyncSetUp, - __post_init__ -exclude-protected = _asdict,_fields,_replace,_source,_make,os._exit -valid-classmethod-first-arg = cls -valid-metaclass-classmethod-first-arg = mcs - -[DESIGN] -exclude-too-few-public-methods = -ignored-parents = -max-args = 5 -max-attributes = 7 -max-bool-expr = 5 -max-branches = 12 -max-locals = 15 -max-parents = 7 -max-positional-arguments = 5 -max-public-methods = 20 -max-returns = 6 -max-statements = 50 -min-public-methods = 2 - -[EXCEPTIONS] -overgeneral-exceptions = builtins.BaseException,builtins.Exception - -[FORMAT] -expected-line-ending-format = -ignore-long-lines = ^\s*(# )??$ -indent-after-paren = 4 -indent-string = ' ' -max-line-length = 120 -max-module-lines = 1000 -single-line-class-stmt = no -single-line-if-stmt = no - -[IMPORTS] -allow-any-import-level = -allow-reexport-from-package = no -allow-wildcard-with-all = no -deprecated-modules = -ext-import-graph = -import-graph = -int-import-graph = -known-standard-library = -known-third-party = enchant -preferred-modules = - -[LOGGING] -logging-format-style = old -logging-modules = logging +[MASTER] +ignore=CVS +ignore-patterns= +persistent=yes +load-plugins= +jobs=1 +unsafe-load-any-extension=no +extension-pkg-allow-list=PySide6 [MESSAGES CONTROL] -confidence = HIGH, - CONTROL_FLOW, - INFERENCE, - INFERENCE_FAILURE, - UNDEFINED -disable = raw-checker-failed,,missing-docstring,too-many-instance-attributes,too-many-public-methods,too-many-statements,too-many-arguments,too-many-locals,too-many-branches,too-many-return-statements,protected-access,duplicate-code,fixme,broad-exception-caught,no-member,invalid-name, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead, - use-implicit-booleaness-not-comparison-to-string, - use-implicit-booleaness-not-comparison-to-zero -enable = - -[METHOD_ARGS] -timeout-methods = requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request - -[MISCELLANEOUS] -check-fixme-in-docstring = no -notes = FIXME, - XXX, - TODO -notes-rgx = - -[REFACTORING] -max-nested-blocks = 5 -never-returning-functions = sys.exit,argparse.parse_error -suggest-join-with-non-empty-separator = yes +disable=raw-checker-failed,bad-inline-option,locally-disabled,file-ignored,suppressed-message,useless-suppression,deprecated-pragma,use-symbolic-message-instead,missing-docstring,too-many-instance-attributes,too-many-public-methods,too-many-statements,too-many-arguments,too-many-locals,too-many-branches,too-many-return-statements,protected-access,duplicate-code,fixme,broad-exception-caught,no-member,invalid-name,import-outside-toplevel,redefined-outer-name,reimported,attribute-defined-outside-init,too-many-nested-blocks,unused-argument,unused-variable,try-except-raise,multiple-statements,unused-import,line-too-long [REPORTS] -evaluation = max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) -msg-template = -reports = no -score = yes - -[SIMILARITIES] -ignore-comments = yes -ignore-docstrings = yes -ignore-imports = yes -ignore-signatures = yes -min-similarity-lines = 4 - -[SPELLING] -max-spelling-suggestions = 4 -spelling-dict = -spelling-ignore-comment-directives = fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: -spelling-ignore-words = -spelling-private-dict-file = -spelling-store-unknown-words = no +output-format=text +reports=no +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -[STRING] -check-quote-consistency = no -check-str-concat-over-line-jumps = no - -[TYPECHECK] -contextmanager-decorators = contextlib.contextmanager -generated-members = -ignore-none = yes -ignore-on-opaque-inference = yes -ignored-checks-for-mixins = no-member, - not-async-context-manager, - not-context-manager, - attribute-defined-outside-init -ignored-classes = optparse.Values,thread._local,_thread._local,argparse.Namespace -missing-member-hint = yes -missing-member-hint-distance = 1 -missing-member-max-choices = 1 -mixin-class-rgx = .*[Mm]ixin -signature-mutators = - -[VARIABLES] -additional-builtins = -allow-global-unused-variables = yes -allowed-redefined-builtins = -callbacks = cb_, - _cb -dummy-variables-rgx = _+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ -ignored-argument-names = _.*|^ignored_|^unused_ -init-import = no -redefining-builtins-modules = six.moves,past.builtins,future.builtins,builtins,io +[FORMAT] +max-line-length=120 +ignore-long-lines=^\s*(# )??$ +single-line-if-stmt=no +max-module-lines=1000 +indent-string=' ' +indent-after-paren=4 +[BASIC] +good-names=i,j,k,ex,Run,_,x,y,fs,ch,db,tc,pt,dp,sz,df,ok,L,tL,pL,ta,pa,tb_,pb2,tr,tg,tb,pr,pg,pb,e,c +bad-names=foo,bar,baz,toto,tutu,tata +name-group= +include-naming-hint=no diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 6830b0e..5c2c2c2 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -5,9 +5,7 @@ import math import os import time -import uuid import shutil -from collections import Counter from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any @@ -231,12 +229,12 @@ def check_connectivity(self) -> tuple[bool, str]: if version < 2025: self._connected = False return False, "Protocol 2025 or newer required" - + from . import ptsl_helpers as ptslh if not ptslh.wait_for_host_ready(engine, timeout=25.0, sleep_time=self._command_delay): self._connected = False return False, "Connected, but Pro Tools is busy or not ready. Please bring its window to the front." - + self._connected = True return True, f"Protocol: {version}" except Exception as e: @@ -266,7 +264,7 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: try: if progress_cb: progress_cb(10, 100, "Connecting to Pro Tools...") - + address = f"{self._host}:{self._port}" engine = Engine( company_name=self._company_name, @@ -285,16 +283,16 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: import uuid temp_session_name = f"SessionPrep_Temp_{uuid.uuid4().hex[:8]}" - + if progress_cb: progress_cb(30, 100, f"Creating temporary session from template '{self._instance_group} / {self._instance_name}'...") - + # Create the temporary session from the template ptslh.create_session_from_template( - engine, - temp_session_name, - self._temp_dir, - self._instance_group, + engine, + temp_session_name, + self._temp_dir, + self._instance_group, self._instance_name ) @@ -351,15 +349,15 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: # Defensive deletion of the temporary session folder target_dir = os.path.join(self._temp_dir, temp_session_name) ptx_file = os.path.join(target_dir, f"{temp_session_name}.ptx") - + # Extreme safety checks to ensure we only delete what we created: # 1. Ensure target_dir actually exists # 2. Ensure target_dir is exactly a direct child of the configured temp dir - # 3. Ensure target_dir contains our specific UUID .ptx file - if (os.path.isdir(target_dir) and + # 3. Ensure target_dir contains our specific UUID .ptx file + if (os.path.isdir(target_dir) and os.path.dirname(os.path.abspath(target_dir)) == os.path.abspath(self._temp_dir) and os.path.isfile(ptx_file)): - + import shutil import time # Retry loop to handle delayed file locks on Windows from Pro Tools closing @@ -371,7 +369,7 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: except Exception: pass time.sleep(0.5) - + if progress_cb: progress_cb(100, 100, "Fetch complete") @@ -409,11 +407,11 @@ def _open_engine(self): def _get_optimal_session_specs(self, session: SessionContext) -> tuple[str, str]: """Determine most common sample rate and bit depth from output tracks. - + Returns (sample_rate_enum, bit_depth_enum). """ from collections import Counter - + rates = [t.samplerate for t in session.output_tracks if t.samplerate > 0] # bitdepth is string, e.g. "PCM_24". Try to extract numeric part. depths = [] @@ -428,12 +426,12 @@ def _get_optimal_session_specs(self, session: SessionContext) -> tuple[str, str] common_depth = Counter(depths).most_common(1)[0][0] if depths else 24 rate_map = { - 44100: "SR_44100", 48000: "SR_48000", 88200: "SR_88200", + 44100: "SR_44100", 48000: "SR_48000", 88200: "SR_88200", 96000: "SR_96000", 176400: "SR_176400", 192000: "SR_192000" } depth_map = {16: "Bit16", 24: "Bit24", 32: "Bit32Float"} - return (rate_map.get(common_rate, "SR_48000"), + return (rate_map.get(common_rate, "SR_48000"), depth_map.get(common_depth, "Bit24")) def transfer( @@ -475,7 +473,7 @@ def transfer( assignments: dict[str, str] = pt_state.get("assignments", {}) folders = pt_state.get("folders", []) track_order = pt_state.get("track_order", {}) - + if not assignments: dbg("No assignments, returning early") return [] @@ -529,7 +527,7 @@ def transfer( if progress_cb: progress_cb(5, 100, "Calculating audio specifications...") - + rate_enum, depth_enum = self._get_optimal_session_specs(session) # ── 1. Create New Session ──────────────────────────── @@ -574,13 +572,16 @@ def transfer( valid_work: list[tuple[str, str, str, str, str, Any]] = [] for eid, fid in work: folder = folder_map.get(fid) - if not folder: continue + if not folder: + continue entry = manifest_map.get(eid) - if not entry: continue + if not entry: + continue out_tc = out_track_map.get(entry.output_filename) audio_path = (out_tc.processed_filepath or out_tc.filepath) if out_tc else None - if not out_tc or not audio_path: continue - + if not out_tc or not audio_path: + continue + filepath = os.path.abspath(audio_path) track_stem = os.path.splitext(entry.daw_track_name)[0] track_format = ("TF_Mono" if out_tc.channels == 1 else "TF_Stereo") @@ -601,7 +602,7 @@ def transfer( all_filepaths = list(dict.fromkeys(fp for _, _, fp, _, _, _ in valid_work)) clip_id_map: dict[str, list[str]] = {} import_failures: set[str] = set() - + try: import_resp = ptslh.batch_import_audio( engine, all_filepaths, batch_job_id=batch_job_id, progress=25) @@ -618,12 +619,13 @@ def transfer( for fail in import_resp.get("failure_list", []): fail_path = fail.get("original_input_path", "") import_failures.add(os.path.normcase(fail_path)) - + results.append(DawCommandResult( command=DawCommand("batch_import", "", {}), success=True)) except Exception as e: - if batch_job_id: ptslh.cancel_batch_job(engine, batch_job_id) - return [DawCommandResult(command=DawCommand("batch_import", "", {}), + if batch_job_id: + ptslh.cancel_batch_job(engine, batch_job_id) + return [DawCommandResult(command=DawCommand("batch_import", "", {}), success=False, error=str(e))] # ── 3. Parallel Track Creation + Spot ──────────────── @@ -631,34 +633,35 @@ def transfer( color_groups: dict[int, list[str]] = {} created_tracks: list[tuple[str, str, Any]] = [] spot_work = [] - for step, (fname, fid, filepath, track_stem, track_format, tc) in enumerate(valid_work): - clip_ids = clip_id_map.get(os.path.normcase(filepath)) - if not clip_ids or os.path.normcase(filepath) in import_failures: + for step, (_, fid, filepath_val, track_stem, track_format, tc) in enumerate(valid_work): + clip_ids = clip_id_map.get(os.path.normcase(filepath_val)) + if not clip_ids or os.path.normcase(filepath_val) in import_failures: continue - spot_work.append((step, fname, fid, filepath, track_stem, track_format, tc, clip_ids)) + spot_work.append((step, fid, filepath_val, track_stem, track_format, tc, clip_ids)) def _create_and_spot(item): - (step, fname, fid, filepath, track_stem, track_format, tc, clip_ids) = item - folder_name = folder_map[fid]["name"] - pct = 30 + int(50 * step / max(len(valid_work), 1)) - + (step_val, fid_val, _, track_stem_val, track_format_val, tc_val, clip_ids_val) = item + folder_name = folder_map[fid_val]["name"] + pct = 30 + int(50 * step_val / max(len(valid_work), 1)) + try: - tid = ptslh.create_track(engine, track_stem, track_format, + tid = ptslh.create_track(engine, track_stem_val, track_format_val, folder_name=folder_name, batch_job_id=batch_job_id, progress=pct) - ptslh.spot_clips(engine, clip_ids, tid, batch_job_id=batch_job_id, progress=pct) - - cinfo = (group_palette_idx[tc.group], track_stem) if tc.group in group_palette_idx else None - return True, (track_stem, tid, tc), cinfo, None - except Exception as e: - return False, None, None, str(e) + ptslh.spot_clips(engine, clip_ids_val, tid, batch_job_id=batch_job_id, progress=pct) + + cinfo = (group_palette_idx[tc_val.group], track_stem_val) if tc_val.group in group_palette_idx else None + return True, (track_stem_val, tid, tc_val), cinfo, None + except Exception as ex: + return False, None, None, str(ex) with ThreadPoolExecutor(max_workers=6) as pool: futures = [pool.submit(_create_and_spot, item) for item in spot_work] for i, fut in enumerate(as_completed(futures)): - ok, tinfo, cinfo, err = fut.result() + ok, tinfo, cinfo, _ = fut.result() if ok: created_tracks.append(tinfo) - if cinfo: color_groups.setdefault(cinfo[0], []).append(cinfo[1]) + if cinfo: + color_groups.setdefault(cinfo[0], []).append(cinfo[1]) if progress_cb: progress_cb(30 + int(50 * i / len(spot_work)), 100, f"Created {i+1}/{len(spot_work)} tracks") @@ -667,28 +670,33 @@ def _create_and_spot(item): for cidx, names in color_groups.items(): try: ptslh.colorize_tracks(engine, names, cidx, batch_job_id=batch_job_id, progress=90) - except: pass + except Exception: + pass # ── 5. Faders ──────────────────────────────────────── proc_id = "bimodal_normalize" bn_enabled = session.config.get(f"{proc_id}_enabled", True) if bn_enabled: - for t_stem, t_id, tc in created_tracks: - if proc_id in tc.processor_skip: continue + for _, t_id, tc in created_tracks: + if proc_id in tc.processor_skip: + continue pr = tc.processor_results.get(proc_id) - if not pr or pr.classification in ("Silent", "Skip"): continue + if not pr or pr.classification in ("Silent", "Skip"): + continue fader_db = pr.data.get("fader_offset", 0.0) - if fader_db == 0.0: continue + if fader_db == 0.0: + continue try: ptslh.set_track_volume(engine, t_id, fader_db, batch_job_id=batch_job_id, progress=95) - except: pass + except Exception: + pass # ── 6. Save & Close ────────────────────────────────── if progress_cb: progress_cb(98, 100, "Saving and closing session...") - + if batch_job_id: ptslh.complete_batch_job(engine, batch_job_id) batch_job_id = None From 3de96f41a6db17a5dd1a835ca754970cf6568e73 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 18:01:29 +0100 Subject: [PATCH 20/56] pylint fixes --- sessionpreplib/config.py | 5 ++--- sessionpreplib/daw_processor.py | 5 ----- sessionpreplib/daw_processors/dawproject.py | 5 +++-- sessionpreplib/daw_processors/ptsl_helpers.py | 8 ++++---- sessionpreplib/detector.py | 7 ++----- sessionpreplib/processor.py | 2 -- 6 files changed, 11 insertions(+), 21 deletions(-) diff --git a/sessionpreplib/config.py b/sessionpreplib/config.py index 9054783..fe1f432 100644 --- a/sessionpreplib/config.py +++ b/sessionpreplib/config.py @@ -16,7 +16,6 @@ class ConfigError(Exception): """Raised when configuration validation fails.""" - pass @dataclass @@ -122,9 +121,9 @@ def load_preset(path: str) -> dict[str, Any]: with open(path, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: - raise ConfigError(f"Invalid JSON in preset file {path}: {e}") + raise ConfigError(f"Invalid JSON in preset file {path}: {e}") from e except OSError as e: - raise ConfigError(f"Cannot read preset file {path}: {e}") + raise ConfigError(f"Cannot read preset file {path}: {e}") from e if not isinstance(data, dict): raise ConfigError(f"Preset file must contain a JSON object, got {type(data).__name__}") diff --git a/sessionpreplib/daw_processor.py b/sessionpreplib/daw_processor.py index 157cac8..a509cad 100644 --- a/sessionpreplib/daw_processor.py +++ b/sessionpreplib/daw_processor.py @@ -93,7 +93,6 @@ def check_connectivity(self) -> tuple[bool, str]: this checks the connection. For file-based DAWs (DAWProject) this might validate the output path. """ - ... @abstractmethod def fetch(self, session: SessionContext) -> SessionContext: @@ -103,7 +102,6 @@ def fetch(self, session: SessionContext) -> SessionContext: (routing folders, track list, colors, etc.). The GUI can then display this data in the Session Setup panel. """ - ... def resolve_output_path( self, @@ -152,7 +150,6 @@ def transfer( Returns the list of DawCommandResult for this batch. """ - ... @abstractmethod def sync(self, session: SessionContext) -> list[DawCommandResult]: @@ -162,7 +159,6 @@ def sync(self, session: SessionContext) -> list[DawCommandResult]: transfer() (in session.daw_state[self.id]) and sends only the deltas. Same internal dispatch as transfer(). """ - ... @abstractmethod def execute_commands( @@ -177,4 +173,3 @@ def execute_commands( Results are appended to session.daw_command_log. """ - ... diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index 2d128e2..810a797 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -130,10 +130,11 @@ def fetch(self, session: SessionContext) -> SessionContext: from dawproject import ( # noqa: F401 ContentType, DawProject, Referenceable, ) - except ImportError: + except ImportError as exc: raise RuntimeError( "dawproject package not installed. " - "Install with: pip install dawproject") + "Install with: pip install dawproject" + ) from exc Referenceable.reset_id() project = DawProject.load_project(self._template_path) diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index 2acce98..dde0b5d 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -199,7 +199,7 @@ def create_session_from_template( Paths use native OS separators. CId_CreateSession automatically opens the session. """ from ptsl import PTSL_pb2 as pt - + location = os.path.abspath(session_location) os.makedirs(location, exist_ok=True) @@ -216,16 +216,16 @@ def create_session_from_template( "is_interleaved": True, "is_cloud_project": False, } - + # 1. Create the session (Pro Tools automatically opens it as well) run_command(engine, pt.CommandId.CId_CreateSession, body) - + # Wait until Pro Tools actually loads the template and writes the PTX file. # It can take a few seconds for the background creation to finish. import time session_dir = os.path.join(location, session_name) session_path = os.path.join(session_dir, f"{session_name}.ptx") - + success = False for _ in range(15): # Wait up to 7.5 seconds if os.path.isfile(session_path): diff --git a/sessionpreplib/detector.py b/sessionpreplib/detector.py index 8439544..7fc8cf0 100644 --- a/sessionpreplib/detector.py +++ b/sessionpreplib/detector.py @@ -49,7 +49,7 @@ def config_params(cls) -> list[ParamSpec]: def html_help(cls) -> str: """Return HTML help text with Description, Results, and Interpretation sections. Displayed as tooltip in the GUI.""" - ... + def configure(self, config: dict[str, Any]) -> None: """ @@ -61,7 +61,6 @@ def configure(self, config: dict[str, Any]) -> None: @abstractmethod def analyze(self, track: TrackContext) -> DetectorResult: """Analyze one track. Return a DetectorResult.""" - ... def effective_severity(self, result: DetectorResult) -> Severity | None: """Return the display severity for *result*, applying ``report_as``. @@ -163,7 +162,7 @@ def config_params(cls) -> list[ParamSpec]: def html_help(cls) -> str: """Return HTML help text with Description, Results, and Interpretation sections. Displayed as tooltip in the GUI.""" - ... + def configure(self, config: dict[str, Any]) -> None: self._report_as: str = config.get(f"{self.id}_report_as", "default") @@ -178,7 +177,6 @@ def effective_severity(self, result: DetectorResult) -> Severity | None: if report_as == "default": return result.severity return _REPORT_AS_MAP.get(report_as, result.severity) - @abstractmethod def analyze(self, session: SessionContext) -> list[DetectorResult]: """ @@ -186,7 +184,6 @@ def analyze(self, session: SessionContext) -> list[DetectorResult]: (typically one per affected track, plus optionally a session-level summary result). """ - ... def render_html(self, result: DetectorResult, track: TrackContext | None = None) -> str: """Return an HTML table row for this detector's result.""" diff --git a/sessionpreplib/processor.py b/sessionpreplib/processor.py index 8daab91..97b9660 100644 --- a/sessionpreplib/processor.py +++ b/sessionpreplib/processor.py @@ -84,7 +84,6 @@ def process(self, track: TrackContext) -> ProcessorResult: Does NOT mutate audio_data. Returns a ProcessorResult. Used in both dry-run and execute mode. """ - ... def render_html(self, result: ProcessorResult, track: TrackContext | None = None, *, verbose: bool = False) -> str: @@ -118,4 +117,3 @@ def apply(self, track: TrackContext, result: ProcessorResult) -> np.ndarray: Returns the modified audio array. Only called in execute mode. """ - ... From ec1a3a03559a67cb848cf0319764c2ad55fc9131 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 18:23:55 +0100 Subject: [PATCH 21/56] pylint fixes --- sessionprepgui/analysis/mixin.py | 20 ++++++------- sessionprepgui/batch/manager.py | 4 +-- sessionprepgui/mainwindow.py | 4 +-- sessionprepgui/prefs/page_colors.py | 3 +- sessionprepgui/settings.py | 12 ++++---- sessionpreplib/detectors/one_sided_silence.py | 4 +-- sessionpreplib/detectors/subsonic.py | 2 +- sessionpreplib/reports.py | 29 +++++-------------- sessionpreplib/topology.py | 5 ++-- sessionpreplib/utils.py | 2 +- 10 files changed, 34 insertions(+), 51 deletions(-) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 18ec646..93d3430 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -208,7 +208,7 @@ def _clear_workspace(self): if getattr(self, "_waveform", None) is not None: self._waveform.set_audio(None, 44100) - + self._phase_tabs.setCurrentIndex(_PHASE_TOPOLOGY) self._phase_tabs.setTabEnabled(_PHASE_ANALYSIS, False) self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) @@ -307,7 +307,7 @@ def _on_save_session(self): ) if not path: return - + try: state = self._capture_session_state() _save_session_file(path, state) @@ -323,14 +323,14 @@ def _capture_session_state(self) -> dict: # Ensure we capture all active edits from the session settings widgets if not getattr(self, "_loading_session_widgets", False): self._session_config = self._read_session_config() - + # Ensure project name is synced from widget if self._session: self._session.project_name = self._project_name_edit.text().strip() - + active_dp_id = getattr(self, "_active_daw_processor", None) active_dp_id = active_dp_id.id if active_dp_id else None - + return { "source_dir": self._source_dir, "active_config_preset": self._active_config_preset_name, @@ -416,10 +416,10 @@ def _restore_session_state(self, data: dict): self._active_config_preset_name = preset_name self._session_config = data.get("session_config") self._session_groups = data.get("session_groups", []) - + if self._session_config: self._load_session_widgets(self._session_config) - + # Ensure the DAW processors and combo reflect the newly loaded session config self._configure_daw_processors() self._populate_daw_combo() @@ -475,12 +475,12 @@ def _restore_session_state(self, data: dict): ) self._session = session - + # Restore project name widget self._project_name_edit.blockSignals(True) self._project_name_edit.setText(session.project_name) self._project_name_edit.blockSignals(False) - + self._summary = build_diagnostic_summary(session) # ── Populate file list in track table ───────────────────────────────── @@ -574,7 +574,7 @@ def _restore_session_state(self, data: dict): session.config["_use_processed"] = True self._use_processed_cb.setChecked(True) self._update_use_processed_action() - + # ── Restore active DAW processor ────────────────────────────────────── active_daw_id = data.get("active_daw_processor_id") if active_daw_id: diff --git a/sessionprepgui/batch/manager.py b/sessionprepgui/batch/manager.py index 1d2d54f..3b87273 100644 --- a/sessionprepgui/batch/manager.py +++ b/sessionprepgui/batch/manager.py @@ -105,8 +105,8 @@ def _on_check_result(self, item: BatchItem, ok: bool, message: str): # e.g., if message indicates a session is already open and shouldn't be. # We rely on the fetch/check logic for now. if "PRO_TOOLS_SESSION_OPEN" in message: - self._handle_item_failure(item, "DAW Check Failed: Pro Tools session is open. Close it first.") - return + self._handle_item_failure(item, "DAW Check Failed: Pro Tools session is open. Close it first.") + return # 3. Start Transfer item.result_text = "Transferring..." diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 65a800f..9e5476b 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -360,7 +360,7 @@ def _on_load_batch_queue(self): if msg.clickedButton() == cancel_btn: return - elif msg.clickedButton() == append_btn: + if msg.clickedButton() == append_btn: append = True self._batch_dock.load_state(state, append=append) @@ -858,7 +858,7 @@ def closeEvent(self, event): if self._batch_dock.has_items: reply = QMessageBox.warning( self, "Pending Batch Items", - f"You have pending sessions in the batch queue. Are you sure you want to quit?", + "You have pending sessions in the batch queue. Are you sure you want to quit?", QMessageBox.Yes | QMessageBox.No ) if reply != QMessageBox.Yes: diff --git a/sessionprepgui/prefs/page_colors.py b/sessionprepgui/prefs/page_colors.py index 07787d5..28b89e7 100644 --- a/sessionprepgui/prefs/page_colors.py +++ b/sessionprepgui/prefs/page_colors.py @@ -164,8 +164,7 @@ def _on_swatch_dbl_click(self, row: int, col: int) -> None: 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()) + argb = f"#{color.alpha():02x}{color.red():02x}{color.green():02x}{color.blue():02x}" item.setBackground(color) item.setData(Qt.UserRole, argb) item.setToolTip(argb) diff --git a/sessionprepgui/settings.py b/sessionprepgui/settings.py index ca57d4a..10a6430 100644 --- a/sessionprepgui/settings.py +++ b/sessionprepgui/settings.py @@ -153,18 +153,18 @@ def _config_dir() -> str: if not base: base = os.path.expanduser("~") return os.path.join(base, "sessionprep") - elif system == "Darwin": + if system == "Darwin": return os.path.join( os.path.expanduser("~"), "Library", "Application Support", "sessionprep", ) - else: # Linux / BSD / … - base = os.environ.get("XDG_CONFIG_HOME") - if not base: - base = os.path.join(os.path.expanduser("~"), ".config") - return os.path.join(base, "sessionprep") + # Linux / BSD / … + base = os.environ.get("XDG_CONFIG_HOME") + if not base: + base = os.path.join(os.path.expanduser("~"), ".config") + return os.path.join(base, "sessionprep") def config_path() -> str: diff --git a/sessionpreplib/detectors/one_sided_silence.py b/sessionpreplib/detectors/one_sided_silence.py index 80bb5c1..a789c60 100644 --- a/sessionpreplib/detectors/one_sided_silence.py +++ b/sessionpreplib/detectors/one_sided_silence.py @@ -81,10 +81,10 @@ def analyze(self, track: TrackContext) -> DetectorResult: one_sided = False side = None - if l_rms_lin <= silence_lin and r_rms_lin > silence_lin: + if l_rms_lin <= silence_lin < r_rms_lin: one_sided = True side = "L" - elif r_rms_lin <= silence_lin and l_rms_lin > silence_lin: + elif r_rms_lin <= silence_lin < l_rms_lin: one_sided = True side = "R" diff --git a/sessionpreplib/detectors/subsonic.py b/sessionpreplib/detectors/subsonic.py index 10fcf33..025fc4e 100644 --- a/sessionpreplib/detectors/subsonic.py +++ b/sessionpreplib/detectors/subsonic.py @@ -206,7 +206,7 @@ def analyze(self, track: TrackContext) -> DetectorResult: f"subsonic energy {float(combined_ratio):.1f} dB " f"(<= {self.cutoff_hz:g} Hz)" ) - elif any_ch_warn: + else: parts = [] for ch in warn_channels: parts.append(f"ch {ch}: {ch_ratios[ch]:.1f} dB") diff --git a/sessionpreplib/reports.py b/sessionpreplib/reports.py index 3cafc88..e2dd9cf 100644 --- a/sessionpreplib/reports.py +++ b/sessionpreplib/reports.py @@ -80,7 +80,7 @@ def generate_report( "FADER POSITIONS (Set these in your DAW to restore original balance)", "-" * 80, "", - "{:<40} {:>12} {:>12}".format("TRACK", "FADER", "TYPE"), + f"{'TRACK':<40} {'FADER':>12} {'TYPE':>12}", "-" * 80, ]) @@ -91,9 +91,7 @@ def generate_report( fader = pr.data.get("fader_offset", 0) if pr else 0 classification = pr.classification if pr else "Unknown" fader_str = "{:+.1f} dB".format(fader) - lines.append("{:<40} {:>12} {:>12}".format( - t.filename[:38], fader_str, classification - )) + lines.append(f"{t.filename[:38]:<40} {fader_str:>12} {classification:>12}") # Tail report rms_anchor = config.get("rms_anchor", "percentile") @@ -146,15 +144,9 @@ def generate_report( any_tail_reported = True for i, reg in enumerate(regions, start=1): lines.append( - " {:>2}. {} - {} | samples {}-{} | max +{:.2f} dB (RMS {:.2f} dBFS)".format( - i, - reg["start_time"], - reg["end_time"], - reg["start_sample"], - reg["end_sample"], - reg["max_exceed_db"], - reg["max_rms_db"], - ) + f" {i:>2}. {reg['start_time']} - {reg['end_time']} | " + f"samples {reg['start_sample']}-{reg['end_sample']} | " + f"max +{reg['max_exceed_db']:.2f} dB (RMS {reg['max_rms_db']:.2f} dBFS)" ) lines.append("") @@ -169,9 +161,7 @@ def generate_report( "FILE OVERVIEW", "-" * 80, "", - "{:<25} {:>8} {:>8} {:>10}".format( - "TRACK", "SR(kHz)", "BIT", "DUR" - ), + f"{'TRACK':<25} {'SR(kHz)':>8} {'BIT':>8} {'DUR':>10}", "-" * 80, ]) @@ -183,12 +173,7 @@ def generate_report( sr_khz = f"{t.samplerate/1000:.1f}" dur_fmt = format_duration(t.total_samples, t.samplerate) - lines.append("{:<25} {:>8} {:>8} {:>10}".format( - t.filename[:23], - sr_khz, - t.bitdepth, - dur_fmt, - )) + lines.append(f"{t.filename[:23]:<25} {sr_khz:>8} {t.bitdepth:>8} {dur_fmt:>10}") if errors: lines.extend([ diff --git a/sessionpreplib/topology.py b/sessionpreplib/topology.py index abb0539..7c94a28 100644 --- a/sessionpreplib/topology.py +++ b/sessionpreplib/topology.py @@ -8,7 +8,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from .models import TrackContext @@ -120,8 +120,7 @@ def resolve_entry_audio( for src in entry.sources: audio, _sr = track_audio[src.input_filename] n = audio.shape[0] - if n > max_samples: - max_samples = n + max_samples = max(max_samples, n) if max_samples == 0: if entry.output_channels == 1: diff --git a/sessionpreplib/utils.py b/sessionpreplib/utils.py index a4135a5..906b785 100644 --- a/sessionpreplib/utils.py +++ b/sessionpreplib/utils.py @@ -28,7 +28,7 @@ def matches_keywords(filename: str, keywords: list[str]) -> bool: # Exact match mode: keyword ending with '$' if kw_lower.endswith('$'): exact_pattern = kw_lower[:-1] # Remove the '$' - if fname_lower == exact_pattern or fname_lower == exact_pattern + '.wav': + if fname_lower in (exact_pattern, exact_pattern + '.wav'): return True # Glob pattern mode: contains * or ? elif '*' in kw_lower or '?' in kw_lower: From 5fd5fe6662d4cc41d89aeb99be0ba8a6d084ee6e Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 18:45:16 +0100 Subject: [PATCH 22/56] pylint fixes --- .vscode/tasks.json | 16 ++++++ sessionprep-gui.py | 1 + sessionprep.py | 37 ++++++------- sessionprepgui/analysis/mixin.py | 3 +- sessionprepgui/batch/manager.py | 38 ++++++------- sessionprepgui/batch/panel.py | 54 +++++++++---------- sessionprepgui/daw/mixin.py | 32 +++++------ sessionprepgui/log.py | 20 +++---- sessionprepgui/mainwindow.py | 30 ++++++----- sessionprepgui/prefs/config_pages.py | 10 ++-- sessionprepgui/session/io.py | 2 +- sessionpreplib/daw_processors/ptsl_helpers.py | 4 +- sessionpreplib/detectors/stereo_compat.py | 2 +- sessionpreplib/detectors/subsonic.py | 2 +- sessionpreplib/pipeline.py | 2 +- sessionpreplib/reports.py | 2 +- 16 files changed, 136 insertions(+), 119 deletions(-) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..cd558a9 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,16 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Pylint: Run Workspace", + "type": "shell", + "command": "uv run pylint sessionpreplib sessionprepgui sessionprep.py sessionprep-gui.py", + "problemMatcher": "$pylint", + "presentation": { + "reveal": "silent", + "panel": "shared", + "clear": true + } + } + ] +} \ No newline at end of file diff --git a/sessionprep-gui.py b/sessionprep-gui.py index 06b4ef3..6b5f2ca 100644 --- a/sessionprep-gui.py +++ b/sessionprep-gui.py @@ -1,3 +1,4 @@ +# pylint: disable=cyclic-import """ SessionPrep GUI — PySide6 front-end for session analysis. diff --git a/sessionprep.py b/sessionprep.py index cd7ab4d..8e17a56 100644 --- a/sessionprep.py +++ b/sessionprep.py @@ -41,18 +41,18 @@ def parse_arguments(): ) parser.add_argument("--version", action="version", version=f"sessionprep {__version__}") - - parser.add_argument("directory", type=str, + + parser.add_argument("directory", type=str, help="Source directory containing audio tracks (.wav, .aif, .aiff)") - + # Targets - parser.add_argument("--target_rms", type=float, default=-18.0, + parser.add_argument("--target_rms", type=float, default=-18.0, help="Target RMS for sustained sources (dBFS)") - parser.add_argument("--target_peak", type=float, default=-6.0, + parser.add_argument("--target_peak", type=float, default=-6.0, help="Target/max peak level (dBFS)") - parser.add_argument("--crest_threshold", type=float, default=12.0, + parser.add_argument("--crest_threshold", type=float, default=12.0, help="Crest factor threshold for transient detection (dB)") - + # Diagnostics & Detection parser.add_argument("--clip_consecutive", type=int, default=3, help="Number of consecutive samples at ±1.0 to flag as clipped") @@ -74,11 +74,11 @@ def parse_arguments(): help="Subsonic detector cutoff frequency (Hz)") parser.add_argument("--subsonic_warn_ratio_db", type=float, default=-20.0, help="Warn if subsonic power ratio (<= cutoff) exceeds this level (dB relative to full-band power)") - + # Analysis - parser.add_argument("--window", type=positive_int, default=400, + parser.add_argument("--window", type=positive_int, default=400, help="RMS analysis window (ms)") - parser.add_argument("--stereo_mode", type=str, choices=["avg", "sum"], + parser.add_argument("--stereo_mode", type=str, choices=["avg", "sum"], default="avg", help="Stereo RMS calculation mode") # RMS Anchor (momentary window statistics) @@ -95,7 +95,7 @@ def parse_arguments(): help="Only report tail regions exceeding the anchor by at least this many dB") parser.add_argument("--tail_hop_ms", type=positive_int, default=10, help="Hop size for tail region reporting (ms). Larger values reduce the number of reported regions.") - + # Balance restoration parser.add_argument("--anchor", type=str, default=None, help="Anchor track filename (fader stays at 0dB)") @@ -122,22 +122,22 @@ def parse_arguments(): help="Execute processing (write processed WAVs and reports). Without -x this runs in analysis-only mode.") parser.add_argument("--output_folder", type=str, default="processed", help="Subfolder name for processed files") - parser.add_argument("--backup", type=str, default="_originals", + parser.add_argument("--backup", type=str, default="_originals", help="Backup folder name (Only used if overwriting files)") - + # Reporting parser.add_argument("--report", type=str, default="sessionprep.txt", help="Output report filename") parser.add_argument("--json", type=str, default="sessionprep.json", help="Output JSON filename for automation") - + if len(sys.argv) == 1: parser.print_help(sys.stderr) sys.exit(1) - + args = parser.parse_args() - if not (0.0 < args.rms_percentile < 100.0): + if not 0.0 < args.rms_percentile < 100.0: parser.error("--rms_percentile must be between 0 and 100 (exclusive)") if args.gate_relative_db < 0.0: @@ -422,11 +422,6 @@ def on_track_write_complete(**data): table.add_column("Fader", justify="right", style="bold green") table.add_column("Status", justify="right") - def _get_primary_pr(track): - if track.processor_results: - return next(iter(track.processor_results.values())) - return None - for t in session.tracks: if t.status != "OK": table.add_row(t.filename, "Error", "\u2014", "\u2014", "\u2014", "[red]ERR[/]") diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 93d3430..15973a4 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines """Analysis mixin: open/save/load session, analyze, prepare, session config tab.""" from __future__ import annotations @@ -45,7 +46,7 @@ from .worker import AnalyzeWorker, PrepareWorker -class AnalysisMixin: +class AnalysisMixin: # pylint: disable=too-few-public-methods """Session lifecycle: open, save, load, analyze, prepare, session config tab. Mixed into ``SessionPrepWindow`` — not meant to be used standalone. diff --git a/sessionprepgui/batch/manager.py b/sessionprepgui/batch/manager.py index 3b87273..cce6218 100644 --- a/sessionprepgui/batch/manager.py +++ b/sessionprepgui/batch/manager.py @@ -30,7 +30,7 @@ def __init__(self, main_window: Any): self._queue: list[BatchItem] = [] self._current_index: int = 0 self._running: bool = False - + # Workers self._check_worker: DawCheckWorker | None = None self._transfer_worker: DawTransferWorker | None = None @@ -41,7 +41,7 @@ def __init__(self, main_window: Any): def start_batch(self, items: list[BatchItem]): if self._running or not items: return - + self._queue = items self._current_index = 0 self._running = True @@ -53,7 +53,7 @@ def start_batch(self, items: list[BatchItem]): def start_single(self, item: BatchItem): if self._running: return - + self._queue = [item] self._current_index = 0 self._running = True @@ -96,14 +96,14 @@ def _process_next(self): @Slot(object, bool, str) def _on_check_result(self, item: BatchItem, ok: bool, message: str): self._check_worker = None - + if not ok: self._handle_item_failure(item, f"DAW Check Failed: {message}") return - + # Optional: Further checks against the DAW state could be placed here if needed. # e.g., if message indicates a session is already open and shouldn't be. - # We rely on the fetch/check logic for now. + # We rely on the fetch/check logic for now. if "PRO_TOOLS_SESSION_OPEN" in message: self._handle_item_failure(item, "DAW Check Failed: Pro Tools session is open. Close it first.") return @@ -112,7 +112,7 @@ def _on_check_result(self, item: BatchItem, ok: bool, message: str): item.result_text = "Transferring..." self.item_finished.emit(item.id, item.status, item.result_text) self.batch_progress_message.emit(f"[{item.project_name}] Transferring...") - + self._transfer_worker = DawTransferWorker( self._current_dp, self._current_session, item.output_path) self._transfer_worker.progress.connect(self._on_transfer_progress) @@ -135,13 +135,13 @@ def _on_transfer_progress_value(self, current: int, total: int): else: overall_total = 100 overall_current = int(fraction * 100) - + self.batch_progress_value.emit(overall_current, overall_total) @Slot(object, bool, str) def _on_transfer_result(self, item: BatchItem, ok: bool, message: str): self._transfer_worker = None - + if ok: item.status = "Success" item.result_text = "Success" @@ -181,9 +181,9 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: tracks = state_dict.get("tracks", []) source_dir = state_dict.get("source_dir", "") - + flat_config = dict(default_config()) - + # Inject defaults for all components so that toggles like protools_enabled exist for det in default_detectors(): for param in getattr(det.__class__, "config_params", lambda: [])(): @@ -198,11 +198,11 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: if state_dict.get("session_config"): from sessionpreplib.config import flatten_structured_config flat_config.update(flatten_structured_config(state_dict["session_config"])) - + # Re-inject the saved groups and colors for the DAW processor to use # (This matches what _do_daw_transfer does before calling the worker) flat_config.setdefault("gui", {})["groups"] = state_dict.get("session_groups", []) - + # Colors must come from the global config. The manager receives the main window reference, # so we can fetch the active global colors from it. from sessionprepgui.theme import PT_DEFAULT_COLORS @@ -211,13 +211,13 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: else: colors = PT_DEFAULT_COLORS flat_config["gui"]["colors"] = colors - + flat_config["_source_dir"] = source_dir all_detectors = default_detectors() for d in all_detectors: d.configure(flat_config) - + all_processors = [] for proc in default_processors(): proc.configure(flat_config) @@ -237,10 +237,10 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: base_transfer_manifest=state_dict.get("base_transfer_manifest", []), project_name=state_dict.get("project_name", ""), ) - # Assuming topology and topology_applied are needed we could restore them too, + # Assuming topology and topology_applied are needed we could restore them too, # but for transfer, `transfer_manifest` and `output_tracks` (which we rebuilt during load) are key. - # Wait, output_tracks are not in state_dict. - # But prepare step created the files, so we might need output_tracks. + # Wait, output_tracks are not in state_dict. + # But prepare step created the files, so we might need output_tracks. # We can let DawTransferWorker handle it or rebuild it if needed. # We need output_tracks for the transfer process to know file names. if state_dict.get("topology_applied", False) and state_dict.get("topology"): @@ -251,7 +251,7 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: prep_folder = flat_config.get("app", {}).get("phase2_output_folder", "sp_02_prepared") topo_dir = os.path.join(source_dir, topo_folder) prep_dir = os.path.join(source_dir, prep_folder) - + manifest_group = {e.output_filename: e.group for e in session.transfer_manifest} rebuilt = [] topology = state_dict.get("topology") diff --git a/sessionprepgui/batch/panel.py b/sessionprepgui/batch/panel.py index 191a654..2c0a74a 100644 --- a/sessionprepgui/batch/panel.py +++ b/sessionprepgui/batch/panel.py @@ -79,59 +79,59 @@ def __init__(self, parent=None): self.setDragDropMode(QAbstractItemView.DragDrop) self.setDefaultDropAction(Qt.MoveAction) self.setDropIndicatorShown(False) - + self._insert_line_y: int | None = None def startDrag(self, supportedActions): selected = self.selectedItems() if not selected: return - + row = selected[0].row() - + # Calculate the bounding rect of the entire row rect = self.visualRect(self.model().index(row, 0)) for col in range(1, self.columnCount()): rect = rect.united(self.visualRect(self.model().index(row, col))) - + # Render the row into a pixmap pixmap = QPixmap(rect.size()) pixmap.fill(Qt.transparent) self.viewport().render(pixmap, QPoint(0, 0), rect) - + # Create a new pixmap with 50% opacity transparent_pixmap = QPixmap(pixmap.size()) transparent_pixmap.fill(Qt.transparent) - + painter = QPainter(transparent_pixmap) painter.setOpacity(0.5) painter.drawPixmap(0, 0, pixmap) painter.end() - + # Start the drag operation drag = QDrag(self) mime = self.model().mimeData(self.selectedIndexes()) drag.setMimeData(mime) drag.setPixmap(transparent_pixmap) - + # Get mouse position relative to the row's top-left so the drag image aligns correctly mouse_pos = self.viewport().mapFromGlobal(self.cursor().pos()) hotspot = mouse_pos - rect.topLeft() drag.setHotSpot(hotspot) - + drag.exec_(supportedActions) def dragMoveEvent(self, event): if event.source() != self: event.ignore() return - + event.setDropAction(Qt.MoveAction) event.accept() - + pos = event.position().toPoint() row = self.rowAt(pos.y()) - + if row == -1: # Hovering below the last row last_row = self.rowCount() - 1 @@ -147,9 +147,9 @@ def dragMoveEvent(self, event): self._insert_line_y = rect.top() else: self._insert_line_y = rect.bottom() - + self.viewport().update() - + def dragEnterEvent(self, event): if event.source() == self: event.setDropAction(Qt.MoveAction) @@ -165,7 +165,7 @@ def dragLeaveEvent(self, event): def dropEvent(self, event: QDropEvent): self._insert_line_y = None self.viewport().update() - + if event.source() != self: event.ignore() return @@ -174,11 +174,11 @@ def dropEvent(self, event: QDropEvent): if not selected: event.ignore() return - + source_row = selected[0].row() pos = event.position().toPoint() target_row = self.rowAt(pos.y()) - + if target_row == -1: target_row = self.rowCount() else: @@ -284,7 +284,7 @@ def _build_ui(self): layout.addLayout(bottom_layout) self.setWidget(container) - + self._table.itemSelectionChanged.connect(self._on_table_selection_changed) @Slot() @@ -342,7 +342,7 @@ def set_running_state(self, is_running: bool): self._progress_bar.setValue(0) self._clear_btn.setEnabled(not is_running) self._run_btn.setEnabled(not is_running and len(self.get_pending_items()) > 0) - + def update_progress(self, current: int, total: int): self._progress_bar.setRange(0, total) self._progress_bar.setValue(current) @@ -351,13 +351,13 @@ def _refresh_table(self): self._table.setRowCount(0) for i, item in enumerate(self._items): self._table.insertRow(i) - + name_item = QTableWidgetItem(item.project_name) name_item.setData(Qt.UserRole, item.id) self._table.setItem(i, 0, name_item) - + self._table.setItem(i, 1, QTableWidgetItem(item.daw_processor_name)) - + status_item = QTableWidgetItem(item.status) if item.status == "Success": status_item.setForeground(Qt.green) # Use a generic green, or from theme if needed @@ -365,16 +365,16 @@ def _refresh_table(self): status_item.setForeground(Qt.red) elif item.status == "Running": status_item.setForeground(Qt.blue) - + self._table.setItem(i, 2, status_item) - + details_item = QTableWidgetItem(item.result_text) details_item.setToolTip(item.result_text) self._table.setItem(i, 3, details_item) pending_count = len(self.get_pending_items()) self._status_label.setText(f"{len(self._items)} queued ({pending_count} pending)") - + if not self._is_running: self._run_btn.setEnabled(pending_count > 0) @@ -386,7 +386,7 @@ def _on_table_reordered(self, source_row: int, target_row: int): item = self._items.pop(source_row) self._items.insert(target_row, item) self._refresh_table() - + # Reselect the moved item self._table.selectRow(target_row) @@ -400,7 +400,7 @@ def _on_run_batch(self): def _on_context_menu(self, pos): if self._is_running: return - + row = self._table.rowAt(pos.y()) if row < 0: return diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index cec7f53..a5d1ac4 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -266,7 +266,7 @@ def _update_daw_lifecycle_buttons(self): is_working = getattr(self, "_daw_check_worker", None) is not None or \ getattr(self, "_daw_fetch_worker", None) is not None or \ getattr(self, "_daw_transfer_worker", None) is not None - + if is_working: self._fetch_action.setEnabled(False) self._auto_assign_action.setEnabled(False) @@ -285,13 +285,13 @@ def _update_daw_lifecycle_buttons(self): has_assignments = bool(dp_state.get("assignments")) self._auto_assign_action.setEnabled(has_folders) self._transfer_action.setEnabled(has_processor and has_assignments) - + batch_mode = getattr(self, "_batch_mode_action", None) if batch_mode and batch_mode.isChecked(): self._transfer_action.setText("Enqueue >>") else: self._transfer_action.setText("Create") - + has_manifest = bool( self._session and self._session.transfer_manifest) self._reset_manifest_action.setEnabled(has_manifest) @@ -354,7 +354,7 @@ def _do_daw_fetch(self): self._setup_right_stack.setCurrentIndex(_SETUP_RIGHT_TREE) self._folder_tree.clear() self._transfer_progress.start("Fetching folder structure\u2026") - + self._daw_fetch_worker = DawFetchWorker( self._active_daw_processor, self._session) self._daw_fetch_worker.progress.connect(self._on_transfer_progress) @@ -367,13 +367,13 @@ def _do_daw_fetch(self): def _on_daw_fetch_result(self, ok: bool, message: str, session): self._daw_fetch_worker = None self._fetch_action.setEnabled(True) - + if "PRO_TOOLS_SESSION_OPEN" in message: self._transfer_progress.fail("Fetch aborted: Pro Tools session is open.") from PySide6.QtWidgets import QMessageBox QMessageBox.warning( - self, - "Pro Tools Session Open", + self, + "Pro Tools Session Open", "A Pro Tools session is currently open.\n\n" "Please save and close the open session in Pro Tools, then try again." ) @@ -413,7 +413,7 @@ def _on_project_name_changed(self, text: str): self._project_name_edit.setText(sanitized) self._project_name_edit.setCursorPosition(max(0, cursor_pos - 1)) self._project_name_edit.blockSignals(False) - + if self._session: self._session.project_name = sanitized @@ -448,7 +448,7 @@ def _on_daw_transfer(self): import os if not self._active_daw_processor or not self._session: return - + # 1. Project Name Validation project_name = self._project_name_edit.text().strip() if not project_name: @@ -493,7 +493,7 @@ def _on_daw_transfer(self): import uuid from ..batch import BatchItem from ..theme import PT_DEFAULT_COLORS - + output_folder = self._config.get("app", {}).get( "phase2_output_folder", "sp_02_prepared") @@ -504,7 +504,7 @@ def _on_daw_transfer(self): self._session.config["gui"]["colors"] = colors self._session.config["_source_dir"] = self._source_dir self._session.config["_output_folder"] = output_folder - + # Resolve output path here so it doesn't prompt during batch execution output_path = self._active_daw_processor.resolve_output_path( self._session, self) @@ -1036,23 +1036,23 @@ def _remove_transfer_entry(self, entry_id: str): def _on_open_active_project_folder(self): if not self._active_daw_processor: return - + project_name = self._project_name_edit.text().strip() project_dir = getattr(self._active_daw_processor, "project_dir", "").strip() - + if not project_dir: QMessageBox.information( self, "Open Project Folder", f"No project directory configured for {self._active_daw_processor.name}." ) return - + import os from PySide6.QtGui import QDesktopServices from PySide6.QtCore import QUrl - + target_path = os.path.join(project_dir, project_name) if project_name else project_dir - + if os.path.isdir(target_path): QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) elif os.path.isdir(project_dir): diff --git a/sessionprepgui/log.py b/sessionprepgui/log.py index 433d774..baf823f 100644 --- a/sessionprepgui/log.py +++ b/sessionprepgui/log.py @@ -20,15 +20,17 @@ import sys import time -_ENABLED: bool | None = None +class _LogConfig: + """Encapsulates the global debug logging configuration state.""" + enabled: bool | None = None - -def _is_enabled() -> bool: - global _ENABLED - if _ENABLED is None: - val = os.environ.get("SP_DEBUG", "").strip().lower() - _ENABLED = val in ("1", "true") - return _ENABLED + @classmethod + def is_enabled(cls) -> bool: + """Check if SP_DEBUG is active, caching the result.""" + if cls.enabled is None: + val = os.environ.get("SP_DEBUG", "").strip().lower() + cls.enabled = val in ("1", "true") + return cls.enabled def _caller_name() -> str: @@ -57,7 +59,7 @@ def dbg(msg: str) -> None: Automatically detects the calling class or module name. Format: ``[HH:MM:SS.mmm ClassName] message`` """ - if not _is_enabled(): + if not _LogConfig.is_enabled(): return t = time.strftime("%H:%M:%S") ms = int((time.time() % 1) * 1000) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index 9e5476b..ff1280e 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -67,7 +67,9 @@ from .batch import BatchQueueDock, BatchManager -class SessionPrepWindow(QMainWindow, AnalysisMixin, TrackColumnsMixin, +class SessionPrepWindow( # pylint: disable=too-many-ancestors + QMainWindow, + AnalysisMixin, TrackColumnsMixin, GroupsMixin, DawMixin, TopologyMixin, DetailMixin): def __init__(self): t_init = time.perf_counter() @@ -117,7 +119,7 @@ def __init__(self): self._daw_fetch_worker: DawFetchWorker | None = None self._daw_transfer_worker: DawTransferWorker | None = None self._prepare_worker: PrepareWorker | None = None - + self._batch_manager = BatchManager(self) self._batch_manager.finished.connect(self._on_batch_finished) self._batch_manager.item_finished.connect(self._on_batch_item_finished) @@ -150,7 +152,7 @@ def __init__(self): t0 = time.perf_counter() self._init_ui() dbg(f"_init_ui: {(time.perf_counter() - t0) * 1000:.1f} ms") - + self._batch_dock = BatchQueueDock(self) self._batch_dock.load_requested.connect(self._on_load_batch_item) self._batch_dock.run_batch_requested.connect(self._batch_manager.start_batch) @@ -158,7 +160,7 @@ def __init__(self): self._batch_dock.open_project_requested.connect(self._on_open_batch_project_folder) self.addDockWidget(Qt.RightDockWidgetArea, self._batch_dock) self._batch_dock.hide() # hidden by default - + self._batch_manager.started.connect(self._on_batch_started) self._batch_manager.batch_progress_value.connect(self._batch_dock.update_progress) self._batch_manager.batch_progress_message.connect(self._status_bar.showMessage) @@ -277,7 +279,7 @@ def _init_menus(self): file_menu.addAction(self._save_batch_action) file_menu.addSeparator() - + self._batch_mode_action = QAction("Batch Processing Mode", self) self._batch_mode_action.setCheckable(True) self._batch_mode_action.toggled.connect(self._on_batch_mode_toggled) @@ -308,7 +310,7 @@ def _on_save_batch_queue(self): if not self._batch_dock.has_items: QMessageBox.information(self, "Save Batch Queue", "The batch queue is empty.") return - + start_dir = self._config.get("app", {}).get("default_project_dir", "") or "" path, _ = QFileDialog.getSaveFileName( self, "Save Batch Queue", start_dir, @@ -341,7 +343,7 @@ def _on_load_batch_queue(self): except Exception as exc: QMessageBox.critical(self, "Load Batch Queue Failed", f"Could not load batch queue:\n\n{exc}") return - + if not isinstance(state, list): QMessageBox.critical(self, "Invalid File", "The selected file is not a valid batch queue format.") return @@ -351,20 +353,20 @@ def _on_load_batch_queue(self): msg = QMessageBox(self) msg.setWindowTitle("Load Batch Queue") msg.setText("How would you like to load the batch queue?") - + replace_btn = msg.addButton("Replace", QMessageBox.AcceptRole) append_btn = msg.addButton("Append", QMessageBox.AcceptRole) cancel_btn = msg.addButton("Cancel", QMessageBox.RejectRole) - + msg.exec() - + if msg.clickedButton() == cancel_btn: return if msg.clickedButton() == append_btn: append = True self._batch_dock.load_state(state, append=append) - + self._batch_mode_action.setChecked(True) self._clear_workspace() self._status_bar.showMessage(f"Batch queue loaded from {path}") @@ -373,7 +375,7 @@ def _on_load_batch_queue(self): def _on_batch_mode_toggled(self, checked: bool): self._batch_dock.setVisible(checked) self._update_daw_lifecycle_buttons() - + @Slot(object) def _on_load_batch_item(self, item): if self._session: @@ -423,7 +425,7 @@ def _on_open_batch_project_folder(self, item): # Otherwise, try the conventional project directory approach target_path = os.path.join(project_dir, item.project_name) if item.project_name else project_dir - + if os.path.isdir(target_path): QDesktopServices.openUrl(QUrl.fromLocalFile(target_path)) elif os.path.isdir(project_dir): @@ -442,7 +444,7 @@ def _on_batch_started(self): def _on_batch_finished(self): self._batch_dock.set_running_state(False) self._status_bar.showMessage("Batch processing complete.") - + @Slot(str, str, str) def _on_batch_item_finished(self, item_id: str, status: str, result_text: str): self._batch_dock.update_item(item_id, status, result_text) diff --git a/sessionprepgui/prefs/config_pages.py b/sessionprepgui/prefs/config_pages.py index ddd075d..7d0d3fc 100644 --- a/sessionprepgui/prefs/config_pages.py +++ b/sessionprepgui/prefs/config_pages.py @@ -508,10 +508,10 @@ def _init_ui(self): self._table.setSelectionBehavior(QTableWidget.SelectRows) self._table.setSelectionMode(QTableWidget.SingleSelection) self._table.cellChanged.connect(lambda r, c: self.templates_changed.emit()) - + # Ensure the table is tall enough to show ~3 rows comfortably self._table.setMinimumHeight(130) - + layout.addWidget(self._table, 1) btn_row = QHBoxLayout() @@ -656,7 +656,7 @@ def build_config_pages( if key == enabled_key and isinstance(widget, QCheckBox): widget.toggled.connect(on_daw_config_changed) break - + if dp.id == "dawproject": tpl_widget = DawProjectTemplatesWidget() tpl_widget.set_templates(dp_sections.get(dp.id, {}).get("dawproject_templates", [])) @@ -727,7 +727,7 @@ def load_config_widgets( for key, widget in widgets_dict[wkey]: if key in vals: _set_widget_value(widget, vals[key]) - + if dp.id == "dawproject" and "dawproject" in daw_custom_widgets: daw_custom_widgets["dawproject"].set_templates(vals.get("dawproject_templates", [])) elif dp.id == "protools" and "protools" in daw_custom_widgets: @@ -789,7 +789,7 @@ def read_config_widgets( section = {} for key, widget in widgets_dict[wkey]: section[key] = _read_widget(widget) - + if dp.id == "dawproject" and "dawproject" in daw_custom_widgets: section["dawproject_templates"] = daw_custom_widgets["dawproject"].get_templates() elif dp.id == "protools" and "protools" in daw_custom_widgets: diff --git a/sessionprepgui/session/io.py b/sessionprepgui/session/io.py index 6a3841d..4800b25 100644 --- a/sessionprepgui/session/io.py +++ b/sessionprepgui/session/io.py @@ -366,7 +366,7 @@ def deserialize_session_state(raw: dict) -> dict: raw = _migrate(raw) source_dir = raw.get("source_dir", "") - + tracks = [ _deserialize_track(fname, source_dir, tdata) for fname, tdata in raw.get("tracks", {}).items() diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index dde0b5d..1e13470 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -188,7 +188,7 @@ def get_session_audio_dir(engine) -> str: # ── Session lifecycle ──────────────────────────────────────────────── -def create_session_from_template( +def create_session_from_template( # pylint: disable=too-many-positional-arguments engine, session_name: str, session_location: str, template_group: str, template_name: str, sample_rate: str = "SR_48000", @@ -314,7 +314,7 @@ def batch_import_audio( # ── Track operations ───────────────────────────────────────────────── -def create_track( +def create_track( # pylint: disable=too-many-positional-arguments engine, name: str, track_format: str, track_type: str = "TT_Audio", timebase: str = "TTB_Samples", diff --git a/sessionpreplib/detectors/stereo_compat.py b/sessionpreplib/detectors/stereo_compat.py index 9b47559..659d7d2 100644 --- a/sessionpreplib/detectors/stereo_compat.py +++ b/sessionpreplib/detectors/stereo_compat.py @@ -268,7 +268,7 @@ def analyze(self, track: TrackContext) -> DetectorResult: # Windowed analysis helper # ------------------------------------------------------------------ - def _windowed_analysis( + def _windowed_analysis( # pylint: disable=too-many-positional-arguments self, track: TrackContext, win_results: list[tuple[int, int, float, float]], diff --git a/sessionpreplib/detectors/subsonic.py b/sessionpreplib/detectors/subsonic.py index 025fc4e..ce2d49a 100644 --- a/sessionpreplib/detectors/subsonic.py +++ b/sessionpreplib/detectors/subsonic.py @@ -277,7 +277,7 @@ def analyze(self, track: TrackContext) -> DetectorResult: # Windowed analysis helper # ------------------------------------------------------------------ - def _windowed_analysis( + def _windowed_analysis( # pylint: disable=too-many-positional-arguments self, track: TrackContext, cutoff: float, diff --git a/sessionpreplib/pipeline.py b/sessionpreplib/pipeline.py index 5a84211..627313c 100644 --- a/sessionpreplib/pipeline.py +++ b/sessionpreplib/pipeline.py @@ -33,7 +33,7 @@ def dbg(msg: str) -> None: # type: ignore[misc] class Pipeline: - def __init__( + def __init__( # pylint: disable=too-many-positional-arguments self, detectors: list, audio_processors: list[AudioProcessor] | None = None, diff --git a/sessionpreplib/reports.py b/sessionpreplib/reports.py index e2dd9cf..c464e96 100644 --- a/sessionpreplib/reports.py +++ b/sessionpreplib/reports.py @@ -90,7 +90,7 @@ def generate_report( pr = _get_primary_processor_result(t) fader = pr.data.get("fader_offset", 0) if pr else 0 classification = pr.classification if pr else "Unknown" - fader_str = "{:+.1f} dB".format(fader) + fader_str = f"{fader:+.1f} dB" lines.append(f"{t.filename[:38]:<40} {fader_str:>12} {classification:>12}") # Tail report From 8de8bf6cd770dcaee9951b11dc0042d26d551e3b Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 19:01:23 +0100 Subject: [PATCH 23/56] pylint fixes --- sessionprepgui/batch/panel.py | 2 +- sessionprepgui/daw/mixin.py | 9 +++++---- sessionprepgui/detail/mixin.py | 2 +- sessionprepgui/detail/playback.py | 1 + sessionprepgui/detail/report.py | 4 ++-- sessionprepgui/log.py | 2 +- sessionprepgui/prefs/config_pages.py | 3 ++- sessionprepgui/prefs/param_form.py | 2 +- sessionprepgui/session/io.py | 2 +- sessionprepgui/topology/mixin.py | 15 +++++++-------- sessionprepgui/topology/output_tree.py | 9 +++++---- sessionprepgui/tracks/columns_mixin.py | 3 ++- sessionprepgui/tracks/groups_mixin.py | 5 +++-- sessionprepgui/waveform/overlay.py | 4 ++-- sessionprepgui/waveform/renderer.py | 8 ++++---- sessionpreplib/processors/__init__.py | 1 + 16 files changed, 39 insertions(+), 33 deletions(-) diff --git a/sessionprepgui/batch/panel.py b/sessionprepgui/batch/panel.py index 2c0a74a..42fd2fb 100644 --- a/sessionprepgui/batch/panel.py +++ b/sessionprepgui/batch/panel.py @@ -333,7 +333,7 @@ def update_item(self, item_id: str, status: str, result_text: str = ""): self._refresh_table() def get_pending_items(self) -> list[BatchItem]: - return [i for i in self._items if i.status == "Pending" or i.status == "Failed"] + return [i for i in self._items if i.status in ("Pending", "Failed")] def set_running_state(self, is_running: bool): self._is_running = is_running diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index a5d1ac4..79ba7b4 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines """DAW integration mixin: processors, fetch, transfer, folder tree, assignments.""" from __future__ import annotations @@ -40,7 +41,7 @@ from ..analysis.worker import DawCheckWorker, DawFetchWorker, DawTransferWorker -class DawMixin: +class DawMixin: # pylint: disable=too-few-public-methods """DAW integration: processors, fetch, transfer, folder tree, assignments. Mixed into ``SessionPrepWindow`` — not meant to be used standalone. @@ -610,8 +611,8 @@ def _populate_folder_tree(self): children_map.setdefault(parent, []).append(f) # Sort children by index - for k in children_map: - children_map[k].sort(key=lambda f: f["index"]) + for children in children_map.values(): + children.sort(key=lambda f: f["index"]) # Build inverse assignments: folder_id -> [filenames] # Use track_order for stable ordering, fall back to sorted @@ -622,7 +623,7 @@ def _populate_folder_tree(self): 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)) + fnames.sort(key=lambda n, om=order_map, length=len(order): (om.get(n, length), n)) # Group color map for track items gcm = self._group_color_map() diff --git a/sessionprepgui/detail/mixin.py b/sessionprepgui/detail/mixin.py index de7952e..c49f0c5 100644 --- a/sessionprepgui/detail/mixin.py +++ b/sessionprepgui/detail/mixin.py @@ -18,7 +18,7 @@ from ..waveform.compute import WaveformLoadWorker -class DetailMixin: +class DetailMixin: # pylint: disable=too-few-public-methods """File detail view, waveform display, overlays, and playback. Mixed into ``SessionPrepWindow`` — not meant to be used standalone. diff --git a/sessionprepgui/detail/playback.py b/sessionprepgui/detail/playback.py index 9aa0c94..ec4606d 100644 --- a/sessionprepgui/detail/playback.py +++ b/sessionprepgui/detail/playback.py @@ -42,6 +42,7 @@ def play_start_sample(self) -> int: def play(self, audio_data, samplerate: int, start_sample: int = 0, mode: str = "as_is", channel: int | None = None): + # pylint: disable=too-many-positional-arguments """Start playback from the given sample position. Parameters diff --git a/sessionprepgui/detail/report.py b/sessionprepgui/detail/report.py index 2f811d3..01711e3 100644 --- a/sessionprepgui/detail/report.py +++ b/sessionprepgui/detail/report.py @@ -2,9 +2,9 @@ from __future__ import annotations +from sessionpreplib.chunks import read_chunks, STANDARD_CHUNKS, detect_origin from ..theme import COLORS, FILE_COLOR_TRANSIENT, FILE_COLOR_SUSTAINED from ..helpers import esc -from sessionpreplib.chunks import read_chunks, STANDARD_CHUNKS, detect_origin # --------------------------------------------------------------------------- @@ -197,7 +197,7 @@ def render_track_detail_html(track, session=None, *, show_clean: bool = True, def _fmt_size(n: int) -> str: if n < 1024: return f"{n} B" - elif n < 1024 * 1024: + if n < 1024 * 1024: return f"{n / 1024:.1f} KB" return f"{n / (1024 * 1024):.1f} MB" chunk_parts = [ diff --git a/sessionprepgui/log.py b/sessionprepgui/log.py index baf823f..21b198e 100644 --- a/sessionprepgui/log.py +++ b/sessionprepgui/log.py @@ -20,7 +20,7 @@ import sys import time -class _LogConfig: +class _LogConfig: # pylint: disable=too-few-public-methods """Encapsulates the global debug logging configuration state.""" enabled: bool | None = None diff --git a/sessionprepgui/prefs/config_pages.py b/sessionprepgui/prefs/config_pages.py index 7d0d3fc..7c2f757 100644 --- a/sessionprepgui/prefs/config_pages.py +++ b/sessionprepgui/prefs/config_pages.py @@ -138,6 +138,7 @@ def _set_row(self, row: int, name: str, color: str, gain_linked: bool, daw_target: str = "", match_method: str = "contains", match_pattern: str = ""): + # pylint: disable=too-many-positional-arguments name_item = QTableWidgetItem(name) self._table.setItem(row, 0, name_item) @@ -210,7 +211,7 @@ def _read_groups(self) -> list[dict]: 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)) + visual_to_logical = sorted(range(n), key=vh.visualIndex) groups: list[dict] = [] for logical in visual_to_logical: name_item = self._table.item(logical, 0) diff --git a/sessionprepgui/prefs/param_form.py b/sessionprepgui/prefs/param_form.py index 998c86c..226d83e 100644 --- a/sessionprepgui/prefs/param_form.py +++ b/sessionprepgui/prefs/param_form.py @@ -34,7 +34,7 @@ # --------------------------------------------------------------------------- @runtime_checkable -class ParamSpec(Protocol): +class ParamSpec(Protocol): # pylint: disable=too-few-public-methods key: str label: str type: type diff --git a/sessionprepgui/session/io.py b/sessionprepgui/session/io.py index 4800b25..2e30d50 100644 --- a/sessionprepgui/session/io.py +++ b/sessionprepgui/session/io.py @@ -170,7 +170,7 @@ def _make_json_safe(obj: Any) -> Any: 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"): + if obj != obj or obj == float("inf") or obj == float("-inf"): # pylint: disable=comparison-with-itself return None return obj if hasattr(obj, "value"): # Enum diff --git a/sessionprepgui/topology/mixin.py b/sessionprepgui/topology/mixin.py index 7c56195..f43e0f8 100644 --- a/sessionprepgui/topology/mixin.py +++ b/sessionprepgui/topology/mixin.py @@ -22,11 +22,10 @@ QWidget, ) -from ..widgets import ProgressPanel - from sessionpreplib.topology import build_default_topology from sessionpreplib.utils import protools_sort_key +from ..widgets import ProgressPanel from ..theme import COLORS from ..tracks.table_widgets import _PHASE_TOPOLOGY, _PHASE_ANALYSIS, _PHASE_SETUP from ..waveform import WaveformPanel @@ -36,7 +35,7 @@ from . import operations as ops -class TopologyMixin: +class TopologyMixin: # pylint: disable=too-few-public-methods """Mixin that adds the Track Layout tab to the main window. Expects the host class to provide: @@ -113,13 +112,13 @@ def _build_topology_page(self) -> QWidget: collapse_action = QAction("Collapse All", self) collapse_action.setToolTip("Collapse all output tree nodes") collapse_action.triggered.connect( - lambda: self._topo_output_tree.collapseAll()) + lambda *_: self._topo_output_tree.collapseAll()) toolbar.addAction(collapse_action) expand_action = QAction("Expand All", self) expand_action.setToolTip("Expand all output tree nodes") expand_action.triggered.connect( - lambda: self._topo_output_tree.expandAll()) + lambda *_: self._topo_output_tree.expandAll()) toolbar.addAction(expand_action) toolbar.addSeparator() @@ -487,9 +486,9 @@ def _update_preview(): names += f", \u2026 ({n} total)" preview.setText(f"\u2192 {names} ({ch} ch each)") - name_edit.textChanged.connect(lambda: _update_preview()) - count_spin.valueChanged.connect(lambda: _update_preview()) - ch_spin.valueChanged.connect(lambda: _update_preview()) + name_edit.textChanged.connect(lambda *_: _update_preview()) + count_spin.valueChanged.connect(lambda *_: _update_preview()) + ch_spin.valueChanged.connect(lambda *_: _update_preview()) _update_preview() # Buttons diff --git a/sessionprepgui/topology/output_tree.py b/sessionprepgui/topology/output_tree.py index a6797e2..ba883d7 100644 --- a/sessionprepgui/topology/output_tree.py +++ b/sessionprepgui/topology/output_tree.py @@ -1,3 +1,4 @@ +# pylint: disable=too-many-lines """Output-tracks tree widget for Phase 1 topology. Editable QTreeWidget that displays topology output entries with channel @@ -28,6 +29,8 @@ QVBoxLayout, ) +from sessionpreplib.topology import ChannelRoute, TopologySource + from ..theme import COLORS, FILE_COLOR_OK from .input_tree import MIME_CHANNEL, COL_NAME, COL_CH, COL_SR, COL_BIT, COL_DUR from .operations import ( @@ -49,8 +52,6 @@ wire_file, ) -from sessionpreplib.topology import ChannelRoute, TopologySource - if TYPE_CHECKING: from sessionpreplib.models import TrackContext from sessionpreplib.topology import TopologyMapping @@ -701,7 +702,7 @@ def _resolve_drop_target(self, event): if int(event.position().y()) > mid: to_ch += 1 return to_fn, to_ch - elif target_data[0] == "file": + if target_data[0] == "file": to_fn = target_data[1] entry = next((e for e in self._topo.entries if e.output_filename == to_fn), None) @@ -738,7 +739,7 @@ def _handle_reorder_drop(self, event): if from_fn == to_fn: # Same file — simple reorder - if from_ch == to_ch or from_ch + 1 == to_ch: + if to_ch in (from_ch, from_ch + 1): event.ignore() return # Adjust for the "insert before" semantic: if inserting after diff --git a/sessionprepgui/tracks/columns_mixin.py b/sessionprepgui/tracks/columns_mixin.py index 9f5c915..0df400b 100644 --- a/sessionprepgui/tracks/columns_mixin.py +++ b/sessionprepgui/tracks/columns_mixin.py @@ -33,7 +33,7 @@ from ..analysis.worker import BatchReanalyzeWorker -class TrackColumnsMixin: +class TrackColumnsMixin: # pylint: disable=too-few-public-methods """Track table population, column widgets, batch operations, row helpers. Mixed into ``SessionPrepWindow`` — not meant to be used standalone. @@ -621,6 +621,7 @@ def _on_processing_toggled(self, checked: bool, action=None): def _batch_apply_combo(self, source_combo, column: int, value: str, prepare_fn, run_detectors: bool = True): + # pylint: disable=too-many-positional-arguments """Apply *value* to the combo in *column* for every selected row. 1. **Sync** — set overrides via *prepare_fn(track)* and update diff --git a/sessionprepgui/tracks/groups_mixin.py b/sessionprepgui/tracks/groups_mixin.py index dec289f..de7bec8 100644 --- a/sessionprepgui/tracks/groups_mixin.py +++ b/sessionprepgui/tracks/groups_mixin.py @@ -31,7 +31,7 @@ from ..widgets import BatchComboBox -class GroupsMixin: +class GroupsMixin: # pylint: disable=too-few-public-methods """Group management: groups tab, colors, group column, auto-group, linked levels. Mixed into ``SessionPrepWindow`` — not meant to be used standalone. @@ -170,6 +170,7 @@ 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 = ""): + # pylint: disable=too-many-positional-arguments """Populate one row in the session-local groups table.""" name_item = QTableWidgetItem(name) self._groups_tab_table.setItem(row, 0, name_item) @@ -369,7 +370,7 @@ def _on_groups_tab_row_moved(self, logical: int, old_visual: int, vh = table.verticalHeader() n = table.rowCount() # Build visual order → logical index mapping - visual_to_logical = sorted(range(n), key=lambda i: vh.visualIndex(i)) + visual_to_logical = sorted(range(n), key=vh.visualIndex) ordered: list[dict] = [] for log_idx in visual_to_logical: name_item = table.item(log_idx, 0) diff --git a/sessionprepgui/waveform/overlay.py b/sessionprepgui/waveform/overlay.py index 016089d..141da03 100644 --- a/sessionprepgui/waveform/overlay.py +++ b/sessionprepgui/waveform/overlay.py @@ -36,7 +36,7 @@ def draw_issue_overlays( num_channels: int, mel_view_min: float, mel_view_max: float, -): +): # pylint: disable=too-many-positional-arguments """Draw detector issue overlays. Works in both waveform and spectrogram modes.""" if not issues or not enabled_overlays: return @@ -103,7 +103,7 @@ def draw_time_scale( view_start: int, view_end: int, samplerate: int, -): +): # pylint: disable=too-many-positional-arguments """Draw horizontal time axis with adaptive tick labels below the waveform.""" if samplerate <= 0 or draw_w <= 0: return diff --git a/sessionprepgui/waveform/renderer.py b/sessionprepgui/waveform/renderer.py index cb9b0d1..f3a8212 100644 --- a/sessionprepgui/waveform/renderer.py +++ b/sessionprepgui/waveform/renderer.py @@ -152,6 +152,7 @@ def paint(self, painter: QPainter, ctx: WaveformRenderCtx): def draw_db_guide(self, painter: QPainter, ctx: WaveformRenderCtx, nch: int, lane_h: float, my: float): + # pylint: disable=too-many-positional-arguments """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)) @@ -628,10 +629,9 @@ def _downsample(wm: np.ndarray, offset: int) -> np.ndarray: 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)) + 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) diff --git a/sessionpreplib/processors/__init__.py b/sessionpreplib/processors/__init__.py index bc6934a..51a08d8 100644 --- a/sessionpreplib/processors/__init__.py +++ b/sessionpreplib/processors/__init__.py @@ -1,3 +1,4 @@ +# pylint: disable=cyclic-import from .bimodal_normalize import BimodalNormalizeProcessor From 5d206505fe9b1126ef4a6409012087d1d591eb3f Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 19:12:13 +0100 Subject: [PATCH 24/56] pylint fixes --- sessionprepgui/prefs/page_general.py | 2 +- sessionpreplib/config.py | 26 ++----------------- sessionpreplib/daw_processor.py | 2 +- sessionpreplib/daw_processors/protools.py | 2 +- sessionpreplib/detector.py | 2 +- sessionpreplib/detectors/audio_classifier.py | 2 +- sessionpreplib/detectors/clipping.py | 2 +- sessionpreplib/detectors/dc_offset.py | 2 +- sessionpreplib/detectors/dual_mono.py | 2 +- sessionpreplib/detectors/one_sided_silence.py | 2 +- sessionpreplib/detectors/stereo_compat.py | 2 +- sessionpreplib/detectors/subsonic.py | 2 +- sessionpreplib/detectors/tail_exceedance.py | 2 +- sessionpreplib/models.py | 24 +++++++++++++++++ sessionpreplib/processor.py | 2 +- .../processors/bimodal_normalize.py | 2 +- 16 files changed, 40 insertions(+), 38 deletions(-) diff --git a/sessionprepgui/prefs/page_general.py b/sessionprepgui/prefs/page_general.py index d6ba672..6d5274c 100644 --- a/sessionprepgui/prefs/page_general.py +++ b/sessionprepgui/prefs/page_general.py @@ -7,7 +7,7 @@ QWidget, ) -from sessionpreplib.config import ParamSpec +from sessionpreplib.models import ParamSpec from .param_form import ( _build_param_page, diff --git a/sessionpreplib/config.py b/sessionpreplib/config.py index fe1f432..4964e40 100644 --- a/sessionpreplib/config.py +++ b/sessionpreplib/config.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from typing import Any +from .models import ParamSpec + PRESET_SCHEMA_VERSION = "1.0" # Keys that are internal/CLI-only and should not be saved in presets @@ -32,30 +34,6 @@ class ConfigFieldError: message: str -@dataclass(frozen=True) -class ParamSpec: - """Declarative specification for a single configuration parameter. - - Used by detectors, processors, and the shared analysis / session - sections to describe their parameters — including type, default, - valid range, allowed values, and human-readable labels. - """ - key: str - type: type | tuple # expected Python type(s) - default: Any - label: str # short UI label - description: str = "" # longer tooltip / help text - min: float | int | None = None # inclusive lower bound (unless min_exclusive) - max: float | int | None = None # inclusive upper bound (unless max_exclusive) - min_exclusive: bool = False - max_exclusive: bool = False - 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) - - def default_config() -> dict[str, Any]: """Returns the built-in default configuration.""" return { diff --git a/sessionpreplib/daw_processor.py b/sessionpreplib/daw_processor.py index a509cad..e32be52 100644 --- a/sessionpreplib/daw_processor.py +++ b/sessionpreplib/daw_processor.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod from typing import Any -from .config import ParamSpec +from .models import ParamSpec from .models import DawCommand, DawCommandResult, SessionContext diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 5c2c2c2..687226e 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -9,7 +9,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any -from ..config import ParamSpec +from ..models import ParamSpec from ..daw_processor import DawProcessor from ..models import DawCommand, DawCommandResult, SessionContext from . import ptsl_helpers as ptslh diff --git a/sessionpreplib/detector.py b/sessionpreplib/detector.py index 7fc8cf0..5d63c27 100644 --- a/sessionpreplib/detector.py +++ b/sessionpreplib/detector.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import Any -from .config import ParamSpec +from .models import ParamSpec from .models import DetectorResult, Severity, TrackContext, SessionContext _REPORT_AS_MAP: dict[str, Severity] = { diff --git a/sessionpreplib/detectors/audio_classifier.py b/sessionpreplib/detectors/audio_classifier.py index aebd9ac..0b0bbb5 100644 --- a/sessionpreplib/detectors/audio_classifier.py +++ b/sessionpreplib/detectors/audio_classifier.py @@ -2,7 +2,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, Severity, TrackContext from ..audio import ( diff --git a/sessionpreplib/detectors/clipping.py b/sessionpreplib/detectors/clipping.py index a60f22d..1ad7745 100644 --- a/sessionpreplib/detectors/clipping.py +++ b/sessionpreplib/detectors/clipping.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, IssueLocation, Severity, TrackContext from ..audio import detect_clipping_ranges, is_silent diff --git a/sessionpreplib/detectors/dc_offset.py b/sessionpreplib/detectors/dc_offset.py index 46a17dd..010a916 100644 --- a/sessionpreplib/detectors/dc_offset.py +++ b/sessionpreplib/detectors/dc_offset.py @@ -2,7 +2,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, IssueLocation, Severity, TrackContext from ..audio import dbfs_offset, linear_to_db, is_silent diff --git a/sessionpreplib/detectors/dual_mono.py b/sessionpreplib/detectors/dual_mono.py index 51e0ef4..2833c1f 100644 --- a/sessionpreplib/detectors/dual_mono.py +++ b/sessionpreplib/detectors/dual_mono.py @@ -2,7 +2,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, Severity, TrackContext from ..audio import get_stereo_channels_subsampled, is_silent diff --git a/sessionpreplib/detectors/one_sided_silence.py b/sessionpreplib/detectors/one_sided_silence.py index a789c60..0d13904 100644 --- a/sessionpreplib/detectors/one_sided_silence.py +++ b/sessionpreplib/detectors/one_sided_silence.py @@ -2,7 +2,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, IssueLocation, Severity, TrackContext from ..audio import dbfs_offset, get_stereo_rms, is_silent, linear_to_db diff --git a/sessionpreplib/detectors/stereo_compat.py b/sessionpreplib/detectors/stereo_compat.py index 659d7d2..e033433 100644 --- a/sessionpreplib/detectors/stereo_compat.py +++ b/sessionpreplib/detectors/stereo_compat.py @@ -4,7 +4,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, IssueLocation, Severity, TrackContext from ..audio import is_silent, windowed_stereo_correlation diff --git a/sessionpreplib/detectors/subsonic.py b/sessionpreplib/detectors/subsonic.py index ce2d49a..c70e6ab 100644 --- a/sessionpreplib/detectors/subsonic.py +++ b/sessionpreplib/detectors/subsonic.py @@ -2,7 +2,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, IssueLocation, Severity, TrackContext from ..audio import is_silent, subsonic_stft_analysis diff --git a/sessionpreplib/detectors/tail_exceedance.py b/sessionpreplib/detectors/tail_exceedance.py index 04c674a..557edbe 100644 --- a/sessionpreplib/detectors/tail_exceedance.py +++ b/sessionpreplib/detectors/tail_exceedance.py @@ -2,7 +2,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..detector import TrackDetector from ..models import DetectorResult, IssueLocation, Severity, TrackContext from ..audio import ( diff --git a/sessionpreplib/models.py b/sessionpreplib/models.py index 36cbe3b..3e01cd1 100644 --- a/sessionpreplib/models.py +++ b/sessionpreplib/models.py @@ -8,6 +8,30 @@ import numpy as np +@dataclass(frozen=True) +class ParamSpec: + """Declarative specification for a single configuration parameter. + + Used by detectors, processors, and the shared analysis / session + sections to describe their parameters — including type, default, + valid range, allowed values, and human-readable labels. + """ + key: str + type: type | tuple # expected Python type(s) + default: Any + label: str # short UI label + description: str = "" # longer tooltip / help text + min: float | int | None = None # inclusive lower bound (unless min_exclusive) + max: float | int | None = None # inclusive upper bound (unless max_exclusive) + min_exclusive: bool = False + max_exclusive: bool = False + 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) + + class Severity(Enum): CLEAN = "clean" INFO = "info" diff --git a/sessionpreplib/processor.py b/sessionpreplib/processor.py index 97b9660..fd7dd31 100644 --- a/sessionpreplib/processor.py +++ b/sessionpreplib/processor.py @@ -5,7 +5,7 @@ import numpy as np -from .config import ParamSpec +from .models import ParamSpec from .models import ProcessorResult, TrackContext diff --git a/sessionpreplib/processors/bimodal_normalize.py b/sessionpreplib/processors/bimodal_normalize.py index 4f025ce..7ee7ab0 100644 --- a/sessionpreplib/processors/bimodal_normalize.py +++ b/sessionpreplib/processors/bimodal_normalize.py @@ -4,7 +4,7 @@ import numpy as np -from ..config import ParamSpec +from ..models import ParamSpec from ..processor import AudioProcessor, PRIORITY_NORMALIZE from ..models import ProcessorResult, TrackContext from ..audio import db_to_linear, dbfs_offset From 038bf186a79bacbeb4324fcf6e4c81ece91fc32f Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 19:28:01 +0100 Subject: [PATCH 25/56] relevancy changes --- sessionpreplib/detectors/length_consistency.py | 4 ++-- sessionpreplib/detectors/subsonic.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/sessionpreplib/detectors/length_consistency.py b/sessionpreplib/detectors/length_consistency.py index ab3f2d7..c268509 100644 --- a/sessionpreplib/detectors/length_consistency.py +++ b/sessionpreplib/detectors/length_consistency.py @@ -22,7 +22,7 @@ def html_help(cls) -> str: "

" "Results
" "OK – File length matches the session's most common length.
" - "PROBLEM – Length differs (reported with sample count and duration)." + "INFO – Length differs (reported with sample count and duration)." "

" "Interpretation
" "In a well-prepared session all stems should have the same length " @@ -77,7 +77,7 @@ def analyze(self, session: SessionContext) -> list[DetectorResult]: eq_fmt = format_duration(int(eq), int(most_common_sr)) results.append(DetectorResult( detector_id=self.id, - severity=Severity.PROBLEM, + severity=Severity.INFO, summary=f"length mismatch ({int(eq)} samples / {eq_fmt})", data={ "expected_samples": int(most_common_len), diff --git a/sessionpreplib/detectors/subsonic.py b/sessionpreplib/detectors/subsonic.py index c70e6ab..e7c6310 100644 --- a/sessionpreplib/detectors/subsonic.py +++ b/sessionpreplib/detectors/subsonic.py @@ -110,7 +110,7 @@ def html_help(cls) -> str: "

" "Results
" "OK \u2013 Subsonic energy is below the sensitivity threshold.
" - "ATTENTION \u2013 Significant subsonic energy detected." + "INFO \u2013 Significant subsonic energy detected." "

" "Interpretation
" "Consider applying a high-pass filter at or near the cutoff " @@ -233,14 +233,14 @@ def analyze(self, track: TrackContext) -> DetectorResult: result_data["windowed_regions"] = windowed_regions # If windowed produced no regions (or windowed is off), fall back to - # a whole-file issue span so ATTENTION always has at least one overlay. + # a whole-file issue span so INFO always has at least one overlay. if not issues: if all_channels_warn or nch == 1: issues.append(IssueLocation( sample_start=0, sample_end=track.total_samples - 1, channel=None, - severity=Severity.ATTENTION, + severity=Severity.INFO, label="subsonic", description=summary, freq_min_hz=0.0, @@ -256,7 +256,7 @@ def analyze(self, track: TrackContext) -> DetectorResult: sample_start=0, sample_end=track.total_samples - 1, channel=ch, - severity=Severity.ATTENTION, + severity=Severity.INFO, label="subsonic", description=desc, freq_min_hz=0.0, @@ -265,7 +265,7 @@ def analyze(self, track: TrackContext) -> DetectorResult: return DetectorResult( detector_id=self.id, - severity=Severity.ATTENTION, + severity=Severity.INFO, summary=summary, data=result_data, detail_lines=detail_lines if detail_lines else [], @@ -345,7 +345,7 @@ def _windowed_analysis( # pylint: disable=too-many-positional-arguments sample_start=reg["sample_start"], sample_end=reg["sample_end"], channel=ch, - severity=Severity.ATTENTION, + severity=Severity.INFO, label="subsonic", description=desc, freq_min_hz=0.0, From 1bb58e3e18f0fe77e0ae501e4061bc5b271f9aa3 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 19:47:30 +0100 Subject: [PATCH 26/56] QThread fixes. --- sessionprepgui/analysis/worker.py | 12 +++--- sessionprepgui/daw/mixin.py | 15 +++++-- sessionpreplib/rendering.py | 66 ++++++++++++++++++++----------- 3 files changed, 61 insertions(+), 32 deletions(-) diff --git a/sessionprepgui/analysis/worker.py b/sessionprepgui/analysis/worker.py index 59417f9..e1ca201 100644 --- a/sessionprepgui/analysis/worker.py +++ b/sessionprepgui/analysis/worker.py @@ -20,8 +20,8 @@ class DawCheckWorker(QThread): result = Signal(bool, str) # (ok, message) - def __init__(self, processor: DawProcessor): - super().__init__() + def __init__(self, processor: DawProcessor, parent=None): + super().__init__(parent) self._processor = processor def run(self): @@ -39,8 +39,8 @@ class DawFetchWorker(QThread): progress_value = Signal(int, int) # (current, total) result = Signal(bool, str, object) # (ok, message, session_or_none) - def __init__(self, processor: DawProcessor, session): - super().__init__() + def __init__(self, processor: DawProcessor, session, parent=None): + super().__init__(parent) self._processor = processor self._session = session @@ -68,8 +68,8 @@ 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, output_path: str): - super().__init__() + def __init__(self, processor: DawProcessor, session, output_path: str, parent=None): + super().__init__(parent) self._processor = processor self._session = session self._output_path = output_path diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 79ba7b4..3098e8e 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -315,13 +315,16 @@ def _run_daw_check_then(self, on_success): self._daw_check_label.setText("Connecting\u2026") self._daw_check_label.setStyleSheet(f"color: {COLORS['dim']};") self._update_daw_lifecycle_buttons() - self._daw_check_worker = DawCheckWorker(self._active_daw_processor) + self._daw_check_worker = DawCheckWorker(self._active_daw_processor, parent=self) 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): + worker = self._daw_check_worker self._daw_check_worker = None + if worker: + worker.deleteLater() if ok: self._daw_check_label.setText(message) self._daw_check_label.setStyleSheet(f"color: {COLORS['clean']};") @@ -357,7 +360,7 @@ def _do_daw_fetch(self): self._transfer_progress.start("Fetching folder structure\u2026") self._daw_fetch_worker = DawFetchWorker( - self._active_daw_processor, self._session) + self._active_daw_processor, self._session, parent=self) self._daw_fetch_worker.progress.connect(self._on_transfer_progress) self._daw_fetch_worker.progress_value.connect(self._on_transfer_progress_value) self._daw_fetch_worker.result.connect(self._on_daw_fetch_result) @@ -366,7 +369,10 @@ def _do_daw_fetch(self): @Slot(bool, str, object) def _on_daw_fetch_result(self, ok: bool, message: str, session): + worker = self._daw_fetch_worker self._daw_fetch_worker = None + if worker: + worker.deleteLater() self._fetch_action.setEnabled(True) if "PRO_TOOLS_SESSION_OPEN" in message: @@ -564,7 +570,7 @@ def _do_daw_transfer(self): self._transfer_progress.start("Preparing\u2026") self._daw_transfer_worker = DawTransferWorker( - self._active_daw_processor, self._session, output_path) + self._active_daw_processor, self._session, output_path, parent=self) self._daw_transfer_worker.progress.connect(self._on_transfer_progress) self._daw_transfer_worker.progress_value.connect( self._on_transfer_progress_value) @@ -582,7 +588,10 @@ def _on_transfer_progress_value(self, current: int, total: int): @Slot(bool, str, object) def _on_daw_transfer_result(self, ok: bool, message: str, results): + worker = self._daw_transfer_worker self._daw_transfer_worker = None + if worker: + worker.deleteLater() self._update_daw_lifecycle_buttons() if ok: self._transfer_progress.finish(message) diff --git a/sessionpreplib/rendering.py b/sessionpreplib/rendering.py index 1673ff9..06c41d9 100644 --- a/sessionpreplib/rendering.py +++ b/sessionpreplib/rendering.py @@ -80,17 +80,22 @@ def _is_skipped(det_id: str) -> bool: det = _det_map.get(det_id) return bool(det and getattr(det, "_report_as", "default") == "skip") - def _routed_bucket(det_id: str, default_bucket: list): + def _routed_bucket(det_id: str, actual_severity: Severity): """Return the target group list for *det_id*, or None if skipped.""" det = _det_map.get(det_id) - if not det: - return default_bucket - ra = getattr(det, "_report_as", "default") - if ra == "skip": + if det and getattr(det, "_report_as", "default") == "skip": return None - if ra == "default": - return default_bucket - return _buckets.get(ra, default_bucket) + + if det and hasattr(det, "effective_severity"): + eff = det.effective_severity(DetectorResult(det_id, actual_severity, "", {})) + else: + eff = actual_severity + + if eff is None: + return None + + sev_val = eff.value if hasattr(eff, "value") else str(eff) + return _buckets.get(sev_val, info_groups) # --- Format consistency (session-level) --- format_mismatch_items = [] @@ -100,9 +105,11 @@ def _routed_bucket(det_id: str, default_bucket: list): most_common_sr = session.config.get("_most_common_sr") most_common_bd = session.config.get("_most_common_bd") + actual_fmt_sev = Severity.PROBLEM for r in format_results: - if r.severity != Severity.PROBLEM: + if r.severity == Severity.CLEAN: continue + actual_fmt_sev = r.severity fname = r.data.get("filename", "") reasons = r.data.get("mismatch_reasons", []) details = ", ".join(reasons) if reasons else "mismatch" @@ -125,7 +132,7 @@ def _routed_bucket(det_id: str, default_bucket: list): if format_summary: mismatch_title = f"Format mismatches. Deviations from {format_summary}" - fmt_bucket = _routed_bucket("format_consistency", problems_groups) + fmt_bucket = _routed_bucket("format_consistency", actual_fmt_sev) if fmt_bucket is not None: add_group(fmt_bucket, mismatch_title, "request corrected exports", format_mismatch_items) @@ -146,9 +153,11 @@ def _routed_bucket(det_id: str, default_bucket: list): most_common_len = session.config.get("_most_common_len") most_common_len_fmt = session.config.get("_most_common_len_fmt") + actual_len_sev = Severity.PROBLEM for r in length_results: - if r.severity != Severity.PROBLEM: + if r.severity == Severity.CLEAN: continue + actual_len_sev = r.severity fname = r.data.get("filename", "") actual_samples = r.data.get("actual_samples") actual_fmt = r.data.get("actual_duration_fmt", "") @@ -175,7 +184,7 @@ def _routed_bucket(det_id: str, default_bucket: list): if length_summary: length_mismatch_title = f"Length mismatches. Deviations from {length_summary}" - len_bucket = _routed_bucket("length_consistency", problems_groups) + len_bucket = _routed_bucket("length_consistency", actual_len_sev) if len_bucket is not None: add_group(len_bucket, length_mismatch_title, "request aligned exports", length_mismatch_items) @@ -324,28 +333,39 @@ def fmt_db(x): issue_names.add(t.filename) # Build groups — route to buckets based on report_as overrides - # Each tuple: (det_id, default_bucket, title, hint, items) + # Each tuple: (det_id, title, hint, items) _det_groups = [ - ("clipping", problems_groups, "Digital clipping", + ("clipping", "Digital clipping", "request reprint / check limiting", clipped_items), - ("dc_offset", attention_groups, "DC offset", + ("dc_offset", "DC offset", "consider DC removal", dc_items), - ("stereo_compat", info_groups, "Stereo compatibility", + ("stereo_compat", "Stereo compatibility", None, stereo_compat_items), - ("dual_mono", info_groups, "Dual-mono (identical L/R)", + ("dual_mono", "Dual-mono (identical L/R)", None, dual_mono_items), - ("silence", attention_groups, "Silent files", + ("silence", "Silent files", "confirm intentional", silent_items), - ("one_sided_silence", attention_groups, "One-sided silence", + ("one_sided_silence", "One-sided silence", "check stereo export / channel routing", one_sided_items), - ("subsonic", attention_groups, "Subsonic content", + ("subsonic", "Subsonic content", f"consider HPF ~{float(session.config.get('subsonic_hz', 30.0)):g} Hz", subsonic_items), - ("tail_exceedance", attention_groups, "Tail regions exceeded anchor", + ("tail_exceedance", "Tail regions exceeded anchor", "check for section-based riding", tail_items), ] - for det_id, default_bucket, title, hint, items in _det_groups: - bucket = _routed_bucket(det_id, default_bucket) + for det_id, title, hint, items in _det_groups: + if not items: + continue + + # Find actual severity emitted by this detector + actual_sev = Severity.INFO + for t in ok_tracks: + dr = t.detector_results.get(det_id) + if dr and dr.severity != Severity.CLEAN: + actual_sev = dr.severity + break + + bucket = _routed_bucket(det_id, actual_sev) if bucket is not None: add_group(bucket, title, hint, items) From fe0365d5b6b8946408efb66d2a70ba49f9395efe Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 21:06:46 +0100 Subject: [PATCH 27/56] restoring the gain/faders when loading a session. --- sessionprepgui/analysis/mixin.py | 13 ++--- sessionprepgui/session/io.py | 56 ++++++++++++++++++- sessionpreplib/daw_processors/protools.py | 5 +- sessionpreplib/daw_processors/ptsl_helpers.py | 45 +++++++++------ 4 files changed, 92 insertions(+), 27 deletions(-) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 15973a4..953ebe8 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -340,6 +340,7 @@ def _capture_session_state(self) -> dict: "daw_state": self._session.daw_state if self._session else {}, "active_daw_processor_id": active_dp_id, "tracks": self._session.tracks if self._session else [], + "output_tracks": getattr(self._session, "output_tracks", []) if self._session else [], "topology": self._topo_topology, "transfer_manifest": self._session.transfer_manifest if self._session else [], "topology_applied": self._topology_dir is not None, @@ -511,17 +512,15 @@ def _restore_session_state(self, data: dict): if os.path.isdir(topo_dir): self._topology_dir = topo_dir - # ── Reconstruct output_tracks from topology + disk ─────────────── - # output_tracks are not persisted in the session file, but the DAW - # transfer needs them for file paths. Rebuild from topology entries - # and the files that exist on disk. - if self._topology_dir and self._topo_topology: + # ── Restore or reconstruct output_tracks ─────────────── + if data.get("output_tracks"): + session.output_tracks = data["output_tracks"] + elif self._topology_dir and self._topo_topology: import soundfile as sf prep_folder = self._config.get("app", {}).get( "phase2_output_folder", "sp_02_prepared") prep_dir = os.path.join(source_dir, prep_folder) - # Build group lookup from transfer manifest so output_tracks - # carry the group for folder-tree coloring and DAW transfer. + # Build group lookup from transfer manifest manifest_group: dict[str, str | None] = { e.output_filename: e.group for e in session.transfer_manifest diff --git a/sessionprepgui/session/io.py b/sessionprepgui/session/io.py index 2e30d50..6ad6cb5 100644 --- a/sessionprepgui/session/io.py +++ b/sessionprepgui/session/io.py @@ -36,7 +36,7 @@ # Version & migration table # --------------------------------------------------------------------------- -CURRENT_VERSION: int = 5 +CURRENT_VERSION: int = 6 # Each entry upgrades from key-version to key+1. _MIGRATIONS: dict[int, Callable[[dict], dict]] = { @@ -61,6 +61,11 @@ "project_name": "", "version": 5, }, + 5: lambda d: { + **d, + "output_tracks": {}, + "version": 6, + }, } @@ -239,6 +244,45 @@ def _deserialize_track(filename: str, source_dir: str, d: dict) -> TrackContext: return track +def _serialize_output_track(track: TrackContext) -> dict: + d = _serialize_track(track) + d["filepath"] = track.filepath + d["processed_filepath"] = track.processed_filepath + return d + + +def _deserialize_output_track(filename: str, d: dict) -> TrackContext: + filepath = d.get("filepath", filename) + 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.processed_filepath = d.get("processed_filepath") + 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 + + # --------------------------------------------------------------------------- # Topology serialisation helpers # --------------------------------------------------------------------------- @@ -344,6 +388,10 @@ def serialize_session_state(data: dict) -> dict: track.filename: _serialize_track(track) for track in data.get("tracks", []) }, + "output_tracks": { + track.filename: _serialize_output_track(track) + for track in data.get("output_tracks", []) + }, "topology": _ser_topology(data.get("topology")), "transfer_manifest": [ _ser_transfer_entry(e) @@ -372,6 +420,11 @@ def deserialize_session_state(raw: dict) -> dict: for fname, tdata in raw.get("tracks", {}).items() ] + output_tracks = [ + _deserialize_output_track(fname, tdata) + for fname, tdata in raw.get("output_tracks", {}).items() + ] + topology = _deser_topology(raw.get("topology")) transfer_manifest = [ _deser_transfer_entry(e) @@ -385,6 +438,7 @@ def deserialize_session_state(raw: dict) -> dict: "session_groups": raw.get("session_groups", []), "daw_state": raw.get("daw_state", {}), "tracks": tracks, + "output_tracks": output_tracks, "topology": topology, "transfer_manifest": transfer_manifest, "topology_applied": raw.get("topology_applied", False), diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 687226e..6e16f1c 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -685,12 +685,13 @@ def _create_and_spot(item): if not pr or pr.classification in ("Silent", "Skip"): continue fader_db = pr.data.get("fader_offset", 0.0) + dbg(f"Fader logic for {t_id}: classification={pr.classification}, fader_db={fader_db}") if fader_db == 0.0: continue try: ptslh.set_track_volume(engine, t_id, fader_db, batch_job_id=batch_job_id, progress=95) - except Exception: - pass + except Exception as e: + dbg(f"Fader set failed for {t_id}: {e}") # ── 6. Save & Close ────────────────────────────────── diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index 1e13470..5749f88 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -212,7 +212,7 @@ def create_session_from_template( # pylint: disable=too-many-positional-argumen "file_type": "FT_WAVE", "sample_rate": sample_rate, "bit_depth": bit_depth, - "input_output_settings": "IO_Last", + "input_output_settings": "IO_StereoMix", "is_interleaved": True, "is_cloud_project": False, } @@ -248,6 +248,12 @@ def close_session(engine, save_on_close: bool = False, delay: float = 0.5) -> No time.sleep(delay) +def save_session(engine) -> None: + """Save the current Pro Tools session without closing it.""" + from ptsl import PTSL_pb2 as pt + run_command(engine, pt.CommandId.CId_SaveSession, {}) + + # ── Batch job lifecycle ────────────────────────────────────────────── def create_batch_job(engine, name: str, description: str, @@ -388,20 +394,25 @@ def set_track_volume( to dBFS (e.g. ``-12.0`` sets the fader to −12 dB). """ from ptsl import PTSL_pb2 as pt - run_command( - engine, pt.CommandId.CId_SetTrackControlBreakpoints, - { - "track_id": track_id, - "control_id": { - "section": "TSId_MainOut", - "control_type": "TCType_Volume", - }, - "breakpoints": [{ - "time": { - "location": "0", - "time_type": "TLType_Samples", + dbg(f"set_track_volume: id={track_id}, db={volume_db}") + try: + run_command( + engine, pt.CommandId.CId_SetTrackControlBreakpoints, + { + "track_id": track_id, + "control_id": { + "section": "TSId_MainOut", + "control_type": "TCType_Volume", }, - "value": volume_db, - }], - }, - batch_job_id=batch_job_id, progress=progress) + "breakpoints": [{ + "time": { + "location": "0", + "time_type": "TLType_Samples", + }, + "value": volume_db, + }], + }, + batch_job_id=batch_job_id, progress=progress) + except Exception as e: + dbg(f"Error in set_track_volume ({track_id}, {volume_db} dB): {e}") + raise From a8e76211d836250352de9587ec821347cc5281ef Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Mon, 2 Mar 2026 21:12:30 +0100 Subject: [PATCH 28/56] fetch progress bar does not disappear now. --- sessionprepgui/widgets.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sessionprepgui/widgets.py b/sessionprepgui/widgets.py index f7bddea..e5747ca 100644 --- a/sessionprepgui/widgets.py +++ b/sessionprepgui/widgets.py @@ -80,9 +80,13 @@ def __init__(self, parent=None): self._bar.setFixedHeight(14) layout.addWidget(self._bar) self.setVisible(False) + self._hide_timer = QTimer(self) + self._hide_timer.setSingleShot(True) + self._hide_timer.timeout.connect(self._auto_hide) def start(self, text: str = "Preparing\u2026"): """Reset bar to 0 and show the panel with *text*.""" + self._hide_timer.stop() self._bar.setValue(0) self._label.setText(text) self.setVisible(True) @@ -101,13 +105,13 @@ def finish(self, text: str, auto_hide: bool = True): self._label.setText(text) self._bar.setValue(self._bar.maximum()) if auto_hide: - QTimer.singleShot(self.AUTO_HIDE_MS, self._auto_hide) + self._hide_timer.start(self.AUTO_HIDE_MS) def fail(self, text: str, auto_hide: bool = True): """Show failure message and optionally auto-hide.""" self._label.setText(f"Failed: {text}") if auto_hide: - QTimer.singleShot(self.AUTO_HIDE_MS, self._auto_hide) + self._hide_timer.start(self.AUTO_HIDE_MS) def _auto_hide(self): self.setVisible(False) From 3d873a9e6b79f4fa1ce7592615c1da1b74721269 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 09:25:42 +0100 Subject: [PATCH 29/56] pt template cache --- sessionprepgui/settings.py | 213 +++++++++--- sessionpreplib/config.py | 243 ++++++++++---- sessionpreplib/daw_processors/protools.py | 375 +++++++++++++++++----- 3 files changed, 628 insertions(+), 203 deletions(-) diff --git a/sessionprepgui/settings.py b/sessionprepgui/settings.py index 10a6430..c1d4f0e 100644 --- a/sessionprepgui/settings.py +++ b/sessionprepgui/settings.py @@ -27,13 +27,12 @@ import json import logging import os -import platform from typing import Any from sessionpreplib.config import ( PRESENTATION_PARAMS, build_structured_defaults, - flatten_structured_config, + get_app_dir, validate_structured_config, ) from .theme import PT_DEFAULT_COLORS @@ -46,9 +45,7 @@ # Presentation defaults (config-preset-scoped) # --------------------------------------------------------------------------- -_PRESENTATION_DEFAULTS: dict[str, Any] = { - p.key: p.default for p in PRESENTATION_PARAMS -} +_PRESENTATION_DEFAULTS: dict[str, Any] = {p.key: p.default for p in PRESENTATION_PARAMS} # --------------------------------------------------------------------------- # Application defaults (global, never per-session) @@ -72,31 +69,164 @@ _DEFAULT_GROUPS: list[dict[str, Any]] = [ # Drums - {"name": "Kick", "color": "Guardsman Red", "gain_linked": True, "daw_target": "Kick", "match_method": "contains", "match_pattern": "kick,kik,kck,bd"}, - {"name": "Snare", "color": "Dodger Blue Light", "gain_linked": True, "daw_target": "Snare", "match_method": "contains", "match_pattern": "snare,snr"}, - {"name": "Toms", "color": "Tia Maria", "gain_linked": True, "daw_target": "Toms", "match_method": "contains", "match_pattern": "tom,floor tom"}, - {"name": "OH", "color": "Java", "gain_linked": True, "daw_target": "OH", "match_method": "contains", "match_pattern": "oh,overhead,hh,hihat,hi-hat,hi hat,cymbal"}, - {"name": "Room", "color": "Purple", "gain_linked": False, "daw_target": "Room", "match_method": "contains", "match_pattern": "room,rm,ambient"}, - {"name": "Perc", "color": "Corn Harvest", "gain_linked": False, "daw_target": "Perc", "match_method": "contains", "match_pattern": "perc,shaker,tamb,conga,bongo"}, - {"name": "Loops", "color": "Cafe Royale Light", "gain_linked": False, "daw_target": "Loops", "match_method": "contains", "match_pattern": "loop"}, + { + "name": "Kick", + "color": "Guardsman Red", + "gain_linked": True, + "daw_target": "Kick", + "match_method": "contains", + "match_pattern": "kick,kik,kck,bd", + }, + { + "name": "Snare", + "color": "Dodger Blue Light", + "gain_linked": True, + "daw_target": "Snare", + "match_method": "contains", + "match_pattern": "snare,snr", + }, + { + "name": "Toms", + "color": "Tia Maria", + "gain_linked": True, + "daw_target": "Toms", + "match_method": "contains", + "match_pattern": "tom,floor tom", + }, + { + "name": "OH", + "color": "Java", + "gain_linked": True, + "daw_target": "OH", + "match_method": "contains", + "match_pattern": "oh,overhead,hh,hihat,hi-hat,hi hat,cymbal", + }, + { + "name": "Room", + "color": "Purple", + "gain_linked": False, + "daw_target": "Room", + "match_method": "contains", + "match_pattern": "room,rm,ambient", + }, + { + "name": "Perc", + "color": "Corn Harvest", + "gain_linked": False, + "daw_target": "Perc", + "match_method": "contains", + "match_pattern": "perc,shaker,tamb,conga,bongo", + }, + { + "name": "Loops", + "color": "Cafe Royale Light", + "gain_linked": False, + "daw_target": "Loops", + "match_method": "contains", + "match_pattern": "loop", + }, # Bass - {"name": "Bass", "color": "Christi", "gain_linked": False, "daw_target": "Bass", "match_method": "contains", "match_pattern": "bass,bas"}, + { + "name": "Bass", + "color": "Christi", + "gain_linked": False, + "daw_target": "Bass", + "match_method": "contains", + "match_pattern": "bass,bas", + }, # Guitars - {"name": "E.Gtr", "color": "Pizza", "gain_linked": False, "daw_target": "E.Gtr", "match_method": "contains", "match_pattern": "e.gtr,egtr,elecgtr,elec gtr,electric guitar,dist gtr"}, - {"name": "A.Gtr", "color": "Lima Dark", "gain_linked": False, "daw_target": "A.Gtr", "match_method": "contains", "match_pattern": "a.gtr,agtr,acoustic gtr,ac gtr,acoustic guitar,nylon"}, + { + "name": "E.Gtr", + "color": "Pizza", + "gain_linked": False, + "daw_target": "E.Gtr", + "match_method": "contains", + "match_pattern": "e.gtr,egtr,elecgtr,elec gtr,electric guitar,dist gtr", + }, + { + "name": "A.Gtr", + "color": "Lima Dark", + "gain_linked": False, + "daw_target": "A.Gtr", + "match_method": "contains", + "match_pattern": "a.gtr,agtr,acoustic gtr,ac gtr,acoustic guitar,nylon", + }, # Keys & Synths - {"name": "Keys", "color": "Malachite", "gain_linked": False, "daw_target": "Keys", "match_method": "contains", "match_pattern": "keys,piano,pno,organ,rhodes,wurli"}, - {"name": "Synths", "color": "Electric Violet Light", "gain_linked": False, "daw_target": "Synths", "match_method": "contains", "match_pattern": "synth,moog"}, - {"name": "Leads", "color": "Electric Violet Dark", "gain_linked": False, "daw_target": "Leads", "match_method": "contains", "match_pattern": "lead"}, + { + "name": "Keys", + "color": "Malachite", + "gain_linked": False, + "daw_target": "Keys", + "match_method": "contains", + "match_pattern": "keys,piano,pno,organ,rhodes,wurli", + }, + { + "name": "Synths", + "color": "Electric Violet Light", + "gain_linked": False, + "daw_target": "Synths", + "match_method": "contains", + "match_pattern": "synth,moog", + }, + { + "name": "Leads", + "color": "Electric Violet Dark", + "gain_linked": False, + "daw_target": "Leads", + "match_method": "contains", + "match_pattern": "lead", + }, # Strings & Pads - {"name": "Strings", "color": "Eastern Blue", "gain_linked": False, "daw_target": "Strings", "match_method": "contains", "match_pattern": "string,violin,viola,cello,fiddle"}, - {"name": "Pads", "color": "Flirt", "gain_linked": False, "daw_target": "Pads", "match_method": "contains", "match_pattern": "pad"}, - {"name": "Brass", "color": "Milano Red", "gain_linked": False, "daw_target": "Brass", "match_method": "contains", "match_pattern": "brass,trumpet,trombone,sax,horn"}, + { + "name": "Strings", + "color": "Eastern Blue", + "gain_linked": False, + "daw_target": "Strings", + "match_method": "contains", + "match_pattern": "string,violin,viola,cello,fiddle", + }, + { + "name": "Pads", + "color": "Flirt", + "gain_linked": False, + "daw_target": "Pads", + "match_method": "contains", + "match_pattern": "pad", + }, + { + "name": "Brass", + "color": "Milano Red", + "gain_linked": False, + "daw_target": "Brass", + "match_method": "contains", + "match_pattern": "brass,trumpet,trombone,sax,horn", + }, # Vocals - {"name": "VOX", "color": "Dodger Blue Dark", "gain_linked": False, "daw_target": "VOX", "match_method": "contains", "match_pattern": "vox,vocal,lead voc,main voc,voice,leadvox"}, - {"name": "BGs", "color": "Matisse", "gain_linked": False, "daw_target": "BGs", "match_method": "contains", "match_pattern": "bg vox,backingvox,bgv,backing,harmony,choir,bg,backingvox"}, + { + "name": "VOX", + "color": "Dodger Blue Dark", + "gain_linked": False, + "daw_target": "VOX", + "match_method": "contains", + "match_pattern": "vox,vocal,lead voc,main voc,voice,leadvox", + }, + { + "name": "BGs", + "color": "Matisse", + "gain_linked": False, + "daw_target": "BGs", + "match_method": "contains", + "match_pattern": "bg vox,backingvox,bgv,backing,harmony,choir,bg,backingvox", + }, # Effects - {"name": "FX", "color": "Lipstick", "gain_linked": False, "daw_target": "FX", "match_method": "contains", "match_pattern": "fx,sfx,effect"}, + { + "name": "FX", + "color": "Lipstick", + "gain_linked": False, + "daw_target": "FX", + "match_method": "contains", + "match_pattern": "fx,sfx,effect", + }, ] @@ -145,26 +275,10 @@ def resolve_config_preset( # Path helpers # --------------------------------------------------------------------------- + def _config_dir() -> str: """Return the OS-specific configuration directory for SessionPrep.""" - system = platform.system() - if system == "Windows": - base = os.environ.get("APPDATA") - if not base: - base = os.path.expanduser("~") - return os.path.join(base, "sessionprep") - if system == "Darwin": - return os.path.join( - os.path.expanduser("~"), - "Library", - "Application Support", - "sessionprep", - ) - # Linux / BSD / … - base = os.environ.get("XDG_CONFIG_HOME") - if not base: - base = os.path.join(os.path.expanduser("~"), ".config") - return os.path.join(base, "sessionprep") + return get_app_dir() def config_path() -> str: @@ -176,6 +290,7 @@ def config_path() -> str: # Load / Save # --------------------------------------------------------------------------- + def load_config() -> dict[str, Any]: """Load the four-section GUI config, creating it with defaults if needed. @@ -204,8 +319,9 @@ def load_config() -> dict[str, Any]: return copy.deepcopy(defaults) if not isinstance(data, dict): - log.warning("Config root is %s, expected object — recreating", - type(data).__name__) + log.warning( + "Config root is %s, expected object — recreating", type(data).__name__ + ) _backup_corrupt(path) save_config(defaults) return copy.deepcopy(defaults) @@ -228,8 +344,9 @@ def load_config() -> dict[str, Any]: errors = validate_structured_config(preset) if errors: msgs = "; ".join(e.message for e in errors) - log.warning("Config preset %r validation failed (%s) — resetting", - name, msgs) + log.warning( + "Config preset %r validation failed (%s) — resetting", name, msgs + ) valid = False break @@ -237,8 +354,7 @@ def load_config() -> dict[str, Any]: _backup_corrupt(path) # Keep app settings and colors, reset presets to defaults defaults["app"] = copy.deepcopy(merged.get("app", _APP_DEFAULTS)) - defaults["colors"] = copy.deepcopy( - merged.get("colors", PT_DEFAULT_COLORS)) + defaults["colors"] = copy.deepcopy(merged.get("colors", PT_DEFAULT_COLORS)) save_config(defaults) return copy.deepcopy(defaults) @@ -269,6 +385,7 @@ def save_config(config: dict[str, Any]) -> str: # Internal helpers # --------------------------------------------------------------------------- + def _merge_structured( defaults: dict[str, Any], overrides: dict[str, Any], diff --git a/sessionpreplib/config.py b/sessionpreplib/config.py index 4964e40..e6b0ca2 100644 --- a/sessionpreplib/config.py +++ b/sessionpreplib/config.py @@ -2,6 +2,7 @@ import json import os +import platform from dataclasses import dataclass from typing import Any @@ -11,11 +12,38 @@ # Keys that are internal/CLI-only and should not be saved in presets _INTERNAL_KEYS = { - "execute", "overwrite", "output_folder", "backup", - "report", "json", "_source_dir", + "execute", + "overwrite", + "output_folder", + "backup", + "report", + "json", + "_source_dir", } +def get_app_dir() -> str: + """Return the OS-specific configuration directory for SessionPrep.""" + system = platform.system() + if system == "Windows": + base = os.environ.get("APPDATA") + if not base: + base = os.path.expanduser("~") + return os.path.join(base, "sessionprep") + if system == "Darwin": + return os.path.join( + os.path.expanduser("~"), + "Library", + "Application Support", + "sessionprep", + ) + # Linux / BSD / … + base = os.environ.get("XDG_CONFIG_HOME") + if not base: + base = os.path.join(os.path.expanduser("~"), ".config") + return os.path.join(base, "sessionprep") + + class ConfigError(Exception): """Raised when configuration validation fails.""" @@ -29,6 +57,7 @@ class ConfigFieldError: value: The offending value. message: Human-readable explanation of what is wrong. """ + key: str value: Any message: str @@ -81,7 +110,12 @@ def merge_configs(*configs: dict[str, Any]) -> dict[str, Any]: result: dict[str, Any] = {} for cfg in configs: for k, v in cfg.items(): - if k in _LIST_KEYS and k in result and isinstance(result[k], list) and isinstance(v, list): + if ( + k in _LIST_KEYS + and k in result + and isinstance(result[k], list) + and isinstance(v, list) + ): result[k] = result[k] + v else: result[k] = v @@ -104,14 +138,20 @@ def load_preset(path: str) -> dict[str, Any]: raise ConfigError(f"Cannot read preset file {path}: {e}") from e if not isinstance(data, dict): - raise ConfigError(f"Preset file must contain a JSON object, got {type(data).__name__}") + raise ConfigError( + f"Preset file must contain a JSON object, got {type(data).__name__}" + ) # Strip metadata keys — they are informational, not config - preset = {k: v for k, v in data.items() if k not in ("schema_version", "_description")} + preset = { + k: v for k, v in data.items() if k not in ("schema_version", "_description") + } return preset -def save_preset(config: dict[str, Any], path: str, *, description: str | None = None) -> None: +def save_preset( + config: dict[str, Any], path: str, *, description: str | None = None +) -> None: """ Save a config dict as a JSON preset file. Internal/CLI-only keys are excluded automatically. @@ -142,18 +182,25 @@ def save_preset(config: dict[str, Any], path: str, *, description: str | None = ANALYSIS_PARAMS: list[ParamSpec] = [ ParamSpec( - key="window", type=int, default=400, min=1, + key="window", + type=int, + default=400, + min=1, label="RMS window size (ms)", description="Momentary-loudness window used for RMS analysis.", ), ParamSpec( - key="stereo_mode", type=str, default="avg", + key="stereo_mode", + type=str, + default="avg", choices=["avg", "sum"], label="Stereo RMS mode", description="How left/right channels are combined for RMS.", ), ParamSpec( - key="rms_anchor", type=str, default="percentile", + key="rms_anchor", + type=str, + default="percentile", choices=["percentile", "max"], label="RMS anchor strategy", description=( @@ -167,8 +214,13 @@ def save_preset(config: dict[str, Any], path: str, *, description: str | None = ), ), ParamSpec( - key="rms_percentile", type=(int, float), default=95.0, - min=0.0, max=100.0, min_exclusive=True, max_exclusive=True, + key="rms_percentile", + type=(int, float), + default=95.0, + min=0.0, + max=100.0, + min_exclusive=True, + max_exclusive=True, label="RMS percentile", description=( "Which percentile of the gated RMS window distribution to use as " @@ -180,7 +232,10 @@ def save_preset(config: dict[str, Any], path: str, *, description: str | None = ), ), ParamSpec( - key="gate_relative_db", type=(int, float), default=40.0, min=0.0, + key="gate_relative_db", + type=(int, float), + default=40.0, + min=0.0, label="Relative gate (dB)", description=( "RMS windows more than this many dB below the loudest window are " @@ -192,7 +247,9 @@ def save_preset(config: dict[str, Any], path: str, *, description: str | None = ), ), ParamSpec( - key="dbfs_convention", type=str, default="standard", + key="dbfs_convention", + type=str, + default="standard", choices=["standard", "aes17"], label="dBFS convention", description=( @@ -202,7 +259,9 @@ def save_preset(config: dict[str, Any], path: str, *, description: str | None = ), # -- Global processing defaults ------------------------------------------ ParamSpec( - key="fader_headroom_db", type=(int, float), default=8.0, + key="fader_headroom_db", + type=(int, float), + default=8.0, min=0.0, label="Fader headroom (dB)", description=( @@ -221,7 +280,9 @@ def save_preset(config: dict[str, Any], path: str, *, description: str | None = PRESENTATION_PARAMS: list[ParamSpec] = [ ParamSpec( - key="show_clean_detectors", type=bool, default=False, + key="show_clean_detectors", + type=bool, + default=False, presentation_only=True, label="Show clean detector results", description=( @@ -238,6 +299,7 @@ def save_preset(config: dict[str, Any], path: str, *, description: str | None = # Validation (ParamSpec-driven) # --------------------------------------------------------------------------- + def validate_param_values( params: list[ParamSpec], values: dict[str, Any], @@ -260,76 +322,103 @@ def validate_param_values( if value is None: if spec.nullable: continue - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must not be empty.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must not be empty.", + ) + ) continue # -- type (bool ⊄ int guard) -- expected = spec.type if expected is not bool and isinstance(value, bool): - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must be {_type_label(expected)}, got boolean.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must be {_type_label(expected)}, got boolean.", + ) + ) continue if not isinstance(value, expected): - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must be {_type_label(expected)}, " - f"got {type(value).__name__}.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must be {_type_label(expected)}, " + f"got {type(value).__name__}.", + ) + ) continue # -- choices -- if spec.choices is not None and value not in spec.choices: opts = ", ".join(repr(c) for c in spec.choices) - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must be one of {opts}.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must be one of {opts}.", + ) + ) continue # -- numeric range -- if isinstance(value, (int, float)) and not isinstance(value, bool): if spec.min is not None: if spec.min_exclusive and value <= spec.min: - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must be greater than {spec.min}.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must be greater than {spec.min}.", + ) + ) continue if not spec.min_exclusive and value < spec.min: - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must be at least {spec.min}.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must be at least {spec.min}.", + ) + ) continue if spec.max is not None: if spec.max_exclusive and value >= spec.max: - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must be less than {spec.max}.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must be less than {spec.max}.", + ) + ) continue if not spec.max_exclusive and value > spec.max: - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label} must be at most {spec.max}.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label} must be at most {spec.max}.", + ) + ) continue # -- list items -- if spec.item_type is not None and isinstance(value, list): for i, item in enumerate(value): if not isinstance(item, spec.item_type): - errors.append(ConfigFieldError( - spec.key, value, - f"{spec.label}[{i}] must be " - f"{spec.item_type.__name__}, " - f"got {type(item).__name__}.", - )) + errors.append( + ConfigFieldError( + spec.key, + value, + f"{spec.label}[{i}] must be " + f"{spec.item_type.__name__}, " + f"got {type(item).__name__}.", + ) + ) break return errors @@ -380,6 +469,7 @@ def validate_config(config: dict[str, Any]) -> None: # Structured config (GUI config file format) # --------------------------------------------------------------------------- + def build_structured_defaults() -> dict[str, Any]: """Build a structured config dict with all defaults, organized by section. @@ -425,13 +515,11 @@ def build_structured_defaults() -> dict[str, Any]: # DAWProject: include templates list default if dp.id == "dawproject": structured["daw_processors"].setdefault(dp.id, {}) - structured["daw_processors"][dp.id].setdefault( - "dawproject_templates", []) + structured["daw_processors"][dp.id].setdefault("dawproject_templates", []) # Pro Tools: include templates list default elif dp.id == "protools": structured["daw_processors"].setdefault(dp.id, {}) - structured["daw_processors"][dp.id].setdefault( - "protools_templates", []) + structured["daw_processors"][dp.id].setdefault("protools_templates", []) return structured @@ -502,9 +590,12 @@ def validate_structured_config( errors: list[ConfigFieldError] = [] # Analysis section - errors.extend(validate_param_values( - ANALYSIS_PARAMS, structured.get("analysis", {}), - )) + errors.extend( + validate_param_values( + ANALYSIS_PARAMS, + structured.get("analysis", {}), + ) + ) # Detector sections det_map = {d.id: d for d in default_detectors()} @@ -514,9 +605,13 @@ def validate_structured_config( if det is None or not isinstance(section, dict): continue for err in validate_param_values(det.config_params(), section): - errors.append(ConfigFieldError( - f"detectors.{det_id}.{err.key}", err.value, err.message, - )) + errors.append( + ConfigFieldError( + f"detectors.{det_id}.{err.key}", + err.value, + err.message, + ) + ) # Processor sections proc_map = {p.id: p for p in default_processors()} @@ -526,9 +621,13 @@ def validate_structured_config( if proc is None or not isinstance(section, dict): continue for err in validate_param_values(proc.config_params(), section): - errors.append(ConfigFieldError( - f"processors.{proc_id}.{err.key}", err.value, err.message, - )) + errors.append( + ConfigFieldError( + f"processors.{proc_id}.{err.key}", + err.value, + err.message, + ) + ) # DAW Processor sections dp_map = {dp.id: dp for dp in default_daw_processors()} @@ -538,9 +637,13 @@ def validate_structured_config( if dp is None or not isinstance(section, dict): continue for err in validate_param_values(dp.config_params(), section): - errors.append(ConfigFieldError( - f"daw_processors.{dp_id}.{err.key}", err.value, err.message, - )) + errors.append( + ConfigFieldError( + f"daw_processors.{dp_id}.{err.key}", + err.value, + err.message, + ) + ) return errors diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 6e16f1c..6cfb89f 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -5,7 +5,6 @@ import math import os import time -import shutil from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any @@ -17,6 +16,7 @@ try: from sessionprepgui.log import dbg except ImportError: + def dbg(msg: str) -> None: # type: ignore[misc] pass @@ -60,7 +60,8 @@ def f(t: float) -> float: def _closest_palette_index( - target_argb: str, palette: list[str], + target_argb: str, + palette: list[str], ) -> int | None: """Find the palette index whose colour is perceptually closest. @@ -113,7 +114,8 @@ def __init__( @classmethod def create_instances( - cls, flat_config: dict[str, Any], + cls, + flat_config: dict[str, Any], ) -> list[ProToolsDawProcessor]: """Create one processor instance per configured template. @@ -132,11 +134,13 @@ def create_instances( name = tpl.get("name", "").strip() if not name or not group: continue - instances.append(cls( - instance_index=idx, - instance_group=group, - instance_name=name, - )) + instances.append( + cls( + instance_index=idx, + instance_group=group, + instance_name=name, + ) + ) return instances @classmethod @@ -205,7 +209,9 @@ def configure(self, config: dict[str, Any]) -> None: self._project_dir: str = config.get("protools_project_dir", "") self._temp_dir: str = config.get("protools_temp_dir", "") self._company_name: str = config.get("protools_company_name", "github.com") - self._application_name: str = config.get("protools_application_name", "sessionprep") + self._application_name: str = config.get( + "protools_application_name", "sessionprep" + ) self._host: str = config.get("protools_host", "localhost") self._port: int = config.get("protools_port", 31416) self._command_delay: float = config.get("protools_command_delay", 0.5) @@ -231,9 +237,15 @@ def check_connectivity(self) -> tuple[bool, str]: return False, "Protocol 2025 or newer required" from . import ptsl_helpers as ptslh - if not ptslh.wait_for_host_ready(engine, timeout=25.0, sleep_time=self._command_delay): + + if not ptslh.wait_for_host_ready( + engine, timeout=25.0, sleep_time=self._command_delay + ): self._connected = False - return False, "Connected, but Pro Tools is busy or not ready. Please bring its window to the front." + return ( + False, + "Connected, but Pro Tools is busy or not ready. Please bring its window to the front.", + ) self._connected = True return True, f"Protocol: {version}" @@ -249,9 +261,76 @@ def check_connectivity(self) -> tuple[bool, str]: def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: if not self._temp_dir: - raise RuntimeError("The 'Temporary project directory' is not configured in Preferences.") + raise RuntimeError( + "The 'Temporary project directory' is not configured in Preferences." + ) if not os.path.isdir(self._temp_dir): - raise RuntimeError(f"The configured temporary project directory does not exist: {self._temp_dir}") + raise RuntimeError( + f"The configured temporary project directory does not exist: {self._temp_dir}" + ) + + # 1. Resolve Path to template file + import platform + from pathlib import Path + + system = platform.system() + template_dir = None + if system == "Windows": + template_dir = Path.home() / "Documents" / "Pro Tools" / "Session Templates" + elif system == "Darwin": + template_dir = Path.home() / "Documents" / "Pro Tools" / "Session Templates" + + template_file = None + current_mtime = None + if template_dir: + # e.g SessionPrep / MiniTemplate.ptxt + template_file = ( + template_dir / self._instance_group / f"{self._instance_name}.ptxt" + ) + if template_file.is_file(): + current_mtime = template_file.stat().st_mtime + + # 2. Check Cache + from sessionpreplib.config import get_app_dir + import json + + cache_file = Path(get_app_dir()) / "pt_template_cache.json" + cache_data = {} + if cache_file.is_file(): + try: + with open(cache_file, "r", encoding="utf-8") as f: + cache_data = json.load(f) + except Exception: + cache_data = {} + + cache_key = f"{self._instance_group}/{self._instance_name}" + if current_mtime is not None and cache_key in cache_data: + entry = cache_data[cache_key] + if entry.get("mtime") == current_mtime: + # Fast Cache Hit + if progress_cb: + progress_cb( + 100, + 100, + f"Loaded structure for '{self._instance_name}' from cache.", + ) + + folders = entry.get("folders", []) + + pt_state = session.daw_state.get(self.id, {}) + old_assignments: dict[str, str] = pt_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 try: from ptsl import Engine @@ -275,17 +354,26 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: if progress_cb: progress_cb(15, 100, "Waiting for Pro Tools to become ready...") - if not ptslh.wait_for_host_ready(engine, timeout=25.0, sleep_time=self._command_delay): - raise RuntimeError("Pro Tools is busy or not ready. Please bring its window to the front to wake it.") + if not ptslh.wait_for_host_ready( + engine, timeout=25.0, sleep_time=self._command_delay + ): + raise RuntimeError( + "Pro Tools is busy or not ready. Please bring its window to the front to wake it." + ) if ptslh.is_session_open(engine): raise RuntimeError("PRO_TOOLS_SESSION_OPEN") import uuid + temp_session_name = f"SessionPrep_Temp_{uuid.uuid4().hex[:8]}" if progress_cb: - progress_cb(30, 100, f"Creating temporary session from template '{self._instance_group} / {self._instance_name}'...") + progress_cb( + 30, + 100, + f"Creating temporary session from template '{self._instance_group} / {self._instance_name}'...", + ) # Create the temporary session from the template ptslh.create_session_from_template( @@ -293,7 +381,7 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: temp_session_name, self._temp_dir, self._instance_group, - self._instance_name + self._instance_name, ) if progress_cb: @@ -304,16 +392,19 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: for track in all_tracks: if track.type in (pt.TrackType.RoutingFolder, pt.TrackType.BasicFolder): folder_type = ( - "routing" if track.type == pt.TrackType.RoutingFolder + "routing" + if track.type == pt.TrackType.RoutingFolder else "basic" ) - folders.append({ - "id": track.id, - "name": track.name, - "folder_type": folder_type, - "index": track.index, - "parent_id": track.parent_folder_id or None, - }) + folders.append( + { + "id": track.id, + "name": track.name, + "folder_type": folder_type, + "index": track.index, + "parent_id": track.parent_folder_id or None, + } + ) if progress_cb: progress_cb(90, 100, "Cleaning up temporary session...") @@ -323,14 +414,24 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: old_assignments: dict[str, str] = pt_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 + fname: fid for fname, fid in old_assignments.items() if fid in valid_ids } session.daw_state[self.id] = { "folders": folders, "assignments": assignments, } + + # Write cache + if current_mtime is not None: + cache_data[cache_key] = {"mtime": current_mtime, "folders": folders} + try: + cache_file.parent.mkdir(parents=True, exist_ok=True) + with open(cache_file, "w", encoding="utf-8") as f: + json.dump(cache_data, f, indent=2, ensure_ascii=False) + except Exception as e: + dbg(f"Failed to write template cache: {e}") + except Exception: raise finally: @@ -354,12 +455,15 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: # 1. Ensure target_dir actually exists # 2. Ensure target_dir is exactly a direct child of the configured temp dir # 3. Ensure target_dir contains our specific UUID .ptx file - if (os.path.isdir(target_dir) and - os.path.dirname(os.path.abspath(target_dir)) == os.path.abspath(self._temp_dir) and - os.path.isfile(ptx_file)): - + if ( + os.path.isdir(target_dir) + and os.path.dirname(os.path.abspath(target_dir)) + == os.path.abspath(self._temp_dir) + and os.path.isfile(ptx_file) + ): import shutil import time + # Retry loop to handle delayed file locks on Windows from Pro Tools closing for _ in range(10): # Try for up to 5 seconds try: @@ -376,7 +480,9 @@ def fetch(self, session: SessionContext, progress_cb=None) -> SessionContext: return session def _resolve_group_color( - self, group_name: str | None, session: SessionContext, + self, + group_name: str | None, + session: SessionContext, ) -> str | None: """Return the ARGB hex for *group_name*, or ``None``.""" if not group_name: @@ -398,6 +504,7 @@ def _resolve_group_color( def _open_engine(self): """Create and return a connected PTSL Engine.""" from ptsl import Engine + address = f"{self._host}:{self._port}" return Engine( company_name=self._company_name, @@ -417,22 +524,31 @@ def _get_optimal_session_specs(self, session: SessionContext) -> tuple[str, str] depths = [] for t in session.output_tracks: bd = str(t.bitdepth).upper() - if "32" in bd: depths.append(32) - elif "24" in bd: depths.append(24) - elif "16" in bd: depths.append(16) + if "32" in bd: + depths.append(32) + elif "24" in bd: + depths.append(24) + elif "16" in bd: + depths.append(16) # Default fallback common_rate = Counter(rates).most_common(1)[0][0] if rates else 48000 common_depth = Counter(depths).most_common(1)[0][0] if depths else 24 rate_map = { - 44100: "SR_44100", 48000: "SR_48000", 88200: "SR_88200", - 96000: "SR_96000", 176400: "SR_176400", 192000: "SR_192000" + 44100: "SR_44100", + 48000: "SR_48000", + 88200: "SR_88200", + 96000: "SR_96000", + 176400: "SR_176400", + 192000: "SR_192000", } depth_map = {16: "Bit16", 24: "Bit24", 32: "Bit32Float"} - return (rate_map.get(common_rate, "SR_48000"), - depth_map.get(common_depth, "Bit24")) + return ( + rate_map.get(common_rate, "SR_48000"), + depth_map.get(common_depth, "Bit24"), + ) def transfer( self, @@ -464,10 +580,13 @@ def transfer( from ptsl import PTSL_pb2 as pt # noqa: F401 – validates install except ImportError: dbg("py-ptsl not installed") - return [DawCommandResult( - command=DawCommand("transfer", "", {}), - success=False, error="py-ptsl package not installed", - )] + return [ + DawCommandResult( + command=DawCommand("transfer", "", {}), + success=False, + error="py-ptsl package not installed", + ) + ] pt_state = session.daw_state.get(self.id, {}) assignments: dict[str, str] = pt_state.get("assignments", {}) @@ -480,10 +599,8 @@ def transfer( # Build lookups folder_map = {f["id"]: f for f in folders} - manifest_map = { - e.entry_id: e for e in session.transfer_manifest} - out_track_map = { - t.filename: t for t in session.output_tracks} + manifest_map = {e.entry_id: e for e in session.transfer_manifest} + out_track_map = {t.filename: t for t in session.output_tracks} # Build ordered work list: [(entry_id, folder_id), ...] work: list[tuple[str, str]] = [] @@ -510,15 +627,21 @@ def transfer( if progress_cb: progress_cb(2, 100, "Waiting for Pro Tools to become ready...") - if not ptslh.wait_for_host_ready(engine, timeout=25.0, sleep_time=self._command_delay): - raise RuntimeError("Pro Tools is busy or not ready. Please bring its window to the front to wake it.") + if not ptslh.wait_for_host_ready( + engine, timeout=25.0, sleep_time=self._command_delay + ): + raise RuntimeError( + "Pro Tools is busy or not ready. Please bring its window to the front to wake it." + ) # ── 0. Setup & Safety Checks ───────────────────────── if not self._project_dir: raise RuntimeError("Pro Tools 'Project directory' is not configured.") if not os.path.isdir(self._project_dir): - raise RuntimeError(f"Pro Tools 'Project directory' does not exist: {self._project_dir}") + raise RuntimeError( + f"Pro Tools 'Project directory' does not exist: {self._project_dir}" + ) if not session.project_name: raise RuntimeError("Project name is empty.") @@ -543,15 +666,22 @@ def transfer( self._instance_group, self._instance_name, sample_rate=rate_enum, - bit_depth=depth_enum + bit_depth=depth_enum, + ) + results.append( + DawCommandResult( + command=DawCommand("create_session", session.project_name, {}), + success=True, + ) ) - results.append(DawCommandResult( - command=DawCommand("create_session", session.project_name, {}), - success=True)) except Exception as e: - return [DawCommandResult( - command=DawCommand("create_session", session.project_name, {}), - success=False, error=str(e))] + return [ + DawCommandResult( + command=DawCommand("create_session", session.project_name, {}), + success=False, + error=str(e), + ) + ] # Re-fetch color palette from the new session pt_palette = ptslh.get_color_palette(engine) @@ -559,8 +689,7 @@ def transfer( if pt_palette: for entry in session.transfer_manifest: if entry.group and entry.group not in group_palette_idx: - argb = self._resolve_group_color( - entry.group, session) + argb = self._resolve_group_color(entry.group, session) if argb: idx = _closest_palette_index(argb, pt_palette) if idx is not None: @@ -578,14 +707,18 @@ def transfer( if not entry: continue out_tc = out_track_map.get(entry.output_filename) - audio_path = (out_tc.processed_filepath or out_tc.filepath) if out_tc else None + audio_path = ( + (out_tc.processed_filepath or out_tc.filepath) if out_tc else None + ) if not out_tc or not audio_path: continue filepath = os.path.abspath(audio_path) track_stem = os.path.splitext(entry.daw_track_name)[0] - track_format = ("TF_Mono" if out_tc.channels == 1 else "TF_Stereo") - valid_work.append((eid, fid, filepath, track_stem, track_format, out_tc)) + track_format = "TF_Mono" if out_tc.channels == 1 else "TF_Stereo" + valid_work.append( + (eid, fid, filepath, track_stem, track_format, out_tc) + ) if not valid_work: dbg("No valid work items") @@ -594,7 +727,8 @@ def transfer( # ── 2. Batch Import ────────────────────────────────── batch_job_id = ptslh.create_batch_job( - engine, "SessionPrep Create", f"Importing {len(valid_work)} tracks") + engine, "SessionPrep Create", f"Importing {len(valid_work)} tracks" + ) if progress_cb: progress_cb(20, 100, "Importing audio to clip list...") @@ -605,7 +739,8 @@ def transfer( try: import_resp = ptslh.batch_import_audio( - engine, all_filepaths, batch_job_id=batch_job_id, progress=25) + engine, all_filepaths, batch_job_id=batch_job_id, progress=25 + ) time.sleep(delay) if import_resp: @@ -620,36 +755,72 @@ def transfer( fail_path = fail.get("original_input_path", "") import_failures.add(os.path.normcase(fail_path)) - results.append(DawCommandResult( - command=DawCommand("batch_import", "", {}), success=True)) + results.append( + DawCommandResult( + command=DawCommand("batch_import", "", {}), success=True + ) + ) except Exception as e: if batch_job_id: ptslh.cancel_batch_job(engine, batch_job_id) - return [DawCommandResult(command=DawCommand("batch_import", "", {}), - success=False, error=str(e))] + return [ + DawCommandResult( + command=DawCommand("batch_import", "", {}), + success=False, + error=str(e), + ) + ] # ── 3. Parallel Track Creation + Spot ──────────────── color_groups: dict[int, list[str]] = {} created_tracks: list[tuple[str, str, Any]] = [] spot_work = [] - for step, (_, fid, filepath_val, track_stem, track_format, tc) in enumerate(valid_work): + for step, (_, fid, filepath_val, track_stem, track_format, tc) in enumerate( + valid_work + ): clip_ids = clip_id_map.get(os.path.normcase(filepath_val)) if not clip_ids or os.path.normcase(filepath_val) in import_failures: continue - spot_work.append((step, fid, filepath_val, track_stem, track_format, tc, clip_ids)) + spot_work.append( + (step, fid, filepath_val, track_stem, track_format, tc, clip_ids) + ) def _create_and_spot(item): - (step_val, fid_val, _, track_stem_val, track_format_val, tc_val, clip_ids_val) = item + ( + step_val, + fid_val, + _, + track_stem_val, + track_format_val, + tc_val, + clip_ids_val, + ) = item folder_name = folder_map[fid_val]["name"] pct = 30 + int(50 * step_val / max(len(valid_work), 1)) try: - tid = ptslh.create_track(engine, track_stem_val, track_format_val, - folder_name=folder_name, batch_job_id=batch_job_id, progress=pct) - ptslh.spot_clips(engine, clip_ids_val, tid, batch_job_id=batch_job_id, progress=pct) + tid = ptslh.create_track( + engine, + track_stem_val, + track_format_val, + folder_name=folder_name, + batch_job_id=batch_job_id, + progress=pct, + ) + ptslh.spot_clips( + engine, + clip_ids_val, + tid, + batch_job_id=batch_job_id, + progress=pct, + ) - cinfo = (group_palette_idx[tc_val.group], track_stem_val) if tc_val.group in group_palette_idx else None + cinfo = ( + (group_palette_idx[tc_val.group], track_stem_val) + if tc_val.group in group_palette_idx + else None + ) return True, (track_stem_val, tid, tc_val), cinfo, None except Exception as ex: return False, None, None, str(ex) @@ -663,13 +834,19 @@ def _create_and_spot(item): if cinfo: color_groups.setdefault(cinfo[0], []).append(cinfo[1]) if progress_cb: - progress_cb(30 + int(50 * i / len(spot_work)), 100, f"Created {i+1}/{len(spot_work)} tracks") + progress_cb( + 30 + int(50 * i / len(spot_work)), + 100, + f"Created {i + 1}/{len(spot_work)} tracks", + ) # ── 4. Colorize ────────────────────────────────────── for cidx, names in color_groups.items(): try: - ptslh.colorize_tracks(engine, names, cidx, batch_job_id=batch_job_id, progress=90) + ptslh.colorize_tracks( + engine, names, cidx, batch_job_id=batch_job_id, progress=90 + ) except Exception: pass @@ -685,11 +862,19 @@ def _create_and_spot(item): if not pr or pr.classification in ("Silent", "Skip"): continue fader_db = pr.data.get("fader_offset", 0.0) - dbg(f"Fader logic for {t_id}: classification={pr.classification}, fader_db={fader_db}") + dbg( + f"Fader logic for {t_id}: classification={pr.classification}, fader_db={fader_db}" + ) if fader_db == 0.0: continue try: - ptslh.set_track_volume(engine, t_id, fader_db, batch_job_id=batch_job_id, progress=95) + ptslh.set_track_volume( + engine, + t_id, + fader_db, + batch_job_id=batch_job_id, + progress=95, + ) except Exception as e: dbg(f"Fader set failed for {t_id}: {e}") @@ -704,15 +889,33 @@ def _create_and_spot(item): try: ptslh.close_session(engine, save_on_close=True, delay=delay) - results.append(DawCommandResult(command=DawCommand("close_session", "", {}), success=True)) + results.append( + DawCommandResult( + command=DawCommand("close_session", "", {}), success=True + ) + ) except Exception as e: - results.append(DawCommandResult(command=DawCommand("close_session", "", {}), success=False, error=str(e))) + results.append( + DawCommandResult( + command=DawCommand("close_session", "", {}), + success=False, + error=str(e), + ) + ) except Exception as e: - results.append(DawCommandResult(command=DawCommand("create_project", "", {}), success=False, error=str(e))) + results.append( + DawCommandResult( + command=DawCommand("create_project", "", {}), + success=False, + error=str(e), + ) + ) finally: - if batch_job_id and engine: ptslh.cancel_batch_job(engine, batch_job_id) - if engine: engine.close() + if batch_job_id and engine: + ptslh.cancel_batch_job(engine, batch_job_id) + if engine: + engine.close() if progress_cb: progress_cb(100, 100, "Project creation complete") @@ -722,6 +925,8 @@ def sync(self, session: SessionContext) -> list[DawCommandResult]: return [] def execute_commands( - self, session: SessionContext, commands: list[DawCommand], + self, + session: SessionContext, + commands: list[DawCommand], ) -> list[DawCommandResult]: return [] From 12de5f0b04f490ba2808da6d108c36dd9a3e21b6 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 14:11:51 +0100 Subject: [PATCH 30/56] phase 1/phase 2 detector split. --- DEVELOPMENT.md | 318 +++++++++--------- KANBAN.md | 4 +- README.md | 30 +- REFERENCE.md | 117 +++---- sessionprep.py | 5 +- sessionprepgui/analysis/mixin.py | 78 ++++- sessionprepgui/analysis/worker.py | 135 +++++++- sessionprepgui/daw/mixin.py | 3 +- sessionprepgui/settings.py | 7 +- sessionprepgui/theme.py | 1 + sessionprepgui/topology/input_tree.py | 46 ++- sessionpreplib/detector.py | 4 +- sessionpreplib/detectors/dual_mono.py | 9 +- .../detectors/format_consistency.py | 3 +- sessionpreplib/detectors/one_sided_silence.py | 10 +- sessionpreplib/detectors/silence.py | 8 +- sessionpreplib/models.py | 6 + sessionpreplib/pipeline.py | 32 +- sessionpreplib/topology.py | 31 +- 19 files changed, 564 insertions(+), 283 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index f6cb199..2b39a74 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -199,13 +199,13 @@ Output goes to `dist_nuitka/`. Each executable name includes a platform and architecture suffix generated automatically by `build_conf.py`: -| Platform | CLI output filename | GUI output filename | -|-----------------|-------------------------------------------------|-----------------------------------------------------| -| Windows x64 | `sessionprep-win-x64.exe` | `sessionprep-gui-win-x64.exe` | -| macOS ARM | `sessionprep-macos-arm64` | `sessionprep-gui-macos-arm64` | -| macOS Intel | `sessionprep-macos-x64` | `sessionprep-gui-macos-x64` | -| Linux x64 | `sessionprep-linux-x64` | `sessionprep-gui-linux-x64` | -| Linux ARM64 | `sessionprep-linux-arm64` | `sessionprep-gui-linux-arm64` | +| Platform | CLI output filename | GUI output filename | +|-------------|---------------------------|-------------------------------| +| Windows x64 | `sessionprep-win-x64.exe` | `sessionprep-gui-win-x64.exe` | +| macOS ARM | `sessionprep-macos-arm64` | `sessionprep-gui-macos-arm64` | +| macOS Intel | `sessionprep-macos-x64` | `sessionprep-gui-macos-x64` | +| Linux x64 | `sessionprep-linux-x64` | `sessionprep-gui-linux-x64` | +| Linux ARM64 | `sessionprep-linux-arm64` | `sessionprep-gui-linux-arm64` | **Note on macOS:** GUI builds always use `onedir` mode (producing a `.app` bundle) because `--onefile` + `--windowed` is deprecated in both engines for @@ -239,13 +239,13 @@ sessionpreplib/_version.py → __version__ = "0.1.0" Everything else reads from this one source: -| Consumer | How it reads the version | -|----------|------------------------| -| `pyproject.toml` | `dynamic = ["version"]` + `[tool.hatch.version] path` | -| `sessionpreplib` | `from ._version import __version__` (re-exported in `__init__.py`) | +| Consumer | How it reads the version | +|------------------------|--------------------------------------------------------------------| +| `pyproject.toml` | `dynamic = ["version"]` + `[tool.hatch.version] path` | +| `sessionpreplib` | `from ._version import __version__` (re-exported in `__init__.py`) | | CLI (`sessionprep.py`) | `from sessionpreplib import __version__` (powers `--version` flag) | -| GUI About dialog | `from sessionpreplib import __version__` | -| PyInstaller builds | Bundled automatically via `--collect-all sessionpreplib` | +| GUI About dialog | `from sessionpreplib import __version__` | +| PyInstaller builds | Bundled automatically via `--collect-all sessionpreplib` | To bump the version, edit only `sessionpreplib/_version.py`. @@ -269,31 +269,31 @@ sudo dnf install gcc patchelf ccache libatomic-static ### 2.7 Project Structure for Packaging -| File | Purpose | -|------|--------| -| `pyproject.toml` | Package metadata, dependencies, build config, entry points | -| `uv.lock` | Lockfile for reproducible dependency resolution | -| `build_conf.py` | Shared build metadata and isolation rules (Source of Truth) | -| `build_pyinstaller.py`| PyInstaller automation (standard builds) | -| `build_nuitka.py` | Nuitka automation (optimized builds) | -| `sessionprep.py` | Thin CLI entry point | -| `sessionprep-gui.py` | Thin GUI entry point | +| File | Purpose | +|------------------------|-------------------------------------------------------------| +| `pyproject.toml` | Package metadata, dependencies, build config, entry points | +| `uv.lock` | Lockfile for reproducible dependency resolution | +| `build_conf.py` | Shared build metadata and isolation rules (Source of Truth) | +| `build_pyinstaller.py` | PyInstaller automation (standard builds) | +| `build_nuitka.py` | Nuitka automation (optimized builds) | +| `sessionprep.py` | Thin CLI entry point | +| `sessionprep-gui.py` | Thin GUI entry point | ### 2.5 Dependencies -| Package | Type | Used by | -|---------|------|--------| -| `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/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/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 | -| `pytest-cov` | Dev | Coverage reporting | -| `pyinstaller` | Dev | Standalone executable builds | -| `Pillow` | Dev | Icon format conversion for PyInstaller (macOS .png → .icns) | +| Package | Type | Used by | +|---------------|----------------|------------------------------------------------------------------------------------------------------------| +| `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/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/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 | +| `pytest-cov` | Dev | Coverage reporting | +| `pyinstaller` | Dev | Standalone executable builds | +| `Pillow` | Dev | Icon format conversion for PyInstaller (macOS .png → .icns) | Core runtime dependencies (`numpy`, `soundfile`, `scipy`) are declared in `[project].dependencies`. GUI-only dependencies (`PySide6`, `sounddevice`) @@ -507,12 +507,12 @@ class ParamSpec: `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` | +| 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 @@ -787,7 +787,7 @@ cycles. #### 6.3.1 SilenceDetector (`silence.py`) - **ID:** `silence` | **Depends on:** (none) -- **Data:** `{"is_silent": bool}` +- **Data:** `{"is_silent": bool, "topology_action": "drop"}` - **Issues:** Whole-file `IssueLocation` (all channels) when track is silent - **Severity:** `ATTENTION` if silent, `CLEAN` otherwise - **Clean message:** `"No silent files detected"` @@ -822,7 +822,7 @@ cycles. - **ID:** `dual_mono` | **Depends on:** `["silence"]` - **Config:** `dual_mono_eps` -- **Data:** `{"dual_mono": bool}` +- **Data:** `{"dual_mono": bool, "topology_action": "extract_channel", "topology_channel": 0}` - **Severity:** `INFO` if dual-mono, `CLEAN` otherwise #### 6.3.6 *(removed — merged into StereoCompatDetector)* @@ -831,7 +831,7 @@ cycles. - **ID:** `one_sided_silence` | **Depends on:** `["silence"]` - **Config:** `one_sided_silence_db` -- **Data:** `{"one_sided_silence": bool, "one_sided_silence_side": str | None, "l_rms_db": float, "r_rms_db": float}` +- **Data:** `{"one_sided_silence": bool, "one_sided_silence_side": str | None, "l_rms_db": float, "r_rms_db": float, "topology_action": "extract_channel", "topology_channel": int | None}` - **Issues:** Per-channel `IssueLocation` spanning the entire file for the silent channel - **Severity:** `ATTENTION` if detected, `CLEAN` otherwise - **Hint:** `"check stereo export / channel routing"` @@ -1129,12 +1129,12 @@ through the `py-ptsl` Python client. **Lifecycle implementation:** -| Method | Behaviour | -|--------|-----------| -| `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)` | 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`). | +| Method | Behaviour | +|------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `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)` | 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), @@ -1178,11 +1178,11 @@ 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` nested inside a `Lanes` within the `Clip` (sibling of the `Audio` element). | +| 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` nested inside a `Lanes` within the `Clip` (sibling of the `Audio` element). | **Gain application logic:** @@ -1219,10 +1219,10 @@ execute() -> Apply gains, backup originals, write processed files (CLI legacy) ### 9.2 Phase Usage by Mode -| Mode | Phases executed | -|------|----------------| -| Dry-run (default) | `analyze` → `plan` | -| GUI with Prepare | `analyze` → `plan` → `prepare` | +| Mode | Phases executed | +|----------------------|--------------------------------| +| Dry-run (default) | `analyze` → `plan` | +| GUI with Prepare | `analyze` → `plan` → `prepare` | | Execute (CLI legacy) | `analyze` → `plan` → `execute` | ### 9.3 Pipeline Class @@ -1441,26 +1441,26 @@ class EventBus: ### Implemented Event Types -| Event | Emitted by | Data | -|-------|-----------|------| -| `track.load` | `load_session` | `filename`, `index`, `total` | -| `track.analyze_start` | Pipeline | `filename`, `index`, `total` | -| `track.analyze_complete` | Pipeline | `filename`, `index`, `total` | -| `detector.start` | Pipeline | `detector_id`, `filename` | -| `detector.complete` | Pipeline | `detector_id`, `filename`, `severity` | -| `session_detector.start` | Pipeline | `detector_id` | -| `session_detector.complete` | Pipeline | `detector_id` | -| `track.plan_start` | Pipeline | `filename`, `index`, `total` | -| `processor.start` | Pipeline | `processor_id`, `filename` | -| `processor.complete` | Pipeline | `processor_id`, `filename` | -| `track.plan_complete` | Pipeline | `filename`, `index`, `total` | -| `track.write_start` | Pipeline | `filename`, `index`, `total` | -| `track.write_complete` | Pipeline | `filename`, `index`, `total` | -| `prepare.start` | Pipeline | `filename` | -| `prepare.complete` | Pipeline | `filename` | -| `prepare.error` | Pipeline | `filename`, `error` | -| `job.start` | Queue | `job_id` | -| `job.complete` | Queue | `job_id`, `status` | +| Event | Emitted by | Data | +|-----------------------------|----------------|---------------------------------------| +| `track.load` | `load_session` | `filename`, `index`, `total` | +| `track.analyze_start` | Pipeline | `filename`, `index`, `total` | +| `track.analyze_complete` | Pipeline | `filename`, `index`, `total` | +| `detector.start` | Pipeline | `detector_id`, `filename` | +| `detector.complete` | Pipeline | `detector_id`, `filename`, `severity` | +| `session_detector.start` | Pipeline | `detector_id` | +| `session_detector.complete` | Pipeline | `detector_id` | +| `track.plan_start` | Pipeline | `filename`, `index`, `total` | +| `processor.start` | Pipeline | `processor_id`, `filename` | +| `processor.complete` | Pipeline | `processor_id`, `filename` | +| `track.plan_complete` | Pipeline | `filename`, `index`, `total` | +| `track.write_start` | Pipeline | `filename`, `index`, `total` | +| `track.write_complete` | Pipeline | `filename`, `index`, `total` | +| `prepare.start` | Pipeline | `filename` | +| `prepare.complete` | Pipeline | `filename` | +| `prepare.error` | Pipeline | `filename`, `error` | +| `job.start` | Queue | `job_id` | +| `job.complete` | Queue | `job_id`, `status` | No EventBus = no overhead. All emissions are guarded with `if self.event_bus`. @@ -1531,13 +1531,13 @@ Currently applies to: ### 15.2 Error Handling Table -| Failure | Policy | -|---------|--------| -| One detector throws on one track | Store `DetectorResult` with `Severity.PROBLEM`, `error` field set; **continue** | -| One track file cannot be read | Mark `TrackContext.status = "Error: ..."`, skip all detectors/processors; **continue** | -| An audio processor throws on one track | Store `ProcessorResult` with `error` field set; skip audio write; **continue** | -| Config validation fails at startup | **Abort** with descriptive error | -| Cyclic detector dependency | **Abort** at pipeline construction | +| Failure | Policy | +|----------------------------------------|----------------------------------------------------------------------------------------| +| One detector throws on one track | Store `DetectorResult` with `Severity.PROBLEM`, `error` field set; **continue** | +| One track file cannot be read | Mark `TrackContext.status = "Error: ..."`, skip all detectors/processors; **continue** | +| An audio processor throws on one track | Store `ProcessorResult` with `error` field set; skip audio write; **continue** | +| Config validation fails at startup | **Abort** with descriptive error | +| Cyclic detector dependency | **Abort** at pipeline construction | ### 15.3 Implementation @@ -1566,12 +1566,12 @@ for the `ParamSpec` dataclass. **Validation entry points:** -| Function | Input | Output | Use case | -|----------|-------|--------|----------| -| `validate_param_values(params, values)` | `list[ParamSpec]`, flat dict | `list[ConfigFieldError]` | Validate any subset of params | -| `validate_config_fields(config)` | flat dict | `list[ConfigFieldError]` | Validate all known params (auto-collects from components) | -| `validate_structured_config(structured)` | structured dict | `list[ConfigFieldError]` | Validate the GUI config file section by section | -| `validate_config(config)` | flat dict | raises `ConfigError` | Backward-compatible wrapper (CLI) | +| Function | Input | Output | Use case | +|------------------------------------------|------------------------------|--------------------------|-----------------------------------------------------------| +| `validate_param_values(params, values)` | `list[ParamSpec]`, flat dict | `list[ConfigFieldError]` | Validate any subset of params | +| `validate_config_fields(config)` | flat dict | `list[ConfigFieldError]` | Validate all known params (auto-collects from components) | +| `validate_structured_config(structured)` | structured dict | `list[ConfigFieldError]` | Validate the GUI config file section by section | +| `validate_config(config)` | flat dict | raises `ConfigError` | Backward-compatible wrapper (CLI) | **Checks performed per field (in order):** @@ -1617,43 +1617,43 @@ group). ### 18.2 Package Architecture -| Module | Responsibility | -|--------|---------------| -| `__init__.py` | Exports `main()` | -| `settings.py` | `load_config()`, `save_config()`, `config_path()` — persistent GUI preferences | -| `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. | -| `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, Track Name inline editing, duplication with `-[N]` naming | -| `topology/mixin.py` | `TopologyMixin` — Phase 1 Channel Topology UI, "Scan subfolders" checkbox (recursive discovery), Apply worker, collapsible waveform preview | -| `topology/input_tree.py` | `InputTree` — QTreeWidget for source tracks, drag-out of channels via custom MIME | -| `topology/output_tree.py` | `OutputTree` — QTreeWidget for output tracks, drag-in from input tree, internal channel reorder with insert-position line, cross-file channel moves | -| `topology/operations.py` | Topology mutation functions: add/remove/reorder/move/wire channels, rename/remove outputs | -| `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, recursive_scan flag) to `.spsession` JSON without re-running analysis. Versioned format (v4) 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`. | -| `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 | +| Module | Responsibility | +|---------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `__init__.py` | Exports `main()` | +| `settings.py` | `load_config()`, `save_config()`, `config_path()` — persistent GUI preferences | +| `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. | +| `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, Track Name inline editing, duplication with `-[N]` naming | +| `topology/mixin.py` | `TopologyMixin` — Phase 1 Channel Topology UI, "Scan subfolders" checkbox (recursive discovery), Apply worker, collapsible waveform preview | +| `topology/input_tree.py` | `InputTree` — QTreeWidget for source tracks, drag-out of channels via custom MIME | +| `topology/output_tree.py` | `OutputTree` — QTreeWidget for output tracks, drag-in from input tree, internal channel reorder with insert-position line, cross-file channel moves | +| `topology/operations.py` | Topology mutation functions: add/remove/reorder/move/wire channels, rename/remove outputs | +| `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, recursive_scan flag) to `.spsession` JSON without re-running analysis. Versioned format (v4) 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`. | +| `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 @@ -1894,10 +1894,10 @@ leaves. `prefs/` reads `ParamSpec` metadata from detectors and processors. The GUI stores all settings in a JSON config file in the OS-specific user preferences directory: -| OS | Path | -|---------|------| -| Windows | `%APPDATA%\sessionprep\sessionprep.config.json` | -| macOS | `~/Library/Application Support/sessionprep/sessionprep.config.json` | +| OS | Path | +|---------|-----------------------------------------------------------------------------------| +| Windows | `%APPDATA%\sessionprep\sessionprep.config.json` | +| macOS | `~/Library/Application Support/sessionprep/sessionprep.config.json` | | Linux | `$XDG_CONFIG_HOME/sessionprep/sessionprep.config.json` (defaults to `~/.config/`) | **Four-section format** — separates global settings from named presets: @@ -2006,34 +2006,34 @@ The CLI is **not** affected by this file — it continues to use its own ### 19.1 Mapping from Original Code to Library -| Original (`sessionprep.py`) | Library location | -|------|-------------| -| `parse_arguments()` | `sessionprep.py` (CLI only) | -| `analyze_audio()` | `audio.py` (DSP) + individual detectors | -| `check_clipping()` / `detect_clipping_ranges()` | `audio.py` + `detectors/clipping.py` | -| `subsonic_ratio_db()` | `audio.py` (`subsonic_stft_analysis`) + `detectors/subsonic.py` | -| `calculate_gain()` | `processors/bimodal_normalize.py` | -| `matches_keywords()` | `utils.py` | -| `parse_group_specs()` / `assign_groups()` | `utils.py` | -| `build_session_overview()` | Session-level detectors + `rendering.py` | -| `build_diagnostic_summary()` | `rendering.py` | -| `render_diagnostic_summary_text()` | `rendering.py` | -| `print_diagnostic_summary()` | `sessionprep.py` (Rich) | -| `generate_report()` / `save_json()` | `sessionprep.py` | -| `process_files()` | `pipeline.py` + `sessionprep.py` | -| `protools_sort_key()` | `utils.py` | -| `db_to_linear()` / `linear_to_db()` / `format_duration()` | `audio.py` | +| Original (`sessionprep.py`) | Library location | +|-----------------------------------------------------------|-----------------------------------------------------------------| +| `parse_arguments()` | `sessionprep.py` (CLI only) | +| `analyze_audio()` | `audio.py` (DSP) + individual detectors | +| `check_clipping()` / `detect_clipping_ranges()` | `audio.py` + `detectors/clipping.py` | +| `subsonic_ratio_db()` | `audio.py` (`subsonic_stft_analysis`) + `detectors/subsonic.py` | +| `calculate_gain()` | `processors/bimodal_normalize.py` | +| `matches_keywords()` | `utils.py` | +| `parse_group_specs()` / `assign_groups()` | `utils.py` | +| `build_session_overview()` | Session-level detectors + `rendering.py` | +| `build_diagnostic_summary()` | `rendering.py` | +| `render_diagnostic_summary_text()` | `rendering.py` | +| `print_diagnostic_summary()` | `sessionprep.py` (Rich) | +| `generate_report()` / `save_json()` | `sessionprep.py` | +| `process_files()` | `pipeline.py` + `sessionprep.py` | +| `protools_sort_key()` | `utils.py` | +| `db_to_linear()` / `linear_to_db()` / `format_duration()` | `audio.py` | ### 19.2 Dependencies -| Package | Used by | -|---------|---------| -| `numpy` | `sessionpreplib` (core dependency) | -| `soundfile` | `sessionpreplib/audio.py` (audio I/O) | -| `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/detail/playback.py` only — optional GUI dependency | +| Package | Used by | +|---------------|---------------------------------------------------------------------------------------------------------------------| +| `numpy` | `sessionpreplib` (core dependency) | +| `soundfile` | `sessionpreplib/audio.py` (audio I/O) | +| `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/detail/playback.py` only — optional GUI dependency | ### 19.3 Layer Cake diff --git a/KANBAN.md b/KANBAN.md index 08a19b5..d9c2329 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -314,9 +314,9 @@ ## In Development +## Done + ### InnoSetup Installer - defaultExpanded: false -## Done - diff --git a/README.md b/README.md index 8100d88..b488b3e 100644 --- a/README.md +++ b/README.md @@ -105,10 +105,10 @@ sessionprep /path/to/tracks -x # analyze + process (writes to processed/) Download from the releases page: -| Executable | Description | -|------------|-------------| +| Executable | Description | +|-------------------|--------------------------------------------------------------| | `sessionprep-gui` | GUI application (interactive analysis + waveform + playback) | -| `sessionprep` | Command-line tool (scripting, batch workflows, CI) | +| `sessionprep` | Command-line tool (scripting, batch workflows, CI) | ### From source @@ -145,11 +145,11 @@ distribution instructions. SessionPrep operates in three phases: -| Phase | Name | What happens | When | -|-------|------|-------------|------| -| **1** | Track Layout | Define how source tracks map to output files: channel routing, reordering, splitting, merging. Drag-and-drop between input/output trees with visual insert-position indicator. Optional recursive subfolder scanning. Output written to `sp_01_tracklayout/`. | GUI Phase 1 (always available) | -| **2** | Analysis & Preparation | Format checks, clipping, DC offset, stereo compatibility, silence, subsonic, peak/RMS measurement, classification, tail exceedance. Bimodal normalization (clip gain adjustment) via Prepare. Output written to `sp_02_prepared/`. | GUI Phase 2 / CLI | -| **3** | DAW Transfer | Transfer tracks into DAW session with per-track naming and folder assignment. Duplicate entries for multi-track scenarios (same clip on different tracks). Fader offsets applied automatically (Pro Tools via PTSL, DAWproject via file generation). Support for unattended batch processing of multiple songs. | GUI Phase 3 | +| Phase | Name | What happens | When | +|-------|------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------| +| **1** | Track Layout | Define how source tracks map to output files: channel routing, reordering, splitting, merging. Automatically optimizes layouts based on Phase 1 diagnostics (e.g. dropping silent files, extracting active channels from dual-mono and one-sided silence). Drag-and-drop between input/output trees with visual insert-position indicator. Optional recursive subfolder scanning. Output written to `sp_01_tracklayout/`. | GUI Phase 1 (always available) | +| **2** | Analysis & Preparation | Format checks, clipping, DC offset, stereo compatibility, silence, subsonic, peak/RMS measurement, classification, tail exceedance. Bimodal normalization (clip gain adjustment) via Prepare. Output written to `sp_02_prepared/`. | GUI Phase 2 / CLI | +| **3** | DAW Transfer | Transfer tracks into DAW session with per-track naming and folder assignment. Duplicate entries for multi-track scenarios (same clip on different tracks). Fader offsets applied automatically (Pro Tools via PTSL, DAWproject via file generation). Support for unattended batch processing of multiple songs. | GUI Phase 3 | ### Diagnostic categories @@ -236,13 +236,13 @@ multiple groups a warning is printed. ## Documentation -| Document | Contents | -|----------|----------| -| [README.md](README.md) | This file — overview, installation, quick start, usage | -| [REFERENCE.md](REFERENCE.md) | Detector reference, analysis metrics, processing details | -| [TECHNICAL.md](TECHNICAL.md) | Audio engineering background, normalization theory, signal chain | -| [DEVELOPMENT.md](DEVELOPMENT.md) | Development setup, building, library architecture | -| [TODO.md](TODO.md) | Backlog and planned features | +| Document | Contents | +|----------------------------------|------------------------------------------------------------------| +| [README.md](README.md) | This file — overview, installation, quick start, usage | +| [REFERENCE.md](REFERENCE.md) | Detector reference, analysis metrics, processing details | +| [TECHNICAL.md](TECHNICAL.md) | Audio engineering background, normalization theory, signal chain | +| [DEVELOPMENT.md](DEVELOPMENT.md) | Development setup, building, library architecture | +| [TODO.md](TODO.md) | Backlog and planned features | --- diff --git a/REFERENCE.md b/REFERENCE.md index cca2a41..0d0ebcd 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -98,6 +98,7 @@ A detector earns its place if it meets at least one of these criteria: - **What it means:** a stereo file appears to have the same signal in L and R. - **Why it matters:** usually a valid delivery choice and often intentional. - **Controls:** `--dual_mono_eps` +- **Track Layout:** Triggers a layout recommendation (`topology_action: extract_channel`) to downmix output to a single channel, saving storage and DSP overhead. - **Categorization:** - INFORMATION: `Dual-mono (identical L/R)` with per-file details. @@ -105,6 +106,7 @@ A detector earns its place if it meets at least one of these criteria: - **What it means:** the file is all zeros (or effectively empty). - **Why it matters:** may be intentional (placeholder) but is often an export issue. +- **Track Layout:** Triggers a layout recommendation (`topology_action: drop`) to skip the file in the session layout outputs entirely. - **Categorization:** - CLEAN: `No silent files detected`. - ATTENTION: `Silent files` with per-file details. @@ -114,6 +116,7 @@ A detector earns its place if it meets at least one of these criteria: - **What it means:** a stereo file has one channel that is effectively silent while the other has signal. - **Why it matters:** often an export/cabling/routing mistake; importing it as stereo can cause unexpected balance issues. - **Controls:** `--one_sided_silence_db` +- **Track Layout:** Triggers a layout recommendation (`topology_action: extract_channel`) to extract specifically the active, non-silent channel into a mono output track. - **Categorization:** - CLEAN: `No one-sided silent stereo files detected`. - ATTENTION: `One-sided silence` with per-file details. @@ -431,20 +434,20 @@ third-party tools. ### 6.1 Mouse -| Action | Effect | -|--------|--------| -| **Click** | Set playback cursor position | -| **Hover** | Crosshair guide with dBFS readout (waveform) or frequency readout (spectrogram) | -| **Ctrl + wheel** | Horizontal zoom (centered on pointer) | -| **Ctrl + Shift + wheel** | Vertical zoom (amplitude in waveform, frequency range in spectrogram) | -| **Shift + Alt + wheel** | Scroll up / down (frequency pan, spectrogram mode) | -| **Shift + wheel** | Scroll left / right | +| Action | Effect | +|--------------------------|---------------------------------------------------------------------------------| +| **Click** | Set playback cursor position | +| **Hover** | Crosshair guide with dBFS readout (waveform) or frequency readout (spectrogram) | +| **Ctrl + wheel** | Horizontal zoom (centered on pointer) | +| **Ctrl + Shift + wheel** | Vertical zoom (amplitude in waveform, frequency range in spectrogram) | +| **Shift + Alt + wheel** | Scroll up / down (frequency pan, spectrogram mode) | +| **Shift + wheel** | Scroll left / right | ### 6.2 Keyboard Shortcuts -| Key | Effect | -|-----|--------| -| **R** | Zoom in (centered on mouse guide, or cursor if not hovering) | +| Key | Effect | +|-------|---------------------------------------------------------------| +| **R** | Zoom in (centered on mouse guide, or cursor if not hovering) | | **T** | Zoom out (centered on mouse guide, or cursor if not hovering) | > The waveform must have keyboard focus (click it first) for keyboard @@ -452,28 +455,28 @@ third-party tools. ### 6.3 Toolbar Buttons -| Button | Effect | -|--------|--------| -| **Waveform / Spectrogram ▾** | Switch between waveform and spectrogram display mode | -| **Display ▾** | Spectrogram settings: FFT Size, Window, Color Theme, dB Floor, dB Ceiling (spectrogram mode only) | -| **Detector Overlays ▾** | Toggle visibility of individual detector overlays (both modes) | -| **Peak / RMS Max** | Toggle peak ("P") and max-RMS ("R") markers (waveform mode, off by default) | -| **RMS L/R** | Toggle per-channel RMS envelope (yellow, waveform mode) | -| **RMS AVG** | Toggle combined RMS envelope (orange, waveform mode) | -| **Fit** | Reset zoom to show entire file | -| **+** | Zoom in at cursor | -| **−** | Zoom out at cursor | -| **↑** | Scale up (amplitude in waveform, frequency range in spectrogram) | -| **↓** | Scale down (amplitude in waveform, frequency range in spectrogram) | +| Button | Effect | +|------------------------------|---------------------------------------------------------------------------------------------------| +| **Waveform / Spectrogram ▾** | Switch between waveform and spectrogram display mode | +| **Display ▾** | Spectrogram settings: FFT Size, Window, Color Theme, dB Floor, dB Ceiling (spectrogram mode only) | +| **Detector Overlays ▾** | Toggle visibility of individual detector overlays (both modes) | +| **Peak / RMS Max** | Toggle peak ("P") and max-RMS ("R") markers (waveform mode, off by default) | +| **RMS L/R** | Toggle per-channel RMS envelope (yellow, waveform mode) | +| **RMS AVG** | Toggle combined RMS envelope (orange, waveform mode) | +| **Fit** | Reset zoom to show entire file | +| **+** | Zoom in at cursor | +| **−** | Zoom out at cursor | +| **↑** | Scale up (amplitude in waveform, frequency range in spectrogram) | +| **↓** | Scale down (amplitude in waveform, frequency range in spectrogram) | ### 6.4 Playback Controls -| Button | Effect | -|--------|--------| -| **▶ Play** | Start playback from cursor position | -| **■ Stop** | Stop playback and return cursor to start position | -| **M** | Toggle mono playback — folds stereo to mono via (L+R)/2 for auditioning mono compatibility. Latched: toggle once, then play/stop freely. Orange when active. | -| **Space** | Toggle play/stop (global shortcut) | +| Button | Effect | +|------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| +| **▶ Play** | Start playback from cursor position | +| **■ Stop** | Stop playback and return cursor to start position | +| **M** | Toggle mono playback — folds stereo to mono via (L+R)/2 for auditioning mono compatibility. Latched: toggle once, then play/stop freely. Orange when active. | +| **Space** | Toggle play/stop (global shortcut) | --- @@ -485,12 +488,12 @@ The **Analysis** column displays per-track severity counts instead of a single worst-severity label. Each severity level is color-coded and only non-zero counts are shown: -| Display | Meaning | -|---------|---------| +| Display | Meaning | +|------------|--------------------------------------------------------------| | `2P 1A 5I` | 2 Problems (red), 1 Attention (orange), 5 Information (blue) | -| `1A` | 1 Attention only | -| `OK` | All detectors clean (green) | -| `Error` | File could not be read (red) | +| `1A` | 1 Attention only | +| `OK` | All detectors clean (green) | +| `Error` | File could not be read (red) | The column is sortable — tracks with problems sort first, then attention, then clean. Within the same worst severity, tracks with more total issues sort @@ -500,11 +503,11 @@ higher. Standard Extended Selection applies to the track table: -| Action | Effect | -|--------|--------| -| **Click** | Select single row | -| **Shift + click** | Extend selection to contiguous range | -| **Ctrl + click** | Toggle individual rows (non-adjacent selection) | +| Action | Effect | +|-------------------|-------------------------------------------------| +| **Click** | Select single row | +| **Shift + click** | Extend selection to contiguous range | +| **Ctrl + click** | Toggle individual rows (non-adjacent selection) | ### 7.3 Batch Editing @@ -523,23 +526,23 @@ where Alt-clicking a control applies it across the track selection. Supported batch dropdowns: -| Dropdown | Column | Effect | -|----------|--------|--------| -| **RMS Anchor** | 5 | Override per-track RMS anchor; triggers full re-analysis (detectors + processors) | -| **Classification** | 3 | Override per-track classification; triggers processor-only re-calculation | +| Dropdown | Column | Effect | +|--------------------|--------|-----------------------------------------------------------------------------------| +| **RMS Anchor** | 5 | Override per-track RMS anchor; triggers full re-analysis (detectors + processors) | +| **Classification** | 3 | Override per-track classification; triggers processor-only re-calculation | ### 7.4 RMS Anchor Override Per-track dropdown overriding the global `rms_anchor` analysis setting. -| Label | Override value | Meaning | -|-------|---------------|---------| -| Default | *(none)* | Use the global setting from Preferences | -| Max | `max` | Loudest gated RMS window | -| P99 | `p99` | 99th percentile of gated RMS windows | -| P95 | `p95` | 95th percentile (default global setting) | -| P90 | `p90` | 90th percentile | -| P85 | `p85` | 85th percentile | +| Label | Override value | Meaning | +|---------|----------------|------------------------------------------| +| Default | *(none)* | Use the global setting from Preferences | +| Max | `max` | Loudest gated RMS window | +| P99 | `p99` | 99th percentile of gated RMS windows | +| P95 | `p95` | 95th percentile (default global setting) | +| P90 | `p90` | 90th percentile | +| P85 | `p85` | 85th percentile | Changing the anchor re-runs all detectors and processors for the affected track(s), since the anchor value influences both tail exceedance detection and @@ -549,11 +552,11 @@ gain calculation. Per-track dropdown overriding the auto-detected audio classification. -| Label | Effect | -|-------|--------| -| Transient | Force peak-based normalization (`target_peak`) | -| Sustained | Force RMS-based normalization (`target_rms`) | -| Skip | Exclude track from processing (gain = 0 dB, spin box disabled) | +| Label | Effect | +|-----------|----------------------------------------------------------------| +| Transient | Force peak-based normalization (`target_peak`) | +| Sustained | Force RMS-based normalization (`target_rms`) | +| Skip | Exclude track from processing (gain = 0 dB, spin box disabled) | Changing the classification re-runs processors only (no detector re-analysis needed), since the classification affects only the normalization method and diff --git a/sessionprep.py b/sessionprep.py index 8e17a56..45d3869 100644 --- a/sessionprep.py +++ b/sessionprep.py @@ -322,7 +322,7 @@ def process_files(): console.print(f"[red]No audio files found in {source_dir}[/]") return - task_id = progress.add_task("[cyan]Loading & analyzing tracks...", total=len(wav_files)) + task_id = progress.add_task("[cyan]Loading & analyzing tracks...", total=len(wav_files) * 2) # Wire up progress callback def on_track_analyze_complete(**data): @@ -336,7 +336,8 @@ def on_track_analyze_complete(**data): return # --- ANALYZE --- - session = pipeline.analyze(session) + session = pipeline.analyze_phase1(session) + session = pipeline.analyze_phase2(session) event_bus.unsubscribe("track.analyze_complete", on_track_analyze_complete) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 953ebe8..30ae033 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -43,7 +43,7 @@ _PHASE_ANALYSIS, _PHASE_TOPOLOGY, _PHASE_SETUP, ) from ..theme import COLORS, FILE_COLOR_OK, FILE_COLOR_ERROR -from .worker import AnalyzeWorker, PrepareWorker +from .worker import AnalyzeWorker, PrepareWorker, Phase1AnalyzeWorker class AnalysisMixin: # pylint: disable=too-few-public-methods @@ -182,9 +182,40 @@ def _on_session_config_reset(self): # ── Slots: file / analysis ──────────────────────────────────────────── + def _cancel_worker(self, worker_attr: str) -> None: + """Safely detach and cancel a background worker to avoid QThread GC destruction.""" + worker = getattr(self, worker_attr, None) + if worker is not None: + if hasattr(worker, "isRunning") and worker.isRunning(): + # Disconnect all signals so it stops updating UI + try: + worker.disconnect() + except RuntimeError: + pass + worker.requestInterruption() + # Maintain python reference until it finishes so it doesn't get GC'd + if not hasattr(self, "_zombie_workers"): + self._zombie_workers = set() + self._zombie_workers.add(worker) + + # Cleanup reference once finished + def _cleanup(*args, w=worker): + if hasattr(self, "_zombie_workers"): + self._zombie_workers.discard(w) + + if hasattr(worker, "finished"): + worker.finished.connect(_cleanup) + if hasattr(worker, "error"): + worker.error.connect(_cleanup) + setattr(self, worker_attr, None) + def _clear_workspace(self): """Clear the UI and reset session state.""" self._on_stop() + self._cancel_worker("_p1_worker") + self._cancel_worker("_worker") + self._cancel_worker("_batch_reanalyze_worker") + self._cancel_worker("_prepare_worker") self._session = None self._summary = None self._current_track = None @@ -195,6 +226,7 @@ def _clear_workspace(self): if getattr(self, "_topo_input_tree", None) is not None: self._topo_input_tree.clear() + self._topo_input_tree.set_source_dir(None) if getattr(self, "_topo_output_tree", None) is not None: self._topo_output_tree.clear() if getattr(self, "_topo_status_label", None) is not None: @@ -214,6 +246,7 @@ def _clear_workspace(self): self._phase_tabs.setTabEnabled(_PHASE_ANALYSIS, False) self._phase_tabs.setTabEnabled(_PHASE_SETUP, False) self._track_table.setRowCount(0) + self._track_table.set_source_dir(None) self._setup_table.setRowCount(0) self._summary_view.clear() self._file_report.clear() @@ -246,6 +279,7 @@ def _on_open_path(self): self._clear_workspace() self._source_dir = path self._track_table.set_source_dir(path) + self._topo_input_tree.set_source_dir(path) app_cfg = self._config.get("app", {}) skip_folders = { @@ -279,21 +313,51 @@ def _on_open_path(self): # Preserve original source tracks for Phase 1 topology input table self._topo_source_tracks = list(tracks) - # Phase 1 topology stored separately from session so Prepare - # doesn't confuse original-source references with Phase 1 outputs. - self._topo_topology = build_default_topology(tracks) + # Phase 1 topology will be built intelligently after analysis finishes. + self._topo_topology = None # Create session with discovered tracks (no topology on session) self._session = SessionContext(tracks=tracks, config={}) - # Populate Phase 1 topology tables + self._run_phase1_analysis() + + def _run_phase1_analysis(self): + """Asynchronously run Phase 1 format and structural checks on newly loaded tracks.""" + self._analyze_action.setEnabled(False) + if getattr(self, "_topo_apply_action", None): + self._topo_apply_action.setEnabled(False) + + self._progress_label.setText("Analyzing Format & Layout\u2026") + self._right_stack.setCurrentIndex(_PAGE_PROGRESS) + self._progress_bar.setRange(0, 0) + + config = self._flat_config() + config["_source_dir"] = self._source_dir + + self._p1_worker = Phase1AnalyzeWorker(self._session, config) + self._p1_worker.progress.connect(self._on_worker_progress) + self._p1_worker.progress_value.connect(self._on_worker_progress_value) + self._p1_worker.finished.connect(self._on_phase1_done) + self._p1_worker.error.connect(self._on_analyze_error) + self._p1_worker.start() + + @Slot(object) + def _on_phase1_done(self, session): + self._session = session + self._p1_worker = None + + # Rebuild topology intelligently using Phase 1 results + self._topo_topology = build_default_topology(session.tracks) + + # Populate Phase 1 topology tables using Phase 1 output issues self._populate_topology_tab() self._analyze_action.setEnabled(True) self._status_bar.showMessage( - f"Discovered {len(tracks)} file(s) from {path} — " - "edit topology, then click Apply" + f"Discovered {len(session.tracks)} file(s) from {self._source_dir} \u2014 " + "review layout, then click Apply" ) + self._right_stack.setCurrentIndex(_PAGE_TABS) self.setWindowTitle("SessionPrep") @Slot() diff --git a/sessionprepgui/analysis/worker.py b/sessionprepgui/analysis/worker.py index e1ca201..a601741 100644 --- a/sessionprepgui/analysis/worker.py +++ b/sessionprepgui/analysis/worker.py @@ -13,6 +13,7 @@ from sessionpreplib.processors import default_processors from sessionpreplib.rendering import build_diagnostic_summary from sessionpreplib.events import EventBus +from sessionpreplib.models import LifecyclePhase class DawCheckWorker(QThread): @@ -92,6 +93,130 @@ def run(self): self.result.emit(False, str(e), None) +class Phase1AnalyzeWorker(QThread): + """Runs Phase 1 (Structural & Format) pipeline analysis in a background thread.""" + + progress = Signal(str) # descriptive text + progress_value = Signal(int, int) # (current_step, total_steps) + track_analyzed = Signal(str, object) # (filename, track) after detectors + finished = Signal(object) # (session) + error = Signal(str) + + def __init__(self, session_context: object, config: dict): + super().__init__() + self.session_context = session_context + self.config = config + + def run(self): + try: + event_bus = EventBus() + + # Use the already loaded session + session = self.session_context + + if not session.tracks: + self.error.emit("No audio files found in session.") + return + + self.progress.emit("Building pipeline\u2026") + detectors = default_detectors() + pipeline = Pipeline( + detectors=detectors, + config=self.config, + event_bus=event_bus, + ) + + # Calculate total progress steps for Phase 1 + ok_tracks = [t for t in session.tracks if t.status == "OK"] + num_ok = len(ok_tracks) + + p1_track_dets = [d for d in pipeline.track_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == LifecyclePhase.PHASE1] + p1_sess_dets = [d for d in pipeline.session_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == LifecyclePhase.PHASE1] + + num_track_det = len(p1_track_dets) + num_session_det = len(p1_sess_dets) + + # Load audio data first, since lightweight discovery doesn't load it + if num_ok > 0 and ok_tracks[0].audio_data is None: + from sessionpreplib.audio import load_track + from concurrent.futures import ThreadPoolExecutor, as_completed + import os + + self.progress.emit("Loading audio data\u2026") + self.progress_value.emit(0, num_ok) + + with ThreadPoolExecutor(max_workers=min(os.cpu_count() or 4, 8)) as pool: + futures = { + pool.submit(load_track, t.filepath): t + for t in ok_tracks + } + loaded = 0 + for future in as_completed(futures): + t = futures[future] + try: + res = future.result() + t.audio_data = res.audio_data + # Preserve metadata since it might be updated + t.total_samples = res.total_samples + t.samplerate = res.samplerate + # VERY IMPORTANT: clear cache in case `get_peak()` was called + # while audio_data was None, so it doesn't stay 0.0 forever. + t._cache.clear() + except Exception as e: + t.status = "ERROR" + t.error = str(e) + loaded += 1 + self.progress_value.emit(loaded, num_ok) + + # Re-filter OK tracks after loading (some might have failed) + ok_tracks = [t for t in session.tracks if t.status == "OK"] + num_ok = len(ok_tracks) + + total_steps = ( + num_ok * num_track_det + + num_session_det + ) + self._step = 0 + self._total = max(total_steps, 1) + self._step_lock = threading.Lock() + + # Track map for emitting track objects + track_map = {t.filename: t for t in session.tracks} + + # Subscribe to pipeline events + def on_detector_complete(detector_id, filename, **_kw): + with self._step_lock: + self._step += 1 + step = self._step + self.progress.emit(f"Checking {filename} \u2014 {detector_id}") + self.progress_value.emit(step, self._total) + + def on_session_detector_complete(detector_id, **_kw): + with self._step_lock: + self._step += 1 + step = self._step + self.progress.emit(f"Session check \u2014 {detector_id}") + self.progress_value.emit(step, self._total) + + def on_track_analyze_complete(filename, **_kw): + track = track_map.get(filename) + if track: + self.track_analyzed.emit(filename, track) + + event_bus.subscribe("detector.complete", on_detector_complete) + event_bus.subscribe("session_detector.complete", on_session_detector_complete) + event_bus.subscribe("track.analyze_complete", on_track_analyze_complete) + + # Phase 1: Analyze + self.progress.emit("Validating Layout\u2026") + self.progress_value.emit(0, self._total) + session = pipeline.analyze_phase1(session) + + self.finished.emit(session) + except Exception as e: + self.error.emit(str(e)) + + class AnalyzeWorker(QThread): """Runs pipeline analysis in a background thread.""" @@ -133,8 +258,8 @@ def run(self): # Calculate total progress steps ok_tracks = [t for t in session.tracks if t.status == "OK"] num_ok = len(ok_tracks) - num_track_det = len(pipeline.track_detectors) - num_session_det = len(pipeline.session_detectors) + num_track_det = len([d for d in pipeline.track_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == LifecyclePhase.PHASE2]) + num_session_det = len([d for d in pipeline.session_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == LifecyclePhase.PHASE2]) num_proc = len(pipeline.audio_processors) total_steps = ( num_ok * num_track_det # analyze: per-track detectors @@ -187,10 +312,10 @@ def on_track_plan_complete(filename, **_kw): event_bus.subscribe("processor.complete", on_processor_complete) event_bus.subscribe("track.plan_complete", on_track_plan_complete) - # Phase 1: Analyze (per-track detectors run in parallel) - self.progress.emit("Analyzing\u2026") + # Phase 2: Analyze (per-track detectors run in parallel) + self.progress.emit("Analyzing Content\u2026") self.progress_value.emit(0, self._total) - session = pipeline.analyze(session) + session = pipeline.analyze_phase2(session) # Phase 2: Plan (per-track processors run in parallel) self.progress.emit("Planning\u2026") diff --git a/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 3098e8e..428aff2 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -349,7 +349,8 @@ 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) + # Skip pre-flight connectivity check so template cache hits are instant + self._do_daw_fetch() def _do_daw_fetch(self): """Actually start the fetch (called after successful connectivity check).""" diff --git a/sessionprepgui/settings.py b/sessionprepgui/settings.py index c1d4f0e..c7e727e 100644 --- a/sessionprepgui/settings.py +++ b/sessionprepgui/settings.py @@ -276,14 +276,9 @@ def resolve_config_preset( # --------------------------------------------------------------------------- -def _config_dir() -> str: - """Return the OS-specific configuration directory for SessionPrep.""" - return get_app_dir() - - def config_path() -> str: """Return the full path to the GUI config file.""" - return os.path.join(_config_dir(), CONFIG_FILENAME) + return os.path.join(get_app_dir(), CONFIG_FILENAME) # --------------------------------------------------------------------------- diff --git a/sessionprepgui/theme.py b/sessionprepgui/theme.py index 704fd69..a79125c 100644 --- a/sessionprepgui/theme.py +++ b/sessionprepgui/theme.py @@ -27,6 +27,7 @@ # File list item colors by status FILE_COLOR_OK = QColor("#cccccc") FILE_COLOR_ERROR = QColor("#ff4444") +FILE_COLOR_WARNING = QColor(COLORS["attention"]) FILE_COLOR_SILENT = QColor("#888888") FILE_COLOR_TRANSIENT = QColor("#cc77ff") FILE_COLOR_SUSTAINED = QColor("#44cccc") diff --git a/sessionprepgui/topology/input_tree.py b/sessionprepgui/topology/input_tree.py index d1a01e6..874476f 100644 --- a/sessionprepgui/topology/input_tree.py +++ b/sessionprepgui/topology/input_tree.py @@ -13,7 +13,7 @@ from PySide6.QtGui import QColor, QDrag, QPixmap from PySide6.QtWidgets import QAbstractItemView, QHeaderView, QTreeWidget, QTreeWidgetItem -from ..theme import COLORS, FILE_COLOR_OK +from ..theme import COLORS, FILE_COLOR_OK, FILE_COLOR_ERROR, FILE_COLOR_WARNING from .operations import channel_label, used_channels if TYPE_CHECKING: @@ -46,6 +46,7 @@ class InputTree(QTreeWidget): def __init__(self, parent=None): super().__init__(parent) + self._source_dir: str | None = None self.setColumnCount(5) self.setHeaderLabels(["File", "Ch", "SR", "Bit", "Duration"]) @@ -67,6 +68,9 @@ def __init__(self, parent=None): self.itemSelectionChanged.connect(self._enforce_single_level) self._enforcing = False + def set_source_dir(self, path: str | None): + self._source_dir = path + # ------------------------------------------------------------------ # Single-level selection # ------------------------------------------------------------------ @@ -128,10 +132,25 @@ def populate( for ch in range(track.channels) ) + # Check for Phase 1 detector warnings/suggestions + p1_warnings = [] + for det_id, res in track.detector_results.items(): + if res.severity and res.severity.name != "CLEAN": + p1_warnings.append(res.summary) + file_color = _DIM if all_used else FILE_COLOR_OK + if not all_used and p1_warnings: + # Use a warning color if there are Phase 1 suggestions + file_color = FILE_COLOR_WARNING file_item = QTreeWidgetItem() - file_item.setText(COL_NAME, track.filename) + + display_name = track.filename + if p1_warnings: + display_name += f" [{', '.join(p1_warnings)}]" + file_item.setToolTip(COL_NAME, "\\n".join(p1_warnings)) + + file_item.setText(COL_NAME, display_name) file_item.setForeground(COL_NAME, file_color) file_item.setText(COL_CH, str(track.channels)) file_item.setForeground(COL_CH, _DIM) @@ -222,11 +241,12 @@ def _restore_state(self, state: dict) -> None: # ------------------------------------------------------------------ def mimeTypes(self): - return [MIME_CHANNEL] + return [MIME_CHANNEL, "text/uri-list"] def mimeData(self, items): - """Encode dragged items as JSON list of channel descriptors.""" + """Encode dragged items as JSON list of channel descriptors, and text/uri-list.""" payload = [] + filenames = set() for item in items: data = item.data(COL_NAME, Qt.UserRole) if not data: @@ -238,8 +258,10 @@ def mimeData(self, items): "source_channel": ch, "drag_type": "channel", }) + filenames.add(filename) elif data[0] == "file": _, filename = data + filenames.add(filename) # Encode all channels of this file for i in range(item.childCount()): child = item.child(i) @@ -250,12 +272,22 @@ def mimeData(self, items): "source_channel": cd[2], "drag_type": "file", }) - if not payload: + if not payload and not filenames: return None mime = QMimeData() - mime.setData(MIME_CHANNEL, - QByteArray(json.dumps(payload).encode("utf-8"))) + + if payload: + mime.setData(MIME_CHANNEL, + QByteArray(json.dumps(payload).encode("utf-8"))) + + if filenames and self._source_dir: + import os + from PySide6.QtCore import QUrl + urls = [QUrl.fromLocalFile(os.path.join(self._source_dir, f)) + for f in filenames] + mime.setUrls(urls) + return mime def startDrag(self, supportedActions): diff --git a/sessionpreplib/detector.py b/sessionpreplib/detector.py index 5d63c27..4dae218 100644 --- a/sessionpreplib/detector.py +++ b/sessionpreplib/detector.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import Any -from .models import ParamSpec +from .models import ParamSpec, LifecyclePhase from .models import DetectorResult, Severity, TrackContext, SessionContext _REPORT_AS_MAP: dict[str, Severity] = { @@ -33,6 +33,7 @@ class TrackDetector(ABC): name: str = "" shorthand: str = "" # short abbreviation for compact UI labels depends_on: list[str] = [] + phase: LifecyclePhase = LifecyclePhase.PHASE2 @classmethod def config_params(cls) -> list[ParamSpec]: @@ -151,6 +152,7 @@ class SessionDetector(ABC): id: str = "" name: str = "" shorthand: str = "" # short abbreviation for compact UI labels + phase: LifecyclePhase = LifecyclePhase.PHASE2 @classmethod def config_params(cls) -> list[ParamSpec]: diff --git a/sessionpreplib/detectors/dual_mono.py b/sessionpreplib/detectors/dual_mono.py index 2833c1f..c89ec97 100644 --- a/sessionpreplib/detectors/dual_mono.py +++ b/sessionpreplib/detectors/dual_mono.py @@ -2,7 +2,7 @@ import numpy as np -from ..models import ParamSpec +from ..models import ParamSpec, LifecyclePhase from ..detector import TrackDetector from ..models import DetectorResult, Severity, TrackContext from ..audio import get_stereo_channels_subsampled, is_silent @@ -12,6 +12,7 @@ class DualMonoDetector(TrackDetector): id = "dual_mono" name = "Dual-Mono (Identical L/R)" shorthand = "DM" + phase = LifecyclePhase.PHASE1 depends_on = ["silence"] @classmethod @@ -74,7 +75,11 @@ def analyze(self, track: TrackContext) -> DetectorResult: detector_id=self.id, severity=Severity.INFO, summary="dual-mono (identical L/R)", - data={"dual_mono": True}, + data={ + "dual_mono": True, + "topology_action": "extract_channel", + "topology_channel": 0, + }, ) return DetectorResult( diff --git a/sessionpreplib/detectors/format_consistency.py b/sessionpreplib/detectors/format_consistency.py index 88af7b9..24c0e13 100644 --- a/sessionpreplib/detectors/format_consistency.py +++ b/sessionpreplib/detectors/format_consistency.py @@ -3,13 +3,14 @@ from collections import Counter from ..detector import SessionDetector -from ..models import DetectorResult, Severity, SessionContext +from ..models import DetectorResult, Severity, SessionContext, LifecyclePhase class FormatConsistencyDetector(SessionDetector): id = "format_consistency" name = "Session Format Consistency" shorthand = "FC" + phase = LifecyclePhase.PHASE1 @classmethod def html_help(cls) -> str: diff --git a/sessionpreplib/detectors/one_sided_silence.py b/sessionpreplib/detectors/one_sided_silence.py index 0d13904..178e02f 100644 --- a/sessionpreplib/detectors/one_sided_silence.py +++ b/sessionpreplib/detectors/one_sided_silence.py @@ -2,7 +2,7 @@ import numpy as np -from ..models import ParamSpec +from ..models import ParamSpec, LifecyclePhase from ..detector import TrackDetector from ..models import DetectorResult, IssueLocation, Severity, TrackContext from ..audio import dbfs_offset, get_stereo_rms, is_silent, linear_to_db @@ -12,6 +12,7 @@ class OneSidedSilenceDetector(TrackDetector): id = "one_sided_silence" name = "One-Sided Silence" shorthand = "OS" + phase = LifecyclePhase.PHASE1 depends_on = ["silence"] @classmethod @@ -88,14 +89,18 @@ def analyze(self, track: TrackContext) -> DetectorResult: one_sided = True side = "R" + ch_idx = 0 if side == "L" else 1 if side == "R" else None + data = { "one_sided_silence": bool(one_sided), "one_sided_silence_side": side, "l_rms_db": l_rms_db, "r_rms_db": r_rms_db, } - + if one_sided: + data["topology_action"] = "extract_channel" + data["topology_channel"] = 1 if side == "L" else 0 # If L is silent, extract R (1). If R is silent, extract L (0). off = self._db_offset def fmt_db(x): @@ -112,7 +117,6 @@ def fmt_db(x): f"one-sided silence " f"(L {fmt_db(l_rms_db)} dBFS, R {fmt_db(r_rms_db)} dBFS)" ) - ch_idx = 0 if side == "L" else 1 if side == "R" else None issues = [IssueLocation( sample_start=0, sample_end=track.total_samples - 1, diff --git a/sessionpreplib/detectors/silence.py b/sessionpreplib/detectors/silence.py index 44af173..69308b5 100644 --- a/sessionpreplib/detectors/silence.py +++ b/sessionpreplib/detectors/silence.py @@ -1,7 +1,7 @@ from __future__ import annotations from ..detector import TrackDetector -from ..models import DetectorResult, IssueLocation, Severity, TrackContext +from ..models import DetectorResult, IssueLocation, Severity, TrackContext, LifecyclePhase from ..audio import get_peak @@ -10,6 +10,7 @@ class SilenceDetector(TrackDetector): name = "Silent Files" shorthand = "SI" depends_on = [] + phase = LifecyclePhase.PHASE1 @classmethod def html_help(cls) -> str: @@ -36,7 +37,10 @@ def analyze(self, track: TrackContext) -> DetectorResult: detector_id=self.id, severity=Severity.ATTENTION, summary="silent", - data={"is_silent": True}, + data={ + "is_silent": True, + "topology_action": "drop", + }, hint="confirm intentional", issues=[IssueLocation( sample_start=0, diff --git a/sessionpreplib/models.py b/sessionpreplib/models.py index 3e01cd1..537d2a1 100644 --- a/sessionpreplib/models.py +++ b/sessionpreplib/models.py @@ -47,6 +47,12 @@ class JobStatus(Enum): CANCELLED = "cancelled" +class LifecyclePhase(Enum): + """Defines when a detector or processor runs in the pipeline.""" + PHASE1 = "topology" # Structural and format checks + PHASE2 = "analysis" # Acoustic and content-based DSP + + @dataclass class IssueLocation: """A detected issue at a specific position or region in the waveform. diff --git a/sessionpreplib/pipeline.py b/sessionpreplib/pipeline.py index 627313c..6eb625b 100644 --- a/sessionpreplib/pipeline.py +++ b/sessionpreplib/pipeline.py @@ -19,6 +19,7 @@ def dbg(msg: str) -> None: # type: ignore[misc] Severity, TrackContext, SessionContext, + LifecyclePhase, ) from .detector import TrackDetector, SessionDetector from .processor import AudioProcessor @@ -98,12 +99,12 @@ def _emit(self, event_type: str, **data): # Phase 1: Analyze (run all detectors) # ------------------------------------------------------------------ - def _analyze_track(self, track: TrackContext, idx: int, total: int): + def _analyze_track(self, track: TrackContext, idx: int, total: int, detectors: list[TrackDetector]): """Run all track-level detectors for a single track (thread-safe).""" self._emit("track.analyze_start", filename=track.filename, index=idx, total=total) t_track_start = time.perf_counter() - for det in self.track_detectors: + for det in detectors: try: self._emit("detector.start", detector_id=det.id, filename=track.filename) @@ -128,12 +129,11 @@ def _analyze_track(self, track: TrackContext, idx: int, total: int): self._emit("track.analyze_complete", filename=track.filename, index=idx, total=total) - def analyze(self, session: SessionContext) -> SessionContext: - """Run all track-level and session-level detectors. + def _run_analysis_phase(self, session: SessionContext, phase: LifecyclePhase) -> SessionContext: + """Run track-level and session-level detectors matching the specified phase.""" + track_dets = [d for d in self.track_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == phase] + session_dets = [d for d in self.session_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == phase] - Track-level detectors run in parallel across files using a thread pool. - Session-level detectors run sequentially after all tracks complete. - """ total = len(session.tracks) ok_items = [ (idx, track) @@ -146,7 +146,7 @@ def analyze(self, session: SessionContext) -> SessionContext: workers = min(self.max_workers, n) if ok_items else 1 with ThreadPoolExecutor(max_workers=workers) as pool: futures = { - pool.submit(self._analyze_track, track, idx, total): track + pool.submit(self._analyze_track, track, idx, total, track_dets): track for idx, track in ok_items } for future in as_completed(futures): @@ -158,12 +158,12 @@ def analyze(self, session: SessionContext) -> SessionContext: index=0, total=total) dt_phase = (time.perf_counter() - t_phase) * 1000 if n: - dbg(f"analyze phase (track detectors): {n} tracks in " + dbg(f"analyze {phase.value} (track detectors): {n} tracks in " f"{dt_phase:.1f} ms ({dt_phase / n:.1f} ms/track avg)") # Session-level detectors track_map = {t.filename: t for t in session.tracks} - for det in self.session_detectors: + for det in session_dets: try: self._emit("session_detector.start", detector_id=det.id) t0 = time.perf_counter() @@ -188,11 +188,19 @@ def analyze(self, session: SessionContext) -> SessionContext: ) ] - # Store configured detector instances on the session for render-time access + # Ensure the overall session.detectors always has all detectors + # required for GUI overlay generation, independent of which phase ran session.detectors = self.track_detectors + self.session_detectors - return session + def analyze_phase1(self, session: SessionContext) -> SessionContext: + """Run structural/formatting detectors for track layout and validation.""" + return self._run_analysis_phase(session, LifecyclePhase.PHASE1) + + def analyze_phase2(self, session: SessionContext) -> SessionContext: + """Run acoustic and DSP-based detectors.""" + return self._run_analysis_phase(session, LifecyclePhase.PHASE2) + # ------------------------------------------------------------------ # Phase 2: Plan (run audio processors, compute gains) # ------------------------------------------------------------------ diff --git a/sessionpreplib/topology.py b/sessionpreplib/topology.py index 7c94a28..6a61318 100644 --- a/sessionpreplib/topology.py +++ b/sessionpreplib/topology.py @@ -72,11 +72,40 @@ def sum_to_mono(source_channels: int) -> list[ChannelRoute]: # --------------------------------------------------------------------------- def build_default_topology(tracks: list[TrackContext]) -> TopologyMapping: - """All-passthrough: each OK input track maps 1:1, preserving all channels.""" + """Intelligent topology builder consulting Phase 1 detector results.""" entries: list[TopologyEntry] = [] for track in tracks: if track.status != "OK": continue + + # Consult detector results if any component has a topology recommendation + action = None + action_ch = 0 + + if track.detector_results: + for res in track.detector_results.values(): + act = res.data.get("topology_action") + if act: + action = act + if act == "extract_channel": + action_ch = res.data.get("topology_channel", 0) + if act == "drop": + break # 'drop' is highest priority, stop checking others + + if action == "drop": + continue + + if action == "extract_channel": + entries.append(TopologyEntry( + output_filename=track.filename, + output_channels=1, + sources=[TopologySource( + input_filename=track.filename, + routes=extract_channel(action_ch), + )], + )) + continue + entries.append(TopologyEntry( output_filename=track.filename, output_channels=track.channels, From 3bdc0c1a414e8b7117422f69f4d006922e323561 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 14:28:11 +0100 Subject: [PATCH 31/56] QThread race condition --- sessionprepgui/analysis/mixin.py | 37 +++++++++++++++++++++++++++----- sessionprepgui/topology/mixin.py | 8 +++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 30ae033..36752ba 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -236,6 +236,10 @@ def _clear_workspace(self): if getattr(self, "_analyze_action", None) is not None: self._analyze_action.setEnabled(False) + if getattr(self, "_topo_reanalyze_action", None) is not None: + self._topo_reanalyze_action.setEnabled(False) + if getattr(self, "_topo_reset_action", None) is not None: + self._topo_reset_action.setEnabled(False) if getattr(self, "_save_session_action", None) is not None: self._save_session_action.setEnabled(False) @@ -276,6 +280,14 @@ def _on_open_path(self): if not path: return + self._load_directory(path) + + @Slot() + def _on_topo_reanalyze(self): + if self._source_dir: + self._load_directory(self._source_dir) + + def _load_directory(self, path: str): self._clear_workspace() self._source_dir = path self._track_table.set_source_dir(path) @@ -344,7 +356,9 @@ def _run_phase1_analysis(self): @Slot(object) def _on_phase1_done(self, session): self._session = session - self._p1_worker = None + if self._p1_worker is not None: + self._p1_worker.deleteLater() + self._p1_worker = None # Rebuild topology intelligently using Phase 1 results self._topo_topology = build_default_topology(session.tracks) @@ -353,6 +367,11 @@ def _on_phase1_done(self, session): self._populate_topology_tab() self._analyze_action.setEnabled(True) + if getattr(self, "_topo_reanalyze_action", None) is not None: + self._topo_reanalyze_action.setEnabled(True) + if getattr(self, "_topo_reset_action", None) is not None: + self._topo_reset_action.setEnabled(True) + self._status_bar.showMessage( f"Discovered {len(session.tracks)} file(s) from {self._source_dir} \u2014 " "review layout, then click Apply" @@ -873,7 +892,9 @@ def _on_analyze_done(self, session, summary): self._summary = summary self._analyze_action.setEnabled(True) self._track_table.setVisible(True) - self._worker = None + if self._worker is not None: + self._worker.deleteLater() + self._worker = None if not self._session_groups: # First analysis — load from Default group preset @@ -953,7 +974,9 @@ def _on_analyze_done(self, session, summary): def _on_analyze_error(self, message: str): self._analyze_action.setEnabled(True) self._track_table.setVisible(True) - self._worker = None + if self._worker is not None: + self._worker.deleteLater() + self._worker = None from ..helpers import esc @@ -1014,7 +1037,9 @@ def _on_prepare_progress_value(self, current: int, total: int): @Slot() def _on_prepare_done(self): - self._prepare_worker = None + if self._prepare_worker is not None: + self._prepare_worker.deleteLater() + self._prepare_worker = None self._update_prepare_button() self._update_use_processed_action() @@ -1048,7 +1073,9 @@ def _on_prepare_done(self): @Slot(str) def _on_prepare_error(self, message: str): - self._prepare_worker = None + if self._prepare_worker is not None: + self._prepare_worker.deleteLater() + self._prepare_worker = None self._prepare_action.setEnabled(True) self._prepare_progress.fail(message) self._status_bar.showMessage(f"Prepare failed: {message}") diff --git a/sessionprepgui/topology/mixin.py b/sessionprepgui/topology/mixin.py index f43e0f8..29dc545 100644 --- a/sessionprepgui/topology/mixin.py +++ b/sessionprepgui/topology/mixin.py @@ -78,10 +78,18 @@ def _build_topology_page(self) -> QWidget: toolbar.addSeparator() + self._topo_reanalyze_action = QAction("Reanalyze", self) + self._topo_reanalyze_action.setToolTip( + "Rescan the current folder for new or changed files") + self._topo_reanalyze_action.triggered.connect(self._on_topo_reanalyze) + self._topo_reanalyze_action.setEnabled(False) + toolbar.addAction(self._topo_reanalyze_action) + self._topo_reset_action = QAction("Reset to Default", self) self._topo_reset_action.setToolTip( "Rebuild the default passthrough topology from input tracks") self._topo_reset_action.triggered.connect(self._on_topo_reset) + self._topo_reset_action.setEnabled(False) toolbar.addAction(self._topo_reset_action) self._topo_add_output_action = QAction("Add Output", self) From e3296f684a3045883f21f6961f1896e9b8721792 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 15:38:29 +0100 Subject: [PATCH 32/56] pylint error fixes, wider file column. --- sessionprepgui/analysis/mixin.py | 16 +- sessionprepgui/analysis/worker.py | 14 +- sessionprepgui/topology/dialogs.py | 127 +++++++++++++++ sessionprepgui/topology/input_tree.py | 20 +-- sessionprepgui/topology/mixin.py | 144 +++++------------- sessionprepgui/topology/output_tree.py | 6 +- sessionpreplib/detectors/one_sided_silence.py | 4 +- 7 files changed, 198 insertions(+), 133 deletions(-) create mode 100644 sessionprepgui/topology/dialogs.py diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 36752ba..88ffbcb 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -197,12 +197,12 @@ def _cancel_worker(self, worker_attr: str) -> None: if not hasattr(self, "_zombie_workers"): self._zombie_workers = set() self._zombie_workers.add(worker) - + # Cleanup reference once finished def _cleanup(*args, w=worker): if hasattr(self, "_zombie_workers"): self._zombie_workers.discard(w) - + if hasattr(worker, "finished"): worker.finished.connect(_cleanup) if hasattr(worker, "error"): @@ -338,14 +338,14 @@ def _run_phase1_analysis(self): self._analyze_action.setEnabled(False) if getattr(self, "_topo_apply_action", None): self._topo_apply_action.setEnabled(False) - + self._progress_label.setText("Analyzing Format & Layout\u2026") self._right_stack.setCurrentIndex(_PAGE_PROGRESS) self._progress_bar.setRange(0, 0) - + config = self._flat_config() config["_source_dir"] = self._source_dir - + self._p1_worker = Phase1AnalyzeWorker(self._session, config) self._p1_worker.progress.connect(self._on_worker_progress) self._p1_worker.progress_value.connect(self._on_worker_progress_value) @@ -359,10 +359,10 @@ def _on_phase1_done(self, session): if self._p1_worker is not None: self._p1_worker.deleteLater() self._p1_worker = None - + # Rebuild topology intelligently using Phase 1 results self._topo_topology = build_default_topology(session.tracks) - + # Populate Phase 1 topology tables using Phase 1 output issues self._populate_topology_tab() @@ -371,7 +371,7 @@ def _on_phase1_done(self, session): self._topo_reanalyze_action.setEnabled(True) if getattr(self, "_topo_reset_action", None) is not None: self._topo_reset_action.setEnabled(True) - + self._status_bar.showMessage( f"Discovered {len(session.tracks)} file(s) from {self._source_dir} \u2014 " "review layout, then click Apply" diff --git a/sessionprepgui/analysis/worker.py b/sessionprepgui/analysis/worker.py index a601741..6465709 100644 --- a/sessionprepgui/analysis/worker.py +++ b/sessionprepgui/analysis/worker.py @@ -110,7 +110,7 @@ def __init__(self, session_context: object, config: dict): def run(self): try: event_bus = EventBus() - + # Use the already loaded session session = self.session_context @@ -129,22 +129,22 @@ def run(self): # Calculate total progress steps for Phase 1 ok_tracks = [t for t in session.tracks if t.status == "OK"] num_ok = len(ok_tracks) - + p1_track_dets = [d for d in pipeline.track_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == LifecyclePhase.PHASE1] p1_sess_dets = [d for d in pipeline.session_detectors if getattr(d, 'phase', LifecyclePhase.PHASE2) == LifecyclePhase.PHASE1] - + num_track_det = len(p1_track_dets) num_session_det = len(p1_sess_dets) - + # Load audio data first, since lightweight discovery doesn't load it if num_ok > 0 and ok_tracks[0].audio_data is None: from sessionpreplib.audio import load_track from concurrent.futures import ThreadPoolExecutor, as_completed import os - + self.progress.emit("Loading audio data\u2026") self.progress_value.emit(0, num_ok) - + with ThreadPoolExecutor(max_workers=min(os.cpu_count() or 4, 8)) as pool: futures = { pool.submit(load_track, t.filepath): t @@ -167,7 +167,7 @@ def run(self): t.error = str(e) loaded += 1 self.progress_value.emit(loaded, num_ok) - + # Re-filter OK tracks after loading (some might have failed) ok_tracks = [t for t in session.tracks if t.status == "OK"] num_ok = len(ok_tracks) diff --git a/sessionprepgui/topology/dialogs.py b/sessionprepgui/topology/dialogs.py new file mode 100644 index 0000000..0054846 --- /dev/null +++ b/sessionprepgui/topology/dialogs.py @@ -0,0 +1,127 @@ +"""Dialogs specifically for the Track Layout tab.""" + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, + QLineEdit, QDialogButtonBox, QSizePolicy +) +from PySide6.QtCore import Qt +from ..theme import COLORS + +def add_output_tracks_dialog(parent, topology, colors=None) -> list[tuple[str, int]]: + """ + Shows a dialog to add one or more output tracks. + Returns list of (filename, channels) or [] if cancelled. + """ + if colors is None: + colors = COLORS + + dlg = QDialog(parent) + dlg.setWindowTitle("Add Output Track(s)") + dlg.setMinimumWidth(520) + outer = QVBoxLayout(dlg) + outer.setSpacing(8) + outer.setContentsMargins(12, 12, 12, 10) + + # Inline row: Create [n] new [ch]-ch tracks Name: [____] + row = QHBoxLayout() + row.setSpacing(6) + + lbl_style = f"color: {colors['dim']};" + create_lbl = QLabel("Create") + create_lbl.setStyleSheet(lbl_style) + row.addWidget(create_lbl) + + count_spin = QSpinBox() + count_spin.setRange(1, 99) + count_spin.setValue(1) + count_spin.setFixedWidth(52) + row.addWidget(count_spin) + + new_lbl = QLabel("new") + new_lbl.setStyleSheet(lbl_style) + row.addWidget(new_lbl) + + ch_spin = QSpinBox() + ch_spin.setRange(1, 64) + ch_spin.setValue(2) + ch_spin.setFixedWidth(52) + row.addWidget(ch_spin) + + ch_lbl = QLabel("-ch track(s)") + ch_lbl.setStyleSheet(lbl_style) + row.addWidget(ch_lbl) + + row.addSpacing(12) + + name_lbl = QLabel("Name:") + name_lbl.setStyleSheet(lbl_style) + row.addWidget(name_lbl) + + name_edit = QLineEdit("new_track.wav") + name_edit.selectAll() + name_edit.setMinimumWidth(160) + row.addWidget(name_edit, 1) + + outer.addLayout(row) + + # Live preview + preview = QLabel() + preview.setStyleSheet( + f"color: {colors['dim']}; font-style: italic; font-size: 11px;" + "padding: 2px 0;") + preview.setWordWrap(True) + outer.addWidget(preview) + + def _update_preview(): + stem_raw = name_edit.text().strip() or "new_track.wav" + dot = stem_raw.rfind(".") + if dot > 0: + s, e = stem_raw[:dot], stem_raw[dot:] + else: + s, e = stem_raw, ".wav" + n = count_spin.value() + ch = ch_spin.value() + if n == 1: + preview.setText(f"\u2192 {s}{e} ({ch} ch)") + else: + names = ", ".join( + f"{s}_{i + 1}{e}" for i in range(min(n, 3))) + if n > 3: + names += f", \u2026 ({n} total)" + preview.setText(f"\u2192 {names} ({ch} ch each)") + + name_edit.textChanged.connect(lambda *_: _update_preview()) + count_spin.valueChanged.connect(lambda *_: _update_preview()) + ch_spin.valueChanged.connect(lambda *_: _update_preview()) + _update_preview() + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + buttons.accepted.connect(dlg.accept) + buttons.rejected.connect(dlg.reject) + outer.addWidget(buttons) + name_edit.setFocus() + + if dlg.exec() != QDialog.Accepted: + return [] + + base_name = name_edit.text().strip() + if not base_name: + return [] + + # Split stem and extension + dot = base_name.rfind(".") + if dot > 0: + stem, ext = base_name[:dot], base_name[dot:] + else: + stem, ext = base_name, ".wav" + + n_tracks = count_spin.value() + n_channels = ch_spin.value() + + results = [] + for i in range(n_tracks): + suffix = f"_{i + 1}" if n_tracks > 1 else "" + results.append((f"{stem}{suffix}{ext}", n_channels)) + return results diff --git a/sessionprepgui/topology/input_tree.py b/sessionprepgui/topology/input_tree.py index 874476f..29d6874 100644 --- a/sessionprepgui/topology/input_tree.py +++ b/sessionprepgui/topology/input_tree.py @@ -52,9 +52,11 @@ def __init__(self, parent=None): self.setHeaderLabels(["File", "Ch", "SR", "Bit", "Duration"]) self.header().setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) h = self.header() - h.setSectionResizeMode(COL_NAME, QHeaderView.Stretch) + h.setSectionsMovable(True) + h.setSectionResizeMode(COL_NAME, QHeaderView.Interactive) + self.setColumnWidth(COL_NAME, 380) for col in (COL_CH, COL_SR, COL_BIT, COL_DUR): - h.setSectionResizeMode(col, QHeaderView.ResizeToContents) + h.setSectionResizeMode(col, QHeaderView.Interactive) self.setEditTriggers(QAbstractItemView.NoEditTriggers) self.setSelectionMode(QAbstractItemView.ExtendedSelection) @@ -138,13 +140,13 @@ def populate( if res.severity and res.severity.name != "CLEAN": p1_warnings.append(res.summary) - file_color = _DIM if all_used else FILE_COLOR_OK - if not all_used and p1_warnings: - # Use a warning color if there are Phase 1 suggestions + if p1_warnings: file_color = FILE_COLOR_WARNING + else: + file_color = _DIM if all_used else FILE_COLOR_OK file_item = QTreeWidgetItem() - + display_name = track.filename if p1_warnings: display_name += f" [{', '.join(p1_warnings)}]" @@ -276,18 +278,18 @@ def mimeData(self, items): return None mime = QMimeData() - + if payload: mime.setData(MIME_CHANNEL, QByteArray(json.dumps(payload).encode("utf-8"))) - + if filenames and self._source_dir: import os from PySide6.QtCore import QUrl urls = [QUrl.fromLocalFile(os.path.join(self._source_dir, f)) for f in filenames] mime.setUrls(urls) - + return mime def startDrag(self, supportedActions): diff --git a/sessionprepgui/topology/mixin.py b/sessionprepgui/topology/mixin.py index 29dc545..e7b150b 100644 --- a/sessionprepgui/topology/mixin.py +++ b/sessionprepgui/topology/mixin.py @@ -33,6 +33,7 @@ from .input_tree import InputTree from .output_tree import OutputTree from . import operations as ops +from .dialogs import add_output_tracks_dialog class TopologyMixin: # pylint: disable=too-few-public-methods @@ -419,114 +420,19 @@ def _on_topo_add_output(self): if not self._topo_topology: return - dlg = QDialog(self) - dlg.setWindowTitle("Add Output Track(s)") - dlg.setMinimumWidth(520) - outer = QVBoxLayout(dlg) - outer.setSpacing(8) - outer.setContentsMargins(12, 12, 12, 10) - - # Inline row: Create [n] new [ch]-ch tracks Name: [____] - row = QHBoxLayout() - row.setSpacing(6) - - lbl_style = f"color: {COLORS['dim']};" - create_lbl = QLabel("Create") - create_lbl.setStyleSheet(lbl_style) - row.addWidget(create_lbl) - - count_spin = QSpinBox() - count_spin.setRange(1, 99) - count_spin.setValue(1) - count_spin.setFixedWidth(52) - row.addWidget(count_spin) - - new_lbl = QLabel("new") - new_lbl.setStyleSheet(lbl_style) - row.addWidget(new_lbl) - - ch_spin = QSpinBox() - ch_spin.setRange(1, 64) - ch_spin.setValue(2) - ch_spin.setFixedWidth(52) - row.addWidget(ch_spin) - - ch_lbl = QLabel("-ch track(s)") - ch_lbl.setStyleSheet(lbl_style) - row.addWidget(ch_lbl) - - row.addSpacing(12) - - name_lbl = QLabel("Name:") - name_lbl.setStyleSheet(lbl_style) - row.addWidget(name_lbl) - - name_edit = QLineEdit("new_track.wav") - name_edit.selectAll() - name_edit.setMinimumWidth(160) - row.addWidget(name_edit, 1) - - outer.addLayout(row) - - # Live preview - preview = QLabel() - preview.setStyleSheet( - f"color: {COLORS['dim']}; font-style: italic; font-size: 11px;" - "padding: 2px 0;") - preview.setWordWrap(True) - outer.addWidget(preview) - - def _update_preview(): - stem_raw = name_edit.text().strip() or "new_track.wav" - dot = stem_raw.rfind(".") - if dot > 0: - s, e = stem_raw[:dot], stem_raw[dot:] - else: - s, e = stem_raw, ".wav" - n = count_spin.value() - ch = ch_spin.value() - if n == 1: - preview.setText(f"\u2192 {s}{e} ({ch} ch)") - else: - names = ", ".join( - f"{s}_{i + 1}{e}" for i in range(min(n, 3))) - if n > 3: - names += f", \u2026 ({n} total)" - preview.setText(f"\u2192 {names} ({ch} ch each)") - - name_edit.textChanged.connect(lambda *_: _update_preview()) - count_spin.valueChanged.connect(lambda *_: _update_preview()) - ch_spin.valueChanged.connect(lambda *_: _update_preview()) - _update_preview() - - # Buttons - buttons = QDialogButtonBox( - QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - buttons.accepted.connect(dlg.accept) - buttons.rejected.connect(dlg.reject) - outer.addWidget(buttons) - name_edit.setFocus() - - if dlg.exec() != QDialog.Accepted: - return - base_name = name_edit.text().strip() - if not base_name: + tracks = add_output_tracks_dialog(self, self._topo_topology, COLORS) + if not tracks: return - # Split stem and extension - dot = base_name.rfind(".") - if dot > 0: - stem, ext = base_name[:dot], base_name[dot:] - else: - stem, ext = base_name, ".wav" - - n_tracks = count_spin.value() - n_channels = ch_spin.value() - for i in range(n_tracks): - suffix = f"_{i + 1}" if n_tracks > 1 else "" - fname = ops.unique_output_name( - self._topo_topology, f"{stem}{suffix}", ext) + for name_hint, n_channels in tracks: + # Result already has extension, but ops.unique_output_name splits again + parts = name_hint.rpartition(".") + stem = parts[0] or parts[2] + ext = f".{parts[2]}" if parts[0] else ".wav" + + fname = ops.unique_output_name(self._topo_topology, stem, ext) ops.new_output_file(self._topo_topology, fname, n_channels) + self._topo_changed() # ── Input tree context menu ─────────────────────────────────────── @@ -551,6 +457,12 @@ def _on_topo_input_context_menu(self, filename: str, selected: list[str], menu = QMenu(self) + act_open_folder = menu.addAction("Open folder") + act_open_folder.triggered.connect( + lambda checked, fn=filename: self._open_folder_for_file(fn) + ) + menu.addSeparator() + if is_excluded: act = menu.addAction("Include in Session") act.triggered.connect( @@ -606,6 +518,28 @@ def _on_topo_input_context_menu(self, filename: str, selected: list[str], if menu.actions() and global_pos is not None: menu.exec(global_pos) + def _open_folder_for_file(self, filename: str): + if not self._source_dir: + return + + import sys + import subprocess + import os + from PySide6.QtGui import QDesktopServices + from PySide6.QtCore import QUrl + + filepath = os.path.join(self._source_dir, filename) + if not os.path.exists(filepath): + return + + if sys.platform == "win32": + subprocess.Popen(["explorer", "/select,", os.path.normpath(filepath)]) + elif sys.platform == "darwin": + subprocess.Popen(["open", "-R", filepath]) + else: + dirpath = os.path.dirname(filepath) + QDesktopServices.openUrl(QUrl.fromLocalFile(dirpath)) + def _input_action(self, op_fn, filename: str): """Call an operations function that takes (topo, track_map, filename).""" topo = self._topo_topology diff --git a/sessionprepgui/topology/output_tree.py b/sessionprepgui/topology/output_tree.py index ba883d7..8827d2a 100644 --- a/sessionprepgui/topology/output_tree.py +++ b/sessionprepgui/topology/output_tree.py @@ -84,9 +84,11 @@ def __init__(self, parent=None): self.setHeaderLabels(["File", "Ch", "SR", "Bit", "Duration"]) self.header().setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) h = self.header() - h.setSectionResizeMode(COL_NAME, QHeaderView.Stretch) + h.setSectionsMovable(True) + h.setSectionResizeMode(COL_NAME, QHeaderView.Interactive) + self.setColumnWidth(COL_NAME, 380) for col in (COL_CH, COL_SR, COL_BIT, COL_DUR): - h.setSectionResizeMode(col, QHeaderView.ResizeToContents) + h.setSectionResizeMode(col, QHeaderView.Interactive) self.setEditTriggers(QAbstractItemView.NoEditTriggers) self.itemDoubleClicked.connect(self._on_double_click) diff --git a/sessionpreplib/detectors/one_sided_silence.py b/sessionpreplib/detectors/one_sided_silence.py index 178e02f..a57202c 100644 --- a/sessionpreplib/detectors/one_sided_silence.py +++ b/sessionpreplib/detectors/one_sided_silence.py @@ -90,14 +90,14 @@ def analyze(self, track: TrackContext) -> DetectorResult: side = "R" ch_idx = 0 if side == "L" else 1 if side == "R" else None - + data = { "one_sided_silence": bool(one_sided), "one_sided_silence_side": side, "l_rms_db": l_rms_db, "r_rms_db": r_rms_db, } - + if one_sided: data["topology_action"] = "extract_channel" data["topology_channel"] = 1 if side == "L" else 0 # If L is silent, extract R (1). If R is silent, extract L (0). From 404500c03b453c84d658eb05ca873df25b0abbfc Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 15:57:23 +0100 Subject: [PATCH 33/56] synchronized phase 1 scrolling --- sessionprepgui/topology/input_tree.py | 13 ++++++++ sessionprepgui/topology/mixin.py | 45 +++++++++++++++++++++++++- sessionprepgui/topology/output_tree.py | 34 ++++++++++++++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/sessionprepgui/topology/input_tree.py b/sessionprepgui/topology/input_tree.py index 29d6874..1a63e41 100644 --- a/sessionprepgui/topology/input_tree.py +++ b/sessionprepgui/topology/input_tree.py @@ -238,6 +238,19 @@ def _restore_state(self, state: dict) -> None: self.verticalScrollBar().setValue(state.get("scroll", 0)) + # ------------------------------------------------------------------ + # Search + # ------------------------------------------------------------------ + + def find_item(self, filename: str) -> QTreeWidgetItem | None: + """Find the top-level file item matching `filename`.""" + for i in range(self.topLevelItemCount()): + item = self.topLevelItem(i) + data = item.data(COL_NAME, Qt.UserRole) + if data and data[0] == "file" and data[1] == filename: + return item + return None + # ------------------------------------------------------------------ # Drag support # ------------------------------------------------------------------ diff --git a/sessionprepgui/topology/mixin.py b/sessionprepgui/topology/mixin.py index e7b150b..ab0e80b 100644 --- a/sessionprepgui/topology/mixin.py +++ b/sessionprepgui/topology/mixin.py @@ -6,6 +6,7 @@ from PySide6.QtCore import Qt, Slot from PySide6.QtGui import QAction, QColor from PySide6.QtWidgets import ( + QAbstractItemView, QCheckBox, QDialog, QDialogButtonBox, @@ -18,6 +19,8 @@ QSpinBox, QSplitter, QToolBar, + QTreeWidget, + QTreeWidgetItem, QVBoxLayout, QWidget, ) @@ -192,6 +195,10 @@ def _build_topology_page(self) -> QWidget: self._topo_output_tree.selectionModel().selectionChanged.connect( lambda sel, desel: self._on_topo_selection_changed("output")) + # Synchronized scrolling + self._syncing_scroll = False + self._topo_input_tree.verticalScrollBar().valueChanged.connect(self._on_input_scroll) + # Waveform preview panel (starts collapsed) self._topo_wf_panel = WaveformPanel(analysis_mode=False) self._topo_wf_panel.setVisible(False) @@ -457,7 +464,7 @@ def _on_topo_input_context_menu(self, filename: str, selected: list[str], menu = QMenu(self) - act_open_folder = menu.addAction("Open folder") + act_open_folder = menu.addAction("Open Folder") act_open_folder.triggered.connect( lambda checked, fn=filename: self._open_folder_for_file(fn) ) @@ -570,6 +577,42 @@ def _input_action_exclude(self, filename: str): ops.exclude_input(topo, filename) self._topo_changed() + # ── Synchronized scrolling ──────────────────────────────────────── + + def _get_item_at_center(self, tree: QTreeWidget) -> QTreeWidgetItem | None: + """Find the item currently in the vertical center of the viewport.""" + viewport = tree.viewport() + center_y = viewport.height() // 2 + # Use itemAt to find what's rendered at that physical pixel + item = tree.itemAt(viewport.width() // 2, center_y) + return item + + @Slot() + def _on_input_scroll(self): + """When input scrolled, attempt to center the corresponding output.""" + if self._syncing_scroll or not self._topo_topology: + return + + item = self._get_item_at_center(self._topo_input_tree) + if not item: + return + + data = item.data(0, Qt.UserRole) + input_filename = None + if data and data[0] == "file": + input_filename = data[1] + elif data and data[0] == "channel": + input_filename = data[1] + + if not input_filename: + return + + target_item = self._topo_output_tree.find_item_for_source(input_filename) + if target_item: + self._syncing_scroll = True + self._topo_output_tree.scrollToItem(target_item, QAbstractItemView.PositionAtCenter) + self._syncing_scroll = False + # ── Cross-tree exclusive selection ──────────────────────────────── def _on_topo_selection_changed(self, side: str): diff --git a/sessionprepgui/topology/output_tree.py b/sessionprepgui/topology/output_tree.py index 8827d2a..0b6879c 100644 --- a/sessionprepgui/topology/output_tree.py +++ b/sessionprepgui/topology/output_tree.py @@ -433,6 +433,23 @@ def highlight_usages( source_channel): self._set_row_bg(src_item, hl) + def find_item_for_source(self, input_filename: str) -> QTreeWidgetItem | None: + """Return the first output item (file, channel, or source) referencing `input_filename`.""" + for i in range(self.topLevelItemCount()): + fi = self.topLevelItem(i) + # Check the output file itself + if self._item_references(fi, input_filename, None): + return fi + for j in range(fi.childCount()): + ch_item = fi.child(j) + if self._item_references(ch_item, input_filename, None): + return ch_item + for k in range(ch_item.childCount()): + src_item = ch_item.child(k) + if self._item_references(src_item, input_filename, None): + return src_item + return None + def clear_highlights(self) -> None: """Remove all usage-highlight backgrounds.""" for i in range(self.topLevelItemCount()): @@ -1086,7 +1103,22 @@ def _ask_channel_count(self) -> int: def _action_remove_output(self, output_filename: str): if not self._topo: return - remove_output(self._topo, output_filename) + + items = self.selectedItems() + selected_files = set() + for item in items: + data = item.data(COL_NAME, Qt.UserRole) + if data and data[0] == "file": + selected_files.add(data[1]) + + if output_filename in selected_files: + # The clicked item is part of the selection; remove all selected files + for fn in selected_files: + remove_output(self._topo, fn) + else: + # The clicked item is NOT part of the selection; remove only it + remove_output(self._topo, output_filename) + self.topology_modified.emit() def _action_clear_channel(self, output_filename: str, target_ch: int): From 25728a9d8b057f669b51e393658da99ee4862e18 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 16:08:31 +0100 Subject: [PATCH 34/56] channel selection fixes --- sessionprepgui/analysis/worker.py | 62 ++++++++++++++++++++-------- sessionprepgui/topology/mixin.py | 68 +++++++++++++++++++++++-------- 2 files changed, 96 insertions(+), 34 deletions(-) diff --git a/sessionprepgui/analysis/worker.py b/sessionprepgui/analysis/worker.py index 6465709..d7af579 100644 --- a/sessionprepgui/analysis/worker.py +++ b/sessionprepgui/analysis/worker.py @@ -452,26 +452,50 @@ def run(self): import soundfile as sf track_arrays = [] # list of 2-D arrays (samples, ch) - track_names = [] # display names for labels track_ch_counts = [] + track_labels_list = [] sr = 44100 + from sessionprepgui.waveform.panel import WaveformPanel + if self._side == "input": - for filepath, name, _n_ch in self._items: + for item in self._items: if self._cancelled: return + filepath = item[0] + name = item[1] + channels_to_keep = item[2] if len(item) > 2 else None + data, file_sr = sf.read(filepath, dtype='float64') sr = file_sr if data.ndim == 1: data = data.reshape(-1, 1) + + if channels_to_keep is not None: + data = data[:, channels_to_keep] + ch_labels = [f"{name} Ch{c}" for c in channels_to_keep] + else: + n_ch = data.shape[1] + ch_labels = [] + names = WaveformPanel._CHANNEL_LABELS.get(n_ch) + for c in range(n_ch): + if names and c < len(names): + ch_labels.append(f"{name} {names[c]}") + else: + ch_labels.append(f"{name} Ch{c}") + track_arrays.append(data) - track_names.append(name) track_ch_counts.append(data.shape[1]) + track_labels_list.append(ch_labels) else: # output from sessionpreplib.topology import resolve_entry_audio - for entry, name in self._items: + for item in self._items: if self._cancelled: return + entry = item[0] + name = item[1] + channels_to_keep = item[2] if len(item) > 2 else None + # Load source audio for this entry track_audio: dict[str, tuple] = {} for src in entry.sources: @@ -481,12 +505,27 @@ def run(self): data, file_sr = sf.read(path, dtype='float64') track_audio[src.input_filename] = (data, file_sr) sr = file_sr + resolved = resolve_entry_audio(entry, track_audio) if resolved.ndim == 1: resolved = resolved.reshape(-1, 1) + + if channels_to_keep is not None: + resolved = resolved[:, channels_to_keep] + ch_labels = [f"{name} Ch{c}" for c in channels_to_keep] + else: + n_ch = resolved.shape[1] + ch_labels = [] + names = WaveformPanel._CHANNEL_LABELS.get(n_ch) + for c in range(n_ch): + if names and c < len(names): + ch_labels.append(f"{name} {names[c]}") + else: + ch_labels.append(f"{name} Ch{c}") + track_arrays.append(resolved) - track_names.append(name) track_ch_counts.append(resolved.shape[1]) + track_labels_list.append(ch_labels) if self._cancelled or not track_arrays: return @@ -517,18 +556,9 @@ def run(self): display_audio = display_audio[:, 0] # --- Channel labels --- - from sessionprepgui.waveform.panel import WaveformPanel labels = [] - ch_idx = 0 - for i, name in enumerate(track_names): - n_ch = track_ch_counts[i] - for c in range(n_ch): - ch_labels = WaveformPanel._CHANNEL_LABELS.get(n_ch) - if ch_labels and c < len(ch_labels): - labels.append(f"{name} {ch_labels[c]}") - else: - labels.append(f"{name} Ch{c}") - ch_idx += 1 + for lst in track_labels_list: + labels.extend(lst) self.finished.emit(display_audio, playback, sr, labels) except Exception as exc: diff --git a/sessionprepgui/topology/mixin.py b/sessionprepgui/topology/mixin.py index ab0e80b..95fef4c 100644 --- a/sessionprepgui/topology/mixin.py +++ b/sessionprepgui/topology/mixin.py @@ -703,15 +703,15 @@ def _topo_load_input_from_items(self, file_items, channel_items=None): if not file_items and not channel_items: return + if channel_items and not file_items: + self._topo_load_multi_input(None, channel_items) + return + if len(file_items) == 1 and not channel_items: data = file_items[0].data(0, Qt.UserRole) self._topo_load_input_waveform(data[1]) - elif len(file_items) > 1: - self._topo_load_multi_input(file_items) - elif channel_items: - # Single channel selected — load full file then show one channel - data = channel_items[0].data(0, Qt.UserRole) - self._topo_load_input_waveform(data[1]) + else: + self._topo_load_multi_input(file_items, channel_items) def _topo_load_input_waveform(self, filename: str): """Load waveform for a single input file.""" @@ -756,14 +756,14 @@ def _on_topo_audio_error(self, message: str): # ── Multi-track input loading ───────────────────────────────────── - def _topo_load_multi_input(self, file_items): - """Load and stack waveforms for multiple input files.""" + def _topo_load_multi_input(self, file_items, channel_items=None): + """Load and stack waveforms for multiple input files or channels.""" self._topo_cancel_workers() self._on_topo_stop() track_map = self._topo_track_map() items = [] - for fi in file_items: + for fi in (file_items or []): data = fi.data(0, Qt.UserRole) if not data or data[0] != "file": continue @@ -771,7 +771,22 @@ def _topo_load_multi_input(self, file_items): track = track_map.get(filename) if track: stem = os.path.splitext(filename)[0] - items.append((track.filepath, stem, track.channels)) + items.append((track.filepath, stem, None)) + + ch_map = {} + for ci in (channel_items or []): + data = ci.data(0, Qt.UserRole) + if not data or data[0] != "channel": + continue + filename = data[1] + source_ch = data[2] + ch_map.setdefault(filename, []).append(source_ch) + + for filename, channels in ch_map.items(): + track = track_map.get(filename) + if track: + stem = os.path.splitext(filename)[0] + items.append((track.filepath, stem, sorted(channels))) if not items: return @@ -795,14 +810,15 @@ def _topo_load_output_from_items(self, file_items, channel_items=None): if not file_items and not channel_items: return + if channel_items and not file_items: + self._topo_load_multi_output(None, channel_items) + return + if len(file_items) == 1 and not channel_items: data = file_items[0].data(0, Qt.UserRole) self._topo_load_output_waveform(data[1]) - elif len(file_items) > 1: - self._topo_load_multi_output(file_items) - elif channel_items: - data = channel_items[0].data(0, Qt.UserRole) - self._topo_load_output_waveform(data[1]) + else: + self._topo_load_multi_output(file_items, channel_items) def _topo_load_output_waveform(self, output_filename: str): """Resolve and display waveform for an output entry.""" @@ -845,7 +861,7 @@ def _on_topo_resolve_error(self, message: str): # ── Multi-track output loading ──────────────────────────────────── - def _topo_load_multi_output(self, file_items): + def _topo_load_multi_output(self, file_items, channel_items=None): """Load and stack waveforms for multiple output entries.""" self._topo_cancel_workers() self._on_topo_stop() @@ -855,7 +871,7 @@ def _topo_load_multi_output(self, file_items): return items = [] - for fi in file_items: + for fi in (file_items or []): data = fi.data(0, Qt.UserRole) if not data or data[0] != "file": continue @@ -864,7 +880,23 @@ def _topo_load_multi_output(self, file_items): if e.output_filename == filename), None) if entry: stem = os.path.splitext(filename)[0] - items.append((entry, stem)) + items.append((entry, stem, None)) + + ch_map = {} + for ci in (channel_items or []): + data = ci.data(0, Qt.UserRole) + if not data or data[0] != "channel": + continue + out_fn = data[1] + target_ch = data[2] + ch_map.setdefault(out_fn, []).append(target_ch) + + for out_fn, channels in ch_map.items(): + entry = next((e for e in topo.entries + if e.output_filename == out_fn), None) + if entry: + stem = os.path.splitext(out_fn)[0] + items.append((entry, stem, sorted(channels))) if not items: return From 4d4d19c1e990631ffb2b224187fa0db87abca16c Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 17:54:52 +0100 Subject: [PATCH 35/56] some ptsl tests. fader adjustment works again. --- KANBAN.md | 16 -- sessionpreplib/daw_processors/ptsl_helpers.py | 119 ++++++++++++- tests/conftest.py | 67 ++++++++ tests/exploration/README.md | 24 +++ tests/exploration/probe_faders.py | 117 +++++++++++++ tests/integration/conftest.py | 46 ++++++ tests/integration/test_ptsl_live.py | 70 ++++++++ tests/unit/test_ptsl_helpers.py | 156 ++++++++++++++++++ 8 files changed, 592 insertions(+), 23 deletions(-) create mode 100644 tests/conftest.py create mode 100644 tests/exploration/README.md create mode 100644 tests/exploration/probe_faders.py create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_ptsl_live.py create mode 100644 tests/unit/test_ptsl_helpers.py diff --git a/KANBAN.md b/KANBAN.md index d9c2329..11d1d2f 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -48,14 +48,6 @@ 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 @@ -98,14 +90,6 @@ 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 diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index 5749f88..c91dd59 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -383,18 +383,80 @@ def colorize_tracks( batch_job_id=batch_job_id, progress=progress) +# ── IMPORTANT: How PTSL fader control works ────────────────────────── +# +# Pro Tools 2025.10 introduced CId_SetTrackControlBreakpoints (command +# 150) which writes AUTOMATION BREAKPOINTS, not live fader positions. +# +# Key behaviours discovered through empirical testing: +# +# 1. The command writes automation data into the track's volume +# automation lane. A single breakpoint at sample 0 effectively +# sets a flat automation value across the entire timeline. +# +# 2. Faders do NOT visually move when the command is issued. They +# only snap to the written value when the TRANSPORT PLAYS and +# Pro Tools reads the automation. This is expected behaviour, +# not a bug. +# +# 3. The value is ACTUAL dB (empirically verified 2025-03-03). +# Despite the proto documentation claiming a -1.0 to +1.0 range +# for TCType_Volume, testing confirms that the float value maps +# directly to dB. No transfer function is needed. +# +12.0 → +12 dB (fader fully up) +# 0.0 → 0 dB (unity gain) +# -6.0 → -6 dB +# -18.0 → -18 dB (SessionPrep sustained target) +# -80.0 → -80 dB (near silence) +# The proto's -1.0/+1.0 range likely applies only to pan, mute, +# LFE, and plugin parameter controls — not volume. +# +# 4. Both track_id (GUID string) and track_name (display name) are +# accepted for track identification. Either field can be used, +# the proto defines them as alternatives. +# +# 5. The command can be wrapped in a batch job (CId_CreateBatchJob / +# CId_CompleteBatchJob) which shows a modal progress dialog in +# Pro Tools and blocks user interaction during the operation. +# +# 6. As of Pro Tools 2025.12, only TCType_Volume (and sends) work. +# TCType_Pan, TCType_Mute, TCType_Lfe, TCType_PluginParameter +# return "Not yet implemented" from the server. +# +# Reference: PTSL SDK 2025.10 documentation, Chapter 3 (Batch Jobs) +# and SetTrackControlBreakpointsRequestBody in PTSL.proto. +# ───────────────────────────────────────────────────────────────────── + + def set_track_volume( engine, track_id: str, volume_db: float, batch_job_id: str | None = None, progress: int = 0, ) -> None: - """Set a track's fader volume to *volume_db* (direct dB value). + """Set a track's fader volume via automation breakpoint (by track_id). + + Writes a single automation breakpoint at sample 0 using + ``CId_SetTrackControlBreakpoints`` with ``TCType_Volume`` on + ``TSId_MainOut``. + + .. important:: + + This writes **automation data**, not a live fader position. + The fader only visually moves when the transport plays and + Pro Tools reads the automation. - Uses ``CId_SetTrackControlBreakpoints`` with ``TCType_Volume`` on - ``TSId_MainOut`` at sample 0. The *volume_db* value maps directly - to dBFS (e.g. ``-12.0`` sets the fader to −12 dB). + Args: + track_id: Pro Tools track GUID, e.g. + ``"{00000000-2a000000-eead9701-ea871516}"``. + volume_db: Fader value in **actual dB**. Pro Tools range is + roughly ``-inf`` to ``+12.0``. E.g. ``0.0`` = unity, + ``-6.0`` = −6 dB, ``+12.0`` = fader fully up. + batch_job_id: Optional batch job ID (from ``create_batch_job``). + progress: Batch job progress percentage (0–100). + + Requires Pro Tools 2025.10+. """ from ptsl import PTSL_pb2 as pt - dbg(f"set_track_volume: id={track_id}, db={volume_db}") + dbg(f"set_track_volume: id={track_id}, value={volume_db}") try: run_command( engine, pt.CommandId.CId_SetTrackControlBreakpoints, @@ -410,9 +472,52 @@ def set_track_volume( "time_type": "TLType_Samples", }, "value": volume_db, - }], + }] + }, + batch_job_id=batch_job_id, progress=progress) + except Exception as e: + dbg(f"Error in set_track_volume ({track_id}, {volume_db}): {e}") + raise + + +def set_track_volume_by_trackname( + engine, track_name: str, volume: float, + batch_job_id: str | None = None, progress: int = 0, +) -> None: + """Set a track's fader volume via automation breakpoint (by track_name). + + Identical to :func:`set_track_volume` but identifies the track by + its display name instead of its GUID. See that function's docstring + for full details on behaviour, value range, and caveats. + + Args: + track_name: Pro Tools track display name, e.g. ``"Audio 1"``. + volume: Fader value in **actual dB** (see :func:`set_track_volume`). + batch_job_id: Optional batch job ID (from ``create_batch_job``). + progress: Batch job progress percentage (0–100). + + Requires Pro Tools 2025.10+. + """ + from ptsl import PTSL_pb2 as pt + dbg(f"set_track_volume_by_trackname: name={track_name}, value={volume}") + try: + run_command( + engine, pt.CommandId.CId_SetTrackControlBreakpoints, + { + "track_name": track_name, + "control_id": { + "section": "TSId_MainOut", + "control_type": "TCType_Volume", + }, + "breakpoints": [{ + "time": { + "location": "0", + "time_type": "TLType_Samples", + }, + "value": volume, + }] }, batch_job_id=batch_job_id, progress=progress) except Exception as e: - dbg(f"Error in set_track_volume ({track_id}, {volume_db} dB): {e}") + dbg(f"Error in set_track_volume_by_trackname ({track_name}, {volume}): {e}") raise diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..873a911 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,67 @@ +import json +from unittest.mock import MagicMock + +import pytest + +try: + from ptsl import PTSL_pb2 as pt +except ImportError: + pt = None + +@pytest.fixture +def mock_engine(): + """Provides a MagicMock of a py-ptsl Engine. + + The raw_client.SendGrpcRequest method is mocked so test functions + can assert what Request was sent, and configure what Response it + should return. + """ + engine = MagicMock() + engine.client = MagicMock() + engine.client.session_id = "test-session-123" + engine.client.raw_client = MagicMock() + + # Default: returning an empty pt.Response just so it doesn't crash + if pt: + engine.client.raw_client.SendGrpcRequest.return_value = pt.Response( + header=pt.ResponseHeader(status=pt.Completed), + response_body_json="{}" + ) + return engine + +def make_ptsl_response(status, body_json="", error_json=""): + """Helper to build a fake pt.Response protobuf.""" + if not pt: + return None + + return pt.Response( + header=pt.ResponseHeader(status=status), + response_body_json=body_json, + response_error_json=error_json + ) + +@pytest.fixture +def ptsl_factory(): + """Factory fixture returning helper functions for creating protobuf responses.""" + + class Factory: + @staticmethod + def ok(body_dict=None): + if body_dict is None: + body_dict = {} + return make_ptsl_response(pt.Completed, json.dumps(body_dict)) + + @staticmethod + def fail(error_msg="Test error", error_type=pt.PT_UnknownError): + # Pro Tools errors are usually a JSON serialized ResponseError protobuf + from google.protobuf import json_format + + err = pt.ResponseError() + e = err.errors.add() + e.command_error_type = error_type + e.command_error_message = error_msg + + err_json = json_format.MessageToJson(err, preserving_proto_field_name=True) + return make_ptsl_response(pt.Failed, error_json=err_json) + + return Factory() diff --git a/tests/exploration/README.md b/tests/exploration/README.md new file mode 100644 index 0000000..d159ec5 --- /dev/null +++ b/tests/exploration/README.md @@ -0,0 +1,24 @@ +# PTSL Exploration Scripts + +This directory contains standalone, ad-hoc Python scripts meant for interactive debugging with a live Avid Pro Tools session. + +Unlike the automated `unit` or `integration` test suites, these scripts are built to perform very specific actions (like slamming all faders down to verify the Mix window reacts) and print verbose feedback directly to stdout. They are typically run one-at-a-time. + +## Preconditions + +For almost all scripts here: + +1. Pro Tools must be running. +2. A session must be actively open in Pro Tools. +3. The PTSL gRPC connection must be enabled in Pro Tools (`Setup -> Preferences -> Operation -> Enable Server`). +4. You must have run `uv sync --all-extras` to ensure `py-ptsl` is installed. + +## Available Scripts + +### `probe_faders.py` +Connects to the current session, iterates sequentially over all `Audio` tracks, and attempts to set their Volume faders to cascading dB levels (starting at -6.0 dB and dropping by 0.5 per track). Useful for validating if fader breakpoints are being correctly acknowledged by the PTSL SDK version. + +**Usage:** +```bash +uv run python tests/exploration/probe_faders.py +``` diff --git a/tests/exploration/probe_faders.py b/tests/exploration/probe_faders.py new file mode 100644 index 0000000..5bef73e --- /dev/null +++ b/tests/exploration/probe_faders.py @@ -0,0 +1,117 @@ +""" +Diagnostic script for interacting with Pro Tools faders manually. + +Run via: uv run python tests/exploration/probe_faders.py +""" +import time +import sys + +try: + from ptsl import PTSL_pb2 as pt + from ptsl import Engine +except ImportError: + print("Error: py-ptsl not installed.") + sys.exit(1) + +from sessionpreplib.daw_processors import ptsl_helpers + +def main(): + print("Connecting to Pro Tools Engine...") + try: + engine = Engine( + company_name="SessionPrep Diagnostic", + application_name="probe_faders", + address="localhost:31416" + ) + except Exception as e: + print(f"Failed to connect: {e}") + sys.exit(1) + + try: + session_name = engine.session_name() + if not session_name: + print("No active Pro Tools session open. Exiting.") + sys.exit(1) + + print(f"Connected to session: '{session_name}'") + + # 1. Fetch tracks + track_list = engine.track_list() + + audio_tracks = [] + for t in track_list: + # Different py-ptsl versions return either "Audio", "TT_Audio", integer 2, or string "2" + t_type = str(t.type) + if t_type in ("Audio", "TT_Audio", str(pt.TT_Audio)): + audio_tracks.append(t) + + if not audio_tracks: + print("No Audio tracks found in the session.") + sys.exit(0) + + print(f"Found {len(audio_tracks)} audio tracks.") + + # Test hypothesis: is the float value actual dB? + # Set recognisable dB values and check the fader readout in Pro Tools + # after hitting Play. Pro Tools fader range is -inf to +12 dB. + test_db_values = [ + +12.0, # fader fully up + +6.0, # hot + +3.0, + 0.0, # unity gain + -3.0, + -6.0, # common mix level + -12.0, + -18.0, # SessionPrep sustained target + -24.0, + -36.0, + -48.0, + -60.0, + -80.0, # near silence + # Rest of tracks: leave some extreme/edge values + ] + + # Pad with 0.0 if we have more tracks than test values + while len(test_db_values) < len(audio_tracks): + test_db_values.append(0.0) + + job_id = None + try: + print("Creating Batch Job...") + job_id = ptsl_helpers.create_batch_job(engine, "Probe Faders", "dB hypothesis test") + print(f"Batch Job ID: {job_id}") + print() + print(f" {'Track':<25} {'Value sent':>12} {'Expected if dB':>16}") + print(f" {'-'*25} {'-'*12} {'-'*16}") + + for i, track in enumerate(audio_tracks[:len(test_db_values)]): + val = test_db_values[i] + progress = int((i + 1) / len(audio_tracks) * 100) + print(f" {track.name:<25} {val:>+12.1f} {'<-- check fader':>16}") + + try: + ptsl_helpers.set_track_volume_by_trackname( + engine, track.name, val, + batch_job_id=job_id, progress=progress) + except Exception as e: + print(f" [FAILED] {e}") + + print() + print("Hit PLAY in Pro Tools, then read the fader dB values.") + print("If they match the 'Value sent' column, the value IS actual dB.") + + finally: + if job_id: + print("Completing Batch Job...") + ptsl_helpers.complete_batch_job(engine, job_id) + + except Exception as e: + print(f"Unexpected error during script execution: {e}") + + finally: + print("Closing engine connection.") + engine.close() + +if __name__ == "__main__": + main() + diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..a5a7d9b --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,46 @@ +import pytest + +try: + from ptsl import PTSL_pb2 as pt + from ptsl import Engine +except ImportError: + pt = None + Engine = None + +from sessionpreplib.daw_processors import ptsl_helpers + +def pytest_configure(config): + config.addinivalue_line("markers", "ptsl_live: mark test to require a live Pro Tools connection") + +@pytest.fixture(scope="session") +def live_engine(): + """Provides a connected PTSL Engine to a running Pro Tools instance. + + Skips the entire test module if Pro Tools cannot be reached. + """ + if not pt or not Engine: + pytest.skip("py-ptsl not installed") + + try: + # Attempt to connect to default gRPC port + engine = Engine( + company_name="SessionPrep tests", + application_name="pytest", + address="localhost:31416" + ) + + # Verify connectivity by getting session name + if not ptsl_helpers.is_session_open(engine): + pytest.skip("Pro Tools is running, but no session is open.") + + yield engine + + except Exception as e: + pytest.skip(f"Could not connect to live Pro Tools instance: {e}") + + finally: + try: + # Clean up connection + engine.close() + except Exception: + pass diff --git a/tests/integration/test_ptsl_live.py b/tests/integration/test_ptsl_live.py new file mode 100644 index 0000000..052b882 --- /dev/null +++ b/tests/integration/test_ptsl_live.py @@ -0,0 +1,70 @@ +import pytest +import time +from sessionpreplib.daw_processors import ptsl_helpers + +# Marks all tests in this file as requiring a live PT connection +pytestmark = pytest.mark.ptsl_live + +def test_read_all_tracks_and_folders(live_engine, capsys): + """ + Reads the track list from the live session and prints basic properties. + """ + with capsys.disabled(): + print("\n--- Live Tracks ---") + + # TrackListInSession is a direct engine operation in py-ptsl + # For ptsl_helpers, we can call the SDK method. + # Usually it returns a pt.TrackListInSessionResponseBody structure + try: + track_list = live_engine.track_list() + + with capsys.disabled(): + for t in track_list: + print(f"Track: '{t.name}' | ID: {t.id} | Type: {t.type} | Folder?: {t.is_folder}") + + assert len(track_list) > 0, "Session must contain at least one track for this test." + + except Exception as e: + pytest.fail(f"Failed to read track list: {e}") + + +def test_set_faders_for_existing_audio_tracks(live_engine, capsys): + """ + Finds all audio tracks and attempts to set a fader value via set_track_volume. + + Note: PTSL currently fails to read back fader values reliably + (CId_GetTrackControlBreakpoints is Unsupported in 2025.10). + Thus, this test only verifies that the command is accepted (Completed) + and doesn't throw a RuntimeError. Visually monitor Pro Tools to confirm! + """ + track_list = live_engine.track_list() + from ptsl import PTSL_pb2 as pt + + # Filter to Audio tracks + audio_tracks = [] + for t in track_list: + t_type = str(t.type) + if t_type in ("Audio", "TT_Audio", str(pt.TT_Audio)): + audio_tracks.append(t) + + if not audio_tracks: + pytest.skip("No audio tracks found in the live session to test faders on.") + + with capsys.disabled(): + print(f"\n--- Setting fader on {len(audio_tracks)} audio tracks ---") + + for i, track in enumerate(audio_tracks): + # Alternate fader values slightly so we can watch them jump + target_db = -6.0 - (0.5 * i) + + with capsys.disabled(): + print(f"[{i+1}/{len(audio_tracks)}] Setting {track.name} (ID: {track.id}) to {target_db} dB") + + # This will raise a RuntimeError if PT rejects the fader command. + ptsl_helpers.set_track_volume(live_engine, track.id, target_db) + + # Give the Mix Engine a tiny bit of time to digest the commands + time.sleep(1.0) + + with capsys.disabled(): + print("Done. Please verify in the PT Mix window.") diff --git a/tests/unit/test_ptsl_helpers.py b/tests/unit/test_ptsl_helpers.py new file mode 100644 index 0000000..13b8af0 --- /dev/null +++ b/tests/unit/test_ptsl_helpers.py @@ -0,0 +1,156 @@ +import json +import pytest + +try: + from ptsl import PTSL_pb2 as pt +except ImportError: + pt = None + +from sessionpreplib.daw_processors import ptsl_helpers + +pytestmark = pytest.mark.skipif( + pt is None, + reason="py-ptsl not installed" +) + +def test_run_command_builds_correct_header(mock_engine, ptsl_factory): + mock_engine.client.raw_client.SendGrpcRequest.return_value = ptsl_factory.ok({"dummy": "value"}) + + resp = ptsl_helpers.run_command( + mock_engine, + pt.CommandId.CId_GetSessionName, + {"body_key": "body_val"} + ) + + # Assert return value equals parsed json + assert resp == {"dummy": "value"} + + # Assert header construction + mock_engine.client.raw_client.SendGrpcRequest.assert_called_once() + req = mock_engine.client.raw_client.SendGrpcRequest.call_args[0][0] + + assert req.header.session_id == "test-session-123" + assert req.header.command == pt.CommandId.CId_GetSessionName + assert req.header.version == 2025 + assert req.header.version_minor == 10 + + # Assert body construction + assert json.loads(req.request_body_json) == {"body_key": "body_val"} + +def test_run_command_with_batch_job_header(mock_engine, ptsl_factory): + mock_engine.client.raw_client.SendGrpcRequest.return_value = ptsl_factory.ok() + + ptsl_helpers.run_command( + mock_engine, + pt.CommandId.CId_GetSessionName, + {}, + batch_job_id="test-batch-uuid", + progress=75 + ) + + req = mock_engine.client.raw_client.SendGrpcRequest.call_args[0][0] + + # Versioned JSON field must contain the batch job header + vheader = json.loads(req.header.versioned_request_header_json) + assert "batch_job_header" in vheader + assert vheader["batch_job_header"]["id"] == "test-batch-uuid" + assert vheader["batch_job_header"]["progress"] == 75 + +def test_run_command_raises_on_failure(mock_engine, ptsl_factory): + # Construct a Failed status response with an error message + mock_engine.client.raw_client.SendGrpcRequest.return_value = ptsl_factory.fail( + error_msg="No session is currently open", + error_type=pt.PT_NoOpenedSession + ) + + with pytest.raises(RuntimeError) as exc: + ptsl_helpers.run_command(mock_engine, pt.CommandId.CId_GetSessionName, {}) + + assert "No session is currently open" in str(exc.value) + assert "PT_NoOpenedSession" in str(exc.value) + +def test_extract_clip_ids_happy_path(): + resp = { + "file_list": [{ + "destination_file_list": [{ + "clip_id_list": ["clip-123", "clip-456"] + }] + }] + } + assert ptsl_helpers.extract_clip_ids(resp) == ["clip-123", "clip-456"] + +def test_extract_clip_ids_malformed(): + with pytest.raises(RuntimeError): + ptsl_helpers.extract_clip_ids({"file_list": []}) + + with pytest.raises(RuntimeError): + ptsl_helpers.extract_clip_ids({}) + +def test_extract_track_id(): + resp = {"created_track_ids": ["new-track-uuid"]} + assert ptsl_helpers.extract_track_id(resp) == "new-track-uuid" + + with pytest.raises(RuntimeError): + ptsl_helpers.extract_track_id({"created_track_ids": []}) + +def test_set_track_volume_body_construction(mock_engine, ptsl_factory): + mock_engine.client.raw_client.SendGrpcRequest.return_value = ptsl_factory.ok() + + ptsl_helpers.set_track_volume(mock_engine, "track-123", -6.5, batch_job_id="batch1") + + req = mock_engine.client.raw_client.SendGrpcRequest.call_args[0][0] + body = json.loads(req.request_body_json) + + assert body["track_id"] == "track-123" + assert body["control_id"]["section"] == "TSId_MainOut" + assert body["control_id"]["control_type"] == "TCType_Volume" + + bp = body["breakpoints"][0] + assert bp["time"]["location"] == "0" + assert bp["time"]["time_type"] == "TLType_Samples" + + # Verify bare float precision without truncation + assert bp["value"] == -6.5 + +def test_set_track_volume_boundary_values(mock_engine, ptsl_factory): + mock_engine.client.raw_client.SendGrpcRequest.return_value = ptsl_factory.ok() + + # Legal boundaries + ptsl_helpers.set_track_volume(mock_engine, "t1", 12.0) + req = mock_engine.client.raw_client.SendGrpcRequest.call_args[0][0] + assert json.loads(req.request_body_json)["breakpoints"][0]["value"] == 12.0 + + ptsl_helpers.set_track_volume(mock_engine, "t1", -144.0) + req = mock_engine.client.raw_client.SendGrpcRequest.call_args[0][0] + assert json.loads(req.request_body_json)["breakpoints"][0]["value"] == -144.0 + +def test_create_track_with_folder(mock_engine, ptsl_factory): + mock_engine.client.raw_client.SendGrpcRequest.return_value = ptsl_factory.ok({ + "created_track_ids": ["t-uuid-001"] + }) + + track_id = ptsl_helpers.create_track( + mock_engine, "Bass", "TF_Stereo", folder_name="Drums Folder" + ) + + assert track_id == "t-uuid-001" + req = mock_engine.client.raw_client.SendGrpcRequest.call_args[0][0] + body = json.loads(req.request_body_json) + + assert body["track_name"] == "Bass" + assert body["track_format"] == "TF_Stereo" + assert body["insertion_point_track_name"] == "Drums Folder" + assert body["insertion_point_position"] == "TIPoint_Last" + +def test_create_track_without_folder(mock_engine, ptsl_factory): + mock_engine.client.raw_client.SendGrpcRequest.return_value = ptsl_factory.ok({ + "created_track_ids": ["t-uuid-002"] + }) + + ptsl_helpers.create_track(mock_engine, "Guitars", "TF_Stereo", folder_name=None) + + req = mock_engine.client.raw_client.SendGrpcRequest.call_args[0][0] + body = json.loads(req.request_body_json) + + assert "insertion_point_track_name" not in body + assert "insertion_point_position" not in body From 5a458f74d7b5ac1f7d2c531e0628c07e84ff8d8b Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 18:41:36 +0100 Subject: [PATCH 36/56] fixed missing faders. --- sessionprepgui/batch/manager.py | 44 +++------------------------------ 1 file changed, 3 insertions(+), 41 deletions(-) diff --git a/sessionprepgui/batch/manager.py b/sessionprepgui/batch/manager.py index cce6218..f07a7c8 100644 --- a/sessionprepgui/batch/manager.py +++ b/sessionprepgui/batch/manager.py @@ -239,47 +239,9 @@ def _rehydrate_session(self, state_dict: dict[str, Any]) -> SessionContext: ) # Assuming topology and topology_applied are needed we could restore them too, # but for transfer, `transfer_manifest` and `output_tracks` (which we rebuilt during load) are key. - # Wait, output_tracks are not in state_dict. - # But prepare step created the files, so we might need output_tracks. - # We can let DawTransferWorker handle it or rebuild it if needed. - # We need output_tracks for the transfer process to know file names. - if state_dict.get("topology_applied", False) and state_dict.get("topology"): - import os - import soundfile as sf - from sessionpreplib.models import TrackContext - topo_folder = flat_config.get("app", {}).get("phase1_output_folder", "sp_01_tracklayout") - prep_folder = flat_config.get("app", {}).get("phase2_output_folder", "sp_02_prepared") - topo_dir = os.path.join(source_dir, topo_folder) - prep_dir = os.path.join(source_dir, prep_folder) - - manifest_group = {e.output_filename: e.group for e in session.transfer_manifest} - rebuilt = [] - topology = state_dict.get("topology") - if topology: - for entry in topology.entries: - topo_path = os.path.join(topo_dir, entry.output_filename) - if not os.path.isfile(topo_path): - continue - try: - info = sf.info(topo_path) - proc_path = os.path.join(prep_dir, entry.output_filename) - out_tc = TrackContext( - filename=entry.output_filename, - filepath=topo_path, - audio_data=None, - samplerate=info.samplerate, - channels=info.channels, - total_samples=info.frames, - bitdepth=str(info.subtype_info) if hasattr(info, 'subtype_info') else "", - subtype=info.subtype, - duration_sec=info.duration, - processed_filepath=(proc_path if os.path.isfile(proc_path) else None) - ) - out_tc.group = manifest_group.get(entry.output_filename) - rebuilt.append(out_tc) - except Exception: - pass - session.output_tracks = rebuilt + # Restore output_tracks directly from the state dict (added in v6 format) + # Rebuilding from topology would lose processor_results (e.g. fader_offset) + session.output_tracks = state_dict.get("output_tracks", []) return session From 200b185fb6b645e2dc4aa40c0c88f35e3f62efc5 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 18:54:07 +0100 Subject: [PATCH 37/56] keep the session open in non-batch mode --- sessionprepgui/analysis/worker.py | 5 +++-- sessionprepgui/daw/mixin.py | 2 +- sessionpreplib/daw_processor.py | 1 + sessionpreplib/daw_processors/dawproject.py | 1 + sessionpreplib/daw_processors/protools.py | 25 +++++++++++++++------ 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/sessionprepgui/analysis/worker.py b/sessionprepgui/analysis/worker.py index d7af579..71ac61b 100644 --- a/sessionprepgui/analysis/worker.py +++ b/sessionprepgui/analysis/worker.py @@ -69,11 +69,12 @@ 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, output_path: str, parent=None): + def __init__(self, processor: DawProcessor, session, output_path: str, parent=None, close_session: bool = True): super().__init__(parent) self._processor = processor self._session = session self._output_path = output_path + self._close_session = close_session def _on_progress(self, current: int, total: int, message: str): self.progress.emit(message) @@ -82,7 +83,7 @@ def _on_progress(self, current: int, total: int, message: str): def run(self): try: results = self._processor.transfer( - self._session, self._output_path, progress_cb=self._on_progress) + self._session, self._output_path, progress_cb=self._on_progress, close_when_done=self._close_session) 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/sessionprepgui/daw/mixin.py b/sessionprepgui/daw/mixin.py index 428aff2..936bee4 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -571,7 +571,7 @@ def _do_daw_transfer(self): self._transfer_progress.start("Preparing\u2026") self._daw_transfer_worker = DawTransferWorker( - self._active_daw_processor, self._session, output_path, parent=self) + self._active_daw_processor, self._session, output_path, parent=self, close_session=False) 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/sessionpreplib/daw_processor.py b/sessionpreplib/daw_processor.py index e32be52..870db3c 100644 --- a/sessionpreplib/daw_processor.py +++ b/sessionpreplib/daw_processor.py @@ -133,6 +133,7 @@ def transfer( session: SessionContext, output_path: str, progress_cb=None, + close_when_done: bool = True, ) -> list[DawCommandResult]: """Initial full push of session data to the DAW. diff --git a/sessionpreplib/daw_processors/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index 810a797..1014efb 100644 --- a/sessionpreplib/daw_processors/dawproject.py +++ b/sessionpreplib/daw_processors/dawproject.py @@ -239,6 +239,7 @@ def transfer( session: SessionContext, output_path: str, progress_cb=None, + close_when_done: bool = True, ) -> list[DawCommandResult]: try: from dawproject import ( diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index 6cfb89f..aed5f38 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -555,6 +555,7 @@ def transfer( session: SessionContext, output_path: str, progress_cb=None, + close_when_done: bool = True, ) -> list[DawCommandResult]: """Create a new Pro Tools session from a template and import audio. @@ -881,23 +882,33 @@ def _create_and_spot(item): # ── 6. Save & Close ────────────────────────────────── if progress_cb: - progress_cb(98, 100, "Saving and closing session...") + msg = "Saving and closing session..." if close_when_done else "Saving session..." + progress_cb(98, 100, msg) if batch_job_id: ptslh.complete_batch_job(engine, batch_job_id) batch_job_id = None try: - ptslh.close_session(engine, save_on_close=True, delay=delay) - results.append( - DawCommandResult( - command=DawCommand("close_session", "", {}), success=True + if close_when_done: + ptslh.close_session(engine, save_on_close=True, delay=delay) + results.append( + DawCommandResult( + command=DawCommand("close_session", "", {}), success=True + ) + ) + else: + ptslh.save_session(engine) + results.append( + DawCommandResult( + command=DawCommand("save_session", "", {}), success=True + ) ) - ) except Exception as e: + cmd_name = "close_session" if close_when_done else "save_session" results.append( DawCommandResult( - command=DawCommand("close_session", "", {}), + command=DawCommand(cmd_name, "", {}), success=False, error=str(e), ) From 1d4ffe508f7e6c089243b9ef6b2ba5d43d9c32b2 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 19:19:44 +0100 Subject: [PATCH 38/56] bundle rps with sessionprep --- .github/workflows/build-nuitka.yml | 10 +-- build_nuitka.py | 96 +++++++++++++++++++++----- packaging/linux/install-sessionprep.sh | 44 ++++++++---- packaging/linux/nfpm.yaml | 20 +++--- 4 files changed, 128 insertions(+), 42 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index c7fa7fa..3574d89 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -76,15 +76,17 @@ jobs: rm "$TMPCONFIG" STAGING=$(mktemp -d) - cp dist_nuitka/sessionprep-linux-x64 "$STAGING/sessionprep" - cp dist_nuitka/sessionprep-gui-linux-x64 "$STAGING/sessionprep-gui" + cp -r dist_nuitka/sessionprep.dist "$STAGING/sessionprep.dist" + cp -r dist_nuitka/sessionprep-gui.dist "$STAGING/sessionprep-gui.dist" + # RPS binaries are now inside the .dist folders via python build script 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.dist sessionprep-gui.dist \ + sessionprep.png \ sessionprep.desktop install-sessionprep.sh - name: Build InnoSetup Installer @@ -146,4 +148,4 @@ jobs: dist_nuitka/*.deb dist_nuitka/*.rpm dist_nuitka/*.tar.gz - if-no-files-found: error \ No newline at end of file + if-no-files-found: error diff --git a/build_nuitka.py b/build_nuitka.py index ad9b239..aa5f465 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -12,6 +12,8 @@ import argparse from build_conf import TARGETS, BASE_DIR, DIST_NUITKA, MACOS_APP_NAME +RPS_VERSION = "0.2.2" + def _check_dependencies(target_key): """Ensure required packages for the target are installed.""" from importlib.util import find_spec @@ -19,9 +21,74 @@ def _check_dependencies(target_key): # Check explicitly for PySide6 if it's the GUI target if target_key == "gui": if find_spec("PySide6") is None: - print(f"\n[ERROR] PySide6 is missing. Run: uv sync --extra gui") + print("\n[ERROR] PySide6 is missing. Run: uv sync --extra gui") sys.exit(1) +def fetch_and_bundle_rps(dist_dir, target): + """Fetch RPS release binaries and bundle them with the executable.""" + import urllib.request + import tarfile + import zipfile + from build_conf import get_platform_suffix + + suffix = get_platform_suffix() + if sys.platform in ("win32", "darwin"): + ext = "zip" + else: + ext = "tar.gz" + + url = f"https://github.com/bzeiss/rps/releases/download/{RPS_VERSION}/rps-{suffix}.{ext}" + archive_path = os.path.join(dist_dir, f"rps-{suffix}.{ext}") + + print(f"\n[POST-PROCESS] Fetching RPS binaries for {suffix}...") + try: + if not os.path.exists(archive_path): + print(f" Downloading {url}") + req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) + with urllib.request.urlopen(req) as response, open(archive_path, 'wb') as out_file: + shutil.copyfileobj(response, out_file) + except Exception as e: + print(f" [WARNING] Could not download {url}: {e}") + return + + # Determine the destination + script_stem = os.path.splitext(os.path.basename(target["script"]))[0] + if sys.platform == "darwin" and not target["console"]: + dest_dir = os.path.join(dist_dir, f"{MACOS_APP_NAME}.app", "Contents", "MacOS") + elif sys.platform in ("win32", "linux"): + dest_dir = os.path.join(dist_dir, f"{script_stem}.dist") + else: + dest_dir = dist_dir + + print(f" Extracting to {dest_dir}...") + os.makedirs(dest_dir, exist_ok=True) + + binaries = ["rps-server", "rps-pluginscanner"] + if sys.platform == "win32": + binaries = [b + ".exe" for b in binaries] + + if ext == "zip": + with zipfile.ZipFile(archive_path, 'r') as zip_ref: + for member in zip_ref.namelist(): + name = os.path.basename(member) + if name in binaries: + source = zip_ref.open(member) + target_path = os.path.join(dest_dir, name) + with open(target_path, "wb") as target_file: + shutil.copyfileobj(source, target_file) + if sys.platform != "win32": + os.chmod(target_path, 0o755) + else: + with tarfile.open(archive_path, 'r:gz') as tar_ref: + for member in tar_ref.getmembers(): + name = os.path.basename(member.name) + if name in binaries: + source = tar_ref.extractfile(member) + target_path = os.path.join(dest_dir, name) + with open(target_path, "wb") as target_file: + shutil.copyfileobj(source, target_file) + os.chmod(target_path, 0o755) + def run_nuitka(target_key, clean=False): _check_dependencies(target_key) target = TARGETS[target_key] @@ -49,10 +116,8 @@ def run_nuitka(target_key, clean=False): "--lto=no", ] - # We use onefile on Linux and macOS (for CLI). - # On Windows, we use directory mode (standalone) for faster startup. - if sys.platform != "win32": - cmd.append("--onefile") + # Windows and Linux use directory mode (standalone) for faster startup. + # macOS GUI uses .app bundle (configured below). macOS CLI is not built. # Platform specific flags if not target["console"]: @@ -69,8 +134,6 @@ def run_nuitka(target_key, clean=False): 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}") @@ -100,14 +163,6 @@ def run_nuitka(target_key, clean=False): print(f" Command: {' '.join(cmd)}") subprocess.check_call(cmd) - # Nuitka on Linux adds .bin suffix to avoid name collisions with source files. - # We rename it back to the target name to match PyInstaller behavior. - if sys.platform == "linux": - bin_path = output_exe + ".bin" - if os.path.exists(bin_path) and not os.path.exists(output_exe): - print(f" Renaming {bin_path} -> {output_exe}") - os.rename(bin_path, output_exe) - # On macOS GUI, output is a .app bundle (directory), not a single file. # Nuitka names the bundle from the script name, not --output-filename. # Rename it to MACOS_APP_NAME for a clean user-facing name. @@ -123,7 +178,11 @@ def run_nuitka(target_key, clean=False): print(f"[SUCCESS] Built {output_exe}") print(f" Size: {os.path.getsize(output_exe) / (1024*1024):.2f} MB") else: - print(f"[SUCCESS] Build completed") + print("[SUCCESS] Build completed") + + # Post processing step: Fetch and bundle RPS C++ plugins (GUI only) + if not target["console"]: + fetch_and_bundle_rps(dist_dir, target) def main(): parser = argparse.ArgumentParser(description="Build SessionPrep with Nuitka") @@ -136,7 +195,10 @@ def main(): targets_to_build = [] if args.target == "all": - targets_to_build = ["cli", "gui"] + if sys.platform == "darwin": + targets_to_build = ["gui"] # macOS only ships the .app bundle + else: + targets_to_build = ["cli", "gui"] else: targets_to_build = [args.target] diff --git a/packaging/linux/install-sessionprep.sh b/packaging/linux/install-sessionprep.sh index f1dd6a0..c955920 100644 --- a/packaging/linux/install-sessionprep.sh +++ b/packaging/linux/install-sessionprep.sh @@ -16,8 +16,12 @@ set -euo pipefail # Constants — filenames and the placeholder in the bundled .desktop template # --------------------------------------------------------------------------- -readonly CLI_BIN="sessionprep" -readonly GUI_BIN="sessionprep-gui" +readonly CLI_DIR="sessionprep.dist" +readonly GUI_DIR="sessionprep-gui.dist" +readonly CLI_BIN="sessionprep-linux-x64" +readonly GUI_BIN="sessionprep-gui-linux-x64" +readonly CLI_CMD="sessionprep" +readonly GUI_CMD="sessionprep-gui" readonly ICON_FILE="sessionprep.png" readonly DESKTOP_FILE="sessionprep.desktop" readonly DESKTOP_EXEC_PLACEHOLDER="Exec=/usr/local/bin/sessionprep-gui" @@ -39,8 +43,8 @@ die() { # 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 + for f in "$CLI_DIR" "$GUI_DIR" "$ICON_FILE" "$DESKTOP_FILE"; do + if [ ! -e "$SCRIPT_DIR/$f" ]; then echo " Missing source file: $SCRIPT_DIR/$f" >&2 missing=1 fi @@ -86,6 +90,7 @@ INSTALL_DIR="${INSTALL_DIR:-$HOME/.local}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BIN_DIR="$INSTALL_DIR/bin" +LIB_DIR="$INSTALL_DIR/lib/sessionprep" PIXMAPS_DIR="$INSTALL_DIR/share/pixmaps" APPS_DIR="$INSTALL_DIR/share/applications" @@ -99,16 +104,23 @@ do_install() { echo "Installing SessionPrep to $INSTALL_DIR ..." - mkdir -p "$BIN_DIR" "$APPS_DIR" "$PIXMAPS_DIR" + mkdir -p "$BIN_DIR" "$APPS_DIR" "$PIXMAPS_DIR" "$LIB_DIR" + + # Copy distribution folders contents into a flat directory + rm -rf "$LIB_DIR"/* + cp -R "$SCRIPT_DIR/$CLI_DIR"/* "$LIB_DIR/" + cp -R "$SCRIPT_DIR/$GUI_DIR"/* "$LIB_DIR/" + + # Symlink executables into PATH + ln -sf "$LIB_DIR/$CLI_BIN" "$BIN_DIR/$CLI_CMD" + ln -sf "$LIB_DIR/$GUI_BIN" "$BIN_DIR/$GUI_CMD" - 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" \ + sed "s|$DESKTOP_EXEC_PLACEHOLDER|Exec=$BIN_DIR/$GUI_CMD|g" \ "$SCRIPT_DIR/$DESKTOP_FILE" > "$tmp_desktop" chmod 644 "$tmp_desktop" mv "$tmp_desktop" "$APPS_DIR/$DESKTOP_FILE" @@ -120,8 +132,8 @@ do_install() { echo "" echo "Done." - echo " CLI: $BIN_DIR/$CLI_BIN" - echo " GUI: $BIN_DIR/$GUI_BIN" + echo " CLI: $BIN_DIR/$CLI_CMD" + echo " GUI: $BIN_DIR/$GUI_CMD" echo "" check_path } @@ -138,18 +150,24 @@ do_uninstall() { local found found=0 for f in \ - "$BIN_DIR/$CLI_BIN" \ - "$BIN_DIR/$GUI_BIN" \ + "$BIN_DIR/$CLI_CMD" \ + "$BIN_DIR/$GUI_CMD" \ "$PIXMAPS_DIR/$ICON_FILE" \ "$APPS_DIR/$DESKTOP_FILE" do - if [ -f "$f" ]; then + if [ -f "$f" ] || [ -L "$f" ]; then rm -f "$f" echo " Removed: $f" found=1 fi done + if [ -d "$LIB_DIR" ]; then + rm -rf "$LIB_DIR" + echo " Removed: $LIB_DIR" + found=1 + fi + if [ "$found" -eq 0 ]; then echo " Nothing found to remove in $INSTALL_DIR." else diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 180287d..2984bc7 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -13,15 +13,11 @@ 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.dist/" + dst: /opt/sessionprep/ - - src: "${DIST_DIR}/sessionprep-gui-linux-x64" - dst: /usr/local/bin/sessionprep-gui - file_info: - mode: 0755 + - src: "${DIST_DIR}/sessionprep-gui.dist/" + dst: /opt/sessionprep/ - src: sessionprepgui/res/sessionprep.png dst: /usr/local/share/pixmaps/sessionprep.png @@ -32,3 +28,11 @@ contents: dst: /usr/local/share/applications/sessionprep.desktop file_info: mode: 0644 + + - src: /opt/sessionprep/sessionprep-linux-x64 + dst: /usr/local/bin/sessionprep + type: symlink + + - src: /opt/sessionprep/sessionprep-gui-linux-x64 + dst: /usr/local/bin/sessionprep-gui + type: symlink From 714699d8db40e05ff8ac91240ef3de8ae167cb04 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 19:56:26 +0100 Subject: [PATCH 39/56] workflow fixes --- build_nuitka.py | 1 - packaging/linux/nfpm.yaml | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/build_nuitka.py b/build_nuitka.py index aa5f465..9f6fe52 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -128,7 +128,6 @@ def run_nuitka(target_key, clean=False): 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") cmd.append("--macos-create-app-bundle") cmd.append(f"--macos-app-name={MACOS_APP_NAME}") icon_path = target.get("icon") diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 2984bc7..e820a5a 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -15,9 +15,11 @@ license: GPL-3.0-or-later contents: - src: "${DIST_DIR}/sessionprep.dist/" dst: /opt/sessionprep/ + type: tree - src: "${DIST_DIR}/sessionprep-gui.dist/" dst: /opt/sessionprep/ + type: tree - src: sessionprepgui/res/sessionprep.png dst: /usr/local/share/pixmaps/sessionprep.png From 110837d32420d1d627cfed2246dad6294e699a47 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 20:43:55 +0100 Subject: [PATCH 40/56] workflow fixes. --- .github/workflows/build-nuitka.yml | 8 ++++++-- packaging/linux/install-sessionprep.sh | 8 +++----- packaging/linux/nfpm.yaml | 4 ---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 3574d89..2571ec3 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -67,6 +67,11 @@ jobs: export VERSION=$(python -c \ "exec(open('sessionpreplib/_version.py').read()); print(__version__)") + + # Merge GUI dist into CLI dist so we have a single unified distribution directory + # This prevents nfpm from throwing "content collision" errors + cp -a dist_nuitka/sessionprep-gui.dist/. dist_nuitka/sessionprep.dist/ + export DIST_DIR=dist_nuitka TMPCONFIG=$(mktemp --suffix=.yaml) @@ -77,7 +82,6 @@ jobs: STAGING=$(mktemp -d) cp -r dist_nuitka/sessionprep.dist "$STAGING/sessionprep.dist" - cp -r dist_nuitka/sessionprep-gui.dist "$STAGING/sessionprep-gui.dist" # RPS binaries are now inside the .dist folders via python build script cp sessionprepgui/res/sessionprep.png "$STAGING/sessionprep.png" cp packaging/linux/sessionprep.desktop "$STAGING/sessionprep.desktop" @@ -85,7 +89,7 @@ jobs: chmod +x "$STAGING/install-sessionprep.sh" tar -czf "dist_nuitka/sessionprep-${VERSION}-linux-x64.tar.gz" \ -C "$STAGING" \ - sessionprep.dist sessionprep-gui.dist \ + sessionprep.dist \ sessionprep.png \ sessionprep.desktop install-sessionprep.sh diff --git a/packaging/linux/install-sessionprep.sh b/packaging/linux/install-sessionprep.sh index c955920..68e9b3a 100644 --- a/packaging/linux/install-sessionprep.sh +++ b/packaging/linux/install-sessionprep.sh @@ -16,8 +16,7 @@ set -euo pipefail # Constants — filenames and the placeholder in the bundled .desktop template # --------------------------------------------------------------------------- -readonly CLI_DIR="sessionprep.dist" -readonly GUI_DIR="sessionprep-gui.dist" +readonly DIST_DIR="sessionprep.dist" readonly CLI_BIN="sessionprep-linux-x64" readonly GUI_BIN="sessionprep-gui-linux-x64" readonly CLI_CMD="sessionprep" @@ -43,7 +42,7 @@ die() { # Check that all source files are present next to this script. validate_sources() { local missing=0 - for f in "$CLI_DIR" "$GUI_DIR" "$ICON_FILE" "$DESKTOP_FILE"; do + for f in "$DIST_DIR" "$ICON_FILE" "$DESKTOP_FILE"; do if [ ! -e "$SCRIPT_DIR/$f" ]; then echo " Missing source file: $SCRIPT_DIR/$f" >&2 missing=1 @@ -108,8 +107,7 @@ do_install() { # Copy distribution folders contents into a flat directory rm -rf "$LIB_DIR"/* - cp -R "$SCRIPT_DIR/$CLI_DIR"/* "$LIB_DIR/" - cp -R "$SCRIPT_DIR/$GUI_DIR"/* "$LIB_DIR/" + cp -R "$SCRIPT_DIR/$DIST_DIR"/* "$LIB_DIR/" # Symlink executables into PATH ln -sf "$LIB_DIR/$CLI_BIN" "$BIN_DIR/$CLI_CMD" diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e820a5a..d4088ca 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -17,10 +17,6 @@ contents: dst: /opt/sessionprep/ type: tree - - src: "${DIST_DIR}/sessionprep-gui.dist/" - dst: /opt/sessionprep/ - type: tree - - src: sessionprepgui/res/sessionprep.png dst: /usr/local/share/pixmaps/sessionprep.png file_info: From feaeaaea456ba1e0eb3372743618db66116c2fc2 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 21:55:09 +0100 Subject: [PATCH 41/56] save session in phase 1. --- sessionprepgui/analysis/mixin.py | 1 + 1 file changed, 1 insertion(+) diff --git a/sessionprepgui/analysis/mixin.py b/sessionprepgui/analysis/mixin.py index 88ffbcb..c8a6d61 100644 --- a/sessionprepgui/analysis/mixin.py +++ b/sessionprepgui/analysis/mixin.py @@ -377,6 +377,7 @@ def _on_phase1_done(self, session): "review layout, then click Apply" ) self._right_stack.setCurrentIndex(_PAGE_TABS) + self._save_session_action.setEnabled(True) self.setWindowTitle("SessionPrep") @Slot() From 638266cedd516bb4846d560c5f30f2e245d8be1a Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 22:02:14 +0100 Subject: [PATCH 42/56] build fixes. --- .github/workflows/build-nuitka.yml | 95 +++++++++++++++++------------- packaging/windows/sessionprep.iss | 11 ++-- 2 files changed, 62 insertions(+), 44 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 2571ec3..1f219c6 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -5,12 +5,30 @@ on: jobs: build: - name: Build on ${{ matrix.os }} + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest, macos-15-intel] + include: + - os: ubuntu-latest + name: Linux x64 + suffix: linux-x64 + - os: ubuntu-24.04-arm + name: Linux arm64 + suffix: linux-arm64 + - os: macos-15 + name: macOS Apple Silicon + suffix: macos-arm64 + - os: macos-15-large + name: macOS Intel + suffix: macos-x64 + - os: windows-latest + name: Windows x64 + suffix: win-x64 + - os: windows-11-arm + name: Windows arm64 + suffix: win-arm64 steps: - uses: actions/checkout@v4 @@ -41,10 +59,10 @@ jobs: ~/.cache/ccache ~/Library/Caches/ccache ~/AppData/Local/ccache - key: ${{ runner.os }}-nuitka-${{ hashFiles('**/uv.lock') }}-${{ github.run_id }} + key: ${{ runner.os }}-${{ matrix.suffix }}-nuitka-${{ hashFiles('**/uv.lock') }}-${{ github.run_id }} restore-keys: | - ${{ runner.os }}-nuitka-${{ hashFiles('**/uv.lock') }}- - ${{ runner.os }}-nuitka- + ${{ runner.os }}-${{ matrix.suffix }}-nuitka-${{ hashFiles('**/uv.lock') }}- + ${{ runner.os }}-${{ matrix.suffix }}-nuitka- - name: Install Dependencies run: uv sync --extra cli --extra gui @@ -57,6 +75,19 @@ jobs: - name: Build with Nuitka run: uv run python build_nuitka.py all + # ----------------------------------------------------------------------- + # Determine version (used by all packaging steps) + # ----------------------------------------------------------------------- + - name: Determine version + id: version + shell: bash + run: | + VER=$(python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)") + echo "ver=$VER" >> "$GITHUB_OUTPUT" + + # ----------------------------------------------------------------------- + # Linux packaging + # ----------------------------------------------------------------------- - name: Package Linux Distributions if: runner.os == 'Linux' shell: bash @@ -65,15 +96,13 @@ jobs: | 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__)") + VERSION="${{ steps.version.outputs.ver }}" + export VERSION DIST_DIR=dist_nuitka # Merge GUI dist into CLI dist so we have a single unified distribution directory # This prevents nfpm from throwing "content collision" errors cp -a dist_nuitka/sessionprep-gui.dist/. dist_nuitka/sessionprep.dist/ - export DIST_DIR=dist_nuitka - TMPCONFIG=$(mktemp --suffix=.yaml) envsubst < packaging/linux/nfpm.yaml > "$TMPCONFIG" nfpm package --config "$TMPCONFIG" --packager deb --target dist_nuitka/ @@ -82,74 +111,60 @@ jobs: STAGING=$(mktemp -d) cp -r dist_nuitka/sessionprep.dist "$STAGING/sessionprep.dist" - # RPS binaries are now inside the .dist folders via python build script 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" \ + tar -czf "dist_nuitka/sessionprep-${VERSION}-${{ matrix.suffix }}.tar.gz" \ -C "$STAGING" \ sessionprep.dist \ sessionprep.png \ sessionprep.desktop install-sessionprep.sh + # ----------------------------------------------------------------------- + # Windows packaging + # ----------------------------------------------------------------------- - name: Build InnoSetup Installer if: runner.os == 'Windows' shell: pwsh run: | - $ver = (python -c "exec(open('sessionpreplib/_version.py').read()); print(__version__)").Trim() + $ver = "${{ steps.version.outputs.ver }}" & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" ` "/DAPP_VERSION=$ver" ` "/DDIST_DIR=dist_nuitka" ` + "/DARCH_SUFFIX=${{ matrix.suffix }}" ` "packaging\windows\sessionprep.iss" + # ----------------------------------------------------------------------- + # macOS packaging + # ----------------------------------------------------------------------- - name: Package macOS .app bundles as DMG if: runner.os == 'macOS' run: | brew install create-dmg - SUFFIX=$(python3 -c "from build_conf import get_platform_suffix; print(get_platform_suffix())") + VERSION="${{ steps.version.outputs.ver }}" for app in dist_nuitka/*.app; do [ -d "$app" ] || continue name=$(basename "$app" .app) - dmg_name="${name}-${SUFFIX}" - # create-dmg uses source folder contents as DMG root, - # so stage the .app inside a temporary directory STAGING=$(mktemp -d) cp -R "$app" "$STAGING/" create-dmg \ --volname "$name" \ --app-drop-link 600 185 \ --sandbox-safe \ - "dist_nuitka/${dmg_name}.dmg" \ + "dist_nuitka/sessionprep-${VERSION}-${{ matrix.suffix }}.dmg" \ "$STAGING" rm -rf "$STAGING" done - # Clean up temporary read-write DMG files left by create-dmg rm -f dist_nuitka/rw.*.dmg - - name: Upload Artifacts (Windows) - if: runner.os == 'Windows' - uses: actions/upload-artifact@v4 - with: - name: sessionprep-${{ matrix.os }} - path: dist_nuitka/SessionPrep-*-setup.exe - 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' + # ----------------------------------------------------------------------- + # Upload + # ----------------------------------------------------------------------- + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: sessionprep-${{ matrix.os }} + name: sessionprep-${{ matrix.suffix }} path: | - dist_nuitka/*.deb - dist_nuitka/*.rpm - dist_nuitka/*.tar.gz + dist_nuitka/sessionprep-*.* if-no-files-found: error diff --git a/packaging/windows/sessionprep.iss b/packaging/windows/sessionprep.iss index c6f0b88..944c98d 100644 --- a/packaging/windows/sessionprep.iss +++ b/packaging/windows/sessionprep.iss @@ -1,7 +1,7 @@ ; SessionPrep Windows Installer (Inno Setup 6) ; ; Build from repo root: -; ISCC /DAPP_VERSION=x.y.z /DDIST_DIR=dist_nuitka packaging\windows\sessionprep.iss +; ISCC /DAPP_VERSION=x.y.z /DDIST_DIR=dist_nuitka /DARCH_SUFFIX=win-x64 packaging\windows\sessionprep.iss ; --------------------------------------------------------------------------- ; Defines @@ -13,12 +13,15 @@ #ifndef DIST_DIR #define DIST_DIR "dist_nuitka" #endif +#ifndef ARCH_SUFFIX + #define ARCH_SUFFIX "win-x64" +#endif #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 AppExe "sessionprep-gui-" + ARCH_SUFFIX + ".exe" +#define AppCli "sessionprep-" + ARCH_SUFFIX + ".exe" #define AppIconSrc "..\..\sessionprepgui\res\sessionprep.ico" ; --------------------------------------------------------------------------- @@ -37,7 +40,7 @@ DefaultDirName={autopf}\{#AppName} DefaultGroupName={#AppName} OutputDir=..\..\{#DIST_DIR} -OutputBaseFilename={#AppName}-{#APP_VERSION}-setup +OutputBaseFilename=sessionprep-{#APP_VERSION}-{#ARCH_SUFFIX}-setup SetupIconFile={#AppIconSrc} UninstallDisplayIcon={app}\sessionprep.ico From 9408f5a60ee72999bbe654a9460b955060578faf Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Tue, 3 Mar 2026 23:57:02 +0100 Subject: [PATCH 43/56] color matrix --- sessionprepgui/prefs/config_pages.py | 61 +++++-- sessionprepgui/prefs/dialog.py | 5 +- sessionprepgui/prefs/page_colors.py | 71 +++++++- sessionprepgui/prefs/page_groups.py | 13 +- sessionprepgui/tracks/groups_mixin.py | 24 +-- sessionprepgui/widgets.py | 237 ++++++++++++++++++++++++++ 6 files changed, 376 insertions(+), 35 deletions(-) diff --git a/sessionprepgui/prefs/config_pages.py b/sessionprepgui/prefs/config_pages.py index 7c2f757..9492642 100644 --- a/sessionprepgui/prefs/config_pages.py +++ b/sessionprepgui/prefs/config_pages.py @@ -27,6 +27,8 @@ QWidget, ) +from ..widgets import ColorPickerButton + from .param_form import ( _build_param_page, _color_swatch_icon, @@ -58,9 +60,12 @@ class GroupsTableWidget(QWidget): groups_changed = Signal() - def __init__(self, color_provider: ColorProvider, parent=None): + def __init__(self, color_provider: ColorProvider, + all_colors_provider: Callable[[], list[dict[str, str]]] | None = None, + parent=None): super().__init__(parent) self._color_provider = color_provider + self._all_colors_provider = all_colors_provider self._init_ui() # ── UI setup ────────────────────────────────────────────────────── @@ -142,17 +147,15 @@ def _set_row(self, row: int, name: str, color: 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) + if self._all_colors_provider: + colors = self._all_colors_provider() + else: + color_names, argb_lookup = self._color_provider() + colors = [{"name": cn, "argb": argb_lookup(cn) or "#ff888888"} + for cn in color_names] + color_picker = ColorPickerButton(colors, self._table) + color_picker.setCurrentColor(color) + self._table.setCellWidget(row, 1, color_picker) chk = QCheckBox() chk.setChecked(gain_linked) @@ -187,8 +190,8 @@ def _read_groups(self) -> list[dict]: name = name_item.text().strip() if not name: continue - color_combo = self._table.cellWidget(row, 1) - color = color_combo.currentText() if color_combo else "" + color_picker = self._table.cellWidget(row, 1) + color = color_picker.currentColor() if color_picker else "" chk_container = self._table.cellWidget(row, 2) gain_linked = False if chk_container: @@ -221,7 +224,7 @@ def _read_groups_visual_order(self) -> list[dict]: if not name: continue cc = self._table.cellWidget(logical, 1) - color = cc.currentText() if cc else "" + color = cc.currentColor() if cc else "" chk_c = self._table.cellWidget(logical, 2) gl = False if chk_c: @@ -239,6 +242,34 @@ def _read_groups_visual_order(self) -> list[dict]: "match_method": mm, "match_pattern": mp}) return groups + # ── Live color refresh ──────────────────────────────────────────── + + def refresh_colors(self): + """Rebuild all ColorPickerButton widgets with fresh color data.""" + if self._all_colors_provider: + colors = self._all_colors_provider() + else: + color_names, argb_lookup = self._color_provider() + colors = [{"name": cn, "argb": argb_lookup(cn) or "#ff888888"} + for cn in color_names] + # Build lookup maps for resolving stale names + new_names = {c["name"] for c in colors if c["name"]} + argb_to_name = {c.get("argb", ""): c["name"] + for c in colors if c["name"]} + for row in range(self._table.rowCount()): + old_picker = self._table.cellWidget(row, 1) + current = old_picker.currentColor() if old_picker else "" + # If the assigned name no longer exists, try ARGB fallback + if current and current not in new_names and old_picker: + old_argb = old_picker._argb_map.get(current) + if old_argb and old_argb in argb_to_name: + current = argb_to_name[old_argb] + else: + current = "" + new_picker = ColorPickerButton(colors, self._table) + new_picker.setCurrentColor(current) + self._table.setCellWidget(row, 1, new_picker) + # ── Name dedup ──────────────────────────────────────────────────── @staticmethod diff --git a/sessionprepgui/prefs/dialog.py b/sessionprepgui/prefs/dialog.py index 5a3906d..0d62899 100644 --- a/sessionprepgui/prefs/dialog.py +++ b/sessionprepgui/prefs/dialog.py @@ -58,7 +58,10 @@ def __init__(self, config: dict[str, Any], parent=None): self._general_page = GeneralPage() self._colors_page = ColorsPage() self._groups_page = GroupsPage( - color_provider=self._colors_page.color_provider) + color_provider=self._colors_page.color_provider, + all_colors_provider=self._colors_page.all_colors_provider) + self._colors_page.colorsChanged.connect( + self._groups_page.refresh_colors) self._init_ui() diff --git a/sessionprepgui/prefs/page_colors.py b/sessionprepgui/prefs/page_colors.py index 28b89e7..e0e7bb2 100644 --- a/sessionprepgui/prefs/page_colors.py +++ b/sessionprepgui/prefs/page_colors.py @@ -5,7 +5,7 @@ import copy from typing import Callable -from PySide6.QtCore import Qt +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import ( QColorDialog, @@ -20,6 +20,7 @@ ) from .param_form import _argb_to_qcolor +from ..widgets import ColorGridPanel class ColorsPage(QWidget): @@ -32,6 +33,8 @@ class ColorsPage(QWidget): Also exposes color_provider() for GroupsPage to reference live data. """ + colorsChanged = Signal() + def __init__(self, parent=None): super().__init__(parent) self._init_ui() @@ -43,13 +46,16 @@ def load(self, config: dict) -> None: colors = config.get("colors", []) if not colors: colors = copy.deepcopy(PT_DEFAULT_COLORS) + self._table.blockSignals(True) self._table.setRowCount(len(colors)) for row, entry in enumerate(colors): self._set_color_row( row, entry.get("name", ""), entry.get("argb", "#ff888888")) + self._table.blockSignals(False) + self._refresh_preview() def commit(self, config: dict) -> None: - config["colors"] = self._read_colors() + config["colors"] = self._read_all_colors() # ── Color provider (for GroupsPage) ─────────────────────────────── @@ -57,6 +63,10 @@ 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 + def all_colors_provider(self) -> list[dict[str, str]]: + """Return full color list including empty-name entries.""" + return self._read_all_colors() + # ── UI setup ───────────────────────────────────────────────────── def _init_ui(self) -> None: @@ -86,6 +96,7 @@ def _init_ui(self) -> None: ch.setSectionResizeMode(2, QHeaderView.Fixed) ch.resizeSection(2, 60) self._table.cellDoubleClicked.connect(self._on_swatch_dbl_click) + self._table.keyPressEvent = self._table_key_press layout.addWidget(self._table, 1) btn_row = QHBoxLayout() @@ -103,6 +114,18 @@ def _init_ui(self) -> None: btn_row.addStretch() layout.addLayout(btn_row) + # Color grid preview + preview_label = QLabel("Palette Preview") + preview_label.setStyleSheet("color: #888; font-size: 9pt;") + layout.addWidget(preview_label) + self._grid_preview = ColorGridPanel(cell_height=22, parent=self) + self._grid_preview.colorClicked.connect(self._on_preview_clicked) + layout.addWidget(self._grid_preview) + + # Refresh preview when table contents change (name edits, etc.) + self._table.cellChanged.connect( + lambda _row, _col: self._refresh_preview()) + # ── Row helpers ─────────────────────────────────────────────────── def _set_color_row(self, row: int, name: str, argb: str) -> None: @@ -133,6 +156,19 @@ def _read_colors(self) -> list[dict[str, str]]: colors.append({"name": name, "argb": argb}) return colors + def _read_all_colors(self) -> list[dict[str, str]]: + """Read all color entries including those with empty names.""" + colors = [] + for row in range(self._table.rowCount()): + name_item = self._table.item(row, 1) + swatch_item = self._table.item(row, 2) + if not swatch_item: + continue + name = name_item.text().strip() if name_item else "" + argb = swatch_item.data(Qt.UserRole) or "#ff888888" + colors.append({"name": name, "argb": argb}) + return colors + def _color_names(self) -> list[str]: names = [] for row in range(self._table.rowCount()): @@ -168,6 +204,7 @@ def _on_swatch_dbl_click(self, row: int, col: int) -> None: item.setBackground(color) item.setData(Qt.UserRole, argb) item.setToolTip(argb) + self._refresh_preview() def _on_add(self) -> None: row = self._table.rowCount() @@ -175,15 +212,45 @@ def _on_add(self) -> None: self._set_color_row(row, "New Color", "#ff888888") self._table.scrollToBottom() self._table.editItem(self._table.item(row, 1)) + self._refresh_preview() def _on_remove(self) -> None: row = self._table.currentRow() if row >= 0: self._table.removeRow(row) + self._refresh_preview() def _on_reset(self) -> None: from ..theme import PT_DEFAULT_COLORS + self._table.blockSignals(True) 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"]) + self._table.blockSignals(False) + self._refresh_preview() + + def _refresh_preview(self): + """Rebuild the grid preview from the current table state.""" + self._grid_preview.set_colors(self._read_all_colors()) + self.colorsChanged.emit() + + def _on_preview_clicked(self, index: int): + """Select and scroll to the table row at *index*.""" + if 0 <= index < self._table.rowCount(): + self._table.selectRow(index) + item = self._table.item(index, 1) + if item: + self._table.scrollToItem(item) + + def _table_key_press(self, event): + """Clear the name column when Delete is pressed on a selected row.""" + if event.key() in (Qt.Key_Delete, Qt.Key_Backspace): + row = self._table.currentRow() + if row >= 0: + item = self._table.item(row, 1) + if item: + item.setText("") + self._refresh_preview() + return + QTableWidget.keyPressEvent(self._table, event) diff --git a/sessionprepgui/prefs/page_groups.py b/sessionprepgui/prefs/page_groups.py index a9872c3..db1f236 100644 --- a/sessionprepgui/prefs/page_groups.py +++ b/sessionprepgui/prefs/page_groups.py @@ -32,9 +32,11 @@ class GroupsPage(QWidget): live color table. """ - def __init__(self, color_provider: Callable, parent=None): + def __init__(self, color_provider: Callable, + all_colors_provider: Callable | None = None, parent=None): super().__init__(parent) self._color_provider = color_provider + self._all_colors_provider = all_colors_provider self._presets_data: dict[str, list[dict]] = {} self._init_ui() @@ -101,9 +103,16 @@ def _init_ui(self) -> None: layout.addLayout(reset_row) self._groups_widget = GroupsTableWidget( - color_provider=self._color_provider) + color_provider=self._color_provider, + all_colors_provider=self._all_colors_provider) layout.addWidget(self._groups_widget, 1) + # ── Color refresh ───────────────────────────────────────────────── + + def refresh_colors(self): + """Rebuild color pickers with the latest palette data.""" + self._groups_widget.refresh_colors() + # ── Preset helpers ──────────────────────────────────────────────── def _load_preset(self, name: str) -> None: diff --git a/sessionprepgui/tracks/groups_mixin.py b/sessionprepgui/tracks/groups_mixin.py index de7bec8..4afadd5 100644 --- a/sessionprepgui/tracks/groups_mixin.py +++ b/sessionprepgui/tracks/groups_mixin.py @@ -28,7 +28,7 @@ from ..settings import build_defaults, save_config from .table_widgets import _SortableItem from ..theme import COLORS, PT_DEFAULT_COLORS -from ..widgets import BatchComboBox +from ..widgets import BatchComboBox, ColorPickerButton class GroupsMixin: # pylint: disable=too-few-public-methods @@ -175,17 +175,11 @@ def _set_groups_tab_row(self, row: int, name: str, color: str, 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) + # Color picker button with grid popup + colors = self._config.get("colors", PT_DEFAULT_COLORS) + color_picker = ColorPickerButton(colors, self._groups_tab_table) + color_picker.setCurrentColor(color) + self._groups_tab_table.setCellWidget(row, 1, color_picker) # Gain-linked checkbox (centered) chk = QCheckBox() @@ -241,8 +235,8 @@ def _read_session_groups(self) -> list[dict]: 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 "" + color_picker = self._groups_tab_table.cellWidget(row, 1) + color = color_picker.currentColor() if color_picker else "" chk_container = self._groups_tab_table.cellWidget(row, 2) gain_linked = False if chk_container: @@ -380,7 +374,7 @@ def _on_groups_tab_row_moved(self, logical: int, old_visual: int, if not name: continue cc = table.cellWidget(log_idx, 1) - color = cc.currentText() if cc else "" + color = cc.currentColor() if cc else "" chk_c = table.cellWidget(log_idx, 2) gl = False if chk_c: diff --git a/sessionprepgui/widgets.py b/sessionprepgui/widgets.py index e5747ca..6f1780f 100644 --- a/sessionprepgui/widgets.py +++ b/sessionprepgui/widgets.py @@ -337,3 +337,240 @@ def mousePressEvent(self, event): # wrapper recreation when slots live on mixin classes. self.setProperty("_batch_mode", self.batch_mode) super().mousePressEvent(event) + + +# --------------------------------------------------------------------------- +# Grid-based color picker +# --------------------------------------------------------------------------- + +from PySide6.QtCore import Signal, QPoint +from PySide6.QtWidgets import QGridLayout, QPushButton, QFrame, QScrollArea + +import time as _time + +from .prefs.param_form import _argb_to_qcolor + + +def _contrast_text(qc: QColor) -> str: + """Return '#000000' or '#ffffff' depending on luminance of *qc*.""" + lum = 0.299 * qc.red() + 0.587 * qc.green() + 0.114 * qc.blue() + return "#000000" if lum > 128 else "#ffffff" + + +class _ColorCell(QPushButton): + """One cell in the color grid — shows color name on a colored background.""" + + def __init__(self, name: str, argb: str, selected: bool = False, + parent=None): + super().__init__(name, parent) + qc = _argb_to_qcolor(argb) + text_col = _contrast_text(qc) + rgb = f"rgb({qc.red()}, {qc.green()}, {qc.blue()})" + self.setFixedSize(75, 36) + self.setCursor(Qt.PointingHandCursor) + border = "2px solid #ffffff" if selected else "1px solid #222" + self.setStyleSheet( + f"QPushButton {{" + f" background-color: {rgb}; color: {text_col};" + f" border: {border}; border-radius: 2px;" + f" font-family: 'Segoe UI', 'Helvetica Neue', sans-serif;" + f" font-size: 8pt; padding: 1px 2px;" + f" text-align: center;" + f"}}" + f"QPushButton:hover {{" + f" border: 2px solid #ffffff;" + f"}}" + ) + self.color_name = name + + +class ColorGridPopup(QFrame): + """Popup frame showing colors in a fixed-column grid.""" + + colorSelected = Signal(str) + closed = Signal() + + COLUMNS = 23 + + def __init__(self, colors: list[dict[str, str]], + selected_name: str = "", + selected_argb: str = "", + parent=None): + super().__init__(parent, Qt.Popup | Qt.FramelessWindowHint) + self.setFrameShape(QFrame.Box) + self.setStyleSheet( + "ColorGridPopup {" + " background-color: #181818;" + " border: 2px solid #888;" + " border-radius: 3px;" + "}" + ) + + grid = QGridLayout(self) + grid.setContentsMargins(6, 6, 6, 6) + grid.setSpacing(2) + + # Check if selected_name matches any entry + has_name_match = any( + e.get("name") == selected_name for e in colors + ) if selected_name else False + + for i, entry in enumerate(colors): + name = entry.get("name", "") + argb = entry.get("argb", "#ff888888") + row, col = divmod(i, self.COLUMNS) + # Highlight by name if possible, otherwise by ARGB + if has_name_match: + is_selected = bool(name and name == selected_name) + else: + is_selected = bool(selected_argb and argb == selected_argb) + cell = _ColorCell(name, argb, selected=is_selected, parent=self) + cell.clicked.connect( + lambda _checked=False, n=name: self._on_pick(n)) + grid.addWidget(cell, row, col) + + def closeEvent(self, event): + self.closed.emit() + super().closeEvent(event) + + def _on_pick(self, name: str): + self.colorSelected.emit(name) + self.close() + + +class ColorPickerButton(QPushButton): + """Button that shows the current color and opens a grid popup on click. + + Drop-in replacement for the QComboBox color pickers in groups tables. + """ + + colorChanged = Signal(str) + + def __init__(self, colors: list[dict[str, str]], parent=None): + super().__init__(parent) + self._colors = colors + self._current = "" + self._argb_map: dict[str, str] = { + c["name"]: c.get("argb", "#ff888888") for c in colors + } + self.setCursor(Qt.PointingHandCursor) + self.clicked.connect(self._show_popup) + self._last_popup_close = 0.0 + self._update_appearance() + + def currentColor(self) -> str: + """Return the currently selected color name.""" + return self._current + + def setCurrentColor(self, name: str): + """Set the current color by name (no signal emitted).""" + self._current = name + self._update_appearance() + + def _update_appearance(self): + """Update button text and background to reflect the current color.""" + argb = self._argb_map.get(self._current) + if argb: + qc = _argb_to_qcolor(argb) + text_col = _contrast_text(qc) + rgb = f"rgb({qc.red()}, {qc.green()}, {qc.blue()})" + self.setText(self._current) + self.setStyleSheet( + f"QPushButton {{" + f" background-color: {rgb}; color: {text_col};" + f" border: 1px solid #555; border-radius: 2px;" + f" font-size: 8pt; padding: 2px 6px;" + f" text-align: left;" + f"}}" + f"QPushButton:hover {{ border: 1px solid #aaa; }}" + ) + else: + self.setText(self._current or "(no color)") + self.setStyleSheet( + "QPushButton { background-color: #3a3a3a; color: #dddddd;" + " border: 1px solid #555; border-radius: 2px;" + " font-size: 8pt; padding: 2px 6px; text-align: left; }" + "QPushButton:hover { border: 1px solid #aaa; }" + ) + + def _show_popup(self): + # Toggle: suppress reopening if popup just closed (Qt.Popup + # auto-closes on outside click before our handler runs) + if _time.monotonic() - self._last_popup_close < 0.3: + return + current_argb = self._argb_map.get(self._current, "") + popup = ColorGridPopup(self._colors, self._current, + selected_argb=current_argb, parent=self) + popup.colorSelected.connect(self._on_selected) + popup.closed.connect(self._on_popup_closed) + popup.adjustSize() + # Center the popup horizontally on the button + btn_center = self.mapToGlobal( + QPoint(self.width() // 2, self.height())) + popup_x = btn_center.x() - popup.sizeHint().width() // 2 + popup_y = btn_center.y() + # Clamp to screen bounds + screen = self.screen() + if screen: + geo = screen.availableGeometry() + popup_w = popup.sizeHint().width() + popup_h = popup.sizeHint().height() + popup_x = max(geo.x(), min(popup_x, geo.right() - popup_w)) + popup_y = max(geo.y(), min(popup_y, geo.bottom() - popup_h)) + popup.move(popup_x, popup_y) + popup.show() + + def _on_popup_closed(self): + self._last_popup_close = _time.monotonic() + + def _on_selected(self, name: str): + if name != self._current: + self._current = name + self._update_appearance() + self.colorChanged.emit(name) + + +class ColorGridPanel(QWidget): + """Embeddable read-only color grid preview. + + Shows colors in a 23-column matrix. Useful as a palette overview + in preference pages. Call ``set_colors()`` to refresh. + Cells stretch horizontally to fill available width. + """ + + colorClicked = Signal(int) + + COLUMNS = 23 + + def __init__(self, colors: list[dict[str, str]] | None = None, + cell_height: int = 22, parent=None): + super().__init__(parent) + self._cell_height = cell_height + self._layout = QGridLayout(self) + self._layout.setContentsMargins(4, 4, 4, 4) + self._layout.setSpacing(1) + if colors: + self._populate(colors) + + def set_colors(self, colors: list[dict[str, str]]): + """Refresh the grid with a new color list.""" + while self._layout.count(): + item = self._layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self._populate(colors) + + def _populate(self, colors: list[dict[str, str]]): + from PySide6.QtWidgets import QSizePolicy + for i, entry in enumerate(colors): + name = entry.get("name", "") + argb = entry.get("argb", "#ff888888") + row, col = divmod(i, self.COLUMNS) + cell = _ColorCell(name, argb, parent=self) + cell.setFixedHeight(self._cell_height) + cell.setMinimumWidth(20) + cell.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + cell.setCursor(Qt.PointingHandCursor) + cell.clicked.connect( + lambda _checked=False, idx=i: self.colorClicked.emit(idx)) + self._layout.addWidget(cell, row, col) From 7df4a1206c040f9e9a13ed3f28014e23f840b42f Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 00:05:09 +0100 Subject: [PATCH 44/56] pylint fixes. --- sessionprepgui/widgets.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/sessionprepgui/widgets.py b/sessionprepgui/widgets.py index 6f1780f..91e99c2 100644 --- a/sessionprepgui/widgets.py +++ b/sessionprepgui/widgets.py @@ -34,22 +34,27 @@ from __future__ import annotations -from PySide6.QtCore import Qt, QItemSelectionModel, QTimer -from PySide6.QtGui import QBrush, QColor, QPainter +import time as _time + +from PySide6.QtCore import Qt, QItemSelectionModel, QTimer, Signal, QPoint +from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import ( QApplication, QComboBox, + QFrame, + QGridLayout, QLabel, QProgressBar, + QPushButton, QStyle, QStyledItemDelegate, - QStyleOptionViewItem, QTableWidget, QToolButton, QVBoxLayout, QWidget, ) +from .prefs.param_form import _argb_to_qcolor from .theme import COLORS @@ -343,12 +348,6 @@ def mousePressEvent(self, event): # Grid-based color picker # --------------------------------------------------------------------------- -from PySide6.QtCore import Signal, QPoint -from PySide6.QtWidgets import QGridLayout, QPushButton, QFrame, QScrollArea - -import time as _time - -from .prefs.param_form import _argb_to_qcolor def _contrast_text(qc: QColor) -> str: From 36f9bae6dc2664005f0700eb34d2659352345076 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 08:26:56 +0100 Subject: [PATCH 45/56] pro tools color picker tool based on the color definitions. --- sessionprepgui/daw_tools/__init__.py | 1 + sessionprepgui/daw_tools/protools/__init__.py | 1 + .../daw_tools/protools/color_tool.py | 151 ++++++++++++++++++ sessionprepgui/daw_tools/protools/window.py | 114 +++++++++++++ sessionprepgui/mainwindow.py | 47 +++++- sessionprepgui/widgets.py | 23 ++- sessionpreplib/daw_processors/protools.py | 69 +------- sessionpreplib/daw_processors/ptsl_helpers.py | 116 +++++++++++++- 8 files changed, 447 insertions(+), 75 deletions(-) create mode 100644 sessionprepgui/daw_tools/__init__.py create mode 100644 sessionprepgui/daw_tools/protools/__init__.py create mode 100644 sessionprepgui/daw_tools/protools/color_tool.py create mode 100644 sessionprepgui/daw_tools/protools/window.py diff --git a/sessionprepgui/daw_tools/__init__.py b/sessionprepgui/daw_tools/__init__.py new file mode 100644 index 0000000..8bacab8 --- /dev/null +++ b/sessionprepgui/daw_tools/__init__.py @@ -0,0 +1 @@ +"""DAW-specific interactive utility tools.""" diff --git a/sessionprepgui/daw_tools/protools/__init__.py b/sessionprepgui/daw_tools/protools/__init__.py new file mode 100644 index 0000000..ba12bfb --- /dev/null +++ b/sessionprepgui/daw_tools/protools/__init__.py @@ -0,0 +1 @@ +"""Pro Tools interactive utility tools.""" diff --git a/sessionprepgui/daw_tools/protools/color_tool.py b/sessionprepgui/daw_tools/protools/color_tool.py new file mode 100644 index 0000000..bd03035 --- /dev/null +++ b/sessionprepgui/daw_tools/protools/color_tool.py @@ -0,0 +1,151 @@ +"""Color Picker tool for Pro Tools. + +Shows the SessionPrep color palette; clicking a color pushes it +to the selected track(s) in Pro Tools via PTSL. +""" + +from __future__ import annotations + + +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QVBoxLayout, + QWidget, +) + +from sessionpreplib.daw_processors import ptsl_helpers as ptslh + +from ...widgets import ColorGridPanel + + +class ColorTool(QWidget): + """Interactive color picker that pushes colors to Pro Tools.""" + + def __init__(self, config: dict, parent=None): + super().__init__(parent) + self._config = config + self._engine = None + self._pt_palette: list[str] = [] + self._init_ui() + self._load_palette() + + # ── UI ──────────────────────────────────────────────────────────── + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + + desc = QLabel( + "Click a color to apply it to the selected track(s) in Pro Tools. " + "Colors are perceptually matched to the Pro Tools palette." + ) + desc.setWordWrap(True) + desc.setStyleSheet("color: #aaa; font-size: 9pt; margin-bottom: 6px;") + layout.addWidget(desc) + + self._grid = ColorGridPanel( + cell_height=28, stretch_vertical=True, parent=self) + self._grid.colorClicked.connect(self._on_color_clicked) + layout.addWidget(self._grid) + + # Status bar + status_row = QHBoxLayout() + self._status = QLabel("") + self._status.setStyleSheet("color: #888; font-size: 8pt;") + status_row.addWidget(self._status) + status_row.addStretch() + layout.addLayout(status_row) + + # ── Public API ─────────────────────────────────────────────────── + + def set_engine(self, engine): + """Set or clear the PTSL engine.""" + self._engine = engine + self._pt_palette = [] + if engine is not None: + self._fetch_pt_palette() + + def update_config(self, config: dict): + """Refresh the palette grid from an updated config.""" + self._config = config + self._load_palette() + + # ── Internal ───────────────────────────────────────────────────── + + def _load_palette(self): + """Load the SessionPrep palette from config into the grid.""" + colors = self._config.get("colors", []) + self._grid.set_colors(colors) + + def _fetch_pt_palette(self): + """Fetch the Pro Tools track color palette via PTSL.""" + if self._engine is None: + return + try: + resp = ptslh.run_command( + self._engine, "CId_GetColorPalette", + {"color_palette_target": "CPTarget_Tracks"}) + self._pt_palette = (resp or {}).get("color_list", []) + count = len(self._pt_palette) + if count: + self._status.setText(f"PT palette loaded ({count} colors)") + self._status.setStyleSheet("color: #4caf50; font-size: 8pt;") + else: + self._status.setText( + f"PT palette empty (response: {resp})") + self._status.setStyleSheet("color: #ff9800; font-size: 8pt;") + except Exception as e: + self._status.setText(f"Failed to fetch PT palette: {e}") + self._status.setStyleSheet("color: #f44336; font-size: 8pt;") + + def _on_color_clicked(self, index: int): + """Handle a palette cell click — push color to Pro Tools.""" + if self._engine is None: + self._status.setText("Not connected to Pro Tools") + self._status.setStyleSheet("color: #f44336; font-size: 8pt;") + return + + colors = self._config.get("colors", []) + if index < 0 or index >= len(colors): + return + + entry = colors[index] + argb = entry.get("argb", "") + name = entry.get("name", "") + if not argb: + return + + # Fetch PT palette if not cached + if not self._pt_palette: + self._fetch_pt_palette() + if not self._pt_palette: + self._status.setText("No PT palette available") + self._status.setStyleSheet("color: #f44336; font-size: 8pt;") + return + + # Find closest PT palette match (0-based → 1-based for PT) + pt_index = ptslh.closest_palette_index(argb, self._pt_palette) + if pt_index is None: + self._status.setText("Could not match color") + self._status.setStyleSheet("color: #f44336; font-size: 8pt;") + return + + # Apply to selected tracks + try: + selected = ptslh.get_selected_track_names(self._engine) + if not selected: + self._status.setText("No tracks selected in Pro Tools") + self._status.setStyleSheet("color: #ff9800; font-size: 8pt;") + return + ptslh.set_track_color( + self._engine, color_index=pt_index + 1, + track_names=selected) + label = name or argb + self._status.setText( + f"Applied '{label}' → PT index {pt_index} " + f"({len(selected)} track{'s' if len(selected) != 1 else ''})") + self._status.setStyleSheet("color: #4caf50; font-size: 8pt;") + except Exception as e: + self._status.setText(f"Error: {e}") + self._status.setStyleSheet("color: #f44336; font-size: 8pt;") diff --git a/sessionprepgui/daw_tools/protools/window.py b/sessionprepgui/daw_tools/protools/window.py new file mode 100644 index 0000000..b0dd43b --- /dev/null +++ b/sessionprepgui/daw_tools/protools/window.py @@ -0,0 +1,114 @@ +"""Pro Tools Utils — standalone utility window. + +Hosts per-tool tabs and manages a shared PTSL engine connection. +""" + +from __future__ import annotations + +from PySide6.QtCore import Qt +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QLabel, + QPushButton, + QTabWidget, + QVBoxLayout, +) + +from .color_tool import ColorTool + + +class ProToolsUtilsWindow(QDialog): + """Detached utility window for Pro Tools interactive tools.""" + + def __init__(self, config: dict, parent=None): + super().__init__(parent) + self.setWindowTitle("Pro Tools Utils") + self.setMinimumSize(600, 300) + self.setAttribute(Qt.WA_DeleteOnClose, False) # reuse window + + self._config = config + self._engine = None + + self._init_ui() + + # ── UI ──────────────────────────────────────────────────────────── + + def _init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + + # Connection header + header = QHBoxLayout() + header.setSpacing(8) + + self._status_label = QLabel("Disconnected") + self._status_label.setStyleSheet("color: #aaa; font-size: 9pt;") + header.addWidget(self._status_label) + + header.addStretch() + + self._connect_btn = QPushButton("Connect") + self._connect_btn.clicked.connect(self._toggle_connection) + header.addWidget(self._connect_btn) + + layout.addLayout(header) + + # Tab widget for tools + self._tabs = QTabWidget() + self._tabs.setDocumentMode(True) + layout.addWidget(self._tabs, 1) + + # Register tools + self._color_tool = ColorTool(self._config, self) + self._tabs.addTab(self._color_tool, "Color Picker") + + # ── Connection management ──────────────────────────────────────── + + def _toggle_connection(self): + if self._engine is not None: + self._disconnect() + else: + self._connect() + + def _connect(self): + try: + from ptsl import Engine + self._engine = Engine( + company_name="SessionPrep", + application_name="Pro Tools Utils", + ) + self._status_label.setText("Connected") + self._status_label.setStyleSheet("color: #4caf50; font-size: 9pt;") + self._connect_btn.setText("Disconnect") + self._color_tool.set_engine(self._engine) + except Exception as e: + self._status_label.setText(f"Connection failed: {e}") + self._status_label.setStyleSheet("color: #f44336; font-size: 9pt;") + self._engine = None + + def _disconnect(self): + if self._engine is not None: + try: + self._engine.close() + except Exception: + pass + self._engine = None + self._status_label.setText("Disconnected") + self._status_label.setStyleSheet("color: #aaa; font-size: 9pt;") + self._connect_btn.setText("Connect") + self._color_tool.set_engine(None) + + def update_config(self, config: dict): + """Update the config (e.g. after preferences change).""" + self._config = config + self._color_tool.update_config(config) + + def showEvent(self, event): + super().showEvent(event) + if self._engine is None: + self._connect() + + def closeEvent(self, event): + self._disconnect() + super().closeEvent(event) diff --git a/sessionprepgui/mainwindow.py b/sessionprepgui/mainwindow.py index ff1280e..dd1cd16 100644 --- a/sessionprepgui/mainwindow.py +++ b/sessionprepgui/mainwindow.py @@ -11,13 +11,12 @@ from PySide6.QtCore import Qt, Slot, QSize from PySide6.QtGui import ( - QAction, QFont, QColor, QIcon, QKeySequence, QShortcut, + QAction, QFont, QIcon, QKeySequence, QShortcut, ) from PySide6.QtWidgets import ( QApplication, QComboBox, QFileDialog, - QHBoxLayout, QHeaderView, QLabel, QMainWindow, @@ -48,7 +47,7 @@ from .log import dbg from .prefs import PreferencesDialog from .detail import render_track_detail_html, PlaybackController, DetailMixin -from .waveform import WaveformWidget, WaveformPanel, WaveformLoadWorker +from .waveform import WaveformPanel, WaveformLoadWorker from .widgets import ProgressPanel from .analysis import ( AnalysisMixin, @@ -57,10 +56,9 @@ ) from .tracks import ( TrackColumnsMixin, GroupsMixin, - _HelpBrowser, _DraggableTrackTable, _SortableItem, - _TAB_SUMMARY, _TAB_FILE, _TAB_GROUPS, _TAB_SESSION, - _PAGE_PROGRESS, _PAGE_TABS, - _PHASE_ANALYSIS, _PHASE_TOPOLOGY, _PHASE_SETUP, + _HelpBrowser, _DraggableTrackTable, _TAB_FILE, _TAB_GROUPS, _TAB_SESSION, + _PAGE_TABS, + _PHASE_ANALYSIS, _PHASE_SETUP, ) from .daw import DawMixin from .topology import TopologyMixin @@ -109,6 +107,7 @@ def __init__(self): self._recursive_scan: bool = False self._session_config: dict[str, Any] | None = None self._session_widgets: dict[str, list[tuple[str, QWidget]]] = {} + self._pt_utils_window = None # singleton Pro Tools Utils window t0 = time.perf_counter() self._detector_help = detector_help_map() @@ -305,6 +304,14 @@ def _init_menus(self): quit_action.triggered.connect(self.close) file_menu.addAction(quit_action) + # ── Tools menu ──────────────────────────────────────────────── + tools_menu = self.menuBar().addMenu("&Tools") + + self._pt_utils_action = QAction("Pro Tools Utils\u2026", self) + self._pt_utils_action.triggered.connect(self._on_open_pt_utils) + tools_menu.addAction(self._pt_utils_action) + self._update_tools_menu() + @Slot() def _on_save_batch_queue(self): if not self._batch_dock.has_items: @@ -797,6 +804,11 @@ def _on_preferences(self): self._populate_daw_combo() self._daw_check_label.setText("") self._update_daw_lifecycle_buttons() + self._update_tools_menu() + + # Update Pro Tools Utils window if open + if self._pt_utils_window is not None: + self._pt_utils_window.update_config(self._config) if self._source_dir: from sessionpreplib.config import strip_presentation_keys @@ -843,6 +855,27 @@ def _on_preferences(self): f"HiDPI scale factor changed from {old_scale} to {new_scale}.\n" "Please restart SessionPrep for the new scaling to take effect.", ) + # ── Tools menu ───────────────────────────────────────────────────────── + + def _update_tools_menu(self): + """Enable/disable Tools menu entries based on active config.""" + preset = self._active_preset() + pt_section = preset.get("daw_processors", {}).get("protools", {}) + pt_enabled = pt_section.get("protools_enabled", False) + self._pt_utils_action.setEnabled(pt_enabled) + + @Slot() + def _on_open_pt_utils(self): + """Open (or activate) the Pro Tools Utils window.""" + from .daw_tools.protools.window import ProToolsUtilsWindow + if self._pt_utils_window is None: + self._pt_utils_window = ProToolsUtilsWindow( + self._config, parent=self) + else: + self._pt_utils_window.update_config(self._config) + self._pt_utils_window.show() + self._pt_utils_window.raise_() + self._pt_utils_window.activateWindow() @Slot() def _on_about(self): diff --git a/sessionprepgui/widgets.py b/sessionprepgui/widgets.py index 91e99c2..0029420 100644 --- a/sessionprepgui/widgets.py +++ b/sessionprepgui/widgets.py @@ -542,9 +542,11 @@ class ColorGridPanel(QWidget): COLUMNS = 23 def __init__(self, colors: list[dict[str, str]] | None = None, - cell_height: int = 22, parent=None): + cell_height: int = 22, stretch_vertical: bool = False, + parent=None): super().__init__(parent) self._cell_height = cell_height + self._stretch_vertical = stretch_vertical self._layout = QGridLayout(self) self._layout.setContentsMargins(4, 4, 4, 4) self._layout.setSpacing(1) @@ -561,15 +563,28 @@ def set_colors(self, colors: list[dict[str, str]]): def _populate(self, colors: list[dict[str, str]]): from PySide6.QtWidgets import QSizePolicy + num_rows = 0 for i, entry in enumerate(colors): name = entry.get("name", "") argb = entry.get("argb", "#ff888888") row, col = divmod(i, self.COLUMNS) + num_rows = max(num_rows, row + 1) cell = _ColorCell(name, argb, parent=self) - cell.setFixedHeight(self._cell_height) - cell.setMinimumWidth(20) - cell.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + if self._stretch_vertical: + # Override _ColorCell's setFixedSize — allow dynamic sizing + cell.setMinimumSize(20, self._cell_height) + cell.setMaximumSize(16777215, 16777215) # QWIDGETSIZE_MAX + cell.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + else: + cell.setFixedHeight(self._cell_height) + cell.setMinimumWidth(20) + cell.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) cell.setCursor(Qt.PointingHandCursor) cell.clicked.connect( lambda _checked=False, idx=i: self.colorClicked.emit(idx)) self._layout.addWidget(cell, row, col) + if self._stretch_vertical: + for r in range(num_rows): + self._layout.setRowStretch(r, 1) + for c in range(self.COLUMNS): + self._layout.setColumnStretch(c, 1) diff --git a/sessionpreplib/daw_processors/protools.py b/sessionpreplib/daw_processors/protools.py index aed5f38..bd27d4a 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -2,7 +2,7 @@ from __future__ import annotations -import math + import os import time from concurrent.futures import ThreadPoolExecutor, as_completed @@ -21,67 +21,12 @@ def dbg(msg: str) -> None: # type: ignore[misc] pass -def _parse_argb(argb: str) -> tuple[int, int, int]: - """Parse '#ffRRGGBB' ARGB hex string to (R, G, B) ints.""" - h = argb.lstrip("#") - if len(h) == 8: - return int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16) - if len(h) == 6: - return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) - return 128, 128, 128 - - -def _srgb_to_linear(c: float) -> float: - """Convert sRGB channel [0..1] to linear.""" - if c <= 0.04045: - return c / 12.92 - return ((c + 0.055) / 1.055) ** 2.4 - - -def _rgb_to_lab(r: int, g: int, b: int) -> tuple[float, float, float]: - """Convert sRGB (0-255) to CIE L*a*b* (D65 illuminant).""" - # sRGB → linear → XYZ (D65) - rl = _srgb_to_linear(r / 255.0) - gl = _srgb_to_linear(g / 255.0) - bl = _srgb_to_linear(b / 255.0) - x = (0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl) / 0.95047 - y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl - z = (0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl) / 1.08883 - - def f(t: float) -> float: - if t > 0.008856: - return t ** (1.0 / 3.0) - return 7.787 * t + 16.0 / 116.0 - - L = 116.0 * f(y) - 16.0 - a = 500.0 * (f(x) - f(y)) - b_ = 200.0 * (f(y) - f(z)) - return L, a, b_ - - -def _closest_palette_index( - target_argb: str, - palette: list[str], -) -> int | None: - """Find the palette index whose colour is perceptually closest. - - Uses CIE L*a*b* Euclidean distance. Returns ``None`` if palette - is empty. - """ - if not palette: - return None - tr, tg, tb = _parse_argb(target_argb) - tL, ta, tb_ = _rgb_to_lab(tr, tg, tb) - best_idx = 0 - best_dist = float("inf") - for idx, entry in enumerate(palette): - pr, pg, pb = _parse_argb(entry) - pL, pa, pb2 = _rgb_to_lab(pr, pg, pb) - dist = math.sqrt((tL - pL) ** 2 + (ta - pa) ** 2 + (tb_ - pb2) ** 2) - if dist < best_dist: - best_dist = dist - best_idx = idx - return best_idx +# Re-export color helpers from ptsl_helpers (private aliases for +# backward compatibility within this module). +_parse_argb = ptslh.parse_argb +_srgb_to_linear = ptslh.srgb_to_linear +_rgb_to_lab = ptslh.rgb_to_lab +_closest_palette_index = ptslh.closest_palette_index class ProToolsDawProcessor(DawProcessor): diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index c91dd59..480f718 100644 --- a/sessionpreplib/daw_processors/ptsl_helpers.py +++ b/sessionpreplib/daw_processors/ptsl_helpers.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import math import os from typing import Any @@ -170,16 +171,36 @@ def wait_for_host_ready(engine, timeout: float = 25.0, sleep_time: float = 0.5) def get_color_palette(engine, target: str = "CPTarget_Tracks") -> list[str]: """Fetch the Pro Tools color palette. Returns ``[]`` on failure.""" - from ptsl import PTSL_pb2 as pt try: resp = run_command( - engine, pt.CommandId.CId_GetColorPalette, + engine, "CId_GetColorPalette", {"color_palette_target": target}) return (resp or {}).get("color_list", []) except Exception: return [] +def get_selected_track_names(engine) -> list[str]: + """Return names of explicitly selected tracks in Pro Tools. + + Only returns tracks the user directly selected (``SetExplicitly``), + not implicit children of selected folders (``SetImplicitly``). + """ + from ptsl import PTSL_pb2 as pt + try: + resp = run_command( + engine, pt.CommandId.CId_GetTrackList, {}) + tracks = (resp or {}).get("track_list", []) + selected = [] + for t in tracks: + attrs = t.get("track_attributes", {}) + if attrs.get("is_selected") == "SetExplicitly": + selected.append(t["name"]) + return selected + except Exception: + return [] + + def get_session_audio_dir(engine) -> str: """Return the session's ``Audio Files`` folder path.""" session_ptx = engine.session_path() @@ -521,3 +542,94 @@ def set_track_volume_by_trackname( except Exception as e: dbg(f"Error in set_track_volume_by_trackname ({track_name}, {volume}): {e}") raise + + +# ── Color helpers ──────────────────────────────────────────────────── + + +def parse_argb(argb: str) -> tuple[int, int, int]: + """Parse '#ffRRGGBB' ARGB hex string to (R, G, B) ints.""" + h = argb.lstrip("#") + if len(h) == 8: + return int(h[2:4], 16), int(h[4:6], 16), int(h[6:8], 16) + if len(h) == 6: + return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return 128, 128, 128 + + +def srgb_to_linear(c: float) -> float: + """Convert sRGB channel [0..1] to linear.""" + if c <= 0.04045: + return c / 12.92 + return ((c + 0.055) / 1.055) ** 2.4 + + +def rgb_to_lab(r: int, g: int, b: int) -> tuple[float, float, float]: + """Convert sRGB (0-255) to CIE L*a*b* (D65 illuminant).""" + rl = srgb_to_linear(r / 255.0) + gl = srgb_to_linear(g / 255.0) + bl = srgb_to_linear(b / 255.0) + x = (0.4124564 * rl + 0.3575761 * gl + 0.1804375 * bl) / 0.95047 + y = 0.2126729 * rl + 0.7151522 * gl + 0.0721750 * bl + z = (0.0193339 * rl + 0.1191920 * gl + 0.9503041 * bl) / 1.08883 + + def f(t: float) -> float: + if t > 0.008856: + return t ** (1.0 / 3.0) + return 7.787 * t + 16.0 / 116.0 + + L = 116.0 * f(y) - 16.0 + a = 500.0 * (f(x) - f(y)) + b_ = 200.0 * (f(y) - f(z)) + return L, a, b_ + + +def closest_palette_index( + target_argb: str, + palette: list[str], +) -> int | None: + """Find the palette index whose colour is perceptually closest. + + Uses CIE L*a*b* Euclidean distance. Returns ``None`` if palette + is empty. + """ + if not palette: + return None + tr, tg, tb = parse_argb(target_argb) + tL, ta, tb_ = rgb_to_lab(tr, tg, tb) + best_idx = 0 + best_dist = float("inf") + for idx, entry in enumerate(palette): + pr, pg, pb = parse_argb(entry) + pL, pa, pb2 = rgb_to_lab(pr, pg, pb) + dist = math.sqrt((tL - pL) ** 2 + (ta - pa) ** 2 + (tb_ - pb2) ** 2) + if dist < best_dist: + best_dist = dist + best_idx = idx + return best_idx + + +# ── Track color ────────────────────────────────────────────────────── + + +def set_track_color( + engine, + color_index: int, + track_names: list[str] | None = None, + track_ids: list[str] | None = None, + batch_job_id: str | None = None, + progress: int = 0, +) -> dict: + """Set the color of one or more tracks by palette index. + + Either *track_names* or *track_ids* must be provided. + Returns the raw response dict. + """ + body: dict[str, Any] = {"color_index": color_index} + if track_names: + body["track_names"] = track_names + if track_ids: + body["track_ids"] = track_ids + return run_command( + engine, "CId_SetTrackColor", body, + batch_job_id=batch_job_id, progress=progress) From 2cd8370aee104ecc447a8e6fcb55627d7d0379a8 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 08:43:51 +0100 Subject: [PATCH 46/56] fixes. --- sessionprepgui/daw_tools/protools/window.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/sessionprepgui/daw_tools/protools/window.py b/sessionprepgui/daw_tools/protools/window.py index b0dd43b..e115a34 100644 --- a/sessionprepgui/daw_tools/protools/window.py +++ b/sessionprepgui/daw_tools/protools/window.py @@ -7,6 +7,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( + QCheckBox, QDialog, QHBoxLayout, QLabel, @@ -48,6 +49,11 @@ def _init_ui(self): header.addStretch() + self._on_top_cb = QCheckBox("Always on Top") + self._on_top_cb.setStyleSheet("color: #aaa; font-size: 8pt;") + self._on_top_cb.toggled.connect(self._toggle_on_top) + header.addWidget(self._on_top_cb) + self._connect_btn = QPushButton("Connect") self._connect_btn.clicked.connect(self._toggle_connection) header.addWidget(self._connect_btn) @@ -65,6 +71,15 @@ def _init_ui(self): # ── Connection management ──────────────────────────────────────── + def _toggle_on_top(self, checked: bool): + was_visible = self.isVisible() + if checked: + self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) + else: + self.setWindowFlags(self.windowFlags() & ~Qt.WindowStaysOnTopHint) + if was_visible: + self.show() + def _toggle_connection(self): if self._engine is not None: self._disconnect() From 3233471033b72feca0994755c382e1f2e2e7ee93 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 11:08:38 +0100 Subject: [PATCH 47/56] build fix --- .github/workflows/build-nuitka.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 1f219c6..ec94b94 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -64,8 +64,19 @@ jobs: ${{ runner.os }}-${{ matrix.suffix }}-nuitka-${{ hashFiles('**/uv.lock') }}- ${{ runner.os }}-${{ matrix.suffix }}-nuitka- + - name: Enable Windows Long Paths (ARM64 grpcio fix) + if: matrix.suffix == 'win-arm64' + shell: pwsh + run: | + reg add HKLM\SYSTEM\CurrentControlSet\Control\FileSystem /v LongPathsEnabled /t REG_DWORD /d 1 /f + git config --system core.longpaths true + - name: Install Dependencies run: uv sync --extra cli --extra gui + env: + # Shorten cache path on ARM64 to avoid MSVC C1083 with deeply + # nested grpcio source paths (no pre-built wheel available). + UV_CACHE_DIR: ${{ matrix.suffix == 'win-arm64' && 'C:\c' || '' }} - name: Disable Windows Defender for build directory if: runner.os == 'Windows' From 5356ce1b06be131c515dddb30ea8b570eacd8918 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 11:11:29 +0100 Subject: [PATCH 48/56] build fix --- .github/workflows/build-nuitka.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index ec94b94..fb8f446 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -72,11 +72,16 @@ jobs: git config --system core.longpaths true - name: Install Dependencies + if: matrix.suffix != 'win-arm64' + run: uv sync --extra cli --extra gui + + - name: Install Dependencies (ARM64 short cache) + if: matrix.suffix == 'win-arm64' run: uv sync --extra cli --extra gui env: # Shorten cache path on ARM64 to avoid MSVC C1083 with deeply # nested grpcio source paths (no pre-built wheel available). - UV_CACHE_DIR: ${{ matrix.suffix == 'win-arm64' && 'C:\c' || '' }} + UV_CACHE_DIR: C:\c - name: Disable Windows Defender for build directory if: runner.os == 'Windows' From ba39f8255f5f5704445970a9141710bb264c95cd Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 12:28:05 +0100 Subject: [PATCH 49/56] try clang on windows --- .github/workflows/build-nuitka.yml | 10 ++++++---- pyproject.toml | 15 ++++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index fb8f446..a9e62a5 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -20,7 +20,7 @@ jobs: - os: macos-15 name: macOS Apple Silicon suffix: macos-arm64 - - os: macos-15-large + - os: macos-15-intel name: macOS Intel suffix: macos-x64 - os: windows-latest @@ -75,12 +75,14 @@ jobs: if: matrix.suffix != 'win-arm64' run: uv sync --extra cli --extra gui - - name: Install Dependencies (ARM64 short cache) + - name: Install Dependencies (ARM64 — clang-cl) if: matrix.suffix == 'win-arm64' run: uv sync --extra cli --extra gui env: - # Shorten cache path on ARM64 to avoid MSVC C1083 with deeply - # nested grpcio source paths (no pre-built wheel available). + # Use clang-cl instead of MSVC cl.exe — handles long paths and + # ARM64 codegen better (grpcio has no pre-built wheel for ARM64). + CC: clang-cl + CXX: clang-cl UV_CACHE_DIR: C:\c - name: Disable Windows Defender for build directory diff --git a/pyproject.toml b/pyproject.toml index ed79731..758508a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,14 +3,10 @@ name = "sessionprep" dynamic = ["version"] description = "Batch audio analyzer and normalizer for mix session preparation" readme = "README.md" -license = {text = "GPL-3.0-or-later"} +license = { text = "GPL-3.0-or-later" } requires-python = ">=3.13,<3.14" -dependencies = [ - "numpy>=1.26", - "soundfile>=0.12", - "scipy>=1.12", -] +dependencies = ["numpy>=1.26", "soundfile>=0.12", "scipy>=1.12"] [project.scripts] sessionprep = "sessionprep:process_files" @@ -28,7 +24,12 @@ path = "sessionpreplib/_version.py" [tool.hatch.build.targets.wheel] packages = ["sessionpreplib", "sessionprepgui"] # Include the CLI script at the top level -include = ["sessionpreplib/**", "sessionprepgui/**", "sessionprep.py", "sessionprep-gui.py"] +include = [ + "sessionpreplib/**", + "sessionprepgui/**", + "sessionprep.py", + "sessionprep-gui.py", +] [tool.pytest.ini_options] testpaths = ["tests"] From 64e5c76f95c728f764d06fbd4c8da414d9ae9085 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 13:05:38 +0100 Subject: [PATCH 50/56] fixes --- .github/workflows/build-nuitka.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index a9e62a5..120bc48 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -18,17 +18,17 @@ jobs: name: Linux arm64 suffix: linux-arm64 - os: macos-15 - name: macOS Apple Silicon + name: macOS Apple arm64 suffix: macos-arm64 - os: macos-15-intel - name: macOS Intel + name: macOS x64 suffix: macos-x64 - os: windows-latest name: Windows x64 suffix: win-x64 - - os: windows-11-arm - name: Windows arm64 - suffix: win-arm64 + # - os: windows-11-arm + # name: Windows arm64 + # suffix: win-arm64 steps: - uses: actions/checkout@v4 From 0fc5a7f75bf9311a793bb36f8cdab64f943ec45f Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 13:20:05 +0100 Subject: [PATCH 51/56] try openssl for windows on arm --- .github/workflows/build-nuitka.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index 120bc48..e43bdb2 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -26,9 +26,9 @@ jobs: - os: windows-latest name: Windows x64 suffix: win-x64 - # - os: windows-11-arm - # name: Windows arm64 - # suffix: win-arm64 + - os: windows-11-arm + name: Windows arm64 + suffix: win-arm64 steps: - uses: actions/checkout@v4 @@ -71,18 +71,27 @@ jobs: reg add HKLM\SYSTEM\CurrentControlSet\Control\FileSystem /v LongPathsEnabled /t REG_DWORD /d 1 /f git config --system core.longpaths true + - name: Install system OpenSSL & zlib (ARM64 grpcio build) + if: matrix.suffix == 'win-arm64' + shell: pwsh + run: | + vcpkg install openssl:arm64-windows zlib:arm64-windows + $root = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows" + echo "INCLUDE=$root\include;$env:INCLUDE" >> $env:GITHUB_ENV + echo "LIB=$root\lib;$env:LIB" >> $env:GITHUB_ENV + - name: Install Dependencies if: matrix.suffix != 'win-arm64' run: uv sync --extra cli --extra gui - - name: Install Dependencies (ARM64 — clang-cl) + - name: Install Dependencies (ARM64 — system SSL) if: matrix.suffix == 'win-arm64' run: uv sync --extra cli --extra gui env: - # Use clang-cl instead of MSVC cl.exe — handles long paths and - # ARM64 codegen better (grpcio has no pre-built wheel for ARM64). - CC: clang-cl - CXX: clang-cl + # Bypass BoringSSL compilation (no ARM64 ASM support) — + # link against system OpenSSL/zlib from vcpkg instead. + GRPC_PYTHON_BUILD_SYSTEM_OPENSSL: "1" + GRPC_PYTHON_BUILD_SYSTEM_ZLIB: "1" UV_CACHE_DIR: C:\c - name: Disable Windows Defender for build directory From 39c65cd47d507fab4de800f7ce436180e4773ca7 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 16:04:58 +0100 Subject: [PATCH 52/56] build fix --- .github/workflows/build-nuitka.yml | 27 --------------------------- pyproject.toml | 5 ++++- uv.lock | 4 ++-- 3 files changed, 6 insertions(+), 30 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index e43bdb2..b10fd7e 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -64,35 +64,8 @@ jobs: ${{ runner.os }}-${{ matrix.suffix }}-nuitka-${{ hashFiles('**/uv.lock') }}- ${{ runner.os }}-${{ matrix.suffix }}-nuitka- - - name: Enable Windows Long Paths (ARM64 grpcio fix) - if: matrix.suffix == 'win-arm64' - shell: pwsh - run: | - reg add HKLM\SYSTEM\CurrentControlSet\Control\FileSystem /v LongPathsEnabled /t REG_DWORD /d 1 /f - git config --system core.longpaths true - - - name: Install system OpenSSL & zlib (ARM64 grpcio build) - if: matrix.suffix == 'win-arm64' - shell: pwsh - run: | - vcpkg install openssl:arm64-windows zlib:arm64-windows - $root = "$env:VCPKG_INSTALLATION_ROOT\installed\arm64-windows" - echo "INCLUDE=$root\include;$env:INCLUDE" >> $env:GITHUB_ENV - echo "LIB=$root\lib;$env:LIB" >> $env:GITHUB_ENV - - name: Install Dependencies - if: matrix.suffix != 'win-arm64' - run: uv sync --extra cli --extra gui - - - name: Install Dependencies (ARM64 — system SSL) - if: matrix.suffix == 'win-arm64' run: uv sync --extra cli --extra gui - env: - # Bypass BoringSSL compilation (no ARM64 ASM support) — - # link against system OpenSSL/zlib from vcpkg instead. - GRPC_PYTHON_BUILD_SYSTEM_OPENSSL: "1" - GRPC_PYTHON_BUILD_SYSTEM_ZLIB: "1" - UV_CACHE_DIR: C:\c - name: Disable Windows Defender for build directory if: runner.os == 'Windows' diff --git a/pyproject.toml b/pyproject.toml index 758508a..51d0c6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,10 @@ cli = ["rich>=13.0"] gui = [ "PySide6>=6.10.2", "sounddevice>=0.5.5", - "py-ptsl>=600.2.0", + # FIXME: grpcio has no ARM64 Windows wheel and cannot be compiled from + # source with MSVC on ARM64 (grpc/grpc#39362, grpc/grpc#39624). + # Remove the platform_machine marker once grpcio publishes win_arm64 wheels. + "py-ptsl>=600.2.0; platform_machine != 'ARM64'", "dawproject @ git+https://github.com/roex-audio/dawproject-py.git@70e65aeb7b260cfec3871ca89ca8d80022c44496", ] diff --git a/uv.lock b/uv.lock index e524722..39bdb39 100644 --- a/uv.lock +++ b/uv.lock @@ -619,7 +619,7 @@ cli = [ ] gui = [ { name = "dawproject" }, - { name = "py-ptsl" }, + { name = "py-ptsl", marker = "platform_machine != 'ARM64'" }, { name = "pyside6" }, { name = "sounddevice" }, ] @@ -641,7 +641,7 @@ dev = [ requires-dist = [ { name = "dawproject", marker = "extra == 'gui'", git = "https://github.com/roex-audio/dawproject-py.git?rev=70e65aeb7b260cfec3871ca89ca8d80022c44496" }, { name = "numpy", specifier = ">=1.26" }, - { name = "py-ptsl", marker = "extra == 'gui'", specifier = ">=600.2.0" }, + { name = "py-ptsl", marker = "platform_machine != 'ARM64' and extra == 'gui'", specifier = ">=600.2.0" }, { name = "pyside6", marker = "extra == 'gui'", specifier = ">=6.10.2" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0" }, { name = "scipy", specifier = ">=1.12" }, From 79d5b9e05c007eb18a42b3829f809017132fd6a9 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 17:14:54 +0100 Subject: [PATCH 53/56] build fix --- .github/workflows/build-nuitka.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index b10fd7e..b9f7855 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -167,4 +167,8 @@ jobs: name: sessionprep-${{ matrix.suffix }} path: | dist_nuitka/sessionprep-*.* + !dist_nuitka/sessionprep-*.build + !dist_nuitka/sessionprep-*.build/** + !dist_nuitka/sessionprep-*.dist + !dist_nuitka/sessionprep-*.dist/** if-no-files-found: error From 0b2f6a6d1522dbc5a32efb489ffbafb76943c922 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 17:22:50 +0100 Subject: [PATCH 54/56] always on top fix. --- sessionprepgui/daw_tools/protools/window.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/sessionprepgui/daw_tools/protools/window.py b/sessionprepgui/daw_tools/protools/window.py index e115a34..c7e4503 100644 --- a/sessionprepgui/daw_tools/protools/window.py +++ b/sessionprepgui/daw_tools/protools/window.py @@ -72,12 +72,18 @@ def _init_ui(self): # ── Connection management ──────────────────────────────────────── def _toggle_on_top(self, checked: bool): + geo = self.geometry() was_visible = self.isVisible() + flags = self.windowFlags() if checked: - self.setWindowFlags(self.windowFlags() | Qt.WindowStaysOnTopHint) + flags |= Qt.WindowStaysOnTopHint else: - self.setWindowFlags(self.windowFlags() & ~Qt.WindowStaysOnTopHint) + flags &= ~Qt.WindowStaysOnTopHint + # Ensure standard title-bar buttons survive the flag change + flags |= Qt.WindowCloseButtonHint | Qt.WindowMinMaxButtonsHint + self.setWindowFlags(flags) if was_visible: + self.setGeometry(geo) self.show() def _toggle_connection(self): From b60501403803fd91ea458212b801b901d7865b8f Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 17:24:11 +0100 Subject: [PATCH 55/56] keyboardinterrupt fix --- sessionprepgui/topology/output_tree.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/sessionprepgui/topology/output_tree.py b/sessionprepgui/topology/output_tree.py index 0b6879c..1455a59 100644 --- a/sessionprepgui/topology/output_tree.py +++ b/sessionprepgui/topology/output_tree.py @@ -622,14 +622,17 @@ def _update_insert_line(self, item, pos_y: int) -> None: self.viewport().update() def paintEvent(self, event): - super().paintEvent(event) - if self._insert_line_y is not None: - painter = QPainter(self.viewport()) - pen = QPen(QColor(255, 255, 255, 200), 2) - painter.setPen(pen) - w = self.viewport().width() - painter.drawLine(0, self._insert_line_y, w, self._insert_line_y) - painter.end() + try: + super().paintEvent(event) + if self._insert_line_y is not None: + painter = QPainter(self.viewport()) + pen = QPen(QColor(255, 255, 255, 200), 2) + painter.setPen(pen) + w = self.viewport().width() + painter.drawLine(0, self._insert_line_y, w, self._insert_line_y) + painter.end() + except KeyboardInterrupt: + pass # ------------------------------------------------------------------ # Drop handling From 1aa6b41a76deccab3a77101f2997bea7d2f0e609 Mon Sep 17 00:00:00 2001 From: Benjamin Zeiss Date: Wed, 4 Mar 2026 17:59:03 +0100 Subject: [PATCH 56/56] build fixes --- .github/workflows/build-nuitka.yml | 9 ++++++++- packaging/linux/nfpm.yaml | 8 ++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index b9f7855..33a91f0 100644 --- a/.github/workflows/build-nuitka.yml +++ b/.github/workflows/build-nuitka.yml @@ -97,7 +97,14 @@ jobs: sudo apt-get update -q && sudo apt-get install -y nfpm VERSION="${{ steps.version.outputs.ver }}" - export VERSION DIST_DIR=dist_nuitka + SUFFIX="${{ matrix.suffix }}" + # Map matrix suffix to nfpm arch (deb/rpm format) + case "$SUFFIX" in + linux-x64) NFPM_ARCH=amd64 ;; + linux-arm64) NFPM_ARCH=arm64 ;; + *) NFPM_ARCH=amd64 ;; + esac + export VERSION SUFFIX NFPM_ARCH DIST_DIR=dist_nuitka # Merge GUI dist into CLI dist so we have a single unified distribution directory # This prevents nfpm from throwing "content collision" errors diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index d4088ca..fedaa6f 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -6,11 +6,11 @@ name: sessionprep version: "${VERSION}" -arch: amd64 +arch: "${NFPM_ARCH}" 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 +license: LGPL-3.0-or-later contents: - src: "${DIST_DIR}/sessionprep.dist/" @@ -27,10 +27,10 @@ contents: file_info: mode: 0644 - - src: /opt/sessionprep/sessionprep-linux-x64 + - src: /opt/sessionprep/sessionprep-${SUFFIX} dst: /usr/local/bin/sessionprep type: symlink - - src: /opt/sessionprep/sessionprep-gui-linux-x64 + - src: /opt/sessionprep/sessionprep-gui-${SUFFIX} dst: /usr/local/bin/sessionprep-gui type: symlink