diff --git a/.gitignore b/.gitignore
index ba61e7f..ed4bbc9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -144,3 +144,8 @@ docs/*
!docs/model-licensing-audit.json
+
+# README screenshots that are captured but not embedded
+assets/screenshots/model-hub.png
+assets/screenshots/settings.png
+assets/screenshots/shortcuts.png
diff --git a/CMakeLists.txt b/CMakeLists.txt
index cdb1919..63080ad 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -550,7 +550,10 @@ if(BUILD_TESTING)
tests/unit/TestMain.cpp
tests/unit/ControllerTests.cpp
tests/unit/TaskCenterPanelTests.cpp
+ tests/unit/ControlDeckTests.cpp
+ tests/unit/UiDecisionTests.cpp
tests/unit/SessionSerializationTests.cpp
+ src/app/ui/SessionManager.cpp
tests/unit/AudioIoTests.cpp
tests/unit/AnalysisTests.cpp
tests/unit/StemOriginSafetyTests.cpp
@@ -590,6 +593,9 @@ if(BUILD_TESTING)
tests/integration/PipelineIntegrationTests.cpp
src/app/ui/TaskCenterPanel.cpp
src/app/style/AutoMixLookAndFeel.cpp
+ src/app/ui/ControlDeck.cpp
+ src/app/ui/StemPanel.cpp
+ src/app/ui/GlowMeters.cpp
src/app/controllers/ModelController.cpp
src/app/controllers/ImportController.cpp
src/app/controllers/ExportController.cpp
diff --git a/README.md b/README.md
index 759d12e..6d33414 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,7 @@
**Version 0.4.2**
-
-
+
**FIXED-RULE AUDIO WORKFLOW FOR MIXING AND MASTERING MUSIC STEMS**
@@ -15,11 +14,10 @@
Overview •
-Feature Set •
+What It Does •
First Session •
-Quick Start •
-System Requirements •
-Build + Install •
+Install •
+What You Need •
Licensing
@@ -27,287 +25,145 @@
## Overview
-AutoMixMaster helps you turn raw stems into a cleaner, release-ready track with fewer manual steps.
+Mixing and mastering take years to learn. AutoMixMaster does both with one button.
-It focuses on predictable, fixed-rule processing so results are repeatable and beginner-friendly, with optional extras like AI stem separation, batch mode, and bundled renderer integrations.
+Drop in your stems. Press **Mix + Master**. Get a finished track.
----
+The app works by fixed rules, so the same stems give the same result every time. The AI features are extras. Each one falls back to the fixed rules when you have no model installed.
+
+
+

+
-## Feature Set
+---
-> **Core capabilities at a glance**
+## What It Does
-| Module | Description |
+| Feature | What you get |
| :--- | :--- |
-| **Auto Mix + Auto Master** | Deterministic stem balancing, gain staging, and limiting workflow. |
-| **One-Click Pipeline** | `Mix + Master` (`Ctrl+Shift+M`) runs Auto Mix → Auto Master → Export. |
-| **ITO-Master AI Mastering** | Experimental AI mastering strategy driving a 46-parameter native white-box FX chain with licensing consent gating. |
-| **AI Stem Separation (Optional)** | Splits a single full-mix import into stems before processing when enabled (`Ctrl+Shift+A`). |
-| **Task-Scoped Model Browser** | Install/uninstall models and set active packs per task (`mix`, `master`, `analysis`, `separation`) from Hugging Face or GitHub Releases. |
-| **Batch Processing** | Queue folders, auto-group stems by filename role patterns, and render one mastered song per group (`_AutoMixMaster_YYYYMMDD_XX.`). Supports recursive discovery via UI toggle or `AUTOMIX_BATCH_RECURSIVE=1`. |
-| **Renderer Integrations** | Built-in discovery for PhaseLimiter, FFmpeg, SoX, and rsgain; only available tools are shown (`*_BIN` env overrides supported). |
-| **Verification Reporting** | Export verification report plus batch completion summary; optional per-export `.report.json` sidecar. |
-| **Task Center + ETA** | Real-time progress tracking with batch ETA countdown, summary status row, timestamped activity log, and copy-log utility. |
-| **Transport & Audio Preview** | Realtime-safe lock-free audio preview buffer, live peak/RMS meters, 0–1.5x gain control (+3.5 dB), and modal confirmation for session clearing. |
-| **Shortcuts & Commands** | `ApplicationCommandManager` integration with built-in Keyboard Shortcuts Cheatsheet modal (`?`). |
-| **Analysis Meters** | Live LUFS and peak metering via GlowMeters. |
+| **Auto Mix + Auto Master** | The app balances your stems, sets their levels and limits the result. |
+| **Mix + Master** | One button (`Ctrl+Shift+M`) mixes, masters and exports. |
+| **Master presets** | Default Streaming, Broadcast, Udio Optimized or Custom. |
+| **Platform targets** | Spotify, Apple Music, YouTube, Amazon Music, Tidal or Broadcast EBU R128. |
+| **AI Stem Separation** | Optional. Splits one full mix into stems, so you can start from a single file. |
+| **Vocal Model** | Optional. BS-RoFormer pulls the vocals out of a mix. It is slow without a strong NVIDIA card. |
+| **Light vocal model** | Optional. Open-Unmix is a 36 MB download for weaker machines. It is fast, and rougher. |
+| **AI mastering** | Experimental. The ITO-Master model picks the mastering settings. |
+| **Models window** | `Ctrl+K`. Download, remove and choose models. |
+| **Batch** | Point the app at folders. It groups the stems by file name and renders one mastered song per group. |
+| **Export** | WAV, AIFF, FLAC, OGG or MP3. Each export comes with a report that checks the result. |
+| **Preview** | Play the mix, watch the level and loudness meters, solo or mute any stem. |
+| **Sessions** | Save, load, undo and redo. The app asks before it throws away unsaved work. |
+| **Progress** | A progress bar, a time estimate for batches and a log you can copy. |
+
+### Shortcuts
+
+| Action | Keys |
+| :--- | :--- |
+| Import | `Ctrl+I` |
+| Auto Mix | `Ctrl+M` |
+| Auto Master | `Ctrl+Shift+A` |
+| Mix + Master | `Ctrl+Shift+M` |
+| Export | `Ctrl+E` |
+| Models | `Ctrl+K` |
+| Save / Load session | `Ctrl+S` / `Ctrl+O` |
+| Undo / Redo | `Ctrl+Z` / `Ctrl+Y` |
+| Play / Pause | `Space` |
+| Show all shortcuts | `Ctrl+/` |
---
## First Session
-Welcome to your first mixing and mastering session. AutoMixMaster simplifies the process into a few core steps:
+1. **Import your audio.** Drop files on the waveform area, click it, or press `Ctrl+I`. The app reads WAV, AIFF, FLAC, MP3 and OGG.
+2. **Only one file? Turn on AI Stem Separation.** The app splits a single full mix into stems first. With several files it treats them as stems and skips this step.
+3. **Press Mix + Master.** The app mixes, masters and exports.
-1. **Import audio**: Drag and drop files onto the waveform area, or click `Import`. Supported formats: WAV, AIFF, FLAC, MP3, OGG.
-2. **(Optional) enable AI Stem Separation**: Toggle **AI Stem Separation** and check the badge beside it (`Separation model: `).
- - If exactly one full-mix track is loaded, separation runs before Auto Mix.
- - If multiple files are loaded, they are treated as regular stems and separation is skipped.
-3. **Manage models in Model Browser**: Open **Models** to fetch catalog entries, install/uninstall models, and set active packs per task (`mix`, `master`, `analysis`, `separation`) using **Set Active** or **Use Selected for Task**.
-4. **Auto Mix**: Click **Auto Mix** to analyze stems and apply deterministic balancing rules.
-5. **Auto Master**: Click **Auto Master** to apply mastering strategy and limiting.
-6. **One-click pipeline**: Click **Mix + Master** (`Ctrl+Shift+M`) to run Auto Mix → Auto Master → Export. If AI Stem Separation is enabled and one full mix is loaded, separation is performed first, then the pipeline continues automatically.
-7. **Export**: Use **Export** (`Ctrl+E`) for manual output control, or rely on pipeline export.
+Want more control? Run **Auto Mix**, **Auto Master** and **Export** one at a time. Pick a master preset and a platform target before you master.
---
-## Quick Start
-
-Getting started with AutoMixMaster is simple.
-
-> ⚠️ **Testing disclaimer:** only the **Windows** version has been manually tested end-to-end so far.
-> Linux, macOS, and ARM64 artifacts are currently provided as best-effort builds.
-
-### Windows (Pre-compiled Executable)
-
-Windows users can download the portable release zip (`AutoMixMaster-windows-.zip`), extract it, and run `AutoMixMaster.exe`.
-
-### macOS (Pre-compiled App Bundle)
-
-macOS users can download the release zip (`AutoMixMaster-macos-.zip`), extract it, and open `AutoMixMaster.app`.
-
-### Linux (Prebuilt Packages)
-
-Linux users can download either:
-
-- **AppImage** (`AutoMixMaster--.AppImage`) for a portable one-file launch.
-- **Debian package** (`automixmaster__.deb`) for Ubuntu/Debian install.
-- **Flatpak bundle** (`AutoMixMaster-linux-.flatpak`) for Flatpak-based installs.
-
-### Build From Source
-
-If you are on Linux, or prefer to build the application from source on Windows, refer to the [Build + Install](#build--install) section below for verified instructions.
-
-## Estimated System Requirements
-
-These are **practical estimates** for AI-heavy workflows (especially ONNX-based separation/mix/master inference), not strict hard limits.
-
-AutoMixMaster is designed to benefit from **GPU acceleration** via ONNX Runtime providers.
-
-### Minimum OS requirements (release artifacts)
-
-- **Windows:** **Windows 10 or Windows 11** (x64 or ARM64¹)
-- **macOS (Apple Silicon / ARM64):** **macOS 14+**
-- **macOS (Intel / x64):** **macOS 15+**
-- **Linux:** **Ubuntu 24.04 LTS+** for current prebuilt `.deb`/AppImage artifacts
-
-> ¹ *Note: On Windows 11 ARM64, PhaseLimiter runs seamlessly via Windows on ARM built-in x64 emulation (WOW64/Prism); AI tensor inference runs natively on ARM64.*
-> Note: Ubuntu 22.04 may still work if you build from source on 22.04 with compatible dependencies, but official CI/release packaging currently targets Ubuntu 24.04.
-
-### Minimum workable
-
-- **CPU:** modern **6-core / 12-thread** desktop CPU (Ryzen 5 5600 / Core i5-12400 class)
-- **RAM:** **16 GB minimum**
-- **GPU:** compatible acceleration path with ~**6 GB VRAM**
- - Windows: **WebGPU (DirectX 12 / Vulkan)** or **NVIDIA CUDA**
- - Linux: **WebGPU (Vulkan, requires `libvulkan1`)** or **NVIDIA CUDA**
- - macOS (Apple Silicon): **CoreML / ANE**
- - *(Note: Intel Macs do not support AI tensor/model inference; heuristics and audio processing remain functional)*
-- **Storage:** ~10 GB free (models, temp files, exports)
-- **PhaseLimiter rendering:** requires ffmpeg (install ffmpeg on Windows; `brew install ffmpeg` / `apt install ffmpeg` elsewhere, or set `FFMPEG_BIN`).
-
-### Recommended (smoother)
-
-- **CPU:** **8 cores / 16 threads or better** (Ryzen 7 / Core i7 class)
-- **RAM:** **32 GB**
-- **GPU:** **8–12 GB VRAM**
-
-### Heavy batch / long sessions
-
-- **CPU:** **12 cores+** strongly recommended
-- **RAM:** **32–64 GB**
-- **GPU:** **12 GB+ VRAM**
-
-### Why these estimates
-
-- GPU acceleration matters most: WebGPU needs a **DirectX 12 or Vulkan-capable** GPU, CoreML uses Apple Silicon GPU/ANE, and CUDA needs an **NVIDIA CUDA-capable** GPU ([CUDA](https://onnxruntime.ai/docs/execution-providers/CUDA-ExecutionProvider.html)).
-- Demucs notes roughly **3 GB minimum** and around **7 GB typical** GPU memory, so **8 GB+ VRAM** is a safer real-world target; CPU-only runs work but are slower ([Demucs README](https://github.com/facebookresearch/demucs/blob/main/README.md)).
+## Install
-### ONNX Runtime
-
-ONNX Runtime is an **optional** dependency. When it is not found at configure time the build
-falls back to a deterministic adapter and every model-dependent feature degrades to a
-heuristic — it does not fail the build.
-
-| | |
-|---|---|
-| Validated against | **ORT 1.30.x** (1.30.0, 2026-09-10) |
-| Minimum for the optional GPU paths | **1.22** |
-| Release cadence | roughly monthly — pin a minor series, not a patch |
-
-The build locates ONNX Runtime with `find_package(onnxruntime 1.30.0 EXACT CONFIG)` when fetched
-via `AUTOMIX_FETCH_ORT=ON`, or `find_path`/`find_library` for system-installed SDKs.
-
-#### Provider status (as of 1.30.x)
-
-| Provider | Status | Notes |
-|---|---|---|
-| **CPU** | always available | The baseline. Every GPU path falls back here on OOM or device loss, so the app never loses inference capability. |
-| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. cuDNN and CUDA runtime libraries are loaded dynamically at runtime when present. |
-| **WebGPU** | current | Native plugin EP on Windows/Linux (via `Microsoft.ML.OnnxRuntime.EP.WebGpu` 0.4.0) and in-tree provider on Apple Silicon macOS. Default non-NVIDIA path for Windows and Linux. Requires `libvulkan1` on Linux. |
-| **CoreML** | current | Built-in on macOS. Covers the **Apple Neural Engine** (`MLComputeUnits=CPUAndNeuralEngine` or `MLComputeUnits=ALL`). Note: Intel Macs do not support AI inference. |
-| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is **frozen at 1.24.4** and caps at **opset ≤ 20**. WebGPU is now the primary non-NVIDIA Windows path. |
-| **OpenVINO** | split | Legacy wheel pinned at 1.24.1; the plugin `onnxruntime-ep-openvino` 1.7.0 requires ORT ≥ 1.23. |
-| **Windows ML** | GA (2025-09-23) | The recommended path for new Windows work. C++ needs the **self-contained** NuGet; framework-dependent C/C++ packages are not published. |
-
-AutoMixMaster probes available providers and walks its own priority chain — **ANE → CoreML →
-CUDA → WebGPU → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). If session creation or inference
-fails, the provider is recorded as failed and the chain continues, so a broken or missing GPU
-runtime degrades to CPU instead of failing the render.
-
-> **fp16 caveat:** the CPU execution provider does not run fp16 graphs. Quantize to int8 (QDQ
-> format) for CPU-only deployment; 16-bit and 4-bit quantization additionally require **opset ≥ 21**.
-
-#### Optional runtime capabilities
-
-Two further runtime paths are detected at configure time and are **off unless the installed
-ONNX Runtime exposes the matching API**. The provider priority chain above is unchanged either
-way, and both features default to off.
-
-| Capability | Compile guard | Minimum ORT | Status |
-|---|---|---|---|
-| WebGPU provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Wired for WebGPU via `OrtRuntime` and `RegisterExecutionProviderLibrary` |
-| Per-GPU compiled-model cache (EPContext) | `AUTOMIX_HAS_EP_CONTEXT` | 1.22 | Policy implemented; the `OrtCompileApi` call is not yet wired |
-
-`src/ai/GpuProvider.h` holds the deciding logic for both — `parseOrtVersion`,
-`supportsEpPlugin`, `supportsEpContext`, `decidePluginEpAttempt` and
-`compiledModelCacheKey` — as pure functions, so it is covered by the test suite even on a
-build with no ONNX Runtime SDK present. The cache key covers the model digest, the provider,
-the GPU architecture, the driver version and the ORT version, so recompiling for a different
-card or driver can never reuse another card's artifact. A model digest that is not a valid
-64-character SHA-256 yields no key at all, because a key that cannot distinguish two models
-would alias their caches.
-
-To finish the wiring, the guarded code should ask `decidePluginEpAttempt(...)` and, when it
-returns `attempt == false`, log its `reason` and continue down the existing priority chain;
-`Ort::GetAvailableProviders()` already covers every built-in provider.
-
-#### GPU acceleration for the vocal model (Windows + NVIDIA)
-
-The BS-RoFormer vocal model runs on CUDA when three things are true: the build links the
-**CUDA build of ONNX Runtime**, the user has installed the **GPU runtime pack**, and the GPU
-has room for the model. Otherwise it runs on the CPU, with the reason in the log.
-
-- **GPU runtime pack.** NVIDIA's CUDA runtime, cuBLAS, cuFFT and cuDNN (~1 GB download,
- ~1.3 GB on disk) are not shipped. When *Vocal Model* is switched on, or from *Settings →
- GPU acceleration*, the app offers a one-time download of NVIDIA's own redistributable
- packages from pypi.org, pinned by SHA-256, into
- `%LOCALAPPDATA%\AutoMixMaster\gpu-runtime\`. No admin rights, no system CUDA, no
- PATH changes; the libraries are preloaded by full path. *Settings* can remove it again.
- It is offered only for an NVIDIA GPU with ≥ 11.5 GiB of memory and driver **580 or newer**
- (CUDA 13). `automix_dev_tools gpu-runtime status|install|remove|upgrade-models` does the
- same from the command line.
-- **Model build.** Where a CUDA session opens and the GPU totals ≥ 11.5 GiB, the catalog
- installs BS-RoFormer's **fp32** build (its external weights are folded into one file at
- install time, because ONNX Runtime 1.30 cannot load that export otherwise); elsewhere the
- smaller **quantized** build, which is faster on CPU but 4-7x slower than fp32 on a GPU. Installing
- the runtime pack upgrades an already-installed quantized model automatically.
-- **Memory.** fp32 needs 10 GiB of free GPU memory while it runs (measured peak 9.2 GiB).
- With less free, it runs on the CPU rather than spill into shared memory, which is slower.
-
-Measured on an RTX 5060 Ti (16 GB) for a 196 s track: fp32 on CUDA **66–91 s**, quantized on
-CPU 687 s, quantized on CUDA 303–504 s.
+> ⚠️ Only the **Windows** version has been tested by hand from start to finish. The Linux, macOS and ARM64 builds may have rough edges.
+| System | Download | Then |
+| :--- | :--- | :--- |
+| **Windows** | `AutoMixMaster-windows-.zip` | Extract it and run `AutoMixMaster.exe`. |
+| **macOS** | `AutoMixMaster-macos-.zip` | Extract it and open `AutoMixMaster.app`. |
+| **Linux** | `.AppImage`, `.deb` or `.flatpak` | Run the AppImage, or install the package. |
----
+**macOS:** the app is unsigned. If macOS refuses to open it, go to **System Settings → Privacy & Security** and click **Open Anyway**.
-## Build + Install
+
+Build it yourself
### Windows (Visual Studio 2026)
-1. Configure
-
```bash
cmake -S . -B build -G "Visual Studio 18 2026" -A x64
-```
-
-1. Build
-
-```bash
cmake --build build --config Release --parallel
+ctest --test-dir build -C Release --output-on-failure
```
-#### Windows release package
-
-`packaging/windows/build-release.ps1` configures a fresh build directory against an ONNX
-Runtime SDK, builds, runs the tests and writes a portable ZIP with CPack. Use the CUDA build
-of ONNX Runtime so the package can use the GPU:
+**Release package.** `packaging/windows/build-release.ps1` builds, runs the tests and writes a portable ZIP. Give it the CUDA build of ONNX Runtime so the package can use the GPU:
```powershell
powershell -File packaging\windows\build-release.ps1 -OnnxRuntimeDir C:\lib\onnxruntime-win-x64-gpu_cuda13-1.30.0
```
-The ZIP holds the app, its assets and the ONNX Runtime DLLs (the CUDA provider adds ~190 MB).
-It never contains model weights or NVIDIA's CUDA libraries: the install step fails if any
-`.onnx` file or `cudart`/`cublas`/`cudnn`/`cufft` library is present. For a developer build
-that runs on CUDA without the runtime pack, point `-DAUTOMIX_CUDA_RUNTIME_DIR` at a folder of
-those DLLs; they are copied next to the executables, never into the package.
+The ZIP never holds model files or NVIDIA's CUDA libraries. The install step fails if it finds one. To run a developer build on CUDA without the GPU pack, point `-DAUTOMIX_CUDA_RUNTIME_DIR` at a folder of those DLLs.
### Ubuntu Linux (24.04+)
-1. Install dependencies
-
```bash
sudo apt-get install -y \
- libasound2-dev libfreetype6-dev libx11-dev libxcomposite-dev \
- libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev \
- libxrender-dev libwebkit2gtk-4.1-dev libglu1-mesa-dev mesa-common-dev
-```
-
-1. Configure + build
+ build-essential cmake pkg-config \
+ libasound2-dev libjack-jackd2-dev libfreetype6-dev libfontconfig1-dev \
+ libx11-dev libxcomposite-dev libxcursor-dev libxext-dev libxinerama-dev \
+ libxrandr-dev libxrender-dev libwebkit2gtk-4.1-dev libgtk-3-dev \
+ libglu1-mesa-dev mesa-common-dev libcurl4-openssl-dev
-```bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --parallel
+ctest --test-dir build --output-on-failure
```
-1. Run tests (optional but recommended)
+Do not skip `libcurl4-openssl-dev`. Without it the app cannot download models.
+
+**Packages.** `./tools/package_linux.sh` writes a `.deb` and an AppImage to `dist/linux/`.
+
+**Flatpak.**
```bash
-ctest --test-dir build --output-on-failure
+sudo apt-get install -y flatpak flatpak-builder
+flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
+flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
+./tools/build_flatpak.sh
```
-### macOS (Apple Silicon + Intel)
+The bundle lands in `dist/flatpak/AutoMixMaster.flatpak`. The manifest is `packaging/flatpak/io.automixmaster.AutoMixMaster.yml`. It fetches its sources ahead of time, so the build needs no network inside the sandbox.
-1. Install build tools
+### macOS (Apple Silicon + Intel)
```bash
xcode-select --install
brew install cmake ninja
```
-1. Configure + build (pick one architecture)
+Pick one architecture:
```bash
-# Apple Silicon (arm64)
+# Apple Silicon
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=arm64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF
-# Intel (x64)
+# Intel
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_OSX_ARCHITECTURES=x86_64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF
cmake --build build --target AutoMixMasterApp --parallel
```
-1. Configure + build (universal binary: arm64 + x86_64)
+Or build for both at once:
```bash
cmake -S . -B build-universal \
@@ -319,7 +175,7 @@ cmake -S . -B build-universal \
cmake --build build-universal --target AutoMixMasterApp --parallel
```
-1. Install app bundle
+Then install the app:
```bash
APP_BUNDLE="$(find build build-universal -maxdepth 6 -type d -name 'AutoMixMaster.app' 2>/dev/null | head -n 1)"
@@ -328,100 +184,154 @@ sudo cp -R "$APP_BUNDLE" /Applications/
open /Applications/AutoMixMaster.app
```
-> **macOS Note:** On first launch of the unsigned application, macOS Gatekeeper may prompt that the developer cannot be verified. To allow the application to open, go to **System Settings → Privacy & Security**, scroll down to the Security section, and click **Open Anyway**.
+### Extra render tools
+The app needs none of these. It finds them if you put them here or set the variable:
-### Linux Package Builds (.deb + AppImage)
+- `assets/ffmpeg/bin/ffmpeg(.exe)` or `FFMPEG_BIN`
+- `assets/sox/bin/sox(.exe)` or `SOX_BIN`
+- `assets/rsgain/bin/rsgain(.exe)` or `RSGAIN_BIN`
-After building, create distributable Linux packages with:
+PhaseLimiter runs only when you select it, and it needs ffmpeg.
-```bash
-./tools/package_linux.sh
-```
+
-Output artifacts are written to `dist/linux/`.
+---
-### Flatpak
+## What You Need
-Manifest path:
+The fixed-rule mix and master run on almost any recent computer. The numbers below are for the AI features. They are estimates, not hard limits.
-`packaging/flatpak/io.automixmaster.AutoMixMaster.yml`
+| | Works | Comfortable | Big batches |
+| :--- | :--- | :--- | :--- |
+| **Processor** | 6 cores | 8 cores | 12 cores or more |
+| **Memory** | 16 GB | 32 GB | 32–64 GB |
+| **Graphics memory** | 6 GB | 8–12 GB | 12 GB or more |
+| **Free disk** | 10 GB | | |
-> Note: the Flatpak manifest prefetches JUCE/nlohmann/libebur128 sources and passes `FETCHCONTENT_SOURCE_DIR_*` flags so CMake does not need live GitHub access inside the Flatpak sandbox.
+**Systems the release builds support**
-Install Flatpak tooling:
+- **Windows** 10 or 11, x64 or ARM64
+- **macOS** 14 or later on Apple Silicon; 15 or later on Intel
+- **Linux:** Ubuntu 24.04 or later
-```bash
-sudo apt-get install -y flatpak flatpak-builder
-```
+**Graphics cards**
-Add Flathub and install required runtime/SDK:
+- **Windows and Linux:** an NVIDIA card is fastest. Other cards work if they support DirectX 12 or Vulkan. Linux needs `libvulkan1`.
+- **Apple Silicon Macs:** the app uses the built-in graphics chip.
+- **Intel Macs:** no AI features. Mixing and mastering still work.
+- **No usable card:** the AI features run on the processor. They work, and they are slow.
-```bash
-flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
-flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
-```
+**Macs with less than 12 GB of memory.** The Vocal Model runs slowly on them. The app warns you and points you to the light Open-Unmix model.
-Build bundle:
+### The Vocal Model on an NVIDIA card (Windows)
-```bash
-./tools/build_flatpak.sh
-```
+The Vocal Model runs on your graphics card when all of this is true:
-Output:
+- The card has at least 12 GB of memory, with 10 GB free.
+- Your NVIDIA driver is version 580 or newer.
+- You have installed the **GPU pack**.
-- `dist/flatpak/AutoMixMaster.flatpak`
+The GPU pack is a one-time download of about 1 GB. The app offers it when you switch on the Vocal Model. You can also install or remove it under **Settings → GPU acceleration**. It needs no admin rights and changes nothing else on your system.
-### Bundled Renderer Tools (Optional)
+Without these, the Vocal Model runs on the processor and the log tells you why. The difference is large: one 196-second track took 66 to 91 seconds on an RTX 5060 Ti and 687 seconds on the processor.
-The renderer registry can auto-discover optional CLI tools if you place binaries under:
+---
-- `assets/ffmpeg/bin/ffmpeg(.exe)` or set `FFMPEG_BIN`
-- `assets/sox/bin/sox(.exe)` or set `SOX_BIN`
-- `assets/rsgain/bin/rsgain(.exe)` or set `RSGAIN_BIN`
+## Licensing
-If a tool is missing, it is hidden from selectable available renderers automatically.
+AutoMixMaster is free software under the **GNU General Public License v3**.
----
+**The app ships with no AI models.** You download the ones you want from the Models window. The app shows each model's license and asks you to accept it first.
-## Licensing
+**Some models are for non-commercial use only.** If you sell your music, use the MIT-licensed models or the fixed rules.
+
+| Model | Used for | License | Commercial use |
+| :--- | :--- | :--- | :--- |
+| BS-RoFormer | Vocal Model | MIT | Yes |
+| Open-Unmix | Light vocal model | MIT | Yes |
+| Whisper, CLAP, PANNs | Analysis | MIT | Yes |
+| Demucs / HTDemucs | Stem separation | CC-BY-NC 4.0 weights | No |
+| Denoiser | Vocal clean-up | CC-BY-NC 4.0 | No |
+| ITO-Master | AI mastering | CC-BY-NC 4.0 | No |
-AutoMixMaster is distributed under the **GNU General Public License v3 (GPLv3)**.
+The full license record for every model is in [docs/model-licensing-audit.json](docs/model-licensing-audit.json).
-### Core & Libraries
+**Software the app is built on**
| Component | License | Role |
| :--- | :--- | :--- |
-| JUCE 8.0.8 | AGPLv3 / Commercial | Audio & GUI Framework |
-| libebur128 | MIT | EBU R128 Loudness Metering |
-| nlohmann/json | MIT | JSON Serialization & Model Metadata |
-| Catch2 3.7.1 | BSL-1.0 | Unit Testing Framework |
-| PhaseLimiter | GPL-2.0 / Custom | Optional External Limiter |
-| FFmpeg | GPL-compatible / LGPL | Optional External Audio Renderer |
-| SoX | GPL-2.0-or-later | Optional External Processor |
-| rsgain | BSD-2-Clause | Optional ReplayGain Tagging Stage |
+| JUCE 8.0.8 | AGPLv3 / Commercial | Audio and interface framework |
+| libebur128 | MIT | Loudness metering |
+| nlohmann/json | MIT | Reading and writing JSON |
+| Catch2 3.7.1 | BSL-1.0 | Tests |
+| PhaseLimiter | GPL-2.0 / Custom | Optional limiter |
+| FFmpeg | GPL-compatible / LGPL | Optional renderer |
+| SoX | GPL-2.0-or-later | Optional processor |
+| rsgain | BSD-2-Clause | Optional loudness tagging |
+
+---
-### AI Model Hub & Third-Party Weights
+## Developer Notes
-Model weights are **not bundled** into the installer or executable binaries. Users can optionally download models on-demand through the built-in Model Hub (`ModelManager`), which preserves and displays upstream model licensing metadata (`license` / `cardData` tags):
+Most people can stop reading here. These notes are for building against ONNX Runtime or writing a model pack.
-| Model / Model Family | Upstream Author | License | Usage & Compatibility |
-| :--- | :--- | :--- | :--- |
-| **HTDemucs / Demucs 4-stem** | Meta Research | MIT (code), CC-BY-NC 4.0 (MUSDB18-HQ weights) | Stem Separation (Non-Commercial weights) |
-| **HTDemucs 6-stem (`htdemucs_6s`)** | Meta / Community ONNX | CC-BY-NC 4.0 | 6-Stem Separation (Guitar/Piano/Drums/Bass/Vocal/Other) |
-| **Denoiser (`dns64` / Speech Enhancer)** | Meta Research | CC-BY-NC 4.0 | Speech & Vocal Denoising (Non-Commercial evaluation) |
-| **ITO-Master (`ito-master-v1`)** | Community / Open | CC-BY-NC 4.0 | AI Mastering (46-param native white-box FX chain) |
-| **Whisper Tiny / Small** | OpenAI | MIT | Transcripts, Vocal Alignment & Pitch Analysis |
-| **CLAP (`clap-htsat`)** | LAION | MIT | Style Retrieval & Audio Embeddings |
-| **PANNs (`PANNs_CNN14`)** | Bio-DSP / Open | MIT | General Audio Tagging & Classification |
+
+ONNX Runtime and graphics providers
+
+### ONNX Runtime
+
+ONNX Runtime is optional. Without it, the build still succeeds and every model feature falls back to the fixed rules.
+
+| | |
+|---|---|
+| Validated against | **ORT 1.30.x** (1.30.0, 2026-09-10) |
+| Minimum for the optional GPU paths | **1.22** |
+| Release cadence | roughly monthly — pin a minor series, not a patch |
+
+The build finds ONNX Runtime with `find_package(onnxruntime 1.30.0 EXACT CONFIG)` when fetched via `AUTOMIX_FETCH_ORT=ON`, or with `find_path`/`find_library` for a system SDK.
+
+#### Provider status (as of 1.30.x)
+
+| Provider | Status | Notes |
+|---|---|---|
+| **CPU** | always available | The baseline. Every GPU path falls back here on out-of-memory or device loss. |
+| **CUDA** | current | Default packages target **CUDA 13.0** since 1.27. cuDNN and the CUDA runtime load at run time when present. |
+| **WebGPU** | current | A plugin provider on Windows and Linux (`Microsoft.ML.OnnxRuntime.EP.WebGpu` 0.4.0) and in-tree on Apple Silicon. The default non-NVIDIA path on Windows and Linux. Needs `libvulkan1` on Linux. |
+| **CoreML** | current | Built in on macOS. Covers the **Apple Neural Engine** (`MLComputeUnits=CPUAndNeuralEngine` or `ALL`). Intel Macs run no AI inference. |
+| **DirectML** | maintenance mode | The `Microsoft.ML.OnnxRuntime.DirectML` NuGet is frozen at 1.24.4 and caps at opset ≤ 20. WebGPU replaces it. |
+| **OpenVINO** | split | The legacy wheel is pinned at 1.24.1. The plugin `onnxruntime-ep-openvino` 1.7.0 needs ORT ≥ 1.23. |
+| **Windows ML** | GA (2025-09-23) | Recommended for new Windows work. C++ needs the self-contained NuGet. |
+
+The app probes the providers and walks its own priority chain: **ANE → CoreML → CUDA → WebGPU → OpenVINO → DirectML → CPU** (`src/ai/GpuProvider.h`). A provider that fails is recorded and skipped. A broken GPU runtime slows the render down. It does not stop it.
+
+> **fp16 caveat:** the CPU provider does not run fp16 graphs. Quantize to int8 (QDQ format) for CPU-only use. 16-bit and 4-bit quantization also need opset ≥ 21.
+
+#### Optional runtime capabilities
+
+Two more paths are detected at configure time. Both are off unless the installed ONNX Runtime exposes the matching API. Neither changes the priority chain.
+
+| Capability | Compile guard | Minimum ORT | Status |
+|---|---|---|---|
+| WebGPU provider supplied as a plugin library | `AUTOMIX_HAS_EP_PLUGIN` | 1.23 | Wired for WebGPU via `OrtRuntime` and `RegisterExecutionProviderLibrary` |
+| Per-GPU compiled-model cache (EPContext) | `AUTOMIX_HAS_EP_CONTEXT` | 1.22 | Policy implemented. The `OrtCompileApi` call is not wired yet. |
+
+`src/ai/GpuProvider.h` holds the deciding logic as pure functions: `parseOrtVersion`, `supportsEpPlugin`, `supportsEpContext`, `decidePluginEpAttempt` and `compiledModelCacheKey`. The tests cover them even on a build with no ONNX Runtime SDK.
+
+The cache key covers the model digest, the provider, the GPU architecture, the driver version and the ORT version. One card can never reuse another card's compiled model. A digest that is not a valid 64-character SHA-256 yields no key at all.
-> **Non-Commercial Notice**: Models licensed under **CC-BY-NC 4.0** (such as Meta Demucs, Denoiser, and ITO-Master weights) are restricted to personal, educational, and non-commercial evaluation use. Commercial workflows can use open-source MIT-licensed models (e.g. Whisper, CLAP) or the built-in deterministic heuristic DSP engines. User consent gating is enforced prior to model download and execution.
+To finish the wiring, the guarded code should call `decidePluginEpAttempt(...)`. When it returns `attempt == false`, log its `reason` and continue down the chain.
-> **Model Licensing Audit**: For complete machine-checkable model license metadata and audit specifications, see [docs/model-licensing-audit.json](docs/model-licensing-audit.json).
+
-#### Mix-Scope Model Contract
+
+Model pack contracts
-No curated `mix` model ships today — the AI mix path is fully wired (`AutoMixStrategyAI`), so it activates as soon as a valid mix pack is installed, and otherwise falls back to the deterministic heuristic. A downloadable mix model must satisfy all of the following:
+### Mix-Scope Model Contract
+
+No curated `mix` model ships today. The AI mix path is wired (`AutoMixStrategyAI`). It activates when you install a valid mix pack. Until then it uses the fixed rules.
+
+A mix model must meet all of these:
| Requirement | Value | Enforced by |
| :--- | :--- | :--- |
@@ -429,18 +339,20 @@ No curated `mix` model ships today — the AI mix path is fully wired (`AutoMixS
| Manifest metadata | non-empty `license`, `source`, `feature_schema_version` | `ModelPackLoader` |
| `feature_schema_version` | `1.0.0` | `FeatureSchemaV1::isCompatible` |
| Required output keys | `confidence`, `global_gain_db` (±12 dB), `global_pan_bias` (±1.0) | `ModelPackLoader` + `OnnxModelInference` |
-| Optional per-stem keys | `stem_gain_db` (±24 dB), `stem_pan` (±1.0) — a superset of the required keys | `AutoMixStrategyAI` |
-| Input features | **66 floats per stem, concatenated** — `input_feature_count` must equal `66 × stem count` exactly | `OnnxModelInference::run` |
+| Optional per-stem keys | `stem_gain_db` (±24 dB), `stem_pan` (±1.0) | `AutoMixStrategyAI` |
+| Input features | **66 floats per stem, concatenated** — `input_feature_count` must equal `66 × stem count` | `OnnxModelInference::run` |
| `allowed_tasks` | must include `mix_parameters` | `OnnxModelInference::run` |
-Two consequences worth knowing before authoring a pack:
+Two things to know before you author a pack:
-- **The stem count is baked into the model's input width.** Because features are concatenated per stem, a pack trained for 4 stems (`input_feature_count: 264`) is rejected outright on a 3-stem session. A model intended for varying stem counts must accept a padded or per-stem input, not a fixed concatenation.
-- **The leading public model is not plug-and-play.** `csteinmetz1/automix-toolkit` (Apache-2.0) is the best-licensed downloadable mixer — it predicts per-track gain and pan, which maps cleanly onto the `stem_gain_db` / `stem_pan` keys — but its published weights are PyTorch `.ckpt` checkpoints and its input is an audio encoder (log-mel/STFT), not the 66-float feature vector. Using it requires an ONNX export **and** a host-side audio-encoder frontend, so it is deliberately absent from the curated list rather than listed as broken.
+- **The stem count is baked into the input width.** A pack trained for 4 stems (`input_feature_count: 264`) is rejected on a 3-stem session. A model for varying stem counts must accept a padded or per-stem input.
+- **The leading public model is not plug-and-play.** `csteinmetz1/automix-toolkit` (Apache-2.0) predicts per-track gain and pan, which maps onto the `stem_*` keys. But its weights are PyTorch `.ckpt` files and its input is an audio encoder, not the 66-float vector. It needs an ONNX export and a host-side encoder, so it is not on the curated list.
-#### Model Inference Contract (all scopes)
+### Model Inference Contracts
-`IModelInference` is a **features-in, scalars-out** interface. A request carries one flat `std::vector` (`InferenceRequest::features`); a response carries flat named scalars (`InferenceResult::outputs`). There is no audio-tensor path through it. These six tasks are the complete set:
+The app has two inference interfaces.
+
+**1. `IModelInference`: features in, scalars out.** A request carries one flat `std::vector`. A response carries named scalars. These tasks use it:
| Task | Input | Output keys | Consumer |
| :--- | :--- | :--- | :--- |
@@ -449,23 +361,27 @@ Two consequences worth knowing before authoring a pack:
| `role_classifier` | 66 floats per stem | `prob_vocals`, `prob_bass`, `prob_drums`, `prob_fx` | `StemRoleClassifierAI` |
| `stem_separation` | per-4096-sample-frame feature vector | `stem_weight` \| `source_weight` \| `mask_` \| `_weight` | `StemSeparator` |
| `mix_master_override` | all stems' features, concatenated | `dryWet`, `targetLufs`, `preGainDb` (legacy) | `ModelStrategy` |
-| `ito_fxencoder`, `ito_predictor` | the first N stereo samples flattened channel-major into one `features` vector (encoder); the same vector with the encoder's 2048 outputs appended to it (predictor) — fed positionally, never bound by name | 2048-dim embedding, then 46 normalized chain parameters | `ItoMasterModelRunner` |
+| `ito_fxencoder`, `ito_predictor` | the first N stereo samples, flattened channel-major (encoder); the same vector plus the encoder's 2048 outputs (predictor) | 2048-dim embedding, then 46 normalized chain parameters | `ItoMasterModelRunner` |
-**Consequence: a model whose output is an audio-shaped tensor, or whose input needs audio semantics, cannot be used through this interface.** The wall is not the number of graph inputs — `xycld/BS-RoFormer-ONNX`, for example, has exactly one input and one output. It is two things the contract has no words for: (1) **output rank and volume** — BS-RoFormer returns a rank-5 `[1, 1, 2050, 801, 2]` float tensor (~3.3 M values), and `InferenceResult::outputs` is a `map` that cannot carry a tensor at any rank; (2) **audio semantics on the way in** — `features` is a flat vector with no shape, no axis meaning, no channel identity, no phase and no STFT front-end, so there is no way to say "801 frames × 1025 bins × 2 channels × real/imag". That excludes essentially the whole published audio ecosystem — Demucs/HTDemucs, BS-Roformer and Mel-Band Roformer, Open-Unmix, Spleeter, Whisper, CLAP, PANNs, CED, Basic Pitch, CREPE, skey, beat-this, chordmini — regardless of license. Installing one yields a pack that validates and downloads, then either fails the `features.size() != input_feature_count` check or receives a feature vector where it expects audio.
+This interface cannot carry audio. Its output is a `map`, and its input has no shape, channels or phase. A model that returns an audio-shaped tensor does not fit.
-This applies to the three **already-curated** separation models (`rysertio/Demucs-onnx`, `StemSplitio/htdemucs-ft-onnx`, `StemSplitio/htdemucs-6s-onnx`): the separator feeds them a per-frame feature vector and reads back per-stem weights, so with no weight key in the response it applies its own heuristic. `StemSeparator` now reports that case honestly — `SeparationResult::usedModel` is `false` and the log says the fallback weights were used — rather than claiming "Model-backed overlap-add separation completed".
+The three curated Demucs models (`rysertio/Demucs-onnx`, `StemSplitio/htdemucs-ft-onnx`, `StemSplitio/htdemucs-6s-onnx`) go through this interface. They return no weight keys, so the separator applies its own fixed rules. `StemSeparator` reports that honestly: `SeparationResult::usedModel` is `false`, and the log says it used fallback weights.
-The verified-later candidates below are held back by that single missing frontend, not by their licenses (licenses confirmed against the Hugging Face model API; all ungated):
+**2. `ITensorInference`: shaped tensors in and out.** `OnnxTensorInference` passes named float32 tensors to ONNX Runtime. `SeparationRunner` adds a host-side STFT around it. This is how the audio models run:
-| Model | License | Why it is not curated yet |
+| Model | Input the host builds | Output the host applies |
| :--- | :--- | :--- |
-| `xycld/BS-RoFormer-ONNX` | MIT | emits a real-valued rank-5 mask tensor (real/imag on the trailing axis) that the caller multiplies against a host-side STFT; needs a tensor-level interface and an STFT front-end |
-| `musetric/skey-onnx` | MIT | expects 22.05 kHz audio, not the 66-float vector |
-| `musetric/chordmini-onnx` | MIT | expects a 144-bin log-CQT the host does not compute |
-| `musetric/beat-this-onnx` | MIT | expects a 128-bin log-mel the host does not compute |
-| `mispeech/ced-base` | Apache-2.0 | expects 16 kHz waveform input |
-| Basic Pitch `nmp.onnx` | Apache-2.0 | expects a 43844-sample CQT input |
+| `xycld/BS-RoFormer-ONNX` | STFT of the mix | a real/imaginary mask |
+| `MixDirective/open-unmix-umxhq-vocals-onnx` | STFT magnitudes | a ratio mask; the mix phase is kept |
-**The unblock is one interface, not a bigger catalog.** `ItoMasterModelRunner` already drives a real audio→audio→parameters graph in-process, so the pattern is proven; generalising it into a tensor-level audio interface (shaped, named float32 tensors in and out, plus a host-side STFT, alongside `IModelInference`) is what would make the entire download ecosystem reachable, and is the reason adding more curated ids before then only adds download size.
+These candidates are still held back. Each needs a front end the host does not compute yet. Their licenses are fine (confirmed against the Hugging Face model API; all ungated):
+| Model | License | What it needs |
+| :--- | :--- | :--- |
+| `musetric/skey-onnx` | MIT | 22.05 kHz audio input |
+| `musetric/chordmini-onnx` | MIT | a 144-bin log-CQT |
+| `musetric/beat-this-onnx` | MIT | a 128-bin log-mel |
+| `mispeech/ced-base` | Apache-2.0 | 16 kHz waveform input |
+| Basic Pitch `nmp.onnx` | Apache-2.0 | a 43844-sample CQT input |
+
diff --git a/assets/screenshots/main-empty.png b/assets/screenshots/main-empty.png
new file mode 100644
index 0000000..52fa8ae
Binary files /dev/null and b/assets/screenshots/main-empty.png differ
diff --git a/assets/screenshots/main-session.png b/assets/screenshots/main-session.png
new file mode 100644
index 0000000..cf2a4e8
Binary files /dev/null and b/assets/screenshots/main-session.png differ
diff --git a/src/app/Main.cpp b/src/app/Main.cpp
index d369706..aab14d7 100644
--- a/src/app/Main.cpp
+++ b/src/app/Main.cpp
@@ -28,6 +28,13 @@ class MainWindow final : public juce::DocumentWindow {
setVisible(true);
}
+ bool requestQuit(std::function quitNow) {
+ if (auto* layout = dynamic_cast(getContentComponent()))
+ return layout->requestQuit(std::move(quitNow));
+ quitNow();
+ return true;
+ }
+
void closeButtonPressed() override {
juce::JUCEApplication::getInstance()->systemRequestedQuit();
}
@@ -50,6 +57,14 @@ class AutoMixMasterApplication final : public juce::JUCEApplication {
automix::ai::OrtRuntime::instance().warmUpAsync();
}
+ void systemRequestedQuit() override {
+ if (mainWindow_ == nullptr) {
+ quit();
+ return;
+ }
+ mainWindow_->requestQuit([] { juce::JUCEApplication::quit(); });
+ }
+
void shutdown() override {
// Let the ONNX Runtime warm-up thread finish before the window and libraries tear down.
automix::ai::OrtRuntime::instance().joinWarmUp();
diff --git a/src/app/style/AutoMixLookAndFeel.cpp b/src/app/style/AutoMixLookAndFeel.cpp
index 10bb576..5d7591a 100644
--- a/src/app/style/AutoMixLookAndFeel.cpp
+++ b/src/app/style/AutoMixLookAndFeel.cpp
@@ -68,28 +68,58 @@ AutoMixLookAndFeel::AutoMixLookAndFeel() {
// Button
// ─────────────────────────────────────────────────────────────────
+namespace {
+juce::String variantOf(const juce::Button& button) {
+ return button.getProperties()[buttonVariant::key].toString();
+}
+} // namespace
+
void AutoMixLookAndFeel::drawButtonBackground(juce::Graphics& g, juce::Button& button,
const juce::Colour& /*backgroundColour*/,
bool shouldDrawButtonAsHighlighted,
bool shouldDrawButtonAsDown) {
auto bounds = button.getLocalBounds().toFloat().reduced(0.5f);
auto cornerSize = metrics::cornerRadius;
-
- juce::Colour bg;
- if (!button.isEnabled()) {
- bg = colour(colours::surfaceBorder);
- } else if (shouldDrawButtonAsDown) {
- bg = colour(colours::primaryPressed);
- } else if (shouldDrawButtonAsHighlighted) {
- bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f);
- } else if (button.getToggleState()) {
- bg = colour(colours::primary);
+ const auto variant = variantOf(button);
+ const bool enabled = button.isEnabled();
+
+ if (!enabled) {
+ // Disabled: no fill, outline only for primary/secondary so it never out-shouts an enabled button.
+ if (variant != buttonVariant::quiet && variant != buttonVariant::danger) {
+ g.setColour(colour(colours::surfaceBorder));
+ g.drawRoundedRectangle(bounds, cornerSize, 1.0f);
+ }
+ } else if (variant == buttonVariant::secondary) {
+ juce::Colour bg = colour(colours::surfaceLight);
+ if (enabled && shouldDrawButtonAsDown)
+ bg = bg.darker(0.1f);
+ else if (enabled && shouldDrawButtonAsHighlighted)
+ bg = bg.brighter(0.1f);
+ g.setColour(bg);
+ g.fillRoundedRectangle(bounds, cornerSize);
+ g.setColour(colour(colours::surfaceBorder));
+ g.drawRoundedRectangle(bounds, cornerSize, 1.0f);
+ } else if (variant == buttonVariant::quiet || variant == buttonVariant::danger) {
+ if (enabled && (shouldDrawButtonAsHighlighted || shouldDrawButtonAsDown)) {
+ g.setColour(variant == buttonVariant::quiet ? colour(colours::surfaceLight)
+ : colour(colours::error).withAlpha(0.15f));
+ g.fillRoundedRectangle(bounds, cornerSize);
+ }
} else {
- bg = colour(colours::primary);
- }
+ juce::Colour bg;
+ if (shouldDrawButtonAsDown) {
+ bg = colour(colours::primaryPressed);
+ } else if (shouldDrawButtonAsHighlighted) {
+ bg = colour(colours::primary).interpolatedWith(colour(colours::primaryHover), 0.6f);
+ } else if (button.getToggleState()) {
+ bg = colour(colours::primary);
+ } else {
+ bg = colour(colours::primary);
+ }
- g.setColour(bg);
- g.fillRoundedRectangle(bounds, cornerSize);
+ g.setColour(bg);
+ g.fillRoundedRectangle(bounds, cornerSize);
+ }
// Focus ring
drawFocusRing(g, button);
@@ -101,7 +131,14 @@ void AutoMixLookAndFeel::drawButtonText(juce::Graphics& g, juce::TextButton& but
auto font = getTextButtonFont(button, button.getHeight());
g.setFont(font);
- auto textColour = button.isEnabled() ? colour(colours::text) : colour(colours::textDisabled);
+ const auto variant = variantOf(button);
+ juce::Colour textColour = colour(colours::text);
+ if (variant == buttonVariant::quiet)
+ textColour = colour(colours::primaryHover);
+ else if (variant == buttonVariant::danger)
+ textColour = colour(colours::error);
+ if (!button.isEnabled())
+ textColour = colour(colours::textDisabled);
g.setColour(textColour);
@@ -366,7 +403,9 @@ void AutoMixLookAndFeel::drawToggleButton(juce::Graphics& g, juce::ToggleButton&
// Fonts
// ─────────────────────────────────────────────────────────────────
-juce::Font AutoMixLookAndFeel::getTextButtonFont(juce::TextButton& /*button*/, int /*buttonHeight*/) {
+juce::Font AutoMixLookAndFeel::getTextButtonFont(juce::TextButton& button, int /*buttonHeight*/) {
+ if (static_cast(button.getProperties().getWithDefault("compact", false)))
+ return typography::caption();
return typography::body();
}
diff --git a/src/app/style/AutoMixLookAndFeel.h b/src/app/style/AutoMixLookAndFeel.h
index b6c5fde..359268c 100644
--- a/src/app/style/AutoMixLookAndFeel.h
+++ b/src/app/style/AutoMixLookAndFeel.h
@@ -6,6 +6,21 @@
namespace automix::app {
+/// Button visual variants, selected via the "variant" component property.
+/// No property (or an unknown value) means primary.
+namespace buttonVariant {
+inline const juce::Identifier key{"variant"};
+inline constexpr const char* primary = "primary";
+inline constexpr const char* secondary = "secondary";
+inline constexpr const char* quiet = "quiet";
+inline constexpr const char* danger = "danger";
+} // namespace buttonVariant
+
+inline void setButtonVariant(juce::Button& button, const char* variant) {
+ button.getProperties().set(buttonVariant::key, juce::String(variant));
+ button.repaint();
+}
+
/// Custom LookAndFeel for AutoMixMaster.
/// Applies the dark audio-app theme defined in Theme.h to all standard JUCE widgets.
class AutoMixLookAndFeel final : public juce::LookAndFeel_V4 {
diff --git a/src/app/ui/ControlDeck.cpp b/src/app/ui/ControlDeck.cpp
index 90cd0bb..0198fbc 100644
--- a/src/app/ui/ControlDeck.cpp
+++ b/src/app/ui/ControlDeck.cpp
@@ -1,5 +1,6 @@
#include "app/ui/ControlDeck.h"
+#include "app/style/AutoMixLookAndFeel.h"
#include "app/ui/GlowMeters.h"
#include "app/ui/StemPanel.h"
@@ -25,7 +26,7 @@ ControlDeck::ControlDeck() {
// Tooltips
importButton_.setTooltip("Import Stems (Ctrl+I)");
autoMixButton_.setTooltip("Auto Mix (Ctrl+M)");
- autoMasterButton_.setTooltip("Auto Master");
+ autoMasterButton_.setTooltip("Auto Master (Ctrl+Shift+A)");
autoMixMasterButton_.setTooltip("One-click: Auto Mix -> Auto Master -> Export (Ctrl+Shift+M)");
batchButton_.setTooltip("Batch Process");
exportButton_.setTooltip("Export (Ctrl+E)");
@@ -35,33 +36,30 @@ ControlDeck::ControlDeck() {
"instrumental that is the residual (mix - vocals), not a second separation. Takes minutes on CPU; falls back to "
"the standard separator if the pack cannot run.");
tensorSeparationToggle_.setEnabled(false);
+ rendererBox_.setTooltip("Engine that renders the exported file. BuiltIn needs no external tools.");
+ exportModeBox_.setTooltip("Final renders at full quality. Quick Preview renders faster for checking the result.");
+ masterPresetBox_.setTooltip("Mastering target. Default Streaming suits Spotify, Apple Music and YouTube.");
+ platformPresetBox_.setTooltip("Loudness target for the platform you will publish to.");
+
+ // Accessibility titles (match the visible labels)
+ rendererBox_.setTitle("Renderer");
+ profileBox_.setTitle("Profile");
+ masterPresetBox_.setTitle("Master preset");
+ platformPresetBox_.setTitle("Platform");
+ exportFormatBox_.setTitle("Export format");
+ exportModeBox_.setTitle("Render mode");
+ rendererChainModeBox_.setTitle("Chain");
+ residualBlendSlider_.setTitle("Residual blend");
batchRecursiveToggle_.setTooltip("Include subfolders when scanning batch input");
rendererChainToggle_.setTooltip("Run renderers in a staged chain");
rendererChainModeBox_.setTooltip("Renderer chain strategy");
residualBlendSlider_.setTooltip("Control residual audio blend");
- // Button visual hierarchy: primary > secondary > tertiary
- // Primary — Mix+Master is the hero action; filled brand colour.
- autoMixMasterButton_.setColour(juce::TextButton::buttonColourId, colour(colours::primary));
- autoMixMasterButton_.setColour(juce::TextButton::buttonOnColourId, colour(colours::primaryPressed));
- autoMixMasterButton_.setColour(juce::TextButton::textColourOffId, juce::Colours::white);
- autoMixMasterButton_.setColour(juce::TextButton::textColourOnId, juce::Colours::white);
-
- // Secondary — Import and Export are the gateway/output actions; tinted text on dark surface.
- for (juce::TextButton* btn : {&importButton_, &exportButton_}) {
- btn->setColour(juce::TextButton::buttonColourId, colour(colours::surface));
- btn->setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight));
- btn->setColour(juce::TextButton::textColourOffId, colour(colours::primary));
- btn->setColour(juce::TextButton::textColourOnId, colour(colours::primaryHover));
- }
-
- // Tertiary — AutoMix, AutoMaster, Batch are advanced ops; visually receded.
- for (juce::TextButton* btn : {&autoMixButton_, &autoMasterButton_, &batchButton_}) {
- btn->setColour(juce::TextButton::buttonColourId, colour(colours::surface));
- btn->setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight));
- btn->setColour(juce::TextButton::textColourOffId, colour(colours::textMuted));
- btn->setColour(juce::TextButton::textColourOnId, colour(colours::text));
- }
+ // Button visual hierarchy: primary > secondary > quiet (see buttonVariant in AutoMixLookAndFeel.h).
+ setButtonVariant(autoMixMasterButton_, buttonVariant::primary);
+ for (juce::TextButton* btn : {&autoMixButton_, &autoMasterButton_, &batchButton_, &exportButton_})
+ setButtonVariant(*btn, buttonVariant::secondary);
+ setButtonVariant(importButton_, buttonVariant::primary);
// Keyboard focus on action buttons
importButton_.setWantsKeyboardFocus(true);
@@ -123,14 +121,14 @@ ControlDeck::ControlDeck() {
rendererChainPreviewLabel_.setFont(typography::caption());
rendererChainPreviewLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted));
rendererChainPreviewLabel_.setJustificationType(juce::Justification::centredLeft);
- addAndMakeVisible(rendererChainPreviewLabel_);
+ addChildComponent(rendererChainPreviewLabel_);
blendLabel_.setFont(typography::caption());
blendLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted));
blendLabel_.setJustificationType(juce::Justification::centredLeft);
separationModelStatusLabel_.setFont(typography::caption());
separationModelStatusLabel_.setColour(juce::Label::textColourId, colour(colours::warning));
separationModelStatusLabel_.setJustificationType(juce::Justification::centredLeft);
- separationModelStatusLabel_.setText("Separation model: none", juce::dontSendNotification);
+ separationModelStatusLabel_.setText("Model: none installed", juce::dontSendNotification);
residualBlendSlider_.setSliderStyle(juce::Slider::LinearHorizontal);
residualBlendSlider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 48, 20);
@@ -139,10 +137,7 @@ ControlDeck::ControlDeck() {
rendererChainModeBox_.setEnabled(false);
// Advanced disclosure toggle
- advancedToggle_.setColour(juce::TextButton::buttonColourId, colour(colours::surface));
- advancedToggle_.setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight));
- advancedToggle_.setColour(juce::TextButton::textColourOffId, colour(colours::textMuted));
- advancedToggle_.setColour(juce::TextButton::textColourOnId, colour(colours::text));
+ setButtonVariant(advancedToggle_, buttonVariant::quiet);
advancedToggle_.onClick = [this] {
advancedExpanded_ = !advancedExpanded_;
advancedToggle_.setButtonText(advancedExpanded_ ? "v Advanced" : "> Advanced");
@@ -151,8 +146,6 @@ ControlDeck::ControlDeck() {
};
// Always-visible settings
- addAndMakeVisible(rendererLabel_);
- addAndMakeVisible(rendererBox_);
addAndMakeVisible(profileLabel_);
addAndMakeVisible(profileBox_);
addAndMakeVisible(masterPresetLabel_);
@@ -160,11 +153,13 @@ ControlDeck::ControlDeck() {
addAndMakeVisible(platformPresetLabel_);
addAndMakeVisible(platformPresetBox_);
addAndMakeVisible(separatedStemsToggle_);
- addAndMakeVisible(tensorSeparationToggle_);
- addAndMakeVisible(separationModelStatusLabel_);
+ addChildComponent(tensorSeparationToggle_);
+ addChildComponent(separationModelStatusLabel_);
addAndMakeVisible(advancedToggle_);
// Advanced settings — hidden by default, revealed by advancedToggle_
+ addChildComponent(rendererLabel_);
+ addChildComponent(rendererBox_);
addChildComponent(exportFormatLabel_);
addChildComponent(exportFormatBox_);
addChildComponent(exportModeLabel_);
@@ -175,6 +170,8 @@ ControlDeck::ControlDeck() {
addChildComponent(blendLabel_);
addChildComponent(residualBlendSlider_);
addChildComponent(batchRecursiveToggle_);
+
+ setHasStems(false);
}
ControlDeck::~ControlDeck() = default;
@@ -191,8 +188,8 @@ void ControlDeck::resized() {
auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium));
// Three-column layout
- int stemWidth = std::max(200, area.getWidth() * 35 / 100);
- int meterWidth = std::max(100, area.getWidth() * 15 / 100);
+ int stemWidth = juce::jlimit(200, 360, area.getWidth() * 28 / 100);
+ int meterWidth = juce::jlimit(110, 180, area.getWidth() * 13 / 100);
auto stemArea = area.removeFromLeft(stemWidth);
area.removeFromLeft(spacing::gapMedium);
@@ -204,38 +201,60 @@ void ControlDeck::resized() {
glowMeters_->setBounds(meterArea);
// Action row
- // Slots: import(1) | autoMix(1) | autoMaster(1) | Mix+Master(2) | batch(1) | export(1) = 7 slots
+ // Slots: import(1) | autoMix(1) | autoMaster(1) | Mix+Master(1.5) | batch(1) | export(1) = 6.5 slots
auto actionRow = centerArea.removeFromTop(44);
- const int slotW = actionRow.getWidth() / 7;
+ const int slotW = juce::roundToInt(static_cast(actionRow.getWidth()) / 6.5f);
+ const bool compact = slotW < 90;
+ for (auto* b : {&importButton_, &autoMixButton_, &autoMasterButton_, &autoMixMasterButton_,
+ &batchButton_, &exportButton_})
+ b->getProperties().set("compact", compact);
importButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2));
autoMixButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2));
autoMasterButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2));
- autoMixMasterButton_.setBounds(actionRow.removeFromLeft(slotW * 2).reduced(2));
+ autoMixMasterButton_.setBounds(actionRow.removeFromLeft(juce::roundToInt(static_cast(slotW) * 1.5f)).reduced(2));
batchButton_.setBounds(actionRow.removeFromLeft(slotW).reduced(2));
exportButton_.setBounds(actionRow.reduced(2));
centerArea.removeFromTop(spacing::gapSmall);
- // Always-visible context rows: Renderer/Profile and Master/Platform
- auto settingsRow1 = centerArea.removeFromTop(28);
- rendererLabel_.setBounds(settingsRow1.removeFromLeft(64).reduced(1));
- rendererBox_.setBounds(settingsRow1.removeFromLeft(160).reduced(1));
- profileLabel_.setBounds(settingsRow1.removeFromLeft(50).reduced(1));
- profileBox_.setBounds(settingsRow1.removeFromLeft(140).reduced(1));
-
- auto settingsRow2 = centerArea.removeFromTop(28);
- masterPresetLabel_.setBounds(settingsRow2.removeFromLeft(50).reduced(1));
- masterPresetBox_.setBounds(settingsRow2.removeFromLeft(140).reduced(1));
- platformPresetLabel_.setBounds(settingsRow2.removeFromLeft(64).reduced(1));
- platformPresetBox_.setBounds(settingsRow2.removeFromLeft(140).reduced(1));
- settingsRow2.removeFromLeft(spacing::gapSmall);
- separatedStemsToggle_.setBounds(settingsRow2.removeFromLeft(180).reduced(1));
- tensorSeparationToggle_.setBounds(settingsRow2.removeFromLeft(120).reduced(1));
- settingsRow2.removeFromLeft(spacing::gapSmall);
- separationModelStatusLabel_.setBounds(settingsRow2.reduced(1));
-
- auto chainPreviewRow = centerArea.removeFromTop(24);
- rendererChainPreviewLabel_.setBounds(chainPreviewRow.removeFromLeft(640).reduced(1));
+ // Always-visible context rows: Profile/Master/Platform, then the separation row
+ // Pairs wrap onto a new row when they do not fit; combos shrink toward their minimum first.
+ {
+ struct Pair {
+ juce::Label* label;
+ juce::ComboBox* box;
+ int labelW, prefW, minW;
+ };
+ const Pair pairs[] = {{&profileLabel_, &profileBox_, 50, 200, 150},
+ {&masterPresetLabel_, &masterPresetBox_, 50, 170, 130},
+ {&platformPresetLabel_, &platformPresetBox_, 64, 150, 120}};
+ const int rowWidth = centerArea.getWidth();
+ auto row = centerArea.removeFromTop(28);
+ int x = 0;
+ for (const auto& p : pairs) {
+ const int avail = rowWidth - x - p.labelW;
+ if (x > 0 && avail < p.minW) {
+ row = centerArea.removeFromTop(28);
+ x = 0;
+ }
+ const int comboW = std::min(p.prefW, std::max(p.minW, rowWidth - x - p.labelW));
+ p.label->setBounds(row.getX() + x, row.getY(), p.labelW, row.getHeight());
+ p.label->setBounds(p.label->getBounds().reduced(1));
+ p.box->setBounds(juce::Rectangle(row.getX() + x + p.labelW, row.getY(), comboW, row.getHeight()).reduced(1));
+ x += p.labelW + comboW;
+ }
+ }
+
+ auto separationRow = centerArea.removeFromTop(28);
+ separatedStemsToggle_.setBounds(separationRow.removeFromLeft(180).reduced(1));
+ tensorSeparationToggle_.setVisible(separationControlsVisible_);
+ separationModelStatusLabel_.setVisible(separationControlsVisible_);
+ if (separationControlsVisible_) {
+ separationRow.removeFromLeft(16);
+ tensorSeparationToggle_.setBounds(separationRow.removeFromLeft(120).reduced(1));
+ separationRow.removeFromLeft(spacing::gapSmall);
+ separationModelStatusLabel_.setBounds(separationRow.reduced(1));
+ }
centerArea.removeFromTop(spacing::gapSmall);
// Advanced disclosure row
@@ -243,6 +262,9 @@ void ControlDeck::resized() {
centerArea.removeFromTop(spacing::gapSmall);
// Advanced settings — shown only when expanded
+ rendererLabel_.setVisible(advancedExpanded_);
+ rendererBox_.setVisible(advancedExpanded_);
+ rendererChainPreviewLabel_.setVisible(advancedExpanded_);
exportFormatLabel_.setVisible(advancedExpanded_);
exportFormatBox_.setVisible(advancedExpanded_);
exportModeLabel_.setVisible(advancedExpanded_);
@@ -255,6 +277,12 @@ void ControlDeck::resized() {
batchRecursiveToggle_.setVisible(advancedExpanded_);
if (advancedExpanded_) {
+ auto rendererRow = centerArea.removeFromTop(28);
+ rendererLabel_.setBounds(rendererRow.removeFromLeft(64).reduced(1));
+ rendererBox_.setBounds(rendererRow.removeFromLeft(160).reduced(1));
+ rendererRow.removeFromLeft(spacing::gapSmall);
+ rendererChainPreviewLabel_.setBounds(rendererRow.removeFromLeft(640).reduced(1));
+
auto settingsRow3 = centerArea.removeFromTop(28);
exportFormatLabel_.setBounds(settingsRow3.removeFromLeft(50).reduced(1));
exportFormatBox_.setBounds(settingsRow3.removeFromLeft(100).reduced(1));
@@ -282,7 +310,22 @@ void ControlDeck::setSeparationModelStatus(const juce::String& text, const bool
separationModelStatusLabel_.setText(text, juce::dontSendNotification);
separationModelStatusLabel_.setColour(
juce::Label::textColourId,
- ready ? colour(colours::success) : colour(colours::warning));
+ ready ? colour(colours::textMuted) : colour(colours::warning));
+}
+
+void ControlDeck::setHasStems(const bool hasStems) {
+ autoMixButton_.setEnabled(hasStems);
+ autoMasterButton_.setEnabled(hasStems);
+ autoMixMasterButton_.setEnabled(hasStems);
+ exportButton_.setEnabled(hasStems);
+ batchButton_.setEnabled(true);
+ importButton_.setEnabled(true);
+ setButtonVariant(importButton_, hasStems ? buttonVariant::secondary : buttonVariant::primary);
+}
+
+void ControlDeck::setSeparationControlsVisible(const bool visible) {
+ separationControlsVisible_ = visible;
+ resized();
}
} // namespace automix::app
diff --git a/src/app/ui/ControlDeck.h b/src/app/ui/ControlDeck.h
index 8f5b891..ada2f5a 100644
--- a/src/app/ui/ControlDeck.h
+++ b/src/app/ui/ControlDeck.h
@@ -46,6 +46,11 @@ class ControlDeck final : public juce::Component {
juce::ToggleButton& getBatchRecursiveToggle() { return batchRecursiveToggle_; }
void setRendererChainPreviewText(const juce::String& text);
void setSeparationModelStatus(const juce::String& text, bool ready);
+ /// Enables Auto Mix, Auto Master, Mix + Master and Export only when stems exist; Import becomes the
+ /// primary action while the session is empty.
+ void setHasStems(bool hasStems);
+ /// Progressive disclosure: the Vocal Model toggle and model label are shown only while AI Stem Separation is on.
+ void setSeparationControlsVisible(bool visible);
private:
std::unique_ptr stemPanel_;
@@ -80,12 +85,13 @@ class ControlDeck final : public juce::Component {
juce::Slider residualBlendSlider_;
juce::ToggleButton separatedStemsToggle_{"AI Stem Separation"};
juce::ToggleButton tensorSeparationToggle_{"Vocal Model"};
- juce::Label separationModelStatusLabel_{"", "Separation model: none"};
+ juce::Label separationModelStatusLabel_{"", "Model: none installed"};
juce::ToggleButton batchRecursiveToggle_{"Recursive Batch"};
// Advanced section toggle
juce::TextButton advancedToggle_{"> Advanced"};
bool advancedExpanded_ = false;
+ bool separationControlsVisible_ = false;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ControlDeck)
};
diff --git a/src/app/ui/GlowMeters.cpp b/src/app/ui/GlowMeters.cpp
index 4bd161e..2206b74 100644
--- a/src/app/ui/GlowMeters.cpp
+++ b/src/app/ui/GlowMeters.cpp
@@ -7,7 +7,14 @@ namespace automix::app {
using namespace theme;
+juce::String GlowMeters::formatReadout(const juce::String& prefix, const double value, const juce::String& unit) {
+ if (!std::isfinite(value) || value <= kNoSignalDb)
+ return prefix + "--" + unit;
+ return prefix + juce::String(value, 1) + unit;
+}
+
GlowMeters::GlowMeters() {
+ setTitle("Loudness meters");
lufsLabel_.setText("I: -- LUFS", juce::dontSendNotification);
lufsLabel_.setFont(typography::caption());
lufsLabel_.setColour(juce::Label::textColourId, colour(colours::textMuted));
@@ -107,10 +114,10 @@ void GlowMeters::timerCallback() {
}
// Pre-allocated string updates (avoid ostringstream allocation per frame)
- lufsText_ = "I: " + juce::String(integratedLufs_, 1) + " LUFS";
- stText_ = "S: " + juce::String(shortTermLufs_, 1) + " LUFS";
- tpText_ = "TP: " + juce::String(truePeakDbtp_, 1) + " dBTP";
- momentText_ = "M: " + juce::String(momentaryLufs_, 1) + " LUFS";
+ lufsText_ = formatReadout("I: ", integratedLufs_, " LUFS");
+ stText_ = formatReadout("S: ", shortTermLufs_, " LUFS");
+ tpText_ = formatReadout("TP: ", truePeakDbtp_, " dBTP");
+ momentText_ = formatReadout("M: ", momentaryLufs_, " LUFS");
lufsLabel_.setText(lufsText_, juce::dontSendNotification);
shortTermLabel_.setText(stText_, juce::dontSendNotification);
@@ -204,8 +211,11 @@ void GlowMeters::paint(juce::Graphics& g) {
auto area = getLocalBounds().toFloat().reduced(metrics::paddingSmall);
// Labels at bottom
- float labelHeight = 68.0f;
- auto meterArea = area.withTrimmedBottom(labelHeight);
+ // 16 px for the LUFS bar strip, then the four readouts and the bar caption.
+ float labelHeight = 84.0f;
+ // The top strip holds the L/R captions drawn above the bars.
+ const float channelCaptionHeight = 14.0f;
+ auto meterArea = area.withTrimmedBottom(labelHeight).withTrimmedTop(channelCaptionHeight);
// Two meter bars side by side
float meterWidth = std::min(32.0f, meterArea.getWidth() * 0.35f);
@@ -224,8 +234,10 @@ void GlowMeters::paint(juce::Graphics& g) {
// L/R labels
g.setColour(colour(colours::textMuted));
g.setFont(typography::caption());
- g.drawText("L", leftBounds.withHeight(14.0f).translated(0.0f, -14.0f), juce::Justification::centred);
- g.drawText("R", rightBounds.withHeight(14.0f).translated(0.0f, -14.0f), juce::Justification::centred);
+ g.drawText("L", leftBounds.withHeight(channelCaptionHeight).translated(0.0f, -channelCaptionHeight),
+ juce::Justification::centred);
+ g.drawText("R", rightBounds.withHeight(channelCaptionHeight).translated(0.0f, -channelCaptionHeight),
+ juce::Justification::centred);
// LUFS bar below meters
auto lufsBarArea = juce::Rectangle(area.getX(), meterArea.getBottom() + 6.0f,
@@ -235,7 +247,8 @@ void GlowMeters::paint(juce::Graphics& g) {
void GlowMeters::resized() {
auto area = getLocalBounds().reduced(static_cast(metrics::paddingSmall));
- auto labelArea = area.removeFromBottom(68);
+ auto labelArea = area.removeFromBottom(84);
+ labelArea.removeFromTop(16); // the LUFS bar is painted here
momentaryLabel_.setBounds(labelArea.removeFromTop(14));
shortTermLabel_.setBounds(labelArea.removeFromTop(14));
diff --git a/src/app/ui/GlowMeters.h b/src/app/ui/GlowMeters.h
index 3e6c282..c188702 100644
--- a/src/app/ui/GlowMeters.h
+++ b/src/app/ui/GlowMeters.h
@@ -21,6 +21,9 @@ class GlowMeters final : public juce::Component, private juce::Timer {
void setTruePeak(double truePeakDbtp);
void setMomentaryLufs(double momentary);
+ /// Values at or below kNoSignalDb (or non-finite) read as "--".
+ static juce::String formatReadout(const juce::String& prefix, double value, const juce::String& unit);
+
private:
void timerCallback() override;
void drawMeter(juce::Graphics& g, juce::Rectangle bounds, float levelDb, float peakDb) const;
@@ -62,6 +65,7 @@ class GlowMeters final : public juce::Component, private juce::Timer {
juce::Label momentaryLabel_;
juce::Label lufsBarLabel_;
+ static constexpr double kNoSignalDb = -69.95;
static constexpr float kMinDb = -60.0f;
static constexpr float kMaxDb = 6.0f;
static constexpr int kPeakHoldFrames = 40;
diff --git a/src/app/ui/HeaderBar.cpp b/src/app/ui/HeaderBar.cpp
index e1362e9..42ec413 100644
--- a/src/app/ui/HeaderBar.cpp
+++ b/src/app/ui/HeaderBar.cpp
@@ -1,5 +1,7 @@
#include "app/ui/HeaderBar.h"
+#include "app/style/AutoMixLookAndFeel.h"
+
namespace automix::app {
using namespace theme;
@@ -15,7 +17,13 @@ HeaderBar::HeaderBar() {
addAndMakeVisible(loadButton_);
addAndMakeVisible(modelsButton_);
addAndMakeVisible(settingsButton_);
- addAndMakeVisible(profileSelector_);
+ // Profile selection lives in the ControlDeck "Profile" combo; this selector is never populated.
+ addChildComponent(profileSelector_);
+ profileSelector_.setVisible(false);
+
+ for (juce::Button* btn : {static_cast(&saveButton_), static_cast(&loadButton_),
+ static_cast(&modelsButton_), static_cast(&settingsButton_)})
+ setButtonVariant(*btn, buttonVariant::quiet);
saveButton_.setTooltip("Save Session (Ctrl+S)");
loadButton_.setTooltip("Load Session (Ctrl+O)");
@@ -78,9 +86,6 @@ void HeaderBar::resized() {
loadButton_.setBounds(rightArea.removeFromRight(80).reduced(2));
saveButton_.setBounds(rightArea.reduced(2));
- auto profileArea = area.removeFromLeft(220);
- profileSelector_.setBounds(profileArea.reduced(2));
-
sessionNameLabel_.setBounds(area);
}
diff --git a/src/app/ui/HeroWaveform.cpp b/src/app/ui/HeroWaveform.cpp
index 02702b0..a966704 100644
--- a/src/app/ui/HeroWaveform.cpp
+++ b/src/app/ui/HeroWaveform.cpp
@@ -127,6 +127,26 @@ void HeroWaveform::mouseDrag(const juce::MouseEvent& event) {
onSeek(progress);
}
+void HeroWaveform::mouseUp(const juce::MouseEvent& event) {
+ if (shouldOpenImportOnClick(hasStems_, event.mods.isLeftButtonDown(), event.mouseWasClicked(),
+ getLocalBounds().contains(event.getPosition())) &&
+ onImportRequested)
+ onImportRequested();
+}
+
+void HeroWaveform::setHasStems(bool hasStems) {
+ if (hasStems_ == hasStems)
+ return;
+ hasStems_ = hasStems;
+ repaint();
+}
+
+juce::MouseCursor HeroWaveform::getMouseCursor() {
+ if (!hasStems_)
+ return juce::MouseCursor::PointingHandCursor;
+ return juce::Component::getMouseCursor();
+}
+
void HeroWaveform::mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) {
const double zoomSensitivity = 0.3;
double zoomDelta = -wheel.deltaY * zoomSensitivity;
@@ -277,7 +297,8 @@ void HeroWaveform::drawZoomControls(juce::Graphics& g) {
};
drawBtn(0, "+", zoomInHover_);
- drawBtn(1, "\u2212", zoomOutHover_);
+ // UTF-8 bytes, not "\u2212": MSVC turns that escape into "?" in a narrow string.
+ drawBtn(1, juce::String(juce::CharPointer_UTF8("\xe2\x88\x92")), zoomOutHover_);
drawBtn(2, "R", zoomResetHover_);
// Zoom level text
@@ -355,17 +376,39 @@ void HeroWaveform::paint(juce::Graphics& g) {
float midY = h * 0.5f;
if (waveformPeaks_.empty()) {
+ const auto zone = bounds.reduced(12.0f);
+ if (isDragOver_) {
+ g.setColour(colour(colours::primary).withAlpha(0.08f));
+ g.fillRoundedRectangle(zone, metrics::cornerRadiusLarge);
+ g.setColour(colour(colours::primary));
+ g.drawRoundedRectangle(zone, metrics::cornerRadiusLarge, 2.0f);
+ } else {
+ juce::Path zonePath;
+ zonePath.addRoundedRectangle(zone, metrics::cornerRadiusLarge);
+ juce::Path dashedPath;
+ const float dashes[] = {6.0f, 4.0f};
+ juce::PathStrokeType(1.5f).createDashedStroke(dashedPath, zonePath, dashes, 2);
+ g.setColour(colour(colours::surfaceBorder));
+ g.fillPath(dashedPath);
+ }
+
auto upperHalf = bounds.withHeight(h * 0.5f);
g.setFont(typography::subhead());
- g.setColour(colour(colours::primary).withAlpha(0.85f));
- g.drawText("Drop stems here or click Import (Ctrl+I)", upperHalf, juce::Justification::centredBottom);
-
- auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f);
- g.setFont(typography::caption());
- g.setColour(colour(colours::textMuted));
- g.drawText("1 Import Stems -> 2 Mix + Master -> 3 Export\n"
- "Tip: enable 'AI Stem Separation' in the control deck to split one full mix into stems.",
- lowerHalf.reduced(0.0f, 8.0f), juce::Justification::centredTop);
+ g.setColour(colour(colours::text));
+ g.drawText(hasStems_ ? "Preview unavailable" : "Drop stems here or click to import", upperHalf,
+ juce::Justification::centredBottom);
+
+ if (!hasStems_) {
+ auto lowerHalf = bounds.withY(h * 0.5f).withHeight(h * 0.5f);
+ g.setFont(typography::caption());
+ g.setColour(colour(colours::textMuted));
+ const auto arrow = juce::String(juce::CharPointer_UTF8("\xe2\x86\x92"));
+ auto steps = lowerHalf.reduced(0.0f, 8.0f);
+ g.drawText("1 Import stems " + arrow + " 2 Mix + Master " + arrow + " 3 Export",
+ steps.removeFromTop(18.0f), juce::Justification::centredTop);
+ g.drawText("One file? Turn on AI Stem Separation first.", steps.removeFromTop(18.0f),
+ juce::Justification::centredTop);
+ }
} else {
// Draw stem overlay if we have per-stem data
if (numStemGroups_ > 0) {
@@ -440,7 +483,7 @@ void HeroWaveform::paint(juce::Graphics& g) {
drawZoomControls(g);
}
- if (isDragOver_) {
+ if (isDragOver_ && !waveformPeaks_.empty()) {
g.setColour(colour(colours::primary).withAlpha(0.12f));
g.fillAll();
g.setColour(colour(colours::primary));
diff --git a/src/app/ui/HeroWaveform.h b/src/app/ui/HeroWaveform.h
index 1af1b33..75cd629 100644
--- a/src/app/ui/HeroWaveform.h
+++ b/src/app/ui/HeroWaveform.h
@@ -21,6 +21,8 @@ class HeroWaveform final : public juce::Component,
void paint(juce::Graphics& g) override;
void mouseDown(const juce::MouseEvent& event) override;
void mouseDrag(const juce::MouseEvent& event) override;
+ void mouseUp(const juce::MouseEvent& event) override;
+ juce::MouseCursor getMouseCursor() override;
void mouseWheelMove(const juce::MouseEvent& event, const juce::MouseWheelDetails& wheel) override;
void resized() override;
@@ -36,8 +38,11 @@ class HeroWaveform final : public juce::Component,
void setLoopRange(bool enabled, double startProgress, double endProgress);
struct StemGroup { std::vector peaks; juce::Colour colour; juce::String name; };
void setStemGroups(const std::vector& groups);
+ /// Whether the session has any stems; drives the empty-state prompt, click-to-import and cursor.
+ void setHasStems(bool hasStems);
// Callbacks
+ std::function onImportRequested; // empty-state zone clicked
std::function onSeek; // progress fraction 0..1
std::function onZoomChanged; // new zoom factor
std::function)> onFilesDropped; // audio/preset files dropped
@@ -59,6 +64,13 @@ class HeroWaveform final : public juce::Component,
return {x, kZoomControlTop, kZoomButtonSize, kZoomButtonSize};
}
+ /// Pure decision backing click-to-import: only a plain left-button click (not a drag) that is
+ /// released inside the component, on a session with no stems, opens Import.
+ static constexpr bool shouldOpenImportOnClick(bool hasStems, bool leftButton, bool wasClicked,
+ bool insideBounds) {
+ return !hasStems && leftButton && wasClicked && insideBounds;
+ }
+
/// Pure dirty-check seam backing setPlayheadProgress: returns true when the playhead's
/// x-pixel changed relative to `lastPixel` (updating `lastPixel`), i.e. a repaint is
/// actually needed. Unchanged pixels skip the repaint.
@@ -114,6 +126,7 @@ class HeroWaveform final : public juce::Component,
int numStemGroups_ = 0;
bool isDragOver_ = false;
+ bool hasStems_ = false;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(HeroWaveform)
};
diff --git a/src/app/ui/MainLayout.cpp b/src/app/ui/MainLayout.cpp
index bcaf3ef..bea9361 100644
--- a/src/app/ui/MainLayout.cpp
+++ b/src/app/ui/MainLayout.cpp
@@ -13,10 +13,12 @@
#include "app/ui/TaskOrchestrator.h"
#include "app/ui/TransportBar.h"
#include "app/ui/VerificationEngine.h"
+#include "ai/BsRoformerPack.h"
#include "ai/GpuRuntimePack.h"
#include "ai/HuggingFaceModelHub.h"
#include "ai/ModelStorage.h"
#include "ai/OnnxTensorInference.h"
+#include "ai/UmxPack.h"
#include "renderers/RendererPipeline.h"
#include "util/FileUtils.h"
@@ -111,12 +113,9 @@ MainLayout::MainLayout() {
addAndMakeVisible(*taskCenter_);
// Destructive Clear: MainLayout-owned (not part of TransportBar), confirmed on click.
- clearTracksButton_.setTooltip("Clear Imported Tracks (asks for confirmation)");
+ clearTracksButton_.setTooltip("Remove all stems from the session");
clearTracksButton_.setWantsKeyboardFocus(true);
- clearTracksButton_.setColour(juce::TextButton::buttonColourId, colour(colours::surface));
- clearTracksButton_.setColour(juce::TextButton::buttonOnColourId, colour(colours::surfaceLight));
- clearTracksButton_.setColour(juce::TextButton::textColourOffId, colour(colours::warning));
- clearTracksButton_.setColour(juce::TextButton::textColourOnId, colour(colours::warning));
+ setButtonVariant(clearTracksButton_, buttonVariant::danger);
clearTracksButton_.onClick = [this] { onClearTracks(); };
addAndMakeVisible(clearTracksButton_);
@@ -147,6 +146,7 @@ MainLayout::MainLayout() {
wireControlDeckCallbacks();
wireHeroWaveformCallbacks();
+ taskCenter_->onPreferredHeightChanged = [this] { resized(); };
taskCenter_->onCancel = [this] { taskOrchestrator_->cancelActiveTask(); };
// 5. Audio device & transport
@@ -159,6 +159,20 @@ MainLayout::MainLayout() {
// 6. Application command manager: keyboard shortcuts are dispatched here
commandManager_.setFirstCommandTarget(this);
commandManager_.registerAllCommandsForTarget(this);
+
+ sessionManager_.markSaved();
+ refreshStemDependentUi();
+}
+
+void MainLayout::refreshStemDependentUi() {
+ const bool hasStems = !sessionManager_.session().stems.empty();
+ if (controlDeck_ != nullptr)
+ controlDeck_->setHasStems(hasStems);
+ if (heroWaveform_ != nullptr)
+ heroWaveform_->setHasStems(hasStems);
+ if (transportBar_ != nullptr)
+ transportBar_->setHasMedia(hasStems);
+ clearTracksButton_.setEnabled(hasStems);
}
// ── Controllers factory ────────────────────────────────────────
@@ -288,6 +302,7 @@ void MainLayout::initControllers() {
safe->sessionManager_.session().stems = result.stems;
safe->sessionManager_.session().originalMixPath = result.originalMixPath;
updateStemPanelFromSession(safe->controlDeck_->getStemPanel(), safe->sessionManager_.session());
+ safe->refreshStemDependentUi();
safe->taskOrchestrator_->finishTaskCompleted(ActiveTask::Import, "Import complete");
safe->rebuildPreview();
@@ -497,16 +512,21 @@ void MainLayout::initControllers() {
if (result.cancelled) {
safe->taskOrchestrator_->finishTaskCancelled(ActiveTask::Session, "Session save cancelled");
+ safe->finishPendingSave(false);
return;
}
if (!result.success) {
safe->taskOrchestrator_->finishTaskFailed(ActiveTask::Session, result.errorText.toStdString());
+ safe->finishPendingSave(false);
return;
}
safe->taskOrchestrator_->appendHistory("Session saved to " + juce::String(result.path));
- safe->headerBar_->setSessionName(juce::File(result.path).getFileNameWithoutExtension());
+ safe->sessionManager_.markSaved();
+ safe->sessionShownModified_ = false;
+ safe->setSessionDisplayName(juce::File(result.path).getFileNameWithoutExtension());
safe->taskOrchestrator_->finishTaskCompleted(ActiveTask::Session, "Session saved");
+ safe->finishPendingSave(true);
});
};
cb.onLoadComplete = [safe](SessionLoadResult result) {
@@ -589,7 +609,7 @@ void MainLayout::resized() {
fb.items.add(juce::FlexItem(*headerBar_).withHeight(static_cast(kHeaderHeight)));
fb.items.add(juce::FlexItem(*heroWaveform_).withFlex(1.5f).withMinHeight(120.0f));
fb.items.add(juce::FlexItem(*controlDeck_).withFlex(2.5f).withMinHeight(180.0f));
- fb.items.add(juce::FlexItem(*taskCenter_).withFlex(1.2f).withMinHeight(160.0f));
+ fb.items.add(juce::FlexItem(*taskCenter_).withHeight(static_cast(taskCenter_->getPreferredHeight())));
fb.performLayout(area);
}
@@ -672,7 +692,7 @@ class ShortcutsDialog final : public juce::Component {
}
closeButton_.setButtonText("Close");
- closeButton_.onClick = [this] { exitModalState(0); };
+ closeButton_.onClick = [this] { closeHostWindow(); };
addAndMakeVisible(closeButton_);
}
@@ -698,13 +718,18 @@ class ShortcutsDialog final : public juce::Component {
bool keyPressed(const juce::KeyPress& key) override {
if (key == juce::KeyPress::escapeKey) {
- exitModalState(0);
+ closeHostWindow();
return true;
}
return juce::Component::keyPressed(key);
}
private:
+ void closeHostWindow() {
+ if (auto* dw = findParentComponentOfClass())
+ dw->exitModalState(0);
+ }
+
static constexpr int kDialogWidth = 460;
static constexpr int kDialogHeight = 560;
@@ -785,11 +810,14 @@ void MainLayout::onTogglePlayPause() {
}
void MainLayout::showShortcutsDialog() {
- auto* dialog = new ShortcutsDialog(shortcutTable());
- dialog->setAlwaysOnTop(true);
- dialog->centreWithSize(dialog->getWidth(), dialog->getHeight());
- dialog->setVisible(true);
- dialog->enterModalState(true, nullptr, true); // modal manager owns + deletes it
+ juce::DialogWindow::LaunchOptions options;
+ options.content.setOwned(new ShortcutsDialog(shortcutTable()));
+ options.dialogTitle = "Keyboard Shortcuts";
+ options.dialogBackgroundColour = colour(colours::surface);
+ options.escapeKeyTriggersCloseButton = true;
+ options.useNativeTitleBar = true;
+ options.resizable = false;
+ options.launchAsync();
}
// ─────────────────────────────────────────────────────────────────
@@ -799,6 +827,15 @@ void MainLayout::showShortcutsDialog() {
void MainLayout::timerCallback() {
updateTransportDisplay();
+ if (++modifiedCheckTicks_ >= 30) {
+ modifiedCheckTicks_ = 0;
+ const bool modified = sessionManager_.isModified();
+ if (modified != sessionShownModified_) {
+ sessionShownModified_ = modified;
+ refreshSessionTitle();
+ }
+ }
+
// Reconcile the transport bar with the atomic play state (covers end-of-track
// auto-stop, which is realtime-only and posts no change message).
const bool playing = transportController_.isPlaying();
@@ -971,10 +1008,10 @@ void MainLayout::onClearTracks() {
return; // Nothing imported; nothing to clear.
juce::AlertWindow::showOkCancelBox(
- juce::MessageBoxIconType::WarningIcon, "Clear imported tracks",
- "Clear " + juce::String(numTracks) +
- " imported tracks? This cannot be undone \xe2\x80\x94 use Undo after T4.1.",
- "Clear", "Cancel", this,
+ juce::MessageBoxIconType::WarningIcon, "Remove all stems?",
+ "Remove all " + juce::String(numTracks) + (numTracks == 1 ? " stem" : " stems") +
+ " from this session? This can't be undone.",
+ "Remove stems", "Cancel", this,
juce::ModalCallbackFunction::create([safe = safeAsync(this), numTracks](int result) {
if (safe != nullptr && confirmClear(numTracks, result == 1))
safe->performClearTracks();
@@ -987,6 +1024,7 @@ void MainLayout::performClearTracks() {
transportBar_->setTimeDisplay(0.0, 0.0);
sessionManager_.session().stems.clear();
detail::updateStemPanelFromSession(controlDeck_->getStemPanel(), sessionManager_.session());
+ refreshStemDependentUi();
}
// ─────────────────────────────────────────────────────────────────
@@ -1076,6 +1114,7 @@ void MainLayout::wireControlDeckCallbacks() {
const bool enabled = controlDeck_->getSeparatedStemsToggle().getToggleState();
sessionManager_.session().aiStemsEnabled = enabled;
controlDeck_->getTensorSeparationToggle().setEnabled(enabled);
+ controlDeck_->setSeparationControlsVisible(enabled);
};
controlDeck_->getTensorSeparationToggle().onClick = [this] {
const bool enabled = controlDeck_->getTensorSeparationToggle().getToggleState();
@@ -1108,6 +1147,7 @@ void MainLayout::wireHeroWaveformCallbacks() {
heroWaveform_->onSeek = [this](double progress) {
transportController_.seekToFraction(std::clamp(progress, 0.0, 1.0));
};
+ heroWaveform_->onImportRequested = [this] { onImport(); };
heroWaveform_->onFilesDropped = [this](std::vector files) {
importFiles(std::move(files));
};
@@ -1538,9 +1578,13 @@ void MainLayout::startBatchVerification(const std::string& outputFolder) {
// Action: Save Session
// ─────────────────────────────────────────────────────────────────
-void MainLayout::onSaveSession() {
+void MainLayout::onSaveSession(std::function done) {
+ finishPendingSave(false);
+ pendingSaveCompletion_ = std::move(done);
+
if (taskOrchestrator_->isTaskRunning()) {
taskOrchestrator_->setStatus("Busy", "A task is already running");
+ finishPendingSave(false);
return;
}
@@ -1554,11 +1598,13 @@ void MainLayout::onSaveSession() {
const auto selected = chooser.getResult();
if (selected == juce::File()) {
saveSessionChooser_.reset();
+ finishPendingSave(false);
return;
}
if (!taskOrchestrator_->beginTask(ActiveTask::Session, "Saving session", "", "Session save started")) {
saveSessionChooser_.reset();
+ finishPendingSave(false);
return;
}
@@ -1570,6 +1616,73 @@ void MainLayout::onSaveSession() {
});
}
+void MainLayout::finishPendingSave(bool success) {
+ if (auto done = std::move(pendingSaveCompletion_)) {
+ pendingSaveCompletion_ = nullptr;
+ done(success);
+ }
+}
+
+void MainLayout::setSessionDisplayName(const juce::String& name) {
+ sessionDisplayName_ = name;
+ refreshSessionTitle();
+}
+
+void MainLayout::refreshSessionTitle() {
+ headerBar_->setSessionName(sessionDisplayName_ + (sessionShownModified_ ? " *" : ""));
+}
+
+bool MainLayout::requestQuit(std::function quitNow) {
+ return requestQuitStep(std::move(quitNow), false);
+}
+
+bool MainLayout::requestQuitStep(std::function quitNow, bool taskQuitConfirmed) {
+ const auto step = nextQuitStep(taskOrchestrator_->isTaskRunning(), taskQuitConfirmed,
+ sessionManager_.isModified());
+ if (step == QuitStep::QuitNow) {
+ quitNow();
+ return true;
+ }
+ if (quitPromptOpen_)
+ return false;
+ quitPromptOpen_ = true;
+
+ if (step == QuitStep::ConfirmRunningTask) {
+ juce::AlertWindow::showOkCancelBox(
+ juce::MessageBoxIconType::WarningIcon, "Task still running",
+ "A task is still running. Quitting now will cancel it, and an unfinished export may be left "
+ "incomplete. Quit anyway?",
+ "Quit", "Cancel", this,
+ juce::ModalCallbackFunction::create([safe = safeAsync(this), quitNow](int result) {
+ if (safe == nullptr)
+ return;
+ safe->quitPromptOpen_ = false;
+ if (result == 1)
+ safe->requestQuitStep(quitNow, true);
+ }));
+ return false;
+ }
+
+ juce::AlertWindow::showYesNoCancelBox(
+ juce::MessageBoxIconType::QuestionIcon, "Save changes?",
+ "Save changes to " + sessionDisplayName_ + " before closing?",
+ "Save", "Don't Save", "Cancel", this,
+ juce::ModalCallbackFunction::create([safe = safeAsync(this), quitNow](int result) {
+ if (safe == nullptr)
+ return;
+ safe->quitPromptOpen_ = false;
+ if (result == 1) {
+ safe->onSaveSession([quitNow](bool saved) {
+ if (saved)
+ quitNow();
+ });
+ } else if (result == 2) {
+ quitNow();
+ }
+ }));
+ return false;
+}
+
// ─────────────────────────────────────────────────────────────────
// Action: Load Session
// ─────────────────────────────────────────────────────────────────
@@ -1726,8 +1839,9 @@ void MainLayout::onModelsDialog() {
options.escapeKeyTriggersCloseButton = true;
options.useNativeTitleBar = true;
options.resizable = true;
- options.launchAsync();
- taskOrchestrator_->appendHistory("Model browser opened");
+ panel->setSize(760, 560);
+ if (auto* dialog = options.launchAsync())
+ dialog->setResizeLimits(640, 460, 4096, 4096);
}
// ─────────────────────────────────────────────────────────────────
@@ -1746,7 +1860,7 @@ void MainLayout::onSettings() {
: "Export sidecar JSON disabled");
},
gpuRuntimeStatusText(), gpuRuntimeButtonText(), [this] { onGpuRuntimeButton(); });
- settingsPanel->setSize(540, 430);
+ settingsPanel->setSize(540, 520);
juce::DialogWindow::LaunchOptions options;
options.content.setOwned(settingsPanel);
@@ -1756,7 +1870,6 @@ void MainLayout::onSettings() {
options.useNativeTitleBar = true;
options.resizable = false;
options.launchAsync();
- taskOrchestrator_->appendHistory("Settings dialog opened");
}
// ─────────────────────────────────────────────────────────────────
@@ -1822,8 +1935,10 @@ void MainLayout::updateMeterPanel(const automaster::MasteringReport& report) {
void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::String& sourcePath) {
sessionManager_.replaceSession(std::move(loadedSession));
const auto& session = sessionManager_.session();
- headerBar_->setSessionName(juce::File(sourcePath).getFileNameWithoutExtension());
+ sessionShownModified_ = false;
+ setSessionDisplayName(juce::File(sourcePath).getFileNameWithoutExtension());
updateStemPanelFromSession(controlDeck_->getStemPanel(), session);
+ refreshStemDependentUi();
transportBar_->setLoopEnabled(session.timeline.loopEnabled);
heroWaveform_->setZoom(session.timeline.zoom, 0.5);
refreshRenderers();
@@ -1833,6 +1948,7 @@ void MainLayout::applyLoadedSession(domain::Session loadedSession, const juce::S
refreshProjectProfiles();
applySessionUiSelections();
syncSessionUiSelections();
+ sessionManager_.markSaved();
taskOrchestrator_->appendHistory("Session loaded: " + sourcePath);
rebuildPreview();
}
@@ -2015,9 +2131,10 @@ void MainLayout::updateSeparationModelBadge() {
return;
}
+ const juce::String noneText(juce::CharPointer_UTF8("Model: none installed \xe2\x80\x94 open Models"));
const auto activeId = modelManager_.activePackId("separation");
if (activeId.empty()) {
- controlDeck_->setSeparationModelStatus("Separation model: none", false);
+ controlDeck_->setSeparationModelStatus(noneText, false);
return;
}
@@ -2026,7 +2143,7 @@ void MainLayout::updateSeparationModelBadge() {
return util::toLower(pack.taskScope) == "separation" && pack.id == activeId;
});
if (selected == packs.end()) {
- controlDeck_->setSeparationModelStatus("Separation model: none", false);
+ controlDeck_->setSeparationModelStatus(noneText, false);
return;
}
@@ -2034,7 +2151,21 @@ void MainLayout::updateSeparationModelBadge() {
const auto modelPath = selected->rootPath / selected->modelFile;
const bool ready = std::filesystem::is_regular_file(modelPath, error) && !error;
const auto displayName = selected->name.empty() ? selected->id : selected->name;
- auto badgeText = juce::String("Separation model: ") + juce::String(displayName);
+ const auto mentions = [&](const char* repoId) {
+ const juce::String repo(repoId);
+ const auto sanitizedRepo = repo.replaceCharacter('/', '_');
+ for (const auto& field : {selected->id, selected->name, selected->source}) {
+ const juce::String value(field);
+ if (value.containsIgnoreCase(repo) || value.containsIgnoreCase(sanitizedRepo))
+ return true;
+ }
+ return false;
+ };
+ juce::String badgeText = "Model: " + juce::String(displayName);
+ if (mentions(ai::kBsRoformerRepoId))
+ badgeText = "Model: BS-RoFormer (best quality, GPU recommended)";
+ else if (mentions(ai::kUmxVocalsRepoId))
+ badgeText = "Model: Open-Unmix (fast, lower quality)";
if (!ready) {
badgeText << " (missing)";
}
@@ -2140,6 +2271,7 @@ void MainLayout::applySessionUiSelections() {
controlDeck_->getTensorSeparationToggle().setToggleState(session.renderSettings.tensorSeparationEnabled,
juce::dontSendNotification);
controlDeck_->getTensorSeparationToggle().setEnabled(session.aiStemsEnabled);
+ controlDeck_->setSeparationControlsVisible(session.aiStemsEnabled);
controlDeck_->getBatchRecursiveToggle().setToggleState(session.batchRecursiveEnabled, juce::dontSendNotification);
controlDeck_->getRendererChainToggle().setToggleState(
session.renderSettings.rendererChainEnabled,
@@ -2376,9 +2508,23 @@ juce::String MainLayout::gpuRuntimeButtonText() const {
void MainLayout::onGpuRuntimeButton() {
namespace pack = ai::GpuRuntimePack;
if (pack::isInstalled(pack::defaultRoot())) {
- const auto removed = pack::uninstall();
- ai::invalidateTensorProviderProbeCache();
- taskOrchestrator_->appendHistory(juce::String(removed.message));
+ juce::AlertWindow::showAsync(
+ juce::MessageBoxOptions::makeOptionsOkCancel(
+ juce::MessageBoxIconType::WarningIcon,
+ "Remove GPU runtime?",
+ "This deletes the downloaded NVIDIA libraries. The vocal model will run on the CPU until you install them "
+ "again.",
+ "Remove",
+ "Cancel"),
+ [safe = juce::Component::SafePointer(this)](const int result) {
+ if (result == 0 || safe == nullptr) {
+ return;
+ }
+ namespace pack = ai::GpuRuntimePack;
+ const auto removed = pack::uninstall();
+ ai::invalidateTensorProviderProbeCache();
+ safe->taskOrchestrator_->appendHistory(juce::String(removed.message));
+ });
return;
}
gpuRuntimeOffered_ = true; // asked explicitly; no need to offer again this run
diff --git a/src/app/ui/MainLayout.h b/src/app/ui/MainLayout.h
index 77a4431..da3cd80 100644
--- a/src/app/ui/MainLayout.h
+++ b/src/app/ui/MainLayout.h
@@ -31,6 +31,17 @@
namespace automix::app {
+/// Next step of the quit flow (pure decision, unit-tested; the prompts themselves are modal UI).
+enum class QuitStep { QuitNow, ConfirmRunningTask, PromptSave };
+
+/// A running task must be confirmed once before anything else; after that (or with no task) a
+/// modified session gets the Save prompt and an unmodified one quits immediately.
+inline QuitStep nextQuitStep(bool taskRunning, bool taskQuitConfirmed, bool modified) {
+ if (taskRunning && !taskQuitConfirmed)
+ return QuitStep::ConfirmRunningTask;
+ return modified ? QuitStep::PromptSave : QuitStep::QuitNow;
+}
+
// ── Keyboard shortcut table (single source of truth) ────────────────────
// Command ids start at 1000 (0 is reserved as "no command" by JUCE).
@@ -145,6 +156,11 @@ class MainLayout final : public juce::Component,
void resized() override;
bool keyPressed(const juce::KeyPress& key) override;
+ /// Runs quitNow immediately when no task is running and the session is unchanged (returns true);
+ /// otherwise confirms quitting over a running task and/or asks Save / Don't Save / Cancel and
+ /// returns false.
+ bool requestQuit(std::function quitNow);
+
private:
// Timer / Listener overrides
void timerCallback() override;
@@ -172,7 +188,12 @@ class MainLayout final : public juce::Component,
void onAutoMixMaster(); // Pipeline: Mix -> Master -> Export
void onBatch();
void onExport();
- void onSaveSession();
+ void onSaveSession(std::function done = {});
+ void finishPendingSave(bool success);
+ void refreshSessionTitle();
+ bool requestQuitStep(std::function quitNow, bool taskQuitConfirmed);
+ void refreshStemDependentUi();
+ void setSessionDisplayName(const juce::String& name);
void onLoadSession();
void onModelsDialog();
void onSettings();
@@ -257,6 +278,13 @@ class MainLayout final : public juce::Component,
std::unique_ptr previewManager_;
SessionManager sessionManager_;
+ // Unsaved-changes tracking (message thread only).
+ juce::String sessionDisplayName_{"Untitled Session"};
+ bool sessionShownModified_ = false;
+ int modifiedCheckTicks_ = 0;
+ bool quitPromptOpen_ = false;
+ std::function pendingSaveCompletion_;
+
// State
std::vector analysisEntries_;
std::vector rendererInfos_;
diff --git a/src/app/ui/MainLayoutInternal.h b/src/app/ui/MainLayoutInternal.h
index 1ecce63..832e007 100644
--- a/src/app/ui/MainLayoutInternal.h
+++ b/src/app/ui/MainLayoutInternal.h
@@ -18,6 +18,7 @@
#include
#include
+#include "app/style/AutoMixLookAndFeel.h"
#include "ai/ModelManager.h"
#include "ai/OnnxModelInference.h"
#include "automaster/IAutoMasterStrategy.h"
@@ -246,6 +247,7 @@ class SettingsPanel final : public juce::Component {
gpuStatusLabel_.setText(gpuStatus, juce::dontSendNotification);
gpuButton_.setButtonText(gpuButtonText);
gpuButton_.setVisible(gpuButtonText.isNotEmpty());
+ setButtonVariant(gpuButton_, gpuButtonText == "Remove" ? buttonVariant::danger : buttonVariant::secondary);
gpuButton_.onClick = [this] {
gpuButton_.setEnabled(false); // one action per dialog
if (onGpuButton_) {
@@ -255,6 +257,15 @@ class SettingsPanel final : public juce::Component {
addAndMakeVisible(gpuStatusLabel_);
addChildComponent(gpuButton_);
+ for (auto* heading : {&exportHeading_, &gpuHeading_, &audioHeading_}) {
+ heading->setFont(theme::typography::subhead());
+ heading->setColour(juce::Label::textColourId, theme::colour(theme::colours::textMuted));
+ addAndMakeVisible(*heading);
+ }
+ exportHeading_.setText("Export", juce::dontSendNotification);
+ gpuHeading_.setText("GPU acceleration", juce::dontSendNotification);
+ audioHeading_.setText("Audio output", juce::dontSendNotification);
+
reportSidecarToggle_.setButtonText("Write .report.json sidecar next to each exported file");
reportSidecarToggle_.setTooltip("Disable to export only audio files without per-file JSON report sidecars.");
reportSidecarToggle_.setToggleState(writeReportJsonSidecar, juce::dontSendNotification);
@@ -269,9 +280,12 @@ class SettingsPanel final : public juce::Component {
}
void resized() override {
+ styleAudioSelectorButtons(audioSelector_);
auto area = getLocalBounds().reduced(10);
+ exportHeading_.setBounds(area.removeFromTop(24));
reportSidecarToggle_.setBounds(area.removeFromTop(28));
- area.removeFromTop(6);
+ area.removeFromTop(10);
+ gpuHeading_.setBounds(area.removeFromTop(24));
auto gpuRow = area.removeFromTop(28);
if (gpuButton_.isVisible()) {
gpuButton_.setBounds(gpuRow.removeFromRight(130));
@@ -279,13 +293,29 @@ class SettingsPanel final : public juce::Component {
}
gpuStatusLabel_.setBounds(gpuRow);
area.removeFromTop(10);
+ audioHeading_.setBounds(area.removeFromTop(24));
audioSelector_.setBounds(area);
}
private:
+ // The "Test" button lives inside JUCE's audio selector; find it by its text.
+ static void styleAudioSelectorButtons(juce::Component& parent) {
+ for (auto* child : parent.getChildren()) {
+ if (auto* button = dynamic_cast(child)) {
+ if (button->getButtonText() == "Test")
+ setButtonVariant(*button, buttonVariant::secondary);
+ } else if (child != nullptr) {
+ styleAudioSelectorButtons(*child);
+ }
+ }
+ }
+
juce::AudioDeviceSelectorComponent audioSelector_;
juce::ToggleButton reportSidecarToggle_;
std::function onWriteReportSidecarChanged_;
+ juce::Label exportHeading_;
+ juce::Label gpuHeading_;
+ juce::Label audioHeading_;
juce::Label gpuStatusLabel_;
juce::TextButton gpuButton_;
std::function onGpuButton_;
diff --git a/src/app/ui/SessionManager.cpp b/src/app/ui/SessionManager.cpp
index 9435f32..bf186e7 100644
--- a/src/app/ui/SessionManager.cpp
+++ b/src/app/ui/SessionManager.cpp
@@ -1,5 +1,7 @@
#include "app/ui/SessionManager.h"
+#include "domain/JsonSerialization.h"
+
namespace automix::app {
domain::Session& SessionManager::session() {
@@ -14,4 +16,17 @@ void SessionManager::replaceSession(domain::Session session) {
session_ = std::move(session);
}
+std::string SessionManager::snapshot() const {
+ const domain::Json json = session_;
+ return json.dump();
+}
+
+void SessionManager::markSaved() {
+ savedSnapshot_ = snapshot();
+}
+
+bool SessionManager::isModified() const {
+ return snapshot() != savedSnapshot_;
+}
+
} // namespace automix::app
diff --git a/src/app/ui/SessionManager.h b/src/app/ui/SessionManager.h
index 536e022..f7e3e0a 100644
--- a/src/app/ui/SessionManager.h
+++ b/src/app/ui/SessionManager.h
@@ -1,5 +1,6 @@
#pragma once
+#include
#include
#include "domain/Session.h"
@@ -15,8 +16,15 @@ class SessionManager {
void replaceSession(domain::Session session);
+ /// Records the current session as the saved baseline for isModified().
+ void markSaved();
+ bool isModified() const;
+
private:
+ std::string snapshot() const;
+
domain::Session session_;
+ std::string savedSnapshot_;
};
} // namespace automix::app
diff --git a/src/app/ui/TaskCenterPanel.cpp b/src/app/ui/TaskCenterPanel.cpp
index 4b55df8..1773d96 100644
--- a/src/app/ui/TaskCenterPanel.cpp
+++ b/src/app/ui/TaskCenterPanel.cpp
@@ -1,5 +1,7 @@
#include "app/ui/TaskCenterPanel.h"
+#include "app/style/AutoMixLookAndFeel.h"
+
#include
#include
@@ -11,16 +13,16 @@ namespace automix::app {
using namespace theme;
TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgressBar_(batchProgressValue_) {
- taskLabel_.setText("Ready", juce::dontSendNotification);
+ taskLabel_.setText("", juce::dontSendNotification);
taskLabel_.setFont(typography::body());
taskLabel_.setColour(juce::Label::textColourId, colour(colours::text));
taskLabel_.setJustificationType(juce::Justification::centredLeft);
- stateBadge_.setText("IDLE", juce::dontSendNotification);
+ stateBadge_.setText("READY", juce::dontSendNotification);
stateBadge_.setFont(typography::caption());
stateBadge_.setJustificationType(juce::Justification::centred);
stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(TaskState::Idle));
- stateBadge_.setColour(juce::Label::textColourId, juce::Colours::white);
+ stateBadge_.setColour(juce::Label::textColourId, stateTextColour(TaskState::Idle));
progressLabel_.setText("0%", juce::dontSendNotification);
progressLabel_.setFont(typography::caption());
@@ -39,10 +41,15 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress
.withPointHeight(12.0f)));
historyEditor_.setText("Task history will appear here.");
+ toggleLogButton_.onClick = [this] { setLogVisible(!logVisible_); };
+ setButtonVariant(toggleLogButton_, buttonVariant::quiet);
+
copyLogButton_.onClick = [this] {
juce::SystemClipboard::copyTextToClipboard(historyEditor_.getText());
};
+ setButtonVariant(copyLogButton_, buttonVariant::quiet);
+ setButtonVariant(cancelButton_, buttonVariant::secondary);
cancelButton_.setEnabled(false);
cancelButton_.onClick = [this] {
if (onCancel)
@@ -76,6 +83,7 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress
addAndMakeVisible(stateBadge_);
addAndMakeVisible(progressBar_);
addAndMakeVisible(progressLabel_);
+ addAndMakeVisible(toggleLogButton_);
addAndMakeVisible(copyLogButton_);
addAndMakeVisible(cancelButton_);
addAndMakeVisible(queueHeaderLabel_);
@@ -86,6 +94,9 @@ TaskCenterPanel::TaskCenterPanel() : progressBar_(progressValue_), batchProgress
addAndMakeVisible(batchProgressBar_);
addAndMakeVisible(historyEditor_);
+ progressLabel_.setVisible(false); // Idle (READY) at start
+ progressBar_.setVisible(false);
+ historyEditor_.setVisible(false); // Log is collapsed by default
updateBatchSummary();
}
@@ -96,7 +107,7 @@ void TaskCenterPanel::paint(juce::Graphics& g) {
g.setColour(colour(colours::surfaceBorder));
g.fillRect(0, 0, getWidth(), 1);
- if (!queueItems_.empty()) {
+ if (batchActive_ && !queueItems_.empty()) {
auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium));
// Mirror resized() layout: skip top row (24) + queue header row (18)
area.removeFromTop(24);
@@ -145,43 +156,92 @@ void TaskCenterPanel::drawQueueItem(juce::Graphics& g, juce::Rectangle bo
void TaskCenterPanel::resized() {
auto area = getLocalBounds().reduced(static_cast(metrics::paddingMedium));
- // Top row: state badge + task label + progress + cancel
- auto topRow = area.removeFromTop(24);
+ // Top row: state badge + task label + progress + log toggle + copy log + cancel
+ auto topRow = area.removeFromTop(kTopRowHeight);
cancelButton_.setBounds(topRow.removeFromRight(64).reduced(1));
copyLogButton_.setBounds(topRow.removeFromRight(84).reduced(1));
+ toggleLogButton_.setBounds(topRow.removeFromRight(84).reduced(1));
progressLabel_.setBounds(topRow.removeFromRight(48).reduced(1));
auto progressArea = topRow.removeFromRight(std::min(220, topRow.getWidth() / 2));
progressBar_.setBounds(progressArea.reduced(2));
stateBadge_.setBounds(topRow.removeFromLeft(80).reduced(1));
taskLabel_.setBounds(topRow);
- // Batch queue header row
- auto queueHeaderRow = area.removeFromTop(18);
- queueEtaLabel_.setBounds(queueHeaderRow.removeFromRight(100).reduced(1));
- queueThroughputLabel_.setBounds(queueHeaderRow.removeFromRight(80).reduced(1));
- queueHeaderLabel_.setBounds(queueHeaderRow.reduced(1));
+ if (batchActive_) {
+ // Batch queue header row
+ auto queueHeaderRow = area.removeFromTop(18);
+ queueEtaLabel_.setBounds(queueHeaderRow.removeFromRight(100).reduced(1));
+ queueThroughputLabel_.setBounds(queueHeaderRow.removeFromRight(80).reduced(1));
+ queueHeaderLabel_.setBounds(queueHeaderRow.reduced(1));
+
+ // Queue items
+ if (!queueItems_.empty()) {
+ int queueHeight = std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight;
+ area.removeFromTop(queueHeight + 4);
+ }
- // Queue items
- if (!queueItems_.empty()) {
- int queueHeight = std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight;
- area.removeFromTop(queueHeight + 4);
+ // Batch ETA / summary row: ETA (left) + overall progress bar (middle) + counts (right)
+ auto summaryRow = area.removeFromTop(22);
+ etaLabel_.setBounds(summaryRow.removeFromLeft(92).reduced(1));
+ batchCountsLabel_.setBounds(summaryRow.removeFromRight(190).reduced(1));
+ batchProgressBar_.setBounds(summaryRow.reduced(2, 4));
}
- // Batch ETA / summary row: ETA (left) + overall progress bar (middle) + counts (right)
- auto summaryRow = area.removeFromTop(22);
- etaLabel_.setBounds(summaryRow.removeFromLeft(92).reduced(1));
- batchCountsLabel_.setBounds(summaryRow.removeFromRight(190).reduced(1));
- batchProgressBar_.setBounds(summaryRow.reduced(2, 4));
-
- // Remaining: history editor
+ // Remaining: history editor (only when the log is shown)
area.removeFromTop(4);
- historyEditor_.setBounds(area);
+ if (logVisible_)
+ historyEditor_.setBounds(area);
}
-void TaskCenterPanel::setQueueItems(const std::vector& items) {
- queueItems_ = items;
+int TaskCenterPanel::getPreferredHeight() const {
+ int height = 2 * static_cast(metrics::paddingMedium) + kTopRowHeight;
+ if (batchActive_) {
+ height += 18 + 22;
+ if (!queueItems_.empty())
+ height += std::min(static_cast(queueItems_.size()), kMaxVisibleQueueItems) * kQueueItemHeight + 4;
+ }
+ if (logVisible_)
+ height += 4 + kLogAreaHeight;
+ return height;
+}
+
+void TaskCenterPanel::setLogVisible(bool visible) {
+ if (logVisible_ == visible)
+ return;
+ logVisible_ = visible;
+ toggleLogButton_.setButtonText(logVisible_ ? "Hide log" : "Show log");
+ historyEditor_.setVisible(logVisible_);
+ resized();
+ repaint();
+ if (onPreferredHeightChanged)
+ onPreferredHeightChanged();
+}
+
+bool TaskCenterPanel::isBatchStripVisible() const {
+ return batchActive_;
+}
+
+bool TaskCenterPanel::isLogVisible() const {
+ return logVisible_;
+}
+
+void TaskCenterPanel::refreshBatchVisibility() {
+ batchActive_ = batchDetail_.totalCount > 0 || !queueItems_.empty();
+ queueHeaderLabel_.setVisible(batchActive_);
+ queueEtaLabel_.setVisible(batchActive_);
+ queueThroughputLabel_.setVisible(batchActive_);
+ etaLabel_.setVisible(batchActive_);
+ batchCountsLabel_.setVisible(batchActive_);
+ batchProgressBar_.setVisible(batchActive_);
resized();
repaint();
+ if (onPreferredHeightChanged)
+ onPreferredHeightChanged();
+}
+
+void TaskCenterPanel::setQueueItems(const std::vector& items) {
+ queueItems_ = items;
+ refreshBatchVisibility();
}
void TaskCenterPanel::setQueueEta(const juce::String& eta) {
@@ -210,8 +270,7 @@ void TaskCenterPanel::removeQueueItem(int index) {
if (index < 0 || index >= static_cast(queueItems_.size()))
return;
queueItems_.erase(queueItems_.begin() + index);
- resized();
- repaint();
+ refreshBatchVisibility();
if (onQueueItemRemoved) onQueueItemRemoved(index);
}
@@ -273,13 +332,14 @@ juce::String TaskCenterPanel::formatEta(const juce::RelativeTime& eta) {
}
void TaskCenterPanel::updateBatchSummary() {
+ refreshBatchVisibility();
const size_t total = batchDetail_.totalCount;
const size_t completed = batchDetail_.completedCount;
const size_t failed = batchDetail_.failedCount;
if (total == 0) {
// No batch items: em dash "—".
- etaLabel_.setText(juce::String(static_cast(0x2014)), juce::dontSendNotification);
+ etaLabel_.setText(juce::String::charToString(static_cast(0x2014)), juce::dontSendNotification);
} else {
const int currentIndex = static_cast(batchDetail_.itemIndex) + 1;
if (currentIndex >= static_cast(total)) {
@@ -301,8 +361,13 @@ void TaskCenterPanel::setCanCancel(bool canCancel) {
void TaskCenterPanel::setTaskState(TaskState state) {
currentState_ = state;
+ progressLabel_.setVisible(state != TaskState::Idle);
+ progressBar_.setVisible(state != TaskState::Idle); // the bar paints its own "0%" text
+ if (state == TaskState::Failed)
+ setLogVisible(true);
stateBadge_.setText(juce::String(stateLabel(state)), juce::dontSendNotification);
stateBadge_.setColour(juce::Label::backgroundColourId, stateColour(state));
+ stateBadge_.setColour(juce::Label::textColourId, stateTextColour(state));
stateBadge_.repaint();
}
@@ -369,9 +434,20 @@ juce::Colour TaskCenterPanel::stateColour(TaskState state) {
return colour(colours::textMuted);
}
+juce::Colour TaskCenterPanel::stateTextColour(TaskState state) {
+ switch (state) {
+ case TaskState::Idle:
+ case TaskState::Cancelled:
+ case TaskState::Completed: return colour(colours::background);
+ case TaskState::Running:
+ case TaskState::Failed: return juce::Colours::white;
+ }
+ return colour(colours::background);
+}
+
const char* TaskCenterPanel::stateLabel(TaskState state) {
switch (state) {
- case TaskState::Idle: return "IDLE";
+ case TaskState::Idle: return "READY";
case TaskState::Running: return "RUNNING";
case TaskState::Cancelled: return "CANCELLED";
case TaskState::Completed: return "COMPLETED";
diff --git a/src/app/ui/TaskCenterPanel.h b/src/app/ui/TaskCenterPanel.h
index bf3a79b..08bd239 100644
--- a/src/app/ui/TaskCenterPanel.h
+++ b/src/app/ui/TaskCenterPanel.h
@@ -55,7 +55,14 @@ class TaskCenterPanel final : public juce::Component {
/// Current item-status counts text ("completed/failed/total").
juce::String batchCountsText() const;
+ /// Height needed for the top row, the batch strip (when active) and the log (when shown).
+ int getPreferredHeight() const;
+ bool isBatchStripVisible() const;
+ bool isLogVisible() const;
+ bool isProgressPercentVisible() const { return progressLabel_.isVisible(); }
+
// Callbacks
+ std::function onPreferredHeightChanged;
std::function onCancel;
std::function onQueueItemMoved;
std::function onQueueItemRemoved;
@@ -65,18 +72,24 @@ class TaskCenterPanel final : public juce::Component {
static constexpr int kHistoryTrimChunkLines = 300;
static constexpr int kQueueItemHeight = 24;
static constexpr int kMaxVisibleQueueItems = 8;
+ static constexpr int kTopRowHeight = 24;
+ static constexpr int kLogAreaHeight = 160;
static juce::Colour stateColour(TaskState state);
+ static juce::Colour stateTextColour(TaskState state);
static const char* stateLabel(TaskState state);
void drawQueueItem(juce::Graphics& g, juce::Rectangle bounds, const BatchQueueItem& item, int index);
static juce::String formatEta(const juce::RelativeTime& eta);
void updateBatchSummary();
+ void refreshBatchVisibility();
+ void setLogVisible(bool visible);
juce::Label taskLabel_;
juce::Label stateBadge_;
double progressValue_ = 0.0;
juce::ProgressBar progressBar_;
juce::Label progressLabel_;
+ juce::TextButton toggleLogButton_{"Show log"};
juce::TextButton copyLogButton_{"Copy Log"};
juce::TextButton cancelButton_{"Cancel"};
juce::TextEditor historyEditor_;
@@ -96,6 +109,8 @@ class TaskCenterPanel final : public juce::Component {
juce::ProgressBar batchProgressBar_;
TaskState currentState_ = TaskState::Idle;
+ bool batchActive_ = false;
+ bool logVisible_ = false;
bool hasHistoryEntries_ = false;
int historyLineCount_ = 0;
diff --git a/src/app/ui/TransportBar.cpp b/src/app/ui/TransportBar.cpp
index f5aec32..9aef5ff 100644
--- a/src/app/ui/TransportBar.cpp
+++ b/src/app/ui/TransportBar.cpp
@@ -1,5 +1,7 @@
#include "app/ui/TransportBar.h"
+#include "app/style/AutoMixLookAndFeel.h"
+
#include
#include
@@ -19,6 +21,20 @@ TransportBar::TransportBar() {
addAndMakeVisible(volumeSlider_);
addAndMakeVisible(loopToggle_);
+ for (juce::Button* btn : {static_cast(&skipStartButton_), static_cast(&playPauseButton_),
+ static_cast(&stopButton_), static_cast(&skipEndButton_),
+ static_cast(&shortcutsButton_)})
+ setButtonVariant(*btn, buttonVariant::secondary);
+
+ // Accessibility titles
+ skipStartButton_.setTitle("Skip to start");
+ playPauseButton_.setTitle("Play");
+ stopButton_.setTitle("Stop");
+ skipEndButton_.setTitle("Skip to end");
+ shortcutsButton_.setTitle("Keyboard shortcuts");
+ loopToggle_.setTitle("Loop");
+ volumeSlider_.setTitle("Volume");
+
// Tooltips
skipStartButton_.setTooltip("Skip to Start (Home)");
playPauseButton_.setTooltip("Play / Pause (Space)");
@@ -110,6 +126,13 @@ void TransportBar::resized() {
timeLabel_.setBounds(area);
}
+void TransportBar::setHasMedia(bool hasMedia) {
+ skipStartButton_.setEnabled(hasMedia);
+ playPauseButton_.setEnabled(hasMedia);
+ stopButton_.setEnabled(hasMedia);
+ skipEndButton_.setEnabled(hasMedia);
+}
+
void TransportBar::setPlaying(bool playing) {
isPlaying_ = playing;
playPauseButton_.setButtonText(playing ? "Pause" : "Play");
diff --git a/src/app/ui/TransportBar.h b/src/app/ui/TransportBar.h
index 236bf53..468dc15 100644
--- a/src/app/ui/TransportBar.h
+++ b/src/app/ui/TransportBar.h
@@ -17,6 +17,8 @@ class TransportBar final : public juce::Component, private juce::Slider::Listene
bool keyPressed(const juce::KeyPress& key) override;
void setPlaying(bool playing);
+ /// Enables the skip/play/stop buttons only when there is media to transport.
+ void setHasMedia(bool hasMedia);
void setTimeDisplay(double currentSeconds, double totalSeconds);
void setVolume(double volume);
void setLoopEnabled(bool enabled);
diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp
index e6c73d9..cccb667 100644
--- a/src/domain/JsonSerialization.cpp
+++ b/src/domain/JsonSerialization.cpp
@@ -311,6 +311,8 @@ void from_json(const Json& j, Session& value) {
value.residualBlend = std::clamp(j.value("residualBlend", 0.0), 0.0, 10.0);
value.aiStemsEnabled = j.value("aiStemsEnabled", false);
value.batchRecursiveEnabled = j.value("batchRecursiveEnabled", false);
+ // Session files saved before this field existed were mastered with Udio Optimized, so keep that
+ // fallback for them; new sessions get Default Streaming from the Session struct default instead.
value.selectedMasterPreset = masterPresetFromString(j.value("selectedMasterPreset", "udio_optimized"));
value.selectedPlatformPreset = masterPresetFromString(j.value("selectedPlatformPreset", "youtube"));
value.stems = j.value("stems", std::vector{});
diff --git a/src/domain/Session.h b/src/domain/Session.h
index e47898a..6ffda79 100644
--- a/src/domain/Session.h
+++ b/src/domain/Session.h
@@ -27,7 +27,7 @@ struct Session {
double residualBlend = 0.0;
bool aiStemsEnabled = false;
bool batchRecursiveEnabled = false;
- MasterPreset selectedMasterPreset = MasterPreset::UdioOptimized;
+ MasterPreset selectedMasterPreset = MasterPreset::DefaultStreaming;
MasterPreset selectedPlatformPreset = MasterPreset::YouTube;
std::vector stems;
std::vector buses;
diff --git a/tests/unit/ControlDeckTests.cpp b/tests/unit/ControlDeckTests.cpp
new file mode 100644
index 0000000..b5daf69
--- /dev/null
+++ b/tests/unit/ControlDeckTests.cpp
@@ -0,0 +1,98 @@
+#include
+#include
+
+#include
+#include
+
+#include "app/ui/ControlDeck.h"
+#include "app/ui/GlowMeters.h"
+
+namespace {
+
+juce::Button* findButtonByText(juce::Component& parent, const juce::String& text) {
+ for (auto* child : parent.getChildren()) {
+ if (auto* button = dynamic_cast(child); button != nullptr && button->getButtonText() == text)
+ return button;
+ if (child != nullptr) {
+ if (auto* nested = findButtonByText(*child, text))
+ return nested;
+ }
+ }
+ return nullptr;
+}
+
+} // namespace
+
+TEST_CASE("ControlDeck gates stem-dependent actions on setHasStems", "[ui][controldeck]") {
+ juce::ScopedJuceInitialiser_GUI juceInit;
+
+ automix::app::ControlDeck deck;
+ deck.setSize(1000, 300);
+
+ const auto enabled = [&](const char* name) {
+ auto* button = findButtonByText(deck, name);
+ REQUIRE(button != nullptr);
+ return button->isEnabled();
+ };
+
+ deck.setHasStems(false);
+ REQUIRE_FALSE(enabled("Auto Mix"));
+ REQUIRE_FALSE(enabled("Auto Master"));
+ REQUIRE_FALSE(enabled("Mix + Master"));
+ REQUIRE_FALSE(enabled("Export"));
+ REQUIRE(enabled("Batch"));
+ REQUIRE(enabled("Import"));
+
+ deck.setHasStems(true);
+ REQUIRE(enabled("Auto Mix"));
+ REQUIRE(enabled("Auto Master"));
+ REQUIRE(enabled("Mix + Master"));
+ REQUIRE(enabled("Export"));
+ REQUIRE(enabled("Batch"));
+ REQUIRE(enabled("Import"));
+}
+
+TEST_CASE("ControlDeck shows the Vocal Model toggle only with AI Stem Separation", "[ui][controldeck]") {
+ juce::ScopedJuceInitialiser_GUI juceInit;
+
+ automix::app::ControlDeck deck;
+ deck.setSize(1000, 300);
+
+ deck.setSeparationControlsVisible(false);
+ REQUIRE_FALSE(deck.getTensorSeparationToggle().isVisible());
+
+ deck.setSeparationControlsVisible(true);
+ REQUIRE(deck.getTensorSeparationToggle().isVisible());
+}
+
+TEST_CASE("GlowMeters readouts show -- for no signal", "[ui][glowmeters]") {
+ using automix::app::GlowMeters;
+ REQUIRE(GlowMeters::formatReadout("M: ", -70.0, " LUFS") == "M: -- LUFS");
+ REQUIRE(GlowMeters::formatReadout("TP: ", -69.95, " dBTP") == "TP: -- dBTP");
+ REQUIRE(GlowMeters::formatReadout("I: ", -std::numeric_limits::infinity(), " LUFS") == "I: -- LUFS");
+ REQUIRE(GlowMeters::formatReadout("S: ", -14.0, " LUFS") == "S: -14.0 LUFS");
+}
+
+TEST_CASE("ControlDeck settings row keeps Platform combo visible and non-overlapping", "[ui][controldeck]") {
+ juce::ScopedJuceInitialiser_GUI juceInit;
+
+ for (const int width : {940, 1500}) {
+ automix::app::ControlDeck deck;
+ deck.setSize(width, 420);
+
+ const auto platform = deck.getPlatformPresetBox().getBounds();
+ const auto profile = deck.getProfileBox().getBounds();
+ const auto master = deck.getMasterPresetBox().getBounds();
+ REQUIRE_FALSE(platform.isEmpty());
+ REQUIRE(deck.getLocalBounds().contains(platform));
+ REQUIRE(platform.getRight() <= deck.getWidth());
+ REQUIRE_FALSE(platform.intersects(profile));
+ REQUIRE_FALSE(platform.intersects(master));
+
+ auto* autoMaster = findButtonByText(deck, "Auto Master");
+ auto* import = findButtonByText(deck, "Import");
+ REQUIRE(autoMaster != nullptr);
+ REQUIRE(import != nullptr);
+ REQUIRE(autoMaster->getHeight() == import->getHeight());
+ }
+}
diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp
index 2763d43..813f2d7 100644
--- a/tests/unit/SessionSerializationTests.cpp
+++ b/tests/unit/SessionSerializationTests.cpp
@@ -3,6 +3,7 @@
#include
#include
+#include "app/ui/SessionManager.h"
#include "domain/JsonSerialization.h"
#include "engine/SessionRepository.h"
@@ -179,4 +180,35 @@ TEST_CASE("Sessions from before PhaseLimiter became opt-in load with BuiltIn", "
REQUIRE(automix::domain::Session{}.schemaVersion == 3);
REQUIRE(automix::domain::RenderSettings{}.rendererName == "BuiltIn");
-}
\ No newline at end of file
+}
+TEST_CASE("Default session uses the Default Streaming master preset", "[session]") {
+ const automix::domain::Session session;
+ REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming);
+}
+
+TEST_CASE("Old session without selectedMasterPreset keeps the Udio Optimized fallback", "[session]") {
+ const nlohmann::json legacy = {{"schemaVersion", 1}, {"sessionName", "legacy"}};
+ REQUIRE(legacy.get().selectedMasterPreset ==
+ automix::domain::MasterPreset::UdioOptimized);
+}
+
+TEST_CASE("Explicit default_streaming master preset round-trips", "[session]") {
+ automix::domain::Session session;
+ REQUIRE(session.selectedMasterPreset == automix::domain::MasterPreset::DefaultStreaming);
+ const nlohmann::json encoded = session;
+ REQUIRE(encoded.at("selectedMasterPreset").get() == "default_streaming");
+ REQUIRE(encoded.get().selectedMasterPreset ==
+ automix::domain::MasterPreset::DefaultStreaming);
+}
+
+TEST_CASE("SessionManager tracks unsaved changes", "[session]") {
+ automix::app::SessionManager manager;
+ manager.markSaved();
+ REQUIRE_FALSE(manager.isModified());
+
+ manager.session().selectedMasterPreset = automix::domain::MasterPreset::Broadcast;
+ REQUIRE(manager.isModified());
+
+ manager.markSaved();
+ REQUIRE_FALSE(manager.isModified());
+}
diff --git a/tests/unit/TaskCenterPanelTests.cpp b/tests/unit/TaskCenterPanelTests.cpp
index 0202658..6a2164f 100644
--- a/tests/unit/TaskCenterPanelTests.cpp
+++ b/tests/unit/TaskCenterPanelTests.cpp
@@ -80,7 +80,7 @@ TEST_CASE("TaskCenterPanel batch ETA row state transitions", "[ui][taskcenter][e
juce::ScopedJuceInitialiser_GUI juceInit;
automix::app::TaskCenterPanel panel;
- const juce::String emDash(static_cast(0x2014));
+ const auto emDash = juce::String::charToString(static_cast(0x2014));
SECTION("no batch items shows dash") {
REQUIRE(panel.batchEtaText() == emDash);
@@ -232,3 +232,52 @@ TEST_CASE("confirmClear gates destructive clear on dialog result and non-empty s
REQUIRE_FALSE(confirmClear(0, false));
}
}
+
+TEST_CASE("TaskCenterPanel shows the batch strip only for a batch", "[ui][taskcenter][batch]") {
+ juce::ScopedJuceInitialiser_GUI juceInit;
+
+ automix::app::TaskCenterPanel panel;
+ panel.setSize(900, 400);
+ REQUIRE_FALSE(panel.isBatchStripVisible());
+ const int idleHeight = panel.getPreferredHeight();
+
+ automix::engine::BatchQueueRunner::ProgressDetail detail;
+ detail.totalCount = 3;
+ panel.setBatchProgress(detail, 0.0);
+ REQUIRE(panel.isBatchStripVisible());
+ REQUIRE(panel.getPreferredHeight() > idleHeight);
+
+ automix::engine::BatchQueueRunner::ProgressDetail cleared;
+ panel.setBatchProgress(cleared, 0.0);
+ REQUIRE_FALSE(panel.isBatchStripVisible());
+}
+
+TEST_CASE("TaskCenterPanel log is hidden by default and shown on failure", "[ui][taskcenter][log]") {
+ juce::ScopedJuceInitialiser_GUI juceInit;
+
+ automix::app::TaskCenterPanel panel;
+ panel.setSize(900, 400);
+ auto* editor = findHistoryEditor(panel);
+ REQUIRE(editor != nullptr);
+ REQUIRE_FALSE(panel.isLogVisible());
+ REQUIRE_FALSE(editor->isVisible());
+ const int collapsedHeight = panel.getPreferredHeight();
+
+ panel.setTaskState(automix::app::TaskState::Failed);
+ REQUIRE(panel.isLogVisible());
+ REQUIRE(editor->isVisible());
+ REQUIRE(panel.getPreferredHeight() == collapsedHeight + 4 + 160);
+}
+
+TEST_CASE("TaskCenterPanel hides the percent label while idle", "[ui][taskcenter]") {
+ juce::ScopedJuceInitialiser_GUI juceInit;
+
+ automix::app::TaskCenterPanel panel;
+ REQUIRE_FALSE(panel.isProgressPercentVisible());
+
+ panel.setTaskState(automix::app::TaskState::Running);
+ REQUIRE(panel.isProgressPercentVisible());
+
+ panel.setTaskState(automix::app::TaskState::Idle);
+ REQUIRE_FALSE(panel.isProgressPercentVisible());
+}
diff --git a/tests/unit/UiDecisionTests.cpp b/tests/unit/UiDecisionTests.cpp
new file mode 100644
index 0000000..36a6b95
--- /dev/null
+++ b/tests/unit/UiDecisionTests.cpp
@@ -0,0 +1,34 @@
+#include
+
+#include "app/ui/HeroWaveform.h"
+#include "app/ui/MainLayout.h"
+
+using automix::app::HeroWaveform;
+using automix::app::QuitStep;
+using automix::app::nextQuitStep;
+
+TEST_CASE("Quit flow confirms a running task before anything else", "[ui][quit]") {
+ // No task: modified -> save prompt, unmodified -> quit now.
+ REQUIRE(nextQuitStep(false, false, false) == QuitStep::QuitNow);
+ REQUIRE(nextQuitStep(false, false, true) == QuitStep::PromptSave);
+ REQUIRE(nextQuitStep(false, true, false) == QuitStep::QuitNow);
+ REQUIRE(nextQuitStep(false, true, true) == QuitStep::PromptSave);
+ // Task running and not yet confirmed: always ask first.
+ REQUIRE(nextQuitStep(true, false, false) == QuitStep::ConfirmRunningTask);
+ REQUIRE(nextQuitStep(true, false, true) == QuitStep::ConfirmRunningTask);
+ // Confirmed: continue into the unsaved-changes flow.
+ REQUIRE(nextQuitStep(true, true, false) == QuitStep::QuitNow);
+ REQUIRE(nextQuitStep(true, true, true) == QuitStep::PromptSave);
+}
+
+TEST_CASE("Click-to-import only fires for a left click inside an empty session", "[ui][import]") {
+ for (int bits = 0; bits < 16; ++bits) {
+ const bool hasStems = (bits & 1) != 0;
+ const bool left = (bits & 2) != 0;
+ const bool clicked = (bits & 4) != 0;
+ const bool inside = (bits & 8) != 0;
+ const bool expected = !hasStems && left && clicked && inside;
+ INFO("hasStems=" << hasStems << " left=" << left << " clicked=" << clicked << " inside=" << inside);
+ REQUIRE(HeroWaveform::shouldOpenImportOnClick(hasStems, left, clicked, inside) == expected);
+ }
+}