diff --git a/.github/workflows/build-nuitka.yml b/.github/workflows/build-nuitka.yml index d6464fd..33a91f0 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 arm64 + suffix: macos-arm64 + - os: macos-15-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 steps: - uses: actions/checkout@v4 @@ -27,17 +45,24 @@ 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 }}-${{ matrix.suffix }}-nuitka-${{ hashFiles('**/uv.lock') }}-${{ github.run_id }} restore-keys: | - ${{ 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 @@ -50,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 @@ -58,9 +96,19 @@ 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__)") - export DIST_DIR=dist_nuitka + VERSION="${{ steps.version.outputs.ver }}" + 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 + cp -a dist_nuitka/sessionprep-gui.dist/. dist_nuitka/sessionprep.dist/ TMPCONFIG=$(mktemp --suffix=.yaml) envsubst < packaging/linux/nfpm.yaml > "$TMPCONFIG" @@ -69,77 +117,65 @@ 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 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 sessionprep-gui sessionprep.png \ + 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' + # ----------------------------------------------------------------------- + # Upload + # ----------------------------------------------------------------------- + - name: Upload Artifacts uses: actions/upload-artifact@v4 with: - name: sessionprep-${{ matrix.os }} + name: sessionprep-${{ matrix.suffix }} path: | - dist_nuitka/sessionprep-win-x64.exe - dist_nuitka/sessionprep-gui-win-x64.exe - dist_nuitka/SessionPrep-*-setup.exe + dist_nuitka/sessionprep-*.* + !dist_nuitka/sessionprep-*.build + !dist_nuitka/sessionprep-*.build/** + !dist_nuitka/sessionprep-*.dist + !dist_nuitka/sessionprep-*.dist/** if-no-files-found: error - - - name: Upload Artifacts (macOS) - if: runner.os == 'macOS' - uses: actions/upload-artifact@v4 - with: - name: sessionprep-${{ matrix.os }} - path: dist_nuitka/*.dmg - if-no-files-found: error - - - name: Upload Artifacts (Linux) - if: runner.os == 'Linux' - uses: actions/upload-artifact@v4 - with: - name: sessionprep-${{ matrix.os }} - path: | - dist_nuitka/*.deb - dist_nuitka/*.rpm - dist_nuitka/*.tar.gz - if-no-files-found: error \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1b73a0e..83e7a15 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,11 @@ 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/* + +GEMINI.md + # Temporary build directories _dawproject_build/ _dawproject_build/* diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..cde8d29 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,30 @@ +[MASTER] +ignore=CVS +ignore-patterns= +persistent=yes +load-plugins= +jobs=1 +unsafe-load-any-extension=no +extension-pkg-allow-list=PySide6 + +[MESSAGES CONTROL] +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] +output-format=text +reports=no +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +[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/.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/.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/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..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 @@ -314,9 +298,9 @@ ## In Development +## Done + ### InnoSetup Installer - defaultExpanded: false -## Done - diff --git a/README.md b/README.md index 13d45ed..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). | 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/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 diff --git a/build_nuitka.py b/build_nuitka.py index 5cc1e1c..9f6fe52 100644 --- a/build_nuitka.py +++ b/build_nuitka.py @@ -12,22 +12,89 @@ 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 - + # 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] 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 +104,21 @@ 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", ] - + + # 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"]: if sys.platform == "win32": @@ -59,14 +128,11 @@ 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") 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}") @@ -95,15 +161,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": - 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. @@ -119,7 +177,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") @@ -132,7 +194,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] @@ -144,4 +209,4 @@ def main(): sys.exit(1) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/packaging/linux/install-sessionprep.sh b/packaging/linux/install-sessionprep.sh index f1dd6a0..68e9b3a 100644 --- a/packaging/linux/install-sessionprep.sh +++ b/packaging/linux/install-sessionprep.sh @@ -16,8 +16,11 @@ set -euo pipefail # Constants — filenames and the placeholder in the bundled .desktop template # --------------------------------------------------------------------------- -readonly CLI_BIN="sessionprep" -readonly GUI_BIN="sessionprep-gui" +readonly DIST_DIR="sessionprep.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 +42,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 "$DIST_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 +89,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 +103,22 @@ 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/$DIST_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 +130,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 +148,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..fedaa6f 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -6,22 +6,16 @@ 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-linux-x64" - dst: /usr/local/bin/sessionprep - file_info: - mode: 0755 - - - src: "${DIST_DIR}/sessionprep-gui-linux-x64" - dst: /usr/local/bin/sessionprep-gui - file_info: - mode: 0755 + - src: "${DIST_DIR}/sessionprep.dist/" + dst: /opt/sessionprep/ + type: tree - src: sessionprepgui/res/sessionprep.png dst: /usr/local/share/pixmaps/sessionprep.png @@ -32,3 +26,11 @@ contents: dst: /usr/local/share/applications/sessionprep.desktop file_info: mode: 0644 + + - src: /opt/sessionprep/sessionprep-${SUFFIX} + dst: /usr/local/bin/sessionprep + type: symlink + + - src: /opt/sessionprep/sessionprep-gui-${SUFFIX} + dst: /usr/local/bin/sessionprep-gui + type: symlink diff --git a/packaging/windows/sessionprep.iss b/packaging/windows/sessionprep.iss index 3a93f41..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 @@ -79,15 +82,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/pyproject.toml b/pyproject.toml index 0d0bc35..51d0c6d 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"] @@ -39,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", ] @@ -53,4 +57,5 @@ dev = [ "zstandard>=0.25.0", "rich>=13.0", "patchelf>=0.17.2.4; sys_platform != 'win32'", + "pylint>=4.0.5", ] 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..45d3869 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: @@ -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) @@ -422,11 +423,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 3374a06..c8a6d61 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 @@ -42,10 +43,10 @@ _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: +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. @@ -112,7 +113,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 +153,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", {}), ) @@ -181,29 +182,75 @@ 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 _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._source_dir = path - self._topology_dir = None - self._track_table.set_source_dir(path) + 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 + 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() + 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: + 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, "_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) + + if getattr(self, "_waveform", None) is not None: + self._waveform.set_audio(None, 44100) - # Reset UI 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._track_table.set_source_dir(None) self._setup_table.setRowCount(0) self._summary_view.clear() self._file_report.clear() @@ -215,11 +262,36 @@ 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._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) + self._topo_input_tree.set_source_dir(path) app_cfg = self._config.get("app", {}) skip_folders = { @@ -253,21 +325,59 @@ 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 + 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() 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(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._save_session_action.setEnabled(True) self.setWindowTitle("SessionPrep") @Slot() @@ -282,22 +392,10 @@ def _on_save_session(self): ) if not path: return + try: - _save_session_file(path, { - "source_dir": self._source_dir, - "active_config_preset": self._active_config_preset_name, - "session_config": self._session_config, - "session_groups": self._session_groups, - "daw_state": self._session.daw_state, - "tracks": self._session.tracks, - "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, - }) + 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( @@ -305,6 +403,38 @@ 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() + + 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 [], + "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, + "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.""" @@ -326,6 +456,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( @@ -336,9 +470,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 @@ -364,33 +497,19 @@ 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 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() + # ── Reconstruct SessionContext from saved tracks ────────────────────── from sessionpreplib.models import SessionContext from sessionpreplib.rendering import build_diagnostic_summary @@ -438,9 +557,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 ───────────────────────────────── @@ -470,17 +596,15 @@ def _on_load_session(self): 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 @@ -534,6 +658,17 @@ def _on_load_session(self): 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: @@ -750,11 +885,17 @@ 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) 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 @@ -834,7 +975,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 @@ -895,7 +1038,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() @@ -929,7 +1074,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/analysis/worker.py b/sessionprepgui/analysis/worker.py index 784c01a..71ac61b 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): @@ -20,8 +21,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): @@ -35,16 +36,27 @@ 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): - super().__init__() + def __init__(self, processor: DawProcessor, session, parent=None): + super().__init__(parent) 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) @@ -57,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): - super().__init__() + 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) @@ -70,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" @@ -81,6 +94,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.""" @@ -122,8 +259,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 @@ -176,10 +313,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") @@ -316,26 +453,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: @@ -345,12 +506,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 @@ -381,18 +557,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/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..f07a7c8 --- /dev/null +++ b/sessionprepgui/batch/manager.py @@ -0,0 +1,253 @@ +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) + # 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__() + 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.started.emit() + 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.started.emit() + self._process_next() + + def _process_next(self): + if self._current_index >= len(self._queue): + 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: + 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.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 + + 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.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"])) + + # 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() + 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. + # 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 + + 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..42fd2fb --- /dev/null +++ b/sessionprepgui/batch/panel.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +import uuid + +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, + QMenu, + QMessageBox, + QProgressBar, + QPushButton, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, + QWidget, + QHeaderView, +) + +from ..theme import COLORS +from ..session.io import serialize_session_state, deserialize_session_state + + +@dataclass +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 = "" + + 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.""" + + 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.""" + + # Signals + 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) + self.setAllowedAreas(Qt.LeftDockWidgetArea | Qt.RightDockWidgetArea) + 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) + + # Table + self._table = _BatchTable() + self._table.setColumnCount(4) + self._table.setHorizontalHeaderLabels(["Project Name", "DAW", "Status", "Details"]) + 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) + + 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) + + # 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") + bottom_layout.addWidget(self._status_label) + + 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) + + 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) + + 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.""" + 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 in ("Pending", "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): + 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 + 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)") + + 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() + if pending: + self.run_batch_requested.emit(pending) + + @Slot(object) + def _on_context_menu(self, pos): + if self._is_running: + return + + 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 + + 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/daw/mixin.py b/sessionprepgui/daw/mixin.py index 502432a..936bee4 100644 --- a/sessionprepgui/daw/mixin.py +++ b/sessionprepgui/daw/mixin.py @@ -1,7 +1,9 @@ +# pylint: disable=too-many-lines """DAW integration mixin: processors, fetch, transfer, folder tree, assignments.""" from __future__ import annotations +import os from typing import Any from PySide6.QtCore import Qt, Slot, QSize, QTimer @@ -13,8 +15,10 @@ QHeaderView, QInputDialog, QLabel, + QLineEdit, QMenu, QMessageBox, + QPushButton, QSizePolicy, QSplitter, QStackedWidget, @@ -37,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. @@ -103,7 +107,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) @@ -183,6 +187,24 @@ 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) + + 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() self._folder_tree.setHeaderLabels(["Folder / Track"]) self._folder_tree.setSelectionMode(QTreeWidget.ExtendedSelection) @@ -241,6 +263,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 @@ -252,6 +286,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) @@ -273,13 +314,17 @@ 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._daw_check_worker = DawCheckWorker(self._active_daw_processor) + self._update_daw_lifecycle_buttons() + 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']};") @@ -304,30 +349,82 @@ 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).""" 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._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) 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): + 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: + self._transfer_progress.fail("Fetch aborted: Pro Tools session is open.") + 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 + # 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() + 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() + 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 = sanitized + # ── Use Processed checkbox ────────────────────────────────────────── @Slot(bool) @@ -356,8 +453,87 @@ 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 + + # 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 + 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", + 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", + 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 (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(): + 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, + daw_processor_name=self._active_daw_processor.name, + 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) @@ -395,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) + 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) @@ -413,7 +589,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) @@ -442,8 +621,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 @@ -454,7 +633,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() @@ -863,3 +1042,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/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..c7e4503 --- /dev/null +++ b/sessionprepgui/daw_tools/protools/window.py @@ -0,0 +1,135 @@ +"""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 ( + QCheckBox, + 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._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) + + 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_on_top(self, checked: bool): + geo = self.geometry() + was_visible = self.isVisible() + flags = self.windowFlags() + if checked: + flags |= Qt.WindowStaysOnTopHint + else: + 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): + 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/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 433d774..21b198e 100644 --- a/sessionprepgui/log.py +++ b/sessionprepgui/log.py @@ -20,15 +20,17 @@ import sys import time -_ENABLED: bool | None = None +class _LogConfig: # pylint: disable=too-few-public-methods + """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 bfa78b2..dd1cd16 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 @@ -10,12 +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, - QHBoxLayout, + QFileDialog, QHeaderView, QLabel, QMainWindow, @@ -46,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, @@ -55,16 +56,18 @@ ) 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 +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() @@ -104,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() @@ -115,6 +119,10 @@ def __init__(self): 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() self._config = load_config() @@ -144,6 +152,18 @@ def __init__(self): 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._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) + t0 = time.perf_counter() apply_dark_theme(self) dbg(f"apply_dark_theme: {(time.perf_counter() - t0) * 1000:.1f} ms") @@ -241,6 +261,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) @@ -249,6 +273,17 @@ 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) + 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 +304,158 @@ 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: + 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 + 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}") + + @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(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) + + @Slot() + 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) + def _init_analysis_toolbar(self): self._analysis_toolbar = QToolBar("Analysis") self._analysis_toolbar.setIconSize(QSize(16, 16)) @@ -617,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 @@ -663,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): @@ -677,6 +890,15 @@ def _on_about(self): ) def closeEvent(self, event): + if self._batch_dock.has_items: + reply = QMessageBox.warning( + self, "Pending Batch Items", + "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) @@ -709,7 +931,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/prefs/config_pages.py b/sessionprepgui/prefs/config_pages.py index 8b7be5c..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 ────────────────────────────────────────────────────── @@ -138,20 +143,19 @@ 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) - 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) @@ -186,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: @@ -210,7 +214,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) @@ -220,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: @@ -238,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 @@ -477,6 +509,91 @@ 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(2) + self._table.setHorizontalHeaderLabels(["Template Group", "Template Name"]) + gh = self._table.horizontalHeader() + gh.setDefaultAlignment(Qt.AlignLeft | Qt.AlignVCenter) + 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() + 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("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()): + 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 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() + + 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 +606,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 +688,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(3, 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 +759,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 +776,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 +821,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..0d62899 100644 --- a/sessionprepgui/prefs/dialog.py +++ b/sessionprepgui/prefs/dialog.py @@ -52,13 +52,16 @@ 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() 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() @@ -173,7 +176,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 +229,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/sessionprepgui/prefs/page_colors.py b/sessionprepgui/prefs/page_colors.py index 07787d5..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()): @@ -164,11 +200,11 @@ 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) + self._refresh_preview() def _on_add(self) -> None: row = self._table.rowCount() @@ -176,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_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/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/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 d37cdd8..6ad6cb5 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 = 6 # Each entry upgrades from key-version to key+1. _MIGRATIONS: dict[int, Callable[[dict], dict]] = { @@ -56,6 +56,16 @@ "recursive_scan": False, "version": 4, }, + 4: lambda d: { + **d, + "project_name": "", + "version": 5, + }, + 5: lambda d: { + **d, + "output_tracks": {}, + "version": 6, + }, } @@ -165,7 +175,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 @@ -234,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 # --------------------------------------------------------------------------- @@ -326,15 +375,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", []), @@ -343,6 +388,10 @@ def save_session(path: str, data: dict) -> None: 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) @@ -356,39 +405,26 @@ 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) - - -def load_session(path: str) -> dict: - """Load and migrate a ``.spsession`` file. - - Returns a plain dict with keys: - - ``source_dir`` (str) - - ``active_config_preset`` (str) - - ``session_config`` (dict | None) - - ``session_groups`` (list) - - ``daw_state`` (dict) - - ``tracks`` (list[TrackContext]) — audio_data is None; filepath validated - - Raises ``ValueError`` on version mismatch or missing required fields. - Raises ``json.JSONDecodeError`` / ``OSError`` on file errors. - """ - with open(path, "r", encoding="utf-8") as fh: - raw = json.load(fh) +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() ] + 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) @@ -402,6 +438,7 @@ def load_session(path: str) -> 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), @@ -412,4 +449,40 @@ 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"), } + +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) diff --git a/sessionprepgui/settings.py b/sessionprepgui/settings.py index ca57d4a..c7e727e 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,37 +275,17 @@ 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") - elif 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") - 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) # --------------------------------------------------------------------------- # Load / Save # --------------------------------------------------------------------------- + def load_config() -> dict[str, Any]: """Load the four-section GUI config, creating it with defaults if needed. @@ -204,8 +314,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 +339,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 +349,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 +380,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/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/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 d1a01e6..1a63e41 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,14 +46,17 @@ 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"]) 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) @@ -67,6 +70,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 +134,25 @@ def populate( for ch in range(track.channels) ) - file_color = _DIM if all_used else FILE_COLOR_OK + # 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) + + if p1_warnings: + file_color = FILE_COLOR_WARNING + else: + file_color = _DIM if all_used else FILE_COLOR_OK 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) @@ -217,16 +238,30 @@ 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 # ------------------------------------------------------------------ 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 +273,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 +287,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/sessionprepgui/topology/mixin.py b/sessionprepgui/topology/mixin.py index 7c56195..95fef4c 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,15 +19,16 @@ QSpinBox, QSplitter, QToolBar, + QTreeWidget, + QTreeWidgetItem, QVBoxLayout, 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 @@ -34,9 +36,10 @@ from .input_tree import InputTree from .output_tree import OutputTree from . import operations as ops +from .dialogs import add_output_tracks_dialog -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: @@ -79,10 +82,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) @@ -113,13 +124,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() @@ -184,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) @@ -412,114 +427,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: - 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) + tracks = add_output_tracks_dialog(self, self._topo_topology, COLORS) + if not tracks: + return + + 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 ─────────────────────────────────────── @@ -544,6 +464,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( @@ -599,6 +525,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 @@ -629,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): @@ -719,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.""" @@ -772,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 @@ -787,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 @@ -811,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.""" @@ -861,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() @@ -871,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 @@ -880,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 diff --git a/sessionprepgui/topology/output_tree.py b/sessionprepgui/topology/output_tree.py index a6797e2..1455a59 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 @@ -83,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) @@ -430,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()): @@ -602,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 @@ -701,7 +724,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 +761,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 @@ -1083,7 +1106,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): 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..4afadd5 100644 --- a/sessionprepgui/tracks/groups_mixin.py +++ b/sessionprepgui/tracks/groups_mixin.py @@ -28,10 +28,10 @@ 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: +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,21 +170,16 @@ 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) - # 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() @@ -240,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: @@ -369,7 +364,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) @@ -379,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/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/sessionprepgui/widgets.py b/sessionprepgui/widgets.py index f7bddea..0029420 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 @@ -80,9 +85,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 +110,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) @@ -333,3 +342,249 @@ 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 +# --------------------------------------------------------------------------- + + + +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, 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) + 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 + 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) + 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/_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" diff --git a/sessionpreplib/config.py b/sessionpreplib/config.py index 7259951..e6b0ca2 100644 --- a/sessionpreplib/config.py +++ b/sessionpreplib/config.py @@ -2,21 +2,50 @@ import json import os +import platform 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 _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.""" - pass @dataclass @@ -28,35 +57,12 @@ class ConfigFieldError: value: The offending value. message: Human-readable explanation of what is wrong. """ + key: str value: Any 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 { @@ -104,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 @@ -122,19 +133,25 @@ 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__}") + 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. @@ -165,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=( @@ -190,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 " @@ -203,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 " @@ -215,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=( @@ -225,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=( @@ -244,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=( @@ -261,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], @@ -283,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 @@ -403,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. @@ -448,8 +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", []) return structured @@ -520,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()} @@ -532,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()} @@ -544,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()} @@ -556,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_processor.py b/sessionpreplib/daw_processor.py index 919a9a8..870db3c 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 @@ -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.""" @@ -76,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: @@ -86,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, @@ -118,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. @@ -135,7 +151,6 @@ def transfer( Returns the list of DawCommandResult for this batch. """ - ... @abstractmethod def sync(self, session: SessionContext) -> list[DawCommandResult]: @@ -145,7 +160,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( @@ -160,4 +174,3 @@ def execute_commands( Results are appended to session.daw_command_log. """ - ... 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/dawproject.py b/sessionpreplib/daw_processors/dawproject.py index 47f24f4..1014efb 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) @@ -203,17 +204,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 (*)", @@ -225,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 8f1d10d..bd27d4a 100644 --- a/sessionpreplib/daw_processors/protools.py +++ b/sessionpreplib/daw_processors/protools.py @@ -2,13 +2,13 @@ from __future__ import annotations -import math + import os import time 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 @@ -16,70 +16,17 @@ try: from sessionprepgui.log import dbg except ImportError: + 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): @@ -93,9 +40,68 @@ class ProToolsDawProcessor(DawProcessor): id = "protools" name = "Pro Tools" + 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}" + 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( + 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`` and ``group``. 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 + group = tpl.get("group", "").strip() + name = tpl.get("name", "").strip() + if not name or not group: + continue + instances.append( + cls( + instance_index=idx, + instance_group=group, + 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,9 +147,16 @@ 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._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) @@ -167,6 +180,18 @@ 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: @@ -179,7 +204,79 @@ 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}" + ) + + # 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 from ptsl import PTSL_pb2 as pt @@ -187,54 +284,150 @@ 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, application_name=self._application_name, 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") + + 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: 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...") # Preserve existing assignments where folder IDs still match pt_state = session.daw_state.get(self.id, {}) old_assignments: dict[str, str] = pt_state.get("assignments", {}) valid_ids = {f["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: 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( - 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: @@ -256,6 +449,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, @@ -263,31 +457,66 @@ 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, + close_when_done: bool = True, ) -> 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. @@ -297,27 +526,27 @@ 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", {}) 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 [] # 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]] = [] @@ -331,113 +560,135 @@ 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) ───────────────────────── + if progress_cb: + progress_cb(2, 100, "Waiting for Pro Tools to become ready...") - pt_palette = ptslh.get_color_palette(engine) + 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}" + ) + 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: 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: 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 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 out_tc = out_track_map.get(entry.output_filename) audio_path = ( - out_tc.processed_filepath or out_tc.filepath - ) if out_tc else None + (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 + 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") + track_format = "TF_Mono" if out_tc.channels == 1 else "TF_Stereo" valid_work.append( - (eid, fid, filepath, track_stem, track_format, out_tc)) + (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…") - - # 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}) + progress_cb(20, 100, "Importing audio to clip list...") - # 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", "") @@ -445,226 +696,193 @@ 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)) + results.append( + DawCommandResult( + 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 + 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): - 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}")) + spot_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, 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 - 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}) + (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 + folder_name = folder_map[fid_val]["name"] + pct = 30 + int(50 * step_val / max(len(valid_work), 1)) + try: - new_track_id = 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) - 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}) - try: + batch_job_id=batch_job_id, + progress=pct, + ) 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)) - 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 + 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): 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, _ = 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") + 30 + int(50 * i / len(spot_work)), + 100, + f"Created {i + 1}/{len(spot_work)} tracks", + ) - # ── Batch colorize by group ──────────────────────── + # ── 4. Colorize ────────────────────────────────────── - 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}) + 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))) + engine, names, cidx, batch_job_id=batch_job_id, progress=90 + ) + except Exception: + 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: + 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 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 - fader_cmd = DawCommand( - "set_fader", t_stem, - {"track_id": t_id, "value": fader_db}) 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 + engine, + t_id, + fader_db, + batch_job_id=batch_job_id, + progress=95, + ) 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") + dbg(f"Fader set failed for {t_id}: {e}") + + # ── 6. Save & Close ────────────────────────────────── - # ── Complete batch job ────────────────────────────── + if progress_cb: + 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 - # 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: + 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(cmd_name, "", {}), + 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: + if batch_job_id and engine: ptslh.cancel_batch_job(engine, batch_job_id) - if engine is not None: - try: - engine.close() - except Exception: - pass + 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]: return [] def execute_commands( - self, session: SessionContext, commands: list[DawCommand], + self, + session: SessionContext, + commands: list[DawCommand], ) -> list[DawCommandResult]: return [] diff --git a/sessionpreplib/daw_processors/ptsl_helpers.py b/sessionpreplib/daw_processors/ptsl_helpers.py index 2a08363..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 @@ -131,24 +132,149 @@ def extract_track_id(resp: dict) -> str: # ── Session queries ────────────────────────────────────────────────── +def is_session_open(engine) -> bool: + """Check if Pro Tools currently has a session open. + + 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 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 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() return os.path.join(os.path.dirname(session_ptx), "Audio Files") +# ── Session lifecycle ──────────────────────────────────────────────── + +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", + bit_depth: str = "Bit24", +) -> 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": sample_rate, + "bit_depth": bit_depth, + "input_output_settings": "IO_StereoMix", + "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, 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) + + +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, @@ -215,7 +341,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", @@ -278,31 +404,232 @@ 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:: - 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). + This writes **automation data**, not a live fader position. + The fader only visually moves when the transport plays and + Pro Tools reads the automation. + + 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 - run_command( - engine, pt.CommandId.CId_SetTrackControlBreakpoints, - { - "track_id": track_id, - "control_id": { - "section": "TSId_MainOut", - "control_type": "TCType_Volume", + dbg(f"set_track_volume: id={track_id}, value={volume_db}") + try: + 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", + }, + "value": volume_db, + }] }, - "breakpoints": [{ - "time": { - "location": "0", - "time_type": "TLType_Samples", + 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", }, - "value": volume_db, - }], - }, + "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_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) diff --git a/sessionpreplib/detector.py b/sessionpreplib/detector.py index 8439544..4dae218 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, 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]: @@ -49,7 +50,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 +62,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``. @@ -152,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]: @@ -163,7 +164,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 +179,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 +186,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/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..c89ec97 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, 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/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/one_sided_silence.py b/sessionpreplib/detectors/one_sided_silence.py index 80bb5c1..a57202c 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, 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 @@ -81,13 +82,15 @@ 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" + 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, @@ -96,6 +99,8 @@ def analyze(self, track: TrackContext) -> DetectorResult: } 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/detectors/stereo_compat.py b/sessionpreplib/detectors/stereo_compat.py index 9b47559..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 @@ -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 10fcf33..e7c6310 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 @@ -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 " @@ -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") @@ -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 [], @@ -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, @@ -345,7 +345,7 @@ def _windowed_analysis( 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, 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 78660c0..537d2a1 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" @@ -23,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. @@ -146,6 +176,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 diff --git a/sessionpreplib/pipeline.py b/sessionpreplib/pipeline.py index 5a84211..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 @@ -33,7 +34,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, @@ -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/processor.py b/sessionpreplib/processor.py index 8daab91..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 @@ -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. """ - ... 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 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 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) diff --git a/sessionpreplib/reports.py b/sessionpreplib/reports.py index 3cafc88..c464e96 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, ]) @@ -90,10 +90,8 @@ 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) - lines.append("{:<40} {:>12} {:>12}".format( - t.filename[:38], fader_str, classification - )) + fader_str = f"{fader:+.1f} dB" + 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..6a61318 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 @@ -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, @@ -120,8 +149,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: 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 diff --git a/uv.lock b/uv.lock index f82fc3f..39bdb39 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" @@ -565,7 +619,7 @@ cli = [ ] gui = [ { name = "dawproject" }, - { name = "py-ptsl" }, + { name = "py-ptsl", marker = "platform_machine != 'ARM64'" }, { name = "pyside6" }, { name = "sounddevice" }, ] @@ -576,6 +630,7 @@ dev = [ { name = "patchelf", marker = "sys_platform != 'win32'" }, { name = "pillow" }, { name = "pyinstaller" }, + { name = "pylint" }, { name = "pytest" }, { name = "pytest-cov" }, { name = "rich" }, @@ -586,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" }, @@ -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"