diff --git a/.github/workflows/nightly_golden_eval.yml b/.github/workflows/nightly_golden_eval.yml new file mode 100644 index 0000000..4568e51 --- /dev/null +++ b/.github/workflows/nightly_golden_eval.yml @@ -0,0 +1,41 @@ +name: Nightly Golden Eval + +on: + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + +jobs: + golden-eval: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Configure + run: cmake -S . -B build-codex -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON + + - name: Build + run: cmake --build build-codex --parallel + + - name: Run Unit + Regression Tests + run: ctest --test-dir build-codex --output-on-failure -j4 + + - name: Update Eval Trend + run: | + ./build-codex/automix_dev_tools eval-trend \ + --baseline tests/regression/baselines.json \ + --work-dir artifacts/ci_eval \ + --trend artifacts/eval/golden_trend.json \ + --out artifacts/eval/nightly_summary.json \ + --json + + - name: Upload Eval Artifacts + uses: actions/upload-artifact@v4 + with: + name: nightly-golden-eval + path: | + artifacts/eval/golden_trend.json + artifacts/eval/nightly_summary.json + artifacts/ci_eval diff --git a/.gitignore b/.gitignore index b7dab29..762993c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,65 +1,79 @@ -# Build artifacts -build/ -build_clean/ +# === Build & Output === +build*/ +out/ +bin/ +_deps/ +*.exe +*.dll +*.lib +*.pdb +*.ilk +*.obj +*.o +*.exp +artefacts/ +*artefacts/ +ntml +docs/ -# CMake generated files +# === CMake === CMakeCache.txt CMakeFiles/ cmake_install.cmake CTestTestfile.cmake DartConfiguration.tcl +CMakeUserPresets.json -# IDE and editor files -.vscode/ +# === IDE & Editors === .vs/ +.vscode/ +.idea/ *.vcxproj.user *.slnx.user - -# Analysis and cache files +*.suo +*.db +*.opendb .cache/ .clangd/ + +# === Python === +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.venv/ +venv/ + +# === Logs & Temporary === +*.log +*.tmp +tmp/ +tmpclaude* +build_config_output*.txt +changed_files.txt +current_diff.txt +local_commits.txt +full_status.txt +nul + +# === Analysis & Data === *.sarif *.analysis assets/**/analysis_data/ +.DS_Store +Thumbs.db +Desktop.ini -# Documentation -docs/ +# === Testing === +Testing/ +*.xml +tests/regression/output/ -# Personal data and sensitive files +# === Personal & Security === .env personal/ *.key *.pem .ssh/ -*.id_rsa -*.id_dsa config.ini - -# Temporary files and directories -tmp/ -*.tmp -*.log - -# Operating system files -Thumbs.db -.DS_Store -Desktop.ini - -# Compiled binaries and libraries -*.exe -*.dll -*.lib -*.pdb -*.ilk -*.obj -*.o - -# Test artifacts -Testing/ -*.xml - -# Dependencies (if any) -_deps/ - -# Artifacts directory (if exists) -artefacts/ \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 37cf127..9e682d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,16 +1,48 @@ cmake_minimum_required(VERSION 3.24) -project(AutoMixMaster VERSION 0.1.0 LANGUAGES CXX) +project(AutoMixMaster VERSION 0.1.0 LANGUAGES C CXX) + +if(CMAKE_VERSION VERSION_GREATER_EQUAL 4.0) + set(CMAKE_POLICY_VERSION_MINIMUM 3.5) +endif() + +if(WIN32 AND CMAKE_GENERATOR MATCHES "Visual Studio") + if(NOT CMAKE_GENERATOR MATCHES "Visual Studio 18 2026") + message(FATAL_ERROR "Windows Visual Studio builds require Visual Studio 2026 tools (generator: \"Visual Studio 18 2026\").") + endif() +endif() + +option(BUILD_SHARED_LIBS "Build libraries as shared" OFF) set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_EXTENSIONS OFF) +set(DISTRIBUTION_MODE "OSS" CACHE STRING "Distribution mode: OSS or PROPRIETARY") +set_property(CACHE DISTRIBUTION_MODE PROPERTY STRINGS OSS PROPRIETARY) +string(TOUPPER "${DISTRIBUTION_MODE}" DISTRIBUTION_MODE) +if(NOT DISTRIBUTION_MODE STREQUAL "OSS" AND NOT DISTRIBUTION_MODE STREQUAL "PROPRIETARY") + message(FATAL_ERROR "DISTRIBUTION_MODE must be OSS or PROPRIETARY.") +endif() + option(ENABLE_PHASELIMITER "Enable optional PhaseLimiter renderer" OFF) option(ENABLE_ONNX "Enable optional ONNX model inference adapter" OFF) option(ENABLE_RTNEURAL "Enable optional RTNeural model inference adapter" OFF) +option(RTNEURAL_XSIMD "Enable RTNeural XSIMD backend (if available)" ON) +option(RTNEURAL_USE_AVX "Enable RTNeural AVX backend on x86_64 (if available)" ON) +option(ENABLE_LIBEBUR128 "Enable libebur128 standards loudness metering" ON) +option(ENABLE_GPL_BUNDLED_LIMITERS "Enable bundled GPL limiters (OSS mode only)" OFF) +option(ENABLE_BUNDLED_LAME "Enable bundled LAME fallback lookup for MP3 export" ON) +option(ENABLE_EXTERNAL_TOOL_SUPPORT "Enable user-supplied external renderer integrations" ON) +option(ENABLE_SANITIZERS "Enable sanitizer instrumentation for debug builds" OFF) +option(ENABLE_CLANG_TIDY "Enable clang-tidy analysis while compiling" OFF) option(BUILD_TESTING "Build tests" ON) option(BUILD_TOOLS "Build developer tools" ON) +if(DISTRIBUTION_MODE STREQUAL "PROPRIETARY" AND ENABLE_GPL_BUNDLED_LIMITERS) + message(WARNING "ENABLE_GPL_BUNDLED_LIMITERS is disabled in PROPRIETARY mode.") + set(ENABLE_GPL_BUNDLED_LIMITERS OFF CACHE BOOL "Enable bundled GPL limiters (OSS mode only)" FORCE) +endif() + include(FetchContent) FetchContent_Declare( @@ -27,70 +59,163 @@ FetchContent_Declare( ) FetchContent_MakeAvailable(juce) +if(ENABLE_LIBEBUR128) + FetchContent_Declare( + libebur128 + GIT_REPOSITORY https://github.com/jiixyj/libebur128.git + GIT_TAG v1.2.6 + ) + FetchContent_MakeAvailable(libebur128) +endif() + add_library(automix_core src/domain/Bus.cpp + src/domain/BatchTypes.cpp src/domain/JsonSerialization.cpp src/domain/MasterPlan.cpp src/domain/MixPlan.cpp + src/domain/ProjectProfile.cpp src/domain/RenderSettings.cpp src/domain/Session.cpp src/domain/Stem.cpp src/domain/StemOrigin.cpp src/engine/AudioBuffer.cpp src/engine/AudioFileIO.cpp + src/engine/AudioPreviewEngine.cpp + src/engine/LoudnessMeter.cpp src/engine/AudioResampler.cpp + src/engine/TransportController.cpp + src/engine/BatchQueueRunner.cpp src/engine/OfflineRenderPipeline.cpp src/engine/ResidualBlendProcessor.cpp src/engine/SessionRepository.cpp + src/dsp/DeEsser.cpp + src/dsp/DynamicDeHarshEq.cpp + src/dsp/LookaheadLimiter.cpp + src/dsp/MidSideProcessor.cpp + src/dsp/MultibandProcessor.cpp src/dsp/SignalMath.cpp + src/dsp/SoftClipper.cpp + src/dsp/TruePeakDetector.cpp src/analysis/ArtifactRiskEstimator.cpp + src/analysis/StemHealthAssistant.cpp src/analysis/StemAnalyzer.cpp src/automix/HeuristicAutoMixStrategy.cpp src/automaster/HeuristicAutoMasterStrategy.cpp src/automaster/OriginalMixReference.cpp src/renderers/BuiltInRenderer.cpp + src/renderers/ExternalLimiterRenderer.cpp + src/renderers/RendererRegistry.cpp src/renderers/PhaseLimiterDiscovery.cpp src/renderers/PhaseLimiterRenderer.cpp src/renderers/RendererFactory.cpp + src/platform/ThirdPartyComponentRegistry.cpp + src/ai/HuggingFaceModelHub.cpp src/ai/ModelPackLoader.cpp src/ai/ModelManager.cpp src/ai/ModelStrategy.cpp src/ai/AutoMixStrategyAI.cpp src/ai/AutoMasterStrategyAI.cpp + src/ai/FeatureSchema.cpp src/ai/MasteringCompliance.cpp + src/ai/OnnxModelInference.cpp src/ai/RtNeuralInference.cpp + src/ai/StemSeparator.cpp src/ai/StemRoleClassifierAI.cpp + src/util/LameDownloader.cpp + src/util/MetadataPolicy.cpp src/util/WavWriter.cpp ) +target_include_directories(automix_core PUBLIC src) + +set(AUTOMIX_HAS_NATIVE_ORT OFF) if(ENABLE_ONNX) - target_sources(automix_core PRIVATE src/ai/OnnxModelInference.cpp) + find_path(ONNXRUNTIME_INCLUDE_DIR + NAMES onnxruntime_cxx_api.h + PATH_SUFFIXES onnxruntime/core/session + ) + find_library(ONNXRUNTIME_LIBRARY NAMES onnxruntime) + if(ONNXRUNTIME_INCLUDE_DIR AND ONNXRUNTIME_LIBRARY) + target_include_directories(automix_core PUBLIC ${ONNXRUNTIME_INCLUDE_DIR}) + target_link_libraries(automix_core PUBLIC ${ONNXRUNTIME_LIBRARY}) + set(AUTOMIX_HAS_NATIVE_ORT ON) + else() + message(WARNING "ENABLE_ONNX is ON but native ONNX Runtime headers/library were not found. Falling back to deterministic ONNX adapter.") + endif() endif() -target_include_directories(automix_core PUBLIC src) - target_link_libraries(automix_core PUBLIC nlohmann_json::nlohmann_json + juce::juce_events juce::juce_audio_formats juce::juce_dsp ) +set(LIBEBUR128_BACKEND_AVAILABLE OFF) +if(ENABLE_LIBEBUR128) + if(DEFINED libebur128_SOURCE_DIR) + target_include_directories(automix_core PUBLIC "${libebur128_SOURCE_DIR}/ebur128") + endif() + if(TARGET ebur128) + target_link_libraries(automix_core PUBLIC ebur128) + set(LIBEBUR128_BACKEND_AVAILABLE ON) + elseif(TARGET libebur128) + target_link_libraries(automix_core PUBLIC libebur128) + set(LIBEBUR128_BACKEND_AVAILABLE ON) + elseif(TARGET libebur128::libebur128) + target_link_libraries(automix_core PUBLIC libebur128::libebur128) + set(LIBEBUR128_BACKEND_AVAILABLE ON) + else() + message(WARNING "libebur128 target not found; falling back to built-in loudness approximation.") + endif() +endif() + target_compile_definitions(automix_core PUBLIC APP_VERSION="${PROJECT_VERSION}" SESSION_SCHEMA_VERSION=2 + DISTRIBUTION_MODE_${DISTRIBUTION_MODE}=1 + JUCE_WEB_BROWSER=0 + JUCE_USE_CURL=0 $<$:ENABLE_PHASELIMITER=1> $<$:ENABLE_ONNX=1> $<$:ENABLE_RTNEURAL=1> + $<$:ENABLE_LIBEBUR128=1> + $<$:ENABLE_GPL_BUNDLED_LIMITERS=1> + $<$:ENABLE_BUNDLED_LAME=1> + $<$:ENABLE_EXTERNAL_TOOL_SUPPORT=1> + $<$:RTNEURAL_XSIMD=1> + $<$:RTNEURAL_USE_AVX=1> + $<$:AUTOMIX_HAS_NATIVE_ORT=1> ) +if(ENABLE_CLANG_TIDY) + find_program(CLANG_TIDY_EXE NAMES clang-tidy) + if(CLANG_TIDY_EXE) + set_target_properties(automix_core PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_EXE}") + else() + message(WARNING "ENABLE_CLANG_TIDY is ON but clang-tidy was not found.") + endif() +endif() + if(MSVC) target_compile_options(automix_core PRIVATE /W4 /permissive-) else() target_compile_options(automix_core PRIVATE -Wall -Wextra -Wpedantic -Wconversion) endif() +if(ENABLE_SANITIZERS) + if(MSVC) + target_compile_options(automix_core PRIVATE /fsanitize=address /Zi) + target_link_options(automix_core PRIVATE /fsanitize=address) + else() + target_compile_options(automix_core PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(automix_core PRIVATE -fsanitize=address,undefined) + endif() +endif() + juce_add_gui_app(AutoMixMasterApp PRODUCT_NAME "AutoMixMaster" COMPANY_NAME "AutoMixMaster" @@ -99,6 +224,11 @@ juce_add_gui_app(AutoMixMasterApp target_sources(AutoMixMasterApp PRIVATE src/app/Main.cpp src/app/MainComponent.cpp + src/app/WaveformPreviewComponent.cpp + src/app/controllers/ModelController.cpp + src/app/controllers/ImportController.cpp + src/app/controllers/ExportController.cpp + src/app/controllers/ProcessingController.cpp ) target_include_directories(AutoMixMasterApp PRIVATE src) @@ -107,6 +237,7 @@ target_link_libraries(AutoMixMasterApp PRIVATE automix_core juce::juce_gui_extra + juce::juce_audio_devices juce::juce_audio_formats juce::juce_dsp ) @@ -117,9 +248,19 @@ target_compile_definitions(AutoMixMasterApp PRIVATE ) if(BUILD_TOOLS) - add_executable(automix_dev_tools tools/dev_tools.cpp) - target_include_directories(automix_dev_tools PRIVATE src) + add_executable(automix_dev_tools + tools/dev_tools.cpp + tools/commands/DevToolsUtils.cpp + tools/commands/ModelCommands.cpp + tools/commands/SessionCommands.cpp + tools/commands/RenderCommands.cpp + tools/commands/EvalCommands.cpp + tools/commands/BatchCommands.cpp + tests/regression/RegressionHarness.cpp + ) + target_include_directories(automix_dev_tools PRIVATE src tools tests/regression) target_link_libraries(automix_dev_tools PRIVATE automix_core) + target_compile_definitions(automix_dev_tools PRIVATE AUTOMIX_SOURCE_DIR="${CMAKE_SOURCE_DIR}") endif() if(BUILD_TESTING) @@ -141,8 +282,22 @@ if(BUILD_TESTING) tests/unit/ResidualBlendTests.cpp tests/unit/PhaseLimiterDiscoveryTests.cpp tests/unit/PhaseLimiterRendererTests.cpp + tests/unit/ExternalLimiterRendererTests.cpp + tests/unit/RendererRegistryTests.cpp + tests/unit/RendererRegistryTrustPolicyTests.cpp + tests/unit/ProjectProfileTests.cpp + tests/unit/MetadataPolicyTests.cpp tests/unit/AutoMasterTests.cpp tests/unit/AiExtensionTests.cpp + tests/unit/LoudnessMeterTests.cpp + tests/unit/LookaheadLimiterTests.cpp + tests/unit/MasteringModulesTests.cpp + tests/unit/AudioPreviewEngineTests.cpp + tests/unit/TransportControllerTests.cpp + tests/unit/BatchProcessingTests.cpp + tests/unit/OfflineRenderPipelineTests.cpp + tests/unit/OnnxModelInferenceTests.cpp + tests/unit/StemSeparatorTests.cpp tests/regression/RegressionHarness.cpp tests/regression/RegressionHarnessTests.cpp ) @@ -151,6 +306,20 @@ if(BUILD_TESTING) target_link_libraries(automix_tests PRIVATE automix_core Catch2::Catch2WithMain) target_compile_definitions(automix_tests PRIVATE AUTOMIX_SOURCE_DIR="${CMAKE_SOURCE_DIR}") + if(ENABLE_CLANG_TIDY AND CLANG_TIDY_EXE) + set_target_properties(automix_tests PROPERTIES CXX_CLANG_TIDY "${CLANG_TIDY_EXE}") + endif() + + if(ENABLE_SANITIZERS) + if(MSVC) + target_compile_options(automix_tests PRIVATE /fsanitize=address /Zi) + target_link_options(automix_tests PRIVATE /fsanitize=address) + else() + target_compile_options(automix_tests PRIVATE -fsanitize=address,undefined -fno-omit-frame-pointer) + target_link_options(automix_tests PRIVATE -fsanitize=address,undefined) + endif() + endif() + add_executable(automix_regression_cli tools/regression_runner.cpp tests/regression/RegressionHarness.cpp diff --git a/README.md b/README.md index 72c16e7..a26dd58 100644 --- a/README.md +++ b/README.md @@ -1,539 +1,130 @@ -# AutoMixMaster +
-AutoMixMaster is a small, cross-platform **desktop app** (JUCE + CMake) that helps you: - -- **Import stems** (individual tracks like vocals, drums, bass...) -- **Analyze** them (levels, simple spectrum split, stereo correlation, "artifact risk") -- Generate an **automatic mix plan** (gain/pan/filter/dynamics suggestions) -- Generate an **automatic master plan** (loudness + true-peak safe-ish targets) -- **Export** a mastered stereo WAV + a machine-readable **JSON report** - -It's intentionally built as a "plan-based" system: - -> Analysis -> `MixPlan` / `MasterPlan` -> deterministic DSP render -> export/report - -That makes it easier to debug, test, and (later) let AI propose parameters without turning the whole app into a black box. - ---- - -## What you can do in the current app (feature tour) - -The GUI (`AutoMixMasterApp`) is a simple offline workflow: - -1. **Import** one or more stem files (`.wav`, `.aiff/.aif`, `.flac`) -2. (Optional) load an **Original Mix** stereo file (used for "residual blend" and mastering reference) -3. (Optional) mark imported stems as **AI-separated** (enables safer mixing heuristics) -4. Click **Auto Mix** to: - - analyze stems - - generate a `MixPlan` - - show a per-stem metric table + JSON report + decision log -5. Click **Auto Master** to: - - render a raw stereo mix from stems - - generate a `MasterPlan` (optionally nudged toward the Original Mix) -6. Choose a **Renderer**: - - `BuiltIn` (default; fully in-process) - - `PhaseLimiter` (optional; external binary if available, otherwise auto-fallback) -7. Click **Export** to produce: - - `output.wav` - - `output.wav.report.json` (metrics + logs) - ---- - -## Build & run (novice-friendly) - -### Prerequisites - -- **CMake** >= 3.24 -- A **C++20** compiler - - Windows: Visual Studio 2022 "Desktop development with C++" - - macOS: Xcode command line tools - - Linux: GCC/Clang + dev packages (see below) - -This project fetches dependencies at configure time via CMake `FetchContent`: - -- JUCE `8.0.8` -- nlohmann/json `v3.11.3` -- Catch2 `v3.7.1` (tests only) - -### Configure + build - -#### Windows (PowerShell) - -```powershell -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build --config Release --parallel ``` - -#### macOS / Linux (bash) - -```bash -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build --parallel + ______________________________________________________________________________ + [ SYSTEM: AUTOMIXMASTER ] [ VERSION: 0.2.0 ] [ STATUS: OPERATIONAL ] + ______________________________________________________________________________ + + _ __ __ _ __ __ _ + /\ | | | \/ (_) | \/ | | | + / \ _ _| |_ ___ | \ / |_ _ _| \ / | __ _ ___| |_ ___ _ __ + / /\ \| | | | __/ _ \| |\/| | \ \/ / |\/| |/ _` / __| __/ _ \ '__| + / ____ \ |_| | || (_) | | | | |> <| | | | (_| \__ \ || __/ | + /_/ \_\__,_|\__\___/|_| |_|_/_/\_\_| |_|\__,_|___/\__\___|_| + + ------------------------------------------------------------------------------ ``` -### What gets built (CMake targets) - -- `automix_core` (library): domain + analysis + engine + renderers + AI scaffolding -- `AutoMixMasterApp` (GUI): the JUCE desktop app -- `automix_dev_tools` (CLI, optional): dataset export + model pack validation (`-DBUILD_TOOLS=ON`) -- `automix_tests` (unit tests, optional): Catch2 test binary (`-DBUILD_TESTING=ON`) -- `automix_regression_cli` (optional): renders a deterministic fixture suite (`-DBUILD_TESTING=ON`) - -### Linux packages (if you see missing X11/ALSA headers) +AutoMixMaster application interface -Ubuntu/Debian equivalent (mirrors CI): - -```bash -sudo apt-get update -sudo apt-get install -y \ - libasound2-dev \ - libx11-dev libxext-dev libxrandr-dev libxinerama-dev libxcursor-dev \ - libfreetype6-dev libfontconfig1-dev \ - libglu1-mesa-dev mesa-common-dev +``` + -------- FIXED-RULE AUDIO WORKFLOW FOR MIXING AND MASTERING MUSIC STEMS -------- ``` -### Run the app - -JUCE CMake builds usually place GUI app artefacts under: +**Designed for amateur music producers and hobbyists** -- `build/AutoMixMasterApp_artefacts//` (multi-config generators like Visual Studio) -- or directly under `build/` (single-config generators) +
-Look for an executable / `.app` bundle with a name like **AutoMixMaster** and run it. +

+Overview • +Workflows • +Feature Set • +Build + Install • +Licensing +

--- -## Using the GUI (step-by-step) - -### 1) Import stems - -- Click **Import** -- Select one or more audio files -- If these stems came from a source-separation model (Spleeter/Demucs/etc), enable **AI-separated stems** *before importing* so the `StemOrigin` is set to `Separated`. - -What this affects: - -- Separated stems get **more conservative gain changes** and **less aggressive compression**. -- High "artifact risk" can trigger **soft expansion** instead of compression. - -### 2) (Optional) Load Original Mix - -- Click **Original Mix** -- Select your original stereo mix (`wav/aiff/flac`) - -This enables two features: - -1. **Residual Blend** (mix stage): add back a tiny bit of what your stems *don't* explain. -2. **Soft reference targeting** (master plan): nudge loudness / tilt / glue settings toward the reference. +## Overview -### 3) Residual Blend (%) +AutoMixMaster is an automation utility for repetitive stem-level audio prep. +It uses a fixed, deterministic workflow for level balancing, gain staging, and output preparation. -The slider is intentionally small: **0.0 to 10.0** means **0% to 10%**. - -Under the hood: - -- The app aligns the Original Mix to the stem sum using cross-correlation. -- Computes `residual = originalMixAligned - stemSum`. -- Blends `stemSum + residual * (blendPercent / 100)`. -- Enforces a conservative ceiling (defaults to `-1 dBTP` in current pipeline call sites). - -This is most useful when: - -- you have separated stems with "holes" or watery artifacts -- you want a *little* of the original glue back without fully replacing your stem mix - -### 4) Auto Mix - -Click **Auto Mix** to: - -- run per-stem analysis -- choose a role (by filename heuristics + simple spectrum rules) -- generate a `MixPlan`: - - gain staging toward role-based target RMS levels - - panning templates by role - - simple high-pass filter suggestions - - conservative compressor/expander toggles depending on stem origin + artifact risk - -The UI shows: - -- A table of metrics (Peak/RMS/Crest, Low/Mid/High energy split, Silence ratio) -- A JSON analysis report -- A plain-English decision log (what it decided and why) - -### 5) Auto Master - -Click **Auto Master** to generate a `MasterPlan`. - -Important: **Auto Master only creates a plan**. The plan is applied during export by the `BuiltIn` renderer. - -If you loaded an Original Mix, the plan is "soft targeted" toward it: - -- loudness target and pre-gain are nudged toward the reference loudness -- glue ratio is adjusted based on crest factor differences -- EQ tilt is enabled when the spectral tilt differs enough - -### 6) Export - -Click **Export** to render offline and write files: - -- **WAV audio** -- **JSON report** next to it: `yourfile.wav.report.json` - -Renderer choices: - -- **BuiltIn** - - Uses the app's offline mix + mastering chain. - - Uses `Session.mixPlan` and `Session.masterPlan` if present. - - Generates a detailed report including the master decision log. -- **PhaseLimiter** - - If PhaseLimiter is found, it runs the external `phase_limiter` binary on the rendered raw mix. - - If not found (or it crashes), it **falls back** to the BuiltIn renderer. - - Current limitation: it does **not** apply `Session.masterPlan` (it's effectively "mix + PhaseLimiter", not "mix + master plan"). +The project is aimed at personal experimentation and iteration speed, especially for creators working with raw multitrack material or AI-generated stems. --- -## Outputs: what gets written - -### Exported audio - -- Always WAV. -- `BuiltIn` renderer writes at `RenderSettings.outputBitDepth` (GUI currently exports 24-bit). -- `PhaseLimiter` renderer currently forces **16-bit WAV output** (it invokes PhaseLimiter with `-bit_depth=16`). - -### JSON report (`.report.json`) - -The renderer writes a JSON file next to your WAV. Typical fields include: +## Workflows -- renderer metadata (`renderer`, output path, PhaseLimiter binary path if used) -- mastering metrics (integrated loudness estimate, true peak estimate) -- spectrum summary (low/mid/high energy ratios, stereo correlation) -- decision log (BuiltIn only) -- render logs (pipeline stages, residual blend alignment details, etc.) - -This report is intended to be: - -- easy to inspect manually -- stable enough for regression tests and dataset generation +| Workflow | What it does | Typical use | +| :--- | :--- | :--- | +| AI seed processing | Applies consistent level balancing to separated stems | Quick listening passes on Udio/Suno-style stems | +| Songwriter prototyping | Moves raw multitrack recordings toward a reference balance | Faster idea review without manual fader passes | +| Bulk folder processing | Processes entire directories to a configured loudness target | Keeping catalog loudness consistent | --- -## Session JSON format (developer-focused, but beginner-usable) - -There's an explicit `Session` domain model and JSON serializer. The GUI currently doesn't expose "Save/Load session", but the format is already supported in code (and used by tools/tests). - -Minimal example: - -```json -{ - "schemaVersion": 2, - "sessionName": "My Session", - "residualBlend": 5.0, - "originalMixPath": "C:/path/to/original_mix.wav", - "stems": [ - { - "id": "stem_1", - "name": "Vox", - "filePath": "C:/path/to/vocals.wav", - "role": "unknown", - "origin": "separated", - "enabled": true - } - ], - "buses": [], - "renderSettings": { - "outputSampleRate": 44100, - "blockSize": 1024, - "outputBitDepth": 24, - "outputPath": "", - "rendererName": "BuiltIn" - } -} -``` - -Fields are forgiving: +## Feature Set -- Missing `originalMixPath`, `mixPlan`, `masterPlan` are fine. -- `residualBlend` is clamped to `0.0 .. 10.0` on load. +| Module | Description | +| :--- | :--- | +| Auto Mix | Applies fixed algorithmic rules for level balancing and basic spatial placement | +| Auto Master | Applies automated gain staging and peak limiting to final output | +| Batch Mode | Processes multiple tracks/folders sequentially with shared settings | +| Analysis Tools | Exposes LUFS and peak measurements for visual monitoring | --- -## Developer tools (datasets + model pack validation) +## Build + Install -If you build with `-DBUILD_TOOLS=ON`, you get `automix_dev_tools` (a CLI utility). +### Windows (Visual Studio 2026) -### Export analysis features (`.jsonl`) +1. Configure ```bash -automix_dev_tools export-features --session path/to/session.json --out path/to/features.jsonl +cmake -S . -B build -G "Visual Studio 18 2026" -A x64 ``` -Each line is a JSON object containing stem identity + safety metadata + analysis metrics. - -### Export short audio segments (for listening tests / training) +2. Build ```bash -automix_dev_tools export-segments --session path/to/session.json --out-dir path/to/dataset --segment-seconds 5 +cmake --build build --config Release --parallel ``` -Outputs: - -- `stems/*.wav`: first `N` seconds per enabled stem -- `mix_segment.wav`: first `N` seconds of the offline raw mix -- `manifest.json`: summary metadata +### Ubuntu Linux (24.04+) -### Validate a model pack +1. Install dependencies ```bash -automix_dev_tools validate-modelpack --pack path/to/ModelPacks/my_pack -``` - -Validation checks: - -- `model.json` parses -- model file exists -- optional checksum matches -- if the backend is enabled, runs a sample inference and checks expected outputs - ---- - -## AI model packs (implemented scaffolding) - -The codebase is ready for drop-in model packs, but the GUI currently only **lists** them and stores the active selection in memory. - -### Where the app looks - -By default it scans a `ModelPacks/` folder next to your working directory: - -``` -ModelPacks/ - my_pack/ - model.json - model.onnx -``` - -### `model.json` (minimum supported shape) - -```json -{ - "schema_version": 1, - "id": "mix-v1", - "type": "mix_parameters", - "engine": "onnxruntime", - "version": "1.0.0", - "model_file": "model.onnx", - "input_feature_count": 5, - "output_keys": ["confidence", "global_gain_db", "global_pan_bias"] -} +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.0-dev libglu1-mesa-dev mesa-common-dev ``` -Inference task names used by the codebase: - -- `role_classifier` (stem role prediction) -- `mix_parameters` (per-stem mix parameter prediction) -- `master_parameters` (mastering parameter prediction) -- `mix_master_override` (global override hook used by `ModelStrategy`) - -Supported `engine` values in current code: - -- `onnxruntime` (compile-time optional; currently a stub backend) -- `rtneural` (compile-time optional; currently a small deterministic stub) -- `unknown` (schema-only validation) - -### Important reality check (as of this repo state) - -The "inference backends" are deliberately lightweight **stubs**: - -- `OnnxModelInference` currently **does not run ONNX Runtime**; it returns fixed placeholder outputs to prove plumbing. -- `RtNeuralInference` currently **does not load real RTNeural weights**; it returns deterministic probabilities from the input features. - -The architecture is real; the heavy runtime integrations are intentionally left as future work. - ---- - -## How it works (codebase walkthrough) - -This section maps user-visible features to the actual modules/classes in `src/`. - -### 1) Domain model (`src/domain`) - -Think of this as the "data contract" of the app: - -- `Session`: everything about a project (stems, optional original mix path, plans, render settings). -- `Stem`: one audio file + metadata (`StemOrigin` and `StemRole`). -- `MixPlan`: per-stem decisions (gain/pan/high-pass + simple dynamics flags). -- `MasterPlan`: mastering targets + parameters (LUFS target, true-peak ceiling, glue settings, dither). -- JSON serialization: `nlohmann::json` conversions for the whole graph. - -Design note: - -- These structs are dependency-light and easy to test/serialize. -- The UI is supposed to *edit* these objects; the engine reads them. - -### 2) Analysis (`src/analysis`) - -`StemAnalyzer` computes: - -- `peakDb`, `rmsDb`, `crestDb` -- low/mid/high energy ratios (simple one-pole filters, not FFT) -- `silenceRatio` (fraction of samples below a small threshold) -- stereo correlation + derived "width" -- `artifactRisk` (heuristic roughness + HF energy + width + silence) - -This analysis is used for: - -- displaying a useful "what's in these stems?" table -- driving heuristic mixing decisions -- exporting training features for ML workflows - -### 3) AutoMix (heuristic) (`src/automix`) - -`HeuristicAutoMixStrategy` produces a `MixPlan` using: - -- role-based loudness targets (e.g., vocals a bit louder than FX) -- panning templates by role (e.g., guitars alternate L/R) -- high-pass defaults by role -- origin-aware "safety caps": - - separated stems get smaller gain moves - - separated stems avoid aggressive compression - - high artifact risk can enable a gentle expander - -Role inference: - -- `StemRoleClassifierAI` can use a model backend, but defaults to: - - filename keyword heuristics (`vox`, `kick`, `bass`, `fx`, ...) - - simple spectrum rules if names are ambiguous - -### 4) Offline mixing/rendering (`src/engine`) - -`OfflineRenderPipeline::renderRawMix()` is the heart of the app: - -1. Read each enabled stem (`AudioFileIO`, JUCE readers) -2. Resample to project rate if needed (`AudioResampler`, linear) -3. Apply per-stem processing from `StemMixDecision`: - - one-pole high-pass filter - - simple compressor (peak detector + ratio + release) - - simple expander (for separated/artifacty content) - - gain - - constant-power panning - - optional "dry/wet" blend between unprocessed and processed stem audio -4. Sum stems block-by-block (supports cancellation) -5. Apply mix-bus headroom normalization (`MixPlan.mixBusHeadroomDb`) -6. If Original Mix + residual blend enabled: - - align original mix to stem sum (cross-correlation) - - compute residual and blend it back (`ResidualBlendProcessor`) - -Notes: - -- This is offline processing: it loads entire files into memory right now. -- There is a `cancelFlag` in the API, but the GUI doesn't currently expose a cancel button. - -### 5) Mastering (heuristic) (`src/automaster`) - -`HeuristicAutoMasterStrategy` implements a basic chain: - -- pre-gain toward target LUFS (simple integrated loudness estimate) -- optional gentle tonal tilt (very small low trim + high lift) -- glue compressor -- limiter (sample clamp; then iterative loudness + true peak correction) -- optional dither for <24-bit exports - -`OriginalMixReference` can "soft target" a plan toward the Original Mix: - -- nudges `targetLufs` and `preGainDb` -- adjusts glue ratio based on crest factor differences -- enables EQ tilt if spectral tilt differs enough - -### 6) Renderers (`src/renderers`) - -Renderers are the "final export" abstraction (`IRenderer`): - -- `BuiltInRenderer` - - renders raw mix - - applies `MasterPlan` (or creates one if missing) - - writes WAV + JSON report -- `PhaseLimiterRenderer` - - discovers a PhaseLimiter binary (`PhaseLimiterDiscovery`) - - renders raw mix - - writes a temp input WAV and runs PhaseLimiter as a child process - - validates output exists; otherwise falls back to BuiltIn - - writes a JSON report with measured output metrics - -PhaseLimiter discovery rules: - -1. If `PHASELIMITER_BIN` is set, use it (file path or directory to scan). -2. Otherwise, scan "assets-ish" folders near the working directory / executable: - - `assets/phaselimiter`, `Assets/PhaseLimiter`, etc. - -### 7) Tests and regression (`tests/`) - -There are two layers: - -- **Unit tests** (Catch2): analysis math, serialization, resampling, residual alignment, PhaseLimiter discovery, etc. -- **Regression suite**: renders a deterministic synthetic fixture through: - - `heuristic` pipeline - - `ai` pipeline (uses deterministic inference stubs) - - compares measured metrics against `tests/regression/baselines.json` - -Run tests after building: +2. Configure + build ```bash -ctest --test-dir build --output-on-failure +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel ``` -Run the regression CLI: +3. Run tests (optional but recommended) ```bash -automix_regression_cli --baseline ./tests/regression/baselines.json -``` - -On Windows with multi-config generators, you may need: - -```powershell -ctest --test-dir build -C Release --output-on-failure -.\build\Release\automix_regression_cli.exe --baseline .\tests\regression\baselines.json +ctest --test-dir build --output-on-failure ``` --- -## CMake options you can toggle - -- `-DBUILD_TESTING=ON|OFF` (default ON) -- `-DBUILD_TOOLS=ON|OFF` (default ON) -- `-DENABLE_ONNX=ON|OFF` (default OFF) - builds the ONNX inference stub -- `-DENABLE_RTNEURAL=ON|OFF` (default OFF) - enables the RTNeural inference stub -- `-DENABLE_PHASELIMITER=ON|OFF` (default OFF) - currently only sets a compile definition; PhaseLimiter renderer is built regardless - ---- - -## Known limitations (honest notes) +## Licensing -This repo is intentionally small and testable, but that means some things are not wired up yet: +AutoMixMaster is distributed under **GNU General Public License v3 (GPLv3)**. -- The GUI does not currently **save/load sessions**, even though the serializer exists. -- There is no **audio preview/playback**; everything is offline render + export. -- The mix plan includes fields like `mudCutDb` that are **not applied** by the current render pipeline. -- Buses (`Session.buses`) exist in the domain model but are not used for routing yet. -- The GUI lists model packs but does not yet run an end-to-end AI pipeline export from selected packs. -- The PhaseLimiter renderer currently ignores `MasterPlan` (it post-processes the raw mix). +| Component | License | Role | +| :--- | :--- | :--- | +| JUCE 8.0.8 | AGPLv3 / Commercial | Framework | +| libebur128 | MIT | Metering | +| nlohmann/json | MIT | Metadata | +| Catch2 3.7.1 | BSL-1.0 | Testing | +| PhaseLimiter | GPL / Custom | Limiting | -If you're extending the project, these are high-value first improvements. +
---- - -## License - -The AutoMixMaster project is licensed under the **GNU General Public License v3 (GPLv3)**. - -In plain English (not legal advice): - -- If you distribute the app (or modified versions), you need to provide the corresponding source under GPLv3. -- You can use it privately without distribution obligations. - -### Third-party components - -This repo also includes/uses third-party software with its own licensing, including: +``` + ______________________________________________________________________________ + [ THIS APPLICATION IS A WORK IN PROGRESS ] + ______________________________________________________________________________ +``` -- JUCE (fetched at build time) -- nlohmann/json (fetched at build time) -- Catch2 (fetched at build time for tests) -- PhaseLimiter binaries and resources under `assets/phaselimiter/` (see that folder for its license files) +
diff --git a/assets/AutoMixMaster.jpg b/assets/AutoMixMaster.jpg new file mode 100644 index 0000000..95e3e73 Binary files /dev/null and b/assets/AutoMixMaster.jpg differ diff --git a/assets/limiters/external-template/renderer.json b/assets/limiters/external-template/renderer.json new file mode 100644 index 0000000..24558ef --- /dev/null +++ b/assets/limiters/external-template/renderer.json @@ -0,0 +1,8 @@ +{ + "id": "ExternalTemplate", + "name": "External Limiter Template", + "version": "1.0", + "licenseId": "User-supplied", + "binaryPath": "your_limiter_binary_here", + "bundledByDefault": false +} diff --git a/assets/limiters/phaselimiter/renderer.json b/assets/limiters/phaselimiter/renderer.json new file mode 100644 index 0000000..60260bf --- /dev/null +++ b/assets/limiters/phaselimiter/renderer.json @@ -0,0 +1,8 @@ +{ + "id": "PhaseLimiterPack", + "name": "PhaseLimiter (pack)", + "version": "external", + "licenseId": "See assets/phaselimiter/licenses", + "binaryPath": "../../phaselimiter/phase_limiter", + "bundledByDefault": false +} diff --git a/assets/mastering/platform_presets.json b/assets/mastering/platform_presets.json new file mode 100644 index 0000000..e27e564 --- /dev/null +++ b/assets/mastering/platform_presets.json @@ -0,0 +1,50 @@ +{ + "version": "1.0", + "presets": { + "default_streaming": { + "targetLufs": -14.0, + "truePeakDbtp": -1.0, + "enableMultiband": false + }, + "broadcast": { + "targetLufs": -23.0, + "truePeakDbtp": -1.0, + "enableMultiband": false + }, + "udio_optimized": { + "targetLufs": -14.0, + "truePeakDbtp": -1.0, + "enableMultiband": true + }, + "spotify": { + "targetLufs": -14.0, + "truePeakDbtp": -1.0, + "enableMultiband": true + }, + "apple_music": { + "targetLufs": -16.0, + "truePeakDbtp": -1.0, + "enableMultiband": true + }, + "youtube": { + "targetLufs": -14.0, + "truePeakDbtp": -1.0, + "enableMultiband": true + }, + "amazon_music": { + "targetLufs": -14.0, + "truePeakDbtp": -2.0, + "enableMultiband": true + }, + "tidal": { + "targetLufs": -14.0, + "truePeakDbtp": -1.0, + "enableMultiband": true + }, + "broadcast_ebu_r128": { + "targetLufs": -23.0, + "truePeakDbtp": -1.0, + "enableMultiband": false + } + } +} diff --git a/assets/models/demo-master-v1/model.json b/assets/models/demo-master-v1/model.json new file mode 100644 index 0000000..2f625cd --- /dev/null +++ b/assets/models/demo-master-v1/model.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "id": "demo-master-v1", + "name": "Demo Master Parameter V1", + "type": "master_parameters", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "CC-BY-4.0", + "source": "AutoMixMaster demo catalog", + "feature_schema_version": "1.0.0", + "output_schema": { + "confidence": "float", + "target_lufs": "float", + "pre_gain_db": "float", + "limiter_ceiling_db": "float", + "glue_ratio": "float" + } +} diff --git a/assets/models/demo-master-v1/model.onnx b/assets/models/demo-master-v1/model.onnx new file mode 100644 index 0000000..6bc2706 --- /dev/null +++ b/assets/models/demo-master-v1/model.onnx @@ -0,0 +1 @@ +AUTOMIX_DEMO_ONNX_MASTER_V1 diff --git a/assets/models/demo-mix-v1/model.json b/assets/models/demo-mix-v1/model.json new file mode 100644 index 0000000..62add46 --- /dev/null +++ b/assets/models/demo-mix-v1/model.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "id": "demo-mix-v1", + "name": "Demo Mix Parameter V1", + "type": "mix_parameters", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "CC-BY-4.0", + "source": "AutoMixMaster demo catalog", + "feature_schema_version": "1.0.0", + "output_schema": { + "confidence": "float", + "global_gain_db": "float", + "global_pan_bias": "float" + } +} diff --git a/assets/models/demo-mix-v1/model.onnx b/assets/models/demo-mix-v1/model.onnx new file mode 100644 index 0000000..aea7be6 --- /dev/null +++ b/assets/models/demo-mix-v1/model.onnx @@ -0,0 +1 @@ +AUTOMIX_DEMO_ONNX_MIX_V1 diff --git a/assets/models/demo-role-v1/model.json b/assets/models/demo-role-v1/model.json new file mode 100644 index 0000000..565cd5f --- /dev/null +++ b/assets/models/demo-role-v1/model.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "id": "demo-role-v1", + "name": "Demo Role Classifier V1", + "type": "role_classifier", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "CC-BY-4.0", + "source": "AutoMixMaster demo catalog", + "feature_schema_version": "1.0.0", + "output_schema": { + "prob_vocals": "float", + "prob_bass": "float", + "prob_drums": "float", + "prob_fx": "float" + } +} diff --git a/assets/models/demo-role-v1/model.onnx b/assets/models/demo-role-v1/model.onnx new file mode 100644 index 0000000..b2eddf9 --- /dev/null +++ b/assets/models/demo-role-v1/model.onnx @@ -0,0 +1 @@ +AUTOMIX_DEMO_ONNX_ROLE_V1 diff --git a/assets/profiles/project_profiles.json b/assets/profiles/project_profiles.json new file mode 100644 index 0000000..9ca9859 --- /dev/null +++ b/assets/profiles/project_profiles.json @@ -0,0 +1,59 @@ +[ + { + "id": "default", + "name": "Default Balanced", + "platformPreset": "spotify", + "rendererName": "BuiltIn", + "outputFormat": "wav", + "lossyBitrateKbps": 320, + "mp3UseVbr": false, + "mp3VbrQuality": 4, + "gpuProvider": "auto", + "roleModelPackId": "none", + "mixModelPackId": "none", + "masterModelPackId": "none", + "safetyPolicyId": "balanced", + "preferredStemCount": 4, + "metadataPolicy": "copy_common", + "metadataTemplate": {}, + "pinnedRendererIds": ["BuiltIn"] + }, + { + "id": "streaming_spotify", + "name": "Streaming Spotify", + "platformPreset": "spotify", + "rendererName": "BuiltIn", + "outputFormat": "mp3", + "lossyBitrateKbps": 256, + "mp3UseVbr": true, + "mp3VbrQuality": 2, + "gpuProvider": "auto", + "roleModelPackId": "demo-role-v1", + "mixModelPackId": "demo-mix-v1", + "masterModelPackId": "demo-master-v1", + "safetyPolicyId": "strict", + "preferredStemCount": 4, + "metadataPolicy": "copy_common", + "metadataTemplate": {}, + "pinnedRendererIds": ["BuiltIn", "PhaseLimiter"] + }, + { + "id": "mobile_fast_turn", + "name": "Mobile Fast Turn", + "platformPreset": "youtube", + "rendererName": "BuiltIn", + "outputFormat": "ogg", + "lossyBitrateKbps": 192, + "mp3UseVbr": false, + "mp3VbrQuality": 4, + "gpuProvider": "cpu", + "roleModelPackId": "none", + "mixModelPackId": "none", + "masterModelPackId": "none", + "safetyPolicyId": "balanced", + "preferredStemCount": 2, + "metadataPolicy": "strip", + "metadataTemplate": {}, + "pinnedRendererIds": ["BuiltIn"] + } +] diff --git a/assets/renderers/trust_policy.json b/assets/renderers/trust_policy.json new file mode 100644 index 0000000..dad8f66 --- /dev/null +++ b/assets/renderers/trust_policy.json @@ -0,0 +1,12 @@ +{ + "enforceSignedDescriptors": false, + "trustedSigners": [ + "automix_official", + "community_vendor" + ], + "profileRendererPins": { + "default": ["BuiltIn"], + "streaming_spotify": ["BuiltIn", "PhaseLimiter"], + "mobile_fast_turn": ["BuiltIn"] + } +} diff --git a/dumpbin_members.err b/dumpbin_members.err new file mode 100644 index 0000000..e69de29 diff --git a/src/ai/AutoMasterStrategyAI.cpp b/src/ai/AutoMasterStrategyAI.cpp index 685ce8a..a9850d3 100644 --- a/src/ai/AutoMasterStrategyAI.cpp +++ b/src/ai/AutoMasterStrategyAI.cpp @@ -2,6 +2,8 @@ #include +#include "ai/FeatureSchema.h" + namespace automix::ai { namespace { @@ -22,11 +24,7 @@ domain::MasterPlan AutoMasterStrategyAI::buildPlan(const analysis::AnalysisResul const InferenceRequest request{ .task = "master_parameters", - .features = {mixBusMetrics.rmsDb, - mixBusMetrics.crestDb, - mixBusMetrics.lowEnergy, - mixBusMetrics.midEnergy, - mixBusMetrics.highEnergy}, + .features = FeatureSchemaV1::extract(mixBusMetrics), }; const InferenceResult result = inference->run(request); if (!result.usedModel) { @@ -35,22 +33,24 @@ domain::MasterPlan AutoMasterStrategyAI::buildPlan(const analysis::AnalysisResul } const double confidence = std::clamp(result.outputs.contains("confidence") ? result.outputs.at("confidence") : 0.5, 0.0, 1.0); + const double conservativeFactor = mixBusMetrics.artifactRisk > 0.6 ? 0.6 : 1.0; + const double effectiveConfidence = std::clamp(confidence * conservativeFactor, 0.0, 1.0); if (result.outputs.contains("target_lufs")) { - plan.targetLufs = blend(plan.targetLufs, result.outputs.at("target_lufs"), confidence); + plan.targetLufs = blend(plan.targetLufs, result.outputs.at("target_lufs"), effectiveConfidence); } if (result.outputs.contains("pre_gain_db")) { - plan.preGainDb = blend(plan.preGainDb, result.outputs.at("pre_gain_db"), confidence); + plan.preGainDb = blend(plan.preGainDb, result.outputs.at("pre_gain_db"), effectiveConfidence); } if (result.outputs.contains("limiter_ceiling_db")) { - const double blendedCeiling = blend(plan.limiterCeilingDb, result.outputs.at("limiter_ceiling_db"), confidence); + const double blendedCeiling = blend(plan.limiterCeilingDb, result.outputs.at("limiter_ceiling_db"), effectiveConfidence); plan.limiterCeilingDb = blendedCeiling; plan.truePeakDbtp = blendedCeiling; } if (result.outputs.contains("glue_ratio")) { - plan.glueRatio = blend(plan.glueRatio, result.outputs.at("glue_ratio"), confidence); + plan.glueRatio = blend(plan.glueRatio, result.outputs.at("glue_ratio"), effectiveConfidence); } if (result.outputs.contains("glue_threshold_db")) { - plan.glueThresholdDb = blend(plan.glueThresholdDb, result.outputs.at("glue_threshold_db"), confidence); + plan.glueThresholdDb = blend(plan.glueThresholdDb, result.outputs.at("glue_threshold_db"), effectiveConfidence); } plan.decisionLog.push_back("AI master strategy blended decisions with confidence=" + std::to_string(confidence)); diff --git a/src/ai/AutoMixStrategyAI.cpp b/src/ai/AutoMixStrategyAI.cpp index ae09891..d4c593f 100644 --- a/src/ai/AutoMixStrategyAI.cpp +++ b/src/ai/AutoMixStrategyAI.cpp @@ -2,6 +2,8 @@ #include +#include "ai/FeatureSchema.h" + namespace automix::ai { namespace { @@ -26,13 +28,10 @@ domain::MixPlan AutoMixStrategyAI::buildPlan(const domain::Session& session, } std::vector features; - features.reserve(analysisEntries.size() * 5); + features.reserve(analysisEntries.size() * FeatureSchemaV1::featureCount()); for (const auto& entry : analysisEntries) { - features.push_back(entry.metrics.rmsDb); - features.push_back(entry.metrics.lowEnergy); - features.push_back(entry.metrics.midEnergy); - features.push_back(entry.metrics.highEnergy); - features.push_back(entry.metrics.artifactRisk); + const auto stemFeatures = FeatureSchemaV1::extract(entry.metrics); + features.insert(features.end(), stemFeatures.begin(), stemFeatures.end()); } const InferenceRequest request{ @@ -73,8 +72,13 @@ domain::MixPlan AutoMixStrategyAI::buildPlan(const domain::Session& session, } const double gainClamp = gainClampForOrigin(origin); - decision.gainDb = std::clamp(blend(decision.gainDb, aiGain, confidence), -gainClamp, gainClamp); - decision.pan = std::clamp(blend(decision.pan, aiPan, confidence), -1.0, 1.0); + const double gainDeviation = std::abs(aiGain - decision.gainDb); + const double panDeviation = std::abs(aiPan - decision.pan); + const double calibration = (gainDeviation > 9.0 ? 0.35 : 1.0) * (panDeviation > 0.8 ? 0.6 : 1.0); + const double effectiveConfidence = std::clamp(confidence * calibration, 0.0, 1.0); + + decision.gainDb = std::clamp(blend(decision.gainDb, aiGain, effectiveConfidence), -gainClamp, gainClamp); + decision.pan = std::clamp(blend(decision.pan, aiPan, effectiveConfidence), -1.0, 1.0); decision.highPassHz = std::clamp(decision.highPassHz, 0.0, 240.0); } diff --git a/src/ai/FeatureSchema.cpp b/src/ai/FeatureSchema.cpp new file mode 100644 index 0000000..05e0a17 --- /dev/null +++ b/src/ai/FeatureSchema.cpp @@ -0,0 +1,206 @@ +#include "ai/FeatureSchema.h" + +#include "analysis/AnalysisResult.h" + +namespace automix::ai { +namespace { + +double vectorValueOrDefault(const std::vector& values, const size_t index) { + return index < values.size() ? values[index] : 0.0; +} + +struct SemanticVersion { + int major = 0; + int minor = 0; + int patch = 0; + bool valid = false; +}; + +SemanticVersion parseVersion(const std::string& versionStr) { + SemanticVersion version; + + if (versionStr.empty()) { + return version; + } + + size_t pos = 0; + size_t dotPos = versionStr.find('.'); + + try { + // Parse major version + if (dotPos == std::string::npos) { + // Partial version with only major - not valid for strict semver + return version; + } + + version.major = std::stoi(versionStr.substr(pos, dotPos - pos)); + pos = dotPos + 1; + + // Parse minor version + dotPos = versionStr.find('.', pos); + if (dotPos == std::string::npos) { + // Partial version with major.minor only - not valid for strict semver + return version; + } + + version.minor = std::stoi(versionStr.substr(pos, dotPos - pos)); + pos = dotPos + 1; + + // Parse patch version + version.patch = std::stoi(versionStr.substr(pos)); + version.valid = true; + } catch (...) { + version.valid = false; + } + + return version; +} + +} // namespace + +const std::vector& FeatureSchemaV1::names() { + static const std::vector kNames = { + "rms_db", + "peak_db", + "crest_db", + "dc_offset", + "low_energy_ratio", + "mid_energy_ratio", + "high_energy_ratio", + "sub_energy_ratio", + "bass_energy_ratio", + "low_mid_energy_ratio", + "high_mid_energy_ratio", + "presence_energy_ratio", + "air_energy_ratio", + "spectral_centroid_hz", + "spectral_spread_hz", + "spectral_flatness", + "spectral_flux", + "silence_ratio", + "stereo_correlation", + "stereo_width", + "channel_balance_db", + "artifact_risk", + "artifact_swirl_risk", + "artifact_smear_risk", + "artifact_noise_dominance", + "artifact_harmonicity", + "artifact_phase_instability", + "crest_factor", + "onset_strength", + "mfcc_0", + "mfcc_1", + "mfcc_2", + "mfcc_3", + "mfcc_4", + "mfcc_5", + "mfcc_6", + "mfcc_7", + "mfcc_8", + "mfcc_9", + "mfcc_10", + "mfcc_11", + "mfcc_12", + "cqt_0", + "cqt_1", + "cqt_2", + "cqt_3", + "cqt_4", + "cqt_5", + "cqt_6", + "cqt_7", + "cqt_8", + "cqt_9", + "cqt_10", + "cqt_11", + "cqt_12", + "cqt_13", + "cqt_14", + "cqt_15", + "cqt_16", + "cqt_17", + "cqt_18", + "cqt_19", + "cqt_20", + "cqt_21", + "cqt_22", + "cqt_23", + }; + return kNames; +} + +bool FeatureSchemaV1::isCompatible(const std::string& version) { + const auto current = parseVersion(kVersion); + const auto provided = parseVersion(version); + + // Both versions must be valid + if (!current.valid || !provided.valid) { + return false; + } + + // Major version must match exactly (breaking changes) + if (provided.major != current.major) { + return false; + } + + // Minor version must be >= current (backward compatible) + if (provided.minor < current.minor) { + return false; + } + + // If minor version is greater, patch doesn't matter (newer compatible version) + if (provided.minor > current.minor) { + return true; + } + + // Minor versions match, so patch must be >= current + return provided.patch >= current.patch; +} + +size_t FeatureSchemaV1::featureCount() { return names().size(); } + +std::vector FeatureSchemaV1::extract(const analysis::AnalysisResult& metrics) { + std::vector values = { + metrics.rmsDb, + metrics.peakDb, + metrics.crestDb, + metrics.dcOffset, + metrics.lowEnergy, + metrics.midEnergy, + metrics.highEnergy, + metrics.subEnergy, + metrics.bassEnergy, + metrics.lowMidEnergy, + metrics.highMidEnergy, + metrics.presenceEnergy, + metrics.airEnergy, + metrics.spectralCentroidHz, + metrics.spectralSpreadHz, + metrics.spectralFlatness, + metrics.spectralFlux, + metrics.silenceRatio, + metrics.stereoCorrelation, + metrics.stereoWidth, + metrics.channelBalanceDb, + metrics.artifactRisk, + metrics.artifactProfile.swirlRisk, + metrics.artifactProfile.smearRisk, + metrics.artifactProfile.noiseDominance, + metrics.artifactProfile.harmonicity, + metrics.artifactProfile.phaseInstability, + metrics.crestFactor, + metrics.onsetStrength, + }; + + for (size_t i = 0; i < 13; ++i) { + values.push_back(vectorValueOrDefault(metrics.mfccCoefficients, i)); + } + for (size_t i = 0; i < 24; ++i) { + values.push_back(vectorValueOrDefault(metrics.constantQBins, i)); + } + + return values; +} + +} // namespace automix::ai diff --git a/src/ai/FeatureSchema.h b/src/ai/FeatureSchema.h new file mode 100644 index 0000000..a6c7d2a --- /dev/null +++ b/src/ai/FeatureSchema.h @@ -0,0 +1,23 @@ +#pragma once + +#include +#include +#include + +namespace automix::analysis { +struct AnalysisResult; +} + +namespace automix::ai { + +class FeatureSchemaV1 { + public: + static constexpr const char* kVersion = "1.0.0"; + + static const std::vector& names(); + static bool isCompatible(const std::string& version); + static size_t featureCount(); + static std::vector extract(const analysis::AnalysisResult& metrics); +}; + +} // namespace automix::ai diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp new file mode 100644 index 0000000..7012ccc --- /dev/null +++ b/src/ai/HuggingFaceModelHub.cpp @@ -0,0 +1,803 @@ +#include "ai/HuggingFaceModelHub.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "util/StringUtils.h" + +namespace automix::ai { +namespace { + +using ::automix::util::toLower; +using ::automix::util::trim; + +std::optional readEnvironment(const char* key) { +#if defined(_WIN32) + char* buffer = nullptr; + size_t length = 0; + if (_dupenv_s(&buffer, &length, key) != 0 || buffer == nullptr) { + return std::nullopt; + } + + std::string value(buffer, length > 0 ? length - 1 : 0); + free(buffer); + value = trim(value); + if (value.empty()) { + return std::nullopt; + } + return value; +#else + const char* value = std::getenv(key); + if (value == nullptr || *value == '\0') { + return std::nullopt; + } + + const auto trimmed = trim(value); + if (trimmed.empty()) { + return std::nullopt; + } + return trimmed; +#endif +} + +std::string iso8601NowUtc() { + return juce::Time::getCurrentTime().toISO8601(true).toStdString(); +} + +std::string sanitizeToken(std::string token) { + token = trim(std::move(token)); + if (!token.empty() && token.rfind("Bearer ", 0) == 0) { + token = token.substr(7); + } + return token; +} + +std::string buildHeaders(const std::string& token) { + std::string headers = "Accept: application/json\n"; + if (!token.empty()) { + headers += "Authorization: Bearer " + token + "\n"; + } + return headers; +} + +std::optional fetchJson(const std::string& url, + const std::string& token, + std::string* detail = nullptr) { + int statusCode = 0; + const auto options = juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(45000) + .withNumRedirectsToFollow(8) + .withStatusCode(&statusCode) + .withExtraHeaders(buildHeaders(token)); + const auto input = juce::URL(url).createInputStream(options); + if (input == nullptr) { + if (detail != nullptr) { + *detail = "Failed to fetch JSON (no stream): " + url; + } + return std::nullopt; + } + + if (statusCode >= 400) { + if (detail != nullptr) { + *detail = "Failed to fetch JSON (HTTP " + std::to_string(statusCode) + "): " + url; + } + return std::nullopt; + } + + const auto text = input->readEntireStreamAsString().toStdString(); + if (text.empty()) { + if (detail != nullptr) { + *detail = "Received empty JSON body."; + } + return std::nullopt; + } + + try { + return nlohmann::json::parse(text); + } catch (const std::exception& error) { + if (detail != nullptr) { + *detail = "Failed parsing JSON: " + std::string(error.what()); + } + return std::nullopt; + } +} + +bool downloadToFile(const std::string& url, + const std::filesystem::path& outputPath, + const std::string& token, + std::string* detail = nullptr) { + int statusCode = 0; + const auto options = juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(60000) + .withNumRedirectsToFollow(8) + .withStatusCode(&statusCode) + .withExtraHeaders(buildHeaders(token)); + const auto input = juce::URL(url).createInputStream(options); + if (input == nullptr) { + if (detail != nullptr) { + *detail = "Download failed (no stream): " + url; + } + return false; + } + + if (statusCode >= 400) { + if (detail != nullptr) { + *detail = "Download failed (HTTP " + std::to_string(statusCode) + "): " + url; + } + return false; + } + + std::error_code error; + std::filesystem::create_directories(outputPath.parent_path(), error); + if (error) { + if (detail != nullptr) { + *detail = "Failed creating output directory: " + outputPath.parent_path().string(); + } + return false; + } + + juce::File outFile(outputPath.string()); + std::unique_ptr out(outFile.createOutputStream()); + if (out == nullptr || !out->openedOk()) { + if (detail != nullptr) { + *detail = "Failed opening output file: " + outputPath.string(); + } + return false; + } + + out->writeFromInputStream(*input, -1); + out->flush(); + + if (!std::filesystem::is_regular_file(outputPath, error) || error) { + if (detail != nullptr) { + *detail = "Downloaded output missing: " + outputPath.string(); + } + return false; + } + + return true; +} + +namespace sha256_impl { + +static constexpr uint32_t K[64] = { + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +}; + +inline uint32_t rotr(uint32_t x, int n) { return (x >> n) | (x << (32 - n)); } + +void processBlock(uint32_t state[8], const uint8_t block[64]) { + uint32_t w[64]; + for (int i = 0; i < 16; ++i) { + w[i] = (static_cast(block[i * 4]) << 24) | + (static_cast(block[i * 4 + 1]) << 16) | + (static_cast(block[i * 4 + 2]) << 8) | + static_cast(block[i * 4 + 3]); + } + for (int i = 16; i < 64; ++i) { + const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3); + const uint32_t s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + s0 + w[i - 7] + s1; + } + + uint32_t a = state[0], b = state[1], c = state[2], d = state[3]; + uint32_t e = state[4], f = state[5], g = state[6], h = state[7]; + for (int i = 0; i < 64; ++i) { + const uint32_t S1 = rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25); + const uint32_t ch = (e & f) ^ (~e & g); + const uint32_t temp1 = h + S1 + ch + K[i] + w[i]; + const uint32_t S0 = rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22); + const uint32_t maj = (a & b) ^ (a & c) ^ (b & c); + const uint32_t temp2 = S0 + maj; + h = g; g = f; f = e; e = d + temp1; + d = c; c = b; b = a; a = temp1 + temp2; + } + + state[0] += a; state[1] += b; state[2] += c; state[3] += d; + state[4] += e; state[5] += f; state[6] += g; state[7] += h; +} + +} // namespace sha256_impl + +std::string computeFileSha256(const std::filesystem::path& filePath) { + std::ifstream file(filePath, std::ios::binary); + if (!file.is_open()) { + return ""; + } + + uint32_t state[8] = { + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, + 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19, + }; + + uint8_t block[64]; + uint64_t totalBytes = 0; + while (file) { + file.read(reinterpret_cast(block), 64); + const auto bytesRead = static_cast(file.gcount()); + totalBytes += bytesRead; + if (bytesRead == 64) { + sha256_impl::processBlock(state, block); + } else { + // Padding + block[bytesRead] = 0x80; + std::fill(block + bytesRead + 1, block + 64, static_cast(0)); + if (bytesRead >= 56) { + sha256_impl::processBlock(state, block); + std::fill(block, block + 64, static_cast(0)); + } + const uint64_t totalBits = totalBytes * 8; + for (int i = 0; i < 8; ++i) { + block[63 - i] = static_cast(totalBits >> (i * 8)); + } + sha256_impl::processBlock(state, block); + } + } + + static const char digits[] = "0123456789abcdef"; + std::string hex; + hex.reserve(64); + for (int i = 0; i < 8; ++i) { + for (int j = 3; j >= 0; --j) { + const uint8_t byte = static_cast(state[i] >> (j * 8)); + hex.push_back(digits[(byte >> 4) & 0x0f]); + hex.push_back(digits[byte & 0x0f]); + } + } + return hex; +} + +std::string sanitizeRepoId(const std::string& repoId) { + std::string out; + out.reserve(repoId.size() + 8); + for (const auto c : repoId) { + if (std::isalnum(static_cast(c)) != 0 || c == '-' || c == '_') { + out.push_back(c); + } else if (c == '/') { + out.append("__"); + } else { + out.push_back('_'); + } + } + return out.empty() ? "model" : out; +} + +std::string escapePathPreservingSlash(const std::string& path) { + std::stringstream stream(path); + std::string token; + std::string encoded; + bool first = true; + while (std::getline(stream, token, '/')) { + if (!first) { + encoded.push_back('/'); + } + first = false; + encoded += juce::URL::addEscapeChars(token, false).toStdString(); + } + return encoded; +} + +std::string sourceUrlForRepo(const std::string& repoId) { + return "https://huggingface.co/" + repoId; +} + +std::string inferUseCase(const std::string& repoId, + const std::vector& tags, + const std::string& fallbackQuery) { + const auto repoLower = toLower(repoId); + auto joined = repoLower; + for (const auto& tag : tags) { + joined += "|" + toLower(tag); + } + joined += "|" + toLower(fallbackQuery); + + if (joined.find("demucs") != std::string::npos || + joined.find("mdx") != std::string::npos || + joined.find("roformer") != std::string::npos || + joined.find("unmix") != std::string::npos || + joined.find("source-separation") != std::string::npos || + joined.find("separator") != std::string::npos) { + return "stem-separation"; + } + if (joined.find("clap") != std::string::npos) { + return "style-retrieval-embedding"; + } + if (joined.find("panns") != std::string::npos) { + return "audio-tagging-embedding"; + } + if (joined.find("basic-pitch") != std::string::npos || joined.find("midi") != std::string::npos) { + return "pitch-midi-analysis"; + } + if (joined.find("master") != std::string::npos || joined.find("loudness") != std::string::npos) { + return "mastering-assistant"; + } + return "general-audio-model"; +} + +std::string inferLicense(const nlohmann::json& json) { + if (json.contains("cardData") && json.at("cardData").is_object()) { + const auto cardLicense = json.at("cardData").value("license", ""); + if (!cardLicense.empty()) { + return cardLicense; + } + } + + if (json.contains("tags") && json.at("tags").is_array()) { + for (const auto& tagJson : json.at("tags")) { + if (!tagJson.is_string()) { + continue; + } + const auto tag = tagJson.get(); + if (tag.rfind("license:", 0) == 0 && tag.size() > 8) { + return tag.substr(8); + } + } + } + + return "unknown"; +} + +std::string pickPrimaryFile(const std::vector& files, + bool* hasOnnxOut = nullptr) { + if (hasOnnxOut != nullptr) { + *hasOnnxOut = false; + } + + if (files.empty()) { + return ""; + } + + const std::vector preferred = { + "model.onnx", + "model_fp16.onnx", + "model_int8.onnx", + "pytorch_model.bin", + "model.safetensors", + "model.pt", + "model.ckpt", + "checkpoint.pt", + }; + + auto lowerFiles = files; + std::vector lower; + lower.reserve(files.size()); + for (const auto& file : files) { + lower.push_back(toLower(file)); + } + + for (const auto& preferredName : preferred) { + const auto needle = toLower(preferredName); + const auto it = std::find(lower.begin(), lower.end(), needle); + if (it != lower.end()) { + const auto index = static_cast(std::distance(lower.begin(), it)); + if (hasOnnxOut != nullptr && needle.size() >= 5 && needle.ends_with(".onnx")) { + *hasOnnxOut = true; + } + return files[index]; + } + } + + for (size_t i = 0; i < files.size(); ++i) { + const auto item = toLower(files[i]); + if (item.ends_with(".onnx")) { + if (hasOnnxOut != nullptr) { + *hasOnnxOut = true; + } + return files[i]; + } + } + + for (size_t i = 0; i < files.size(); ++i) { + const auto item = toLower(files[i]); + if (item.ends_with(".safetensors") || item.ends_with(".bin") || item.ends_with(".pt") || item.ends_with(".ckpt")) { + return files[i]; + } + } + + return ""; +} + +bool trustedOrganization(const std::string& repoId) { + const auto slash = repoId.find('/'); + const auto org = toLower(slash == std::string::npos ? repoId : repoId.substr(0, slash)); + static const std::unordered_set trusted = { + "laion", + "speechbrain", + "spotify", + "faroit", + "intel", + "espnet", + "openai", + "mozilla", + "facebook", + "meta", + "microsoft", + }; + return trusted.find(org) != trusted.end(); +} + +nlohmann::json loadJsonIfPresent(const std::filesystem::path& path) { + try { + std::ifstream in(path); + if (!in.is_open()) { + return nlohmann::json::array(); + } + nlohmann::json parsed; + in >> parsed; + return parsed; + } catch (...) { + return nlohmann::json::array(); + } +} + +void writeJson(const std::filesystem::path& path, const nlohmann::json& json) { + std::error_code error; + std::filesystem::create_directories(path.parent_path(), error); + std::ofstream out(path); + out << json.dump(2); +} + +void updateInstallRegistry(const std::filesystem::path& root, + const HubModelInfo& model, + const HubInstallResult& result) { + const auto registryPath = root / "install_registry.json"; + auto registry = loadJsonIfPresent(registryPath); + if (!registry.is_array()) { + registry = nlohmann::json::array(); + } + + bool updated = false; + for (auto& item : registry) { + if (!item.is_object() || item.value("repoId", "") != model.repoId) { + continue; + } + item = { + {"repoId", model.repoId}, + {"revision", result.revision}, + {"installedAtUtc", iso8601NowUtc()}, + {"installPath", result.installPath.string()}, + {"primaryFile", result.primaryFilePath.filename().string()}, + {"useCase", model.useCase}, + {"license", model.license}, + {"sourceUrl", model.sourceUrl}, + {"downloads", model.downloads}, + {"likes", model.likes}, + }; + updated = true; + break; + } + + if (!updated) { + registry.push_back({ + {"repoId", model.repoId}, + {"revision", result.revision}, + {"installedAtUtc", iso8601NowUtc()}, + {"installPath", result.installPath.string()}, + {"primaryFile", result.primaryFilePath.filename().string()}, + {"useCase", model.useCase}, + {"license", model.license}, + {"sourceUrl", model.sourceUrl}, + {"downloads", model.downloads}, + {"likes", model.likes}, + }); + } + + std::sort(registry.begin(), registry.end(), [](const nlohmann::json& a, const nlohmann::json& b) { + return a.value("repoId", "") < b.value("repoId", ""); + }); + writeJson(registryPath, registry); +} + +void appendInstallLog(const std::filesystem::path& root, + const HubModelInfo& model, + const HubInstallResult& result) { + const auto logPath = root / "install_log.jsonl"; + std::error_code error; + std::filesystem::create_directories(logPath.parent_path(), error); + + nlohmann::json event = { + {"timestampUtc", iso8601NowUtc()}, + {"repoId", model.repoId}, + {"revision", result.revision}, + {"success", result.success}, + {"message", result.message}, + {"installPath", result.installPath.string()}, + {"primaryFile", result.primaryFilePath.filename().string()}, + {"useCase", model.useCase}, + {"license", model.license}, + }; + + std::ofstream out(logPath, std::ios::app); + out << event.dump() << "\n"; +} + +} // namespace + +std::vector HuggingFaceModelHub::defaultRecommendedSearchTerms() { + return { + "demucs", + "mdx23c", + "bs-roformer", + "mel-band-roformer", + "open-unmix", + "clap", + "basic-pitch", + "panns", + }; +} + +std::string HuggingFaceModelHub::resolveToken(const std::string& explicitToken) const { + auto token = sanitizeToken(explicitToken); + if (!token.empty()) { + return token; + } + + for (const auto* key : {"AUTOMIX_HF_TOKEN", "HF_TOKEN", "HUGGINGFACE_TOKEN", "HUGGINGFACE_HUB_TOKEN"}) { + if (const auto value = readEnvironment(key); value.has_value()) { + token = sanitizeToken(*value); + if (!token.empty()) { + return token; + } + } + } + + return ""; +} + +std::optional HuggingFaceModelHub::modelInfo(const std::string& repoId, const std::string& token) const { + if (repoId.empty()) { + return std::nullopt; + } + + const auto effectiveToken = resolveToken(token); + const auto url = "https://huggingface.co/api/models/" + juce::URL::addEscapeChars(repoId, false).toStdString(); + std::string detail; + const auto payload = fetchJson(url, effectiveToken, &detail); + if (!payload.has_value() || !payload->is_object()) { + return std::nullopt; + } + + HubModelInfo info; + info.repoId = payload->value("id", repoId); + info.displayName = info.repoId; + info.revision = payload->value("sha", "main"); + info.downloads = payload->value("downloads", 0); + info.likes = payload->value("likes", 0); + info.privateRepo = payload->value("private", false); + info.gated = payload->value("gated", false); + info.disabled = payload->value("disabled", false); + info.lastModified = payload->value("lastModified", ""); + info.license = inferLicense(*payload); + info.sourceUrl = sourceUrlForRepo(info.repoId); + + if (payload->contains("tags") && payload->at("tags").is_array()) { + for (const auto& tag : payload->at("tags")) { + if (tag.is_string()) { + info.tags.push_back(tag.get()); + } + } + } + + if (payload->contains("siblings") && payload->at("siblings").is_array()) { + for (const auto& sibling : payload->at("siblings")) { + if (!sibling.is_object()) { + continue; + } + const auto file = sibling.value("rfilename", ""); + if (!file.empty()) { + info.files.push_back(file); + if (sibling.contains("lfs") && sibling.at("lfs").is_object()) { + const auto sha = sibling.at("lfs").value("sha256", ""); + if (!sha.empty()) { + info.fileSha256[file] = sha; + } + } + } + } + } + + info.primaryFile = pickPrimaryFile(info.files, &info.hasOnnx); + info.useCase = inferUseCase(info.repoId, info.tags, ""); + + return info; +} + +std::vector HuggingFaceModelHub::discoverRecommended(const HubModelQueryOptions& options) const { + const auto effectiveToken = resolveToken(options.token); + std::vector discovered; + std::set seen; + + for (const auto& query : defaultRecommendedSearchTerms()) { + const auto escaped = juce::URL::addEscapeChars(query, false).toStdString(); + const auto url = "https://huggingface.co/api/models?search=" + escaped + + "&sort=downloads&direction=-1&limit=" + std::to_string(std::max(1, options.maxResultsPerQuery)); + + const auto response = fetchJson(url, effectiveToken); + if (!response.has_value() || !response->is_array()) { + continue; + } + + for (const auto& item : *response) { + if (!item.is_object()) { + continue; + } + + const auto repoId = item.value("id", ""); + if (repoId.empty() || !seen.insert(repoId).second) { + continue; + } + + auto info = modelInfo(repoId, effectiveToken); + if (!info.has_value()) { + continue; + } + + if (info->privateRepo || info->disabled) { + continue; + } + if (!options.includeGated && info->gated) { + continue; + } + if (info->primaryFile.empty()) { + continue; + } + + info->useCase = inferUseCase(info->repoId, info->tags, query); + const bool trust = trustedOrganization(info->repoId); + const bool hasOpenLicense = info->license != "unknown" && info->license != "other"; + info->recommended = trust || (hasOpenLicense && info->downloads >= 50); + discovered.push_back(std::move(info.value())); + } + } + + std::sort(discovered.begin(), discovered.end(), [](const HubModelInfo& a, const HubModelInfo& b) { + if (a.recommended != b.recommended) { + return a.recommended > b.recommended; + } + if (a.downloads != b.downloads) { + return a.downloads > b.downloads; + } + if (a.likes != b.likes) { + return a.likes > b.likes; + } + return a.repoId < b.repoId; + }); + + if (discovered.size() > 40) { + discovered.resize(40); + } + return discovered; +} + +HubInstallResult HuggingFaceModelHub::installModel(const std::string& repoId, const HubInstallOptions& options) const { + HubInstallResult result; + result.repoId = repoId; + + const auto effectiveToken = resolveToken(options.token); + const auto info = modelInfo(repoId, effectiveToken); + if (!info.has_value()) { + result.message = "Unable to fetch model info from Hugging Face."; + return result; + } + + if (info->privateRepo || info->disabled) { + result.message = "Model is private or disabled."; + return result; + } + if (info->gated && effectiveToken.empty()) { + result.message = "Model is gated. Set HF token in AUTOMIX_HF_TOKEN/HF_TOKEN/HUGGINGFACE_TOKEN."; + return result; + } + if (info->primaryFile.empty()) { + result.message = "Model does not expose a downloadable primary model file."; + return result; + } + + const auto destinationRoot = options.destinationRoot.empty() ? std::filesystem::path("assets/modelhub") : options.destinationRoot; + const auto installPath = destinationRoot / sanitizeRepoId(info->repoId); + const auto primaryPath = installPath / std::filesystem::path(info->primaryFile).filename(); + result.installPath = installPath; + result.primaryFilePath = primaryPath; + result.revision = info->revision.empty() ? "main" : info->revision; + + std::error_code error; + if (std::filesystem::is_regular_file(primaryPath, error) && !error && !options.overwrite) { + result.success = true; + result.message = "Model already installed."; + result.downloadedFiles = {primaryPath.filename().string()}; + updateInstallRegistry(destinationRoot, info.value(), result); + appendInstallLog(destinationRoot, info.value(), result); + return result; + } + + std::filesystem::create_directories(installPath, error); + if (error) { + result.message = "Failed to create install directory: " + installPath.string(); + return result; + } + + const auto revision = result.revision; + const auto filePathInRepo = escapePathPreservingSlash(info->primaryFile); + const auto downloadUrl = "https://huggingface.co/" + info->repoId + "/resolve/" + revision + "/" + filePathInRepo; + std::string detail; + if (!downloadToFile(downloadUrl, primaryPath, effectiveToken, &detail)) { + result.message = detail.empty() ? "Model download failed." : detail; + appendInstallLog(destinationRoot, info.value(), result); + return result; + } + + const auto expectedShaIt = info->fileSha256.find(info->primaryFile); + if (expectedShaIt != info->fileSha256.end() && !expectedShaIt->second.empty()) { + const auto computedSha = computeFileSha256(primaryPath); + if (computedSha != expectedShaIt->second) { + std::filesystem::remove(primaryPath, error); + result.message = "SHA-256 verification failed for " + primaryPath.filename().string() + + ". Expected: " + expectedShaIt->second + + ", computed: " + (computedSha.empty() ? "(unable to compute)" : computedSha) + + ". Corrupted download removed."; + appendInstallLog(destinationRoot, info.value(), result); + return result; + } + } + + result.downloadedFiles.push_back(primaryPath.filename().string()); + + if (options.downloadReadme) { + const auto readmeIt = std::find_if(info->files.begin(), info->files.end(), [](const std::string& file) { + return toLower(file) == "readme.md"; + }); + if (readmeIt != info->files.end()) { + const auto readmeUrl = "https://huggingface.co/" + info->repoId + "/resolve/" + revision + "/README.md"; + const auto readmePath = installPath / "README.md"; + if (downloadToFile(readmeUrl, readmePath, effectiveToken, nullptr)) { + result.downloadedFiles.push_back("README.md"); + } + } + } + + nlohmann::json metadata = { + {"schemaVersion", 1}, + {"repoId", info->repoId}, + {"revision", revision}, + {"name", info->displayName}, + {"useCase", info->useCase}, + {"license", info->license}, + {"sourceUrl", info->sourceUrl}, + {"downloads", info->downloads}, + {"likes", info->likes}, + {"installedAtUtc", iso8601NowUtc()}, + {"primaryFile", primaryPath.filename().string()}, + {"primaryFileSha256", computeFileSha256(primaryPath)}, + {"hasOnnx", info->hasOnnx}, + {"tokenUsed", !effectiveToken.empty()}, + {"availableFiles", info->files}, + {"tags", info->tags}, + }; + result.metadataPath = installPath / "modelhub.json"; + writeJson(result.metadataPath, metadata); + + result.success = true; + result.message = "Model installed successfully."; + updateInstallRegistry(destinationRoot, info.value(), result); + appendInstallLog(destinationRoot, info.value(), result); + return result; +} + +} // namespace automix::ai diff --git a/src/ai/HuggingFaceModelHub.h b/src/ai/HuggingFaceModelHub.h new file mode 100644 index 0000000..8f9a49f --- /dev/null +++ b/src/ai/HuggingFaceModelHub.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace automix::ai { + +struct HubModelInfo { + std::string repoId; + std::string displayName; + std::string useCase; + std::string license; + std::string revision; + int downloads = 0; + int likes = 0; + bool privateRepo = false; + bool gated = false; + bool disabled = false; + bool hasOnnx = false; + bool recommended = false; + std::string lastModified; + std::string sourceUrl; + std::string primaryFile; + std::vector tags; + std::vector files; + std::unordered_map fileSha256; +}; + +struct HubModelQueryOptions { + size_t maxResultsPerQuery = 8; + bool includeGated = false; + std::string token; +}; + +struct HubInstallOptions { + std::filesystem::path destinationRoot = "assets/modelhub"; + std::string token; + bool downloadReadme = true; + bool overwrite = false; +}; + +struct HubInstallResult { + bool success = false; + std::string repoId; + std::filesystem::path installPath; + std::filesystem::path primaryFilePath; + std::filesystem::path metadataPath; + std::string revision; + std::string message; + std::vector downloadedFiles; +}; + +class HuggingFaceModelHub { + public: + std::vector discoverRecommended(const HubModelQueryOptions& options = {}) const; + std::optional modelInfo(const std::string& repoId, const std::string& token = "") const; + HubInstallResult installModel(const std::string& repoId, const HubInstallOptions& options = {}) const; + + std::string resolveToken(const std::string& explicitToken = "") const; + static std::vector defaultRecommendedSearchTerms(); +}; + +} // namespace automix::ai diff --git a/src/ai/MasteringCompliance.cpp b/src/ai/MasteringCompliance.cpp index e4b04bf..6e0603a 100644 --- a/src/ai/MasteringCompliance.cpp +++ b/src/ai/MasteringCompliance.cpp @@ -3,11 +3,53 @@ #include #include +#include "engine/LoudnessMeter.h" + namespace automix::ai { namespace { +// Target LUFS convergence tolerance: 0.5 LU is below typical loudness +// measurement uncertainty and small enough that further tightening yields +// negligible perceptual benefit while increasing iteration cost. +constexpr double kLoudnessToleranceLu = 0.5; + +// Upper bound on gain-correction iterations: the algorithm converges +// geometrically in practice, so 4 passes are sufficient to reach +// kLoudnessToleranceLu for realistic material without unnecessary CPU use. +constexpr int kMaxCorrectionIterations = 4; + double dbToLinear(const double db) { return std::pow(10.0, db / 20.0); } +double monoCorrelation(const engine::AudioBuffer& buffer) { + if (buffer.getNumChannels() < 2 || buffer.getNumSamples() == 0) { + return 1.0; + } + + double sumL = 0.0; + double sumR = 0.0; + double sumLL = 0.0; + double sumRR = 0.0; + double sumLR = 0.0; + const double n = static_cast(buffer.getNumSamples()); + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const double l = buffer.getSample(0, i); + const double r = buffer.getSample(1, i); + sumL += l; + sumR += r; + sumLL += l * l; + sumRR += r * r; + sumLR += l * r; + } + + const double cov = sumLR - (sumL * sumR) / n; + const double varL = sumLL - (sumL * sumL) / n; + const double varR = sumRR - (sumR * sumR) / n; + if (varL <= 1.0e-9 || varR <= 1.0e-9) { + return 1.0; + } + return std::clamp(cov / std::sqrt(varL * varR), -1.0, 1.0); +} + } // namespace domain::MasterPlan MasteringCompliance::enforcePlanBounds(const domain::MasterPlan& plan) const { @@ -27,26 +69,33 @@ engine::AudioBuffer MasteringCompliance::enforceOutput(const engine::AudioBuffer automaster::MasteringReport* reportOut) const { engine::AudioBuffer corrected = masteredBuffer; - double lufs = strategy.measureIntegratedLufs(corrected); - double truePeak = strategy.estimateTruePeakDbtp(corrected, 4); + for (int i = 0; i < kMaxCorrectionIterations; ++i) { + const double lufs = strategy.measureIntegratedLufs(corrected); + const double loudnessError = plan.targetLufs - lufs; + if (std::abs(loudnessError) > kLoudnessToleranceLu) { + corrected.applyGain(static_cast(dbToLinear(std::clamp(loudnessError, -2.5, 2.5)))); + } - if (truePeak > plan.truePeakDbtp) { - corrected.applyGain(static_cast(dbToLinear(plan.truePeakDbtp - truePeak))); - truePeak = strategy.estimateTruePeakDbtp(corrected, 4); - } + const double truePeak = strategy.estimateTruePeakDbtp(corrected, 4); + if (truePeak > plan.truePeakDbtp) { + corrected.applyGain(static_cast(dbToLinear(plan.truePeakDbtp - truePeak))); + } - const double loudnessError = plan.targetLufs - lufs; - if (std::abs(loudnessError) > 0.7) { - corrected.applyGain(static_cast(dbToLinear(loudnessError))); - const double adjustedPeak = strategy.estimateTruePeakDbtp(corrected, 4); - if (adjustedPeak > plan.truePeakDbtp) { - corrected.applyGain(static_cast(dbToLinear(plan.truePeakDbtp - adjustedPeak))); + if (std::abs(loudnessError) <= kLoudnessToleranceLu && truePeak <= plan.truePeakDbtp) { + break; } } if (reportOut != nullptr) { - reportOut->integratedLufs = strategy.measureIntegratedLufs(corrected); + engine::LoudnessMeter meter; + const auto metrics = meter.analyze(corrected); + reportOut->integratedLufs = metrics.integratedLufs; + reportOut->shortTermLufs = metrics.shortTermLufs; + reportOut->loudnessRange = metrics.loudnessRange; + reportOut->samplePeakDbfs = metrics.samplePeakDbfs; reportOut->truePeakDbtp = strategy.estimateTruePeakDbtp(corrected, 4); + reportOut->crestDb = reportOut->samplePeakDbfs - reportOut->integratedLufs; + reportOut->monoCorrelation = monoCorrelation(corrected); } return corrected; diff --git a/src/ai/ModelManager.cpp b/src/ai/ModelManager.cpp index be32829..83c1c87 100644 --- a/src/ai/ModelManager.cpp +++ b/src/ai/ModelManager.cpp @@ -1,29 +1,169 @@ #include "ai/ModelManager.h" #include +#include +#include +#include +#include +#include + +#include "util/StringUtils.h" namespace automix::ai { +namespace { -ModelManager::ModelManager(std::filesystem::path rootPath) : rootPath_(std::move(rootPath)) {} +using ::automix::util::toLower; -void ModelManager::setRootPath(const std::filesystem::path& rootPath) { rootPath_ = rootPath; } +std::vector defaultRoots() { + return { + "ModelPacks", + "assets/models", + "assets/modelpacks", + "assets/ModelPacks", + "Assets/ModelPacks", + }; +} -std::vector ModelManager::scan() { - availablePacks_.clear(); +std::vector parseEnvRoots() { + std::vector roots; + const char* raw = std::getenv("AUTOMIX_MODELPACK_PATHS"); + if (raw == nullptr || *raw == '\0') { + return roots; + } + + constexpr char kPathDelimiter = +#if defined(_WIN32) + ';'; +#else + ':'; +#endif + + std::stringstream stream(raw); + std::string token; + while (std::getline(stream, token, kPathDelimiter)) { + if (!token.empty()) { + roots.emplace_back(token); + } + } + return roots; +} + +bool isModelMetadataFile(const std::filesystem::path& path) { + return toLower(path.filename().string()) == "model.json"; +} + +std::vector expandRootCandidates(const std::filesystem::path& root) { + std::vector candidates; + if (root.empty()) { + return candidates; + } + + if (root.is_absolute()) { + candidates.push_back(root); + return candidates; + } std::error_code error; - if (!std::filesystem::exists(rootPath_, error) || error) { - return availablePacks_; + auto base = std::filesystem::current_path(error); + if (error) { + candidates.push_back(root); + return candidates; + } + + for (int depth = 0; depth < 6; ++depth) { + candidates.push_back(base / root); + if (!base.has_parent_path()) { + break; + } + const auto parent = base.parent_path(); + if (parent == base) { + break; + } + base = parent; } - for (const auto& entry : std::filesystem::directory_iterator(rootPath_)) { - if (!entry.is_directory()) { - continue; + return candidates; +} + +} // namespace + +ModelManager::ModelManager(std::filesystem::path rootPath) { rootPaths_.push_back(std::move(rootPath)); } + +void ModelManager::setRootPath(const std::filesystem::path& rootPath) { + rootPaths_.clear(); + rootPaths_.push_back(rootPath); +} + +void ModelManager::setRootPaths(const std::vector& rootPaths) { + rootPaths_.clear(); + for (const auto& root : rootPaths) { + if (!root.empty()) { + rootPaths_.push_back(root); + } + } +} + +const std::vector& ModelManager::rootPaths() const { return rootPaths_; } + +std::vector ModelManager::scan() { + availablePacks_.clear(); + + std::vector scanRoots; + scanRoots.reserve(rootPaths_.size() + 8); + for (const auto& root : rootPaths_) { + if (!root.empty()) { + scanRoots.push_back(root); } + } + for (const auto& root : defaultRoots()) { + scanRoots.push_back(root); + } + for (const auto& root : parseEnvRoots()) { + scanRoots.push_back(root); + } + + std::set visitedRoots; + std::unordered_set seenPackIds; + std::error_code error; + for (const auto& root : scanRoots) { + for (const auto& candidate : expandRootCandidates(root)) { + error.clear(); + const auto absoluteRoot = std::filesystem::absolute(candidate, error); + if (error) { + continue; + } + const auto rootKey = toLower(absoluteRoot.string()); + if (!visitedRoots.insert(rootKey).second) { + continue; + } + if (!std::filesystem::exists(absoluteRoot, error) || error) { + continue; + } + + std::filesystem::recursive_directory_iterator iterator( + absoluteRoot, + std::filesystem::directory_options::skip_permission_denied, + error); + if (error) { + continue; + } - const auto pack = loader_.load(entry.path()); - if (pack.has_value()) { - availablePacks_.push_back(pack.value()); + for (const auto& entry : iterator) { + if (!entry.is_regular_file()) { + continue; + } + if (!isModelMetadataFile(entry.path())) { + continue; + } + const auto packDir = entry.path().parent_path(); + const auto pack = loader_.load(packDir); + if (!pack.has_value()) { + continue; + } + if (seenPackIds.insert(pack->id).second) { + availablePacks_.push_back(pack.value()); + } + } } } diff --git a/src/ai/ModelManager.h b/src/ai/ModelManager.h index e228fbc..3b58116 100644 --- a/src/ai/ModelManager.h +++ b/src/ai/ModelManager.h @@ -14,6 +14,8 @@ class ModelManager { explicit ModelManager(std::filesystem::path rootPath = "ModelPacks"); void setRootPath(const std::filesystem::path& rootPath); + void setRootPaths(const std::vector& rootPaths); + const std::vector& rootPaths() const; std::vector scan(); const std::vector& availablePacks() const; std::vector packsForType(const std::string& type) const; @@ -22,7 +24,7 @@ class ModelManager { std::string activePackId(const std::string& task) const; private: - std::filesystem::path rootPath_; + std::vector rootPaths_; ModelPackLoader loader_; std::vector availablePacks_; std::unordered_map activePackByTask_; diff --git a/src/ai/ModelPackLoader.cpp b/src/ai/ModelPackLoader.cpp index 173dba4..9114330 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -2,11 +2,30 @@ #include #include -#include #include +#include "ai/FeatureSchema.h" +#include "util/HashUtils.h" + namespace automix::ai { +namespace { + +std::vector readStringArray(const nlohmann::json& json, const char* keyA, const char* keyB = nullptr) { + if (json.contains(keyA) && json.at(keyA).is_array()) { + return json.at(keyA).get>(); + } + if (keyB != nullptr && json.contains(keyB) && json.at(keyB).is_array()) { + return json.at(keyB).get>(); + } + return {}; +} + +bool hasRequiredMetadata(const ModelPack& pack) { + return !pack.licenseId.empty() && !pack.source.empty() && !pack.featureSchemaVersion.empty(); +} + +} // namespace std::optional ModelPackLoader::load(const std::filesystem::path& directory) const { const auto metadataPath = directory / "model.json"; @@ -26,6 +45,10 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire pack.engine = json.value("engine", "unknown"); pack.minAppVersion = json.value("min_app_version", json.value("minAppVersion", "0.0.0")); pack.version = json.value("version", "0.0.0"); + pack.licenseId = json.value("license", json.value("licenseId", "")); + pack.source = json.value("source", ""); + pack.intendedUse = json.value("intended_use", json.value("intendedUse", "")); + pack.featureSchemaVersion = json.value("feature_schema_version", json.value("featureSchemaVersion", "")); pack.modelFile = json.value("modelFile", json.value("model_file", "model.onnx")); pack.checksum = json.value("checksum", ""); if (json.contains("inputFeatureCount")) { @@ -36,15 +59,50 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire pack.inputFeatureCount.reset(); } - if (json.contains("expectedOutputKeys") && json.at("expectedOutputKeys").is_array()) { - pack.expectedOutputKeys = json.at("expectedOutputKeys").get>(); - } else if (json.contains("output_keys") && json.at("output_keys").is_array()) { - pack.expectedOutputKeys = json.at("output_keys").get>(); + pack.expectedOutputKeys = readStringArray(json, "expectedOutputKeys", "output_keys"); + pack.inputNames = readStringArray(json, "inputNames", "input_names"); + pack.outputNames = readStringArray(json, "outputNames", "output_names"); + + if (pack.expectedOutputKeys.empty() && json.contains("output_schema") && json.at("output_schema").is_object()) { + for (const auto& entry : json.at("output_schema").items()) { + pack.expectedOutputKeys.push_back(entry.key()); + } + } + + pack.preferredPrecision = json.value("preferredPrecision", json.value("preferred_precision", "auto")); + pack.providerAffinity = readStringArray(json, "providerAffinity", "provider_affinity"); + if (json.contains("defaultIntraOpThreads")) { + pack.defaultIntraOpThreads = json.at("defaultIntraOpThreads").get(); + } else if (json.contains("default_intra_op_threads")) { + pack.defaultIntraOpThreads = json.at("default_intra_op_threads").get(); + } else { + pack.defaultIntraOpThreads.reset(); + } + if (json.contains("defaultInterOpThreads")) { + pack.defaultInterOpThreads = json.at("defaultInterOpThreads").get(); + } else if (json.contains("default_inter_op_threads")) { + pack.defaultInterOpThreads = json.at("default_inter_op_threads").get(); } else { - pack.expectedOutputKeys.clear(); + pack.defaultInterOpThreads.reset(); + } + pack.enableProfiling = json.value("enableProfiling", json.value("enable_profiling", false)); + + if (pack.featureSchemaVersion.empty() && json.contains("feature_schema") && json.at("feature_schema").is_object()) { + pack.featureSchemaVersion = json.at("feature_schema").value("version", ""); } + pack.rootPath = directory; + if (!hasRequiredMetadata(pack)) { + return std::nullopt; + } + if (!FeatureSchemaV1::isCompatible(pack.featureSchemaVersion)) { + return std::nullopt; + } + if (pack.inputFeatureCount.has_value() && pack.inputFeatureCount.value() == 0) { + return std::nullopt; + } + const auto modelPath = directory / pack.modelFile; if (!std::filesystem::exists(modelPath)) { return std::nullopt; @@ -68,18 +126,17 @@ std::string ModelPackLoader::computeChecksum(const std::filesystem::path& filePa return ""; } - uint64_t hash = 14695981039346656037ull; - constexpr uint64_t prime = 1099511628211ull; - - char byte = 0; - while (in.get(byte)) { - hash ^= static_cast(byte); - hash *= prime; + uint64_t hash = util::kFnv1a64OffsetBasis; + char buffer[4096]; + while (in.good()) { + in.read(buffer, static_cast(sizeof(buffer))); + const auto readCount = static_cast(in.gcount()); + if (readCount > 0) { + hash = util::fnv1a64Update(hash, buffer, readCount); + } } - std::ostringstream output; - output << std::hex << hash; - return output.str(); + return util::toHex(hash); } } // namespace automix::ai diff --git a/src/ai/ModelPackLoader.h b/src/ai/ModelPackLoader.h index 11c830f..26244f3 100644 --- a/src/ai/ModelPackLoader.h +++ b/src/ai/ModelPackLoader.h @@ -15,10 +15,21 @@ struct ModelPack { std::string engine; std::string minAppVersion; std::string version; + std::string licenseId; + std::string source; + std::string intendedUse; + std::string featureSchemaVersion; std::string modelFile; std::string checksum; std::optional inputFeatureCount; + std::string preferredPrecision; + std::vector providerAffinity; + std::optional defaultIntraOpThreads; + std::optional defaultInterOpThreads; + bool enableProfiling = false; std::vector expectedOutputKeys; + std::vector inputNames; + std::vector outputNames; std::filesystem::path rootPath; }; diff --git a/src/ai/ModelStrategy.cpp b/src/ai/ModelStrategy.cpp index 49a578f..8c562f0 100644 --- a/src/ai/ModelStrategy.cpp +++ b/src/ai/ModelStrategy.cpp @@ -2,6 +2,8 @@ #include +#include "ai/FeatureSchema.h" + namespace automix::ai { std::pair ModelStrategy::applyOverrides( @@ -17,12 +19,10 @@ std::pair ModelStrategy::applyOverrides( } std::vector features; - features.reserve(analysisEntries.size() * 4); + features.reserve(analysisEntries.size() * FeatureSchemaV1::featureCount()); for (const auto& entry : analysisEntries) { - features.push_back(entry.metrics.rmsDb); - features.push_back(entry.metrics.lowEnergy); - features.push_back(entry.metrics.midEnergy); - features.push_back(entry.metrics.highEnergy); + const auto stemFeatures = FeatureSchemaV1::extract(entry.metrics); + features.insert(features.end(), stemFeatures.begin(), stemFeatures.end()); } const InferenceRequest request{ diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index 0220561..531c2f5 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -1,36 +1,951 @@ #include "ai/OnnxModelInference.h" -#ifdef ENABLE_ONNX +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "util/StringUtils.h" + +#ifndef AUTOMIX_HAS_NATIVE_ORT +#define AUTOMIX_HAS_NATIVE_ORT 0 +#endif + +#if AUTOMIX_HAS_NATIVE_ORT +#include +#endif namespace automix::ai { +namespace { + +using ::automix::util::toLower; + +double clamp01(const double value) { return std::clamp(value, 0.0, 1.0); } + +double normalizeFeatureValue(const double value) { + return std::copysign(std::log1p(std::abs(value)), value); +} + +std::string canonicalProviderName(const std::string& rawProvider) { + const auto provider = toLower(rawProvider); + if (provider.find("cpu") != std::string::npos) { + return "cpu"; + } + if (provider.find("cuda") != std::string::npos) { + return "cuda"; + } + if (provider.find("dml") != std::string::npos || provider.find("directml") != std::string::npos) { + return "directml"; + } + if (provider.find("coreml") != std::string::npos) { + return "coreml"; + } + return provider; +} + +std::string platformPreferredProvider() { +#if defined(_WIN32) + return "directml"; +#elif defined(__APPLE__) + return "coreml"; +#else + return "cuda"; +#endif +} + +std::filesystem::path pickQuantizedVariant(const std::filesystem::path& modelPath, const std::string& preferredPrecision) { + if (modelPath.empty()) { + return modelPath; + } + + const auto stem = modelPath.stem().string(); + const auto ext = modelPath.extension().string(); + const auto parent = modelPath.parent_path(); + + const auto int8Variant = parent / (stem + "_int8" + ext); + const auto fp16Variant = parent / (stem + "_fp16" + ext); + std::error_code error; + const auto precision = toLower(preferredPrecision); + + if (precision == "int8") { + if (std::filesystem::is_regular_file(int8Variant, error) && !error) { + return int8Variant; + } + error.clear(); + if (std::filesystem::is_regular_file(fp16Variant, error) && !error) { + return fp16Variant; + } + return modelPath; + } + + if (precision == "fp16") { + if (std::filesystem::is_regular_file(fp16Variant, error) && !error) { + return fp16Variant; + } + error.clear(); + if (std::filesystem::is_regular_file(int8Variant, error) && !error) { + return int8Variant; + } + return modelPath; + } + + if (std::filesystem::is_regular_file(int8Variant, error) && !error) { + return int8Variant; + } + error.clear(); + if (std::filesystem::is_regular_file(fp16Variant, error) && !error) { + return fp16Variant; + } + return modelPath; +} + +#if AUTOMIX_HAS_NATIVE_ORT +struct SessionTuning { + std::string hardwareTier = "standard"; + int intraOpThreads = 0; + int interOpThreads = 0; + bool memPattern = true; + bool cpuArena = true; + bool sequentialExecution = false; +}; + +SessionTuning tuningForProvider(const std::string& provider, const int hardwareThreads) { + SessionTuning tuning; + const auto normalized = canonicalProviderName(provider); + const int clampedThreads = std::max(1, hardwareThreads); + if (clampedThreads <= 4) { + tuning.hardwareTier = "low"; + } else if (clampedThreads >= 12) { + tuning.hardwareTier = "high"; + } + + if (normalized == "cuda") { + tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 8); + tuning.interOpThreads = 1; + tuning.memPattern = false; + tuning.cpuArena = true; + tuning.sequentialExecution = false; + return tuning; + } + + if (normalized == "directml") { + tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 4); + tuning.interOpThreads = 1; + tuning.memPattern = false; + tuning.cpuArena = false; + tuning.sequentialExecution = true; + return tuning; + } + + if (normalized == "coreml") { + tuning.intraOpThreads = std::clamp(clampedThreads / 2, 1, 4); + tuning.interOpThreads = 1; + tuning.memPattern = false; + tuning.cpuArena = false; + tuning.sequentialExecution = true; + return tuning; + } + + tuning.intraOpThreads = std::clamp(clampedThreads, 1, 16); + tuning.interOpThreads = std::clamp(clampedThreads / 2, 1, 8); + tuning.memPattern = true; + tuning.cpuArena = true; + tuning.sequentialExecution = false; + return tuning; +} +#endif + +#if AUTOMIX_HAS_NATIVE_ORT + +std::string makeProfilePrefix(const std::filesystem::path& modelPath) { + const auto timeTag = std::to_string( + std::chrono::duration_cast(std::chrono::high_resolution_clock::now().time_since_epoch()).count()); + + std::error_code error; + auto base = std::filesystem::temp_directory_path(error); + if (error) { + base = modelPath.parent_path(); + } + base /= "automix_ort_profiles"; + std::filesystem::create_directories(base, error); + + const auto stem = modelPath.stem().string(); + return (base / (stem + "_" + timeTag)).string(); +} + +void appendExecutionProvider(Ort::SessionOptions& options, const std::string& provider) { + const auto normalized = canonicalProviderName(provider); + if (normalized == "cpu" || normalized == "auto" || normalized.empty()) { + return; + } + + std::unordered_map providerOptions; + if (normalized == "cuda") { + options.AppendExecutionProvider("CUDA", providerOptions); + return; + } + if (normalized == "directml") { + options.AppendExecutionProvider("DML", providerOptions); + return; + } + if (normalized == "coreml") { + providerOptions["ModelFormat"] = "MLProgram"; + options.AppendExecutionProvider("CoreML", providerOptions); + } +} + +std::vector discoverAvailableRuntimeProviders() { + std::vector providers = {"cpu"}; + try { + const auto runtimeProviders = Ort::GetAvailableProviders(); + providers.reserve(providers.size() + runtimeProviders.size()); + for (const auto& provider : runtimeProviders) { + providers.push_back(canonicalProviderName(provider)); + } + } catch (...) { + } + + std::sort(providers.begin(), providers.end()); + providers.erase(std::unique(providers.begin(), providers.end()), providers.end()); + return providers; +} + +#endif + +} // namespace + +struct OnnxModelInference::NativeState { +#if AUTOMIX_HAS_NATIVE_ORT + std::unique_ptr env; + std::unique_ptr sessionOptions; + std::unique_ptr session; + std::vector inputNames; + std::vector outputNames; + std::vector inputShape; + std::filesystem::path profilingPrefix; +#endif +}; + +OnnxModelInference::~OnnxModelInference() noexcept = default; bool OnnxModelInference::isAvailable() const { return loaded_; } -bool OnnxModelInference::loadModel(const std::filesystem::path&) { - // ONNX runtime integration point. Stub returns true to validate plumbing. +bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { + const auto configuredPrecision = preferredPrecision_; + const auto configuredIntraOpThreads = intraOpThreads_; + const auto configuredInterOpThreads = interOpThreads_; + const auto configuredProfiling = profilingEnabled_; + + std::error_code error; + if (!std::filesystem::exists(modelPath, error) || error || !std::filesystem::is_regular_file(modelPath, error)) { + loaded_ = false; + nativeSessionActive_ = false; + modelPath_.clear(); + expectedFeatureCount_.reset(); + allowedTasks_.clear(); + availableExecutionProviders_.clear(); + preferredPrecision_ = "auto"; + intraOpThreads_ = 0; + interOpThreads_ = 0; + profilingEnabled_ = false; + profilingArtifacts_.clear(); + profilingCaptured_ = false; + nativeState_.reset(); + resetMetrics(); + diagnostics_ = "ONNX load failed: missing model file."; + return false; + } + + nativeState_.reset(); + nativeSessionActive_ = false; + nativeAvailable_ = false; + + const auto baseModelPath = std::filesystem::absolute(modelPath); + expectedFeatureCount_.reset(); + allowedTasks_.clear(); + availableExecutionProviders_.clear(); + preferredPrecision_ = toLower(configuredPrecision.empty() ? "auto" : configuredPrecision); + intraOpThreads_ = std::max(0, configuredIntraOpThreads); + interOpThreads_ = std::max(0, configuredInterOpThreads); + profilingEnabled_ = configuredProfiling; + profilingArtifacts_.clear(); + profilingCaptured_ = false; + + const auto metadataPath = std::filesystem::path(modelPath.string() + ".meta.json"); + if (std::filesystem::exists(metadataPath, error) && !error) { + std::ifstream in(metadataPath); + if (in.is_open()) { + nlohmann::json meta; + in >> meta; + + if (meta.contains("input_feature_count")) { + expectedFeatureCount_ = meta.at("input_feature_count").get(); + } + if (meta.contains("allowed_tasks") && meta.at("allowed_tasks").is_array()) { + allowedTasks_ = meta.at("allowed_tasks").get>(); + } + if (meta.contains("execution_providers") && meta.at("execution_providers").is_array()) { + availableExecutionProviders_ = meta.at("execution_providers").get>(); + } else if (meta.contains("available_execution_providers") && meta.at("available_execution_providers").is_array()) { + availableExecutionProviders_ = meta.at("available_execution_providers").get>(); + } else if (meta.contains("provider_affinity") && meta.at("provider_affinity").is_array()) { + availableExecutionProviders_ = meta.at("provider_affinity").get>(); + } else if (meta.contains("providerAffinity") && meta.at("providerAffinity").is_array()) { + availableExecutionProviders_ = meta.at("providerAffinity").get>(); + } + if (meta.contains("graph_optimization") && meta.at("graph_optimization").is_boolean()) { + graphOptimizationEnabled_ = meta.at("graph_optimization").get(); + } + if (meta.contains("preferred_precision") && meta.at("preferred_precision").is_string()) { + preferredPrecision_ = toLower(meta.at("preferred_precision").get()); + } else if (meta.contains("preferredPrecision") && meta.at("preferredPrecision").is_string()) { + preferredPrecision_ = toLower(meta.at("preferredPrecision").get()); + } + if (meta.contains("intra_op_threads") && meta.at("intra_op_threads").is_number_integer()) { + intraOpThreads_ = std::max(0, meta.at("intra_op_threads").get()); + } else if (meta.contains("intraOpThreads") && meta.at("intraOpThreads").is_number_integer()) { + intraOpThreads_ = std::max(0, meta.at("intraOpThreads").get()); + } + if (meta.contains("inter_op_threads") && meta.at("inter_op_threads").is_number_integer()) { + interOpThreads_ = std::max(0, meta.at("inter_op_threads").get()); + } else if (meta.contains("interOpThreads") && meta.at("interOpThreads").is_number_integer()) { + interOpThreads_ = std::max(0, meta.at("interOpThreads").get()); + } + if (meta.contains("enable_profiling") && meta.at("enable_profiling").is_boolean()) { + profilingEnabled_ = meta.at("enable_profiling").get(); + } else if (meta.contains("profiling") && meta.at("profiling").is_boolean()) { + profilingEnabled_ = meta.at("profiling").get(); + } + if (meta.contains("preferred_execution_provider") && meta.at("preferred_execution_provider").is_string() && + requestedExecutionProvider_ == "auto") { + requestedExecutionProvider_ = toLower(meta.at("preferred_execution_provider").get()); + } + if (meta.contains("expected_output_keys") && meta.at("expected_output_keys").is_array()) { + expectedOutputKeys_ = meta.at("expected_output_keys").get>(); + } else if (meta.contains("output_keys") && meta.at("output_keys").is_array()) { + expectedOutputKeys_ = meta.at("output_keys").get>(); + } + if (meta.contains("output_names") && meta.at("output_names").is_array()) { + outputNames_ = meta.at("output_names").get>(); + } else if (meta.contains("outputNames") && meta.at("outputNames").is_array()) { + outputNames_ = meta.at("outputNames").get>(); + } + if (meta.contains("input_names") && meta.at("input_names").is_array()) { + inputNames_ = meta.at("input_names").get>(); + } else if (meta.contains("inputNames") && meta.at("inputNames").is_array()) { + inputNames_ = meta.at("inputNames").get>(); + } + } + } + + if (availableExecutionProviders_.empty()) { + availableExecutionProviders_.push_back("cpu"); + const auto platformProvider = platformPreferredProvider(); + if (platformProvider != "cpu") { + availableExecutionProviders_.push_back(platformProvider); + } + } + + for (auto& provider : availableExecutionProviders_) { + provider = canonicalProviderName(provider); + } + + std::sort(availableExecutionProviders_.begin(), availableExecutionProviders_.end()); + availableExecutionProviders_.erase( + std::unique(availableExecutionProviders_.begin(), availableExecutionProviders_.end()), + availableExecutionProviders_.end()); + + auto selectedModelPath = baseModelPath; + if (preferQuantizedVariants_) { + selectedModelPath = pickQuantizedVariant(baseModelPath, preferredPrecision_); + } + modelPath_ = selectedModelPath; + + activeExecutionProvider_ = resolveExecutionProvider(); loaded_ = true; + warmupRan_ = false; + resetMetrics(); + + std::ostringstream diagnostics; + diagnostics << "ONNX model loaded from " << modelPath_.string() + << "; provider=" << activeExecutionProvider_ + << "; graph_opt=" << (graphOptimizationEnabled_ ? "ORT_ENABLE_ALL" : "disabled") + << "; preferred_precision=" << preferredPrecision_ + << "; intra_threads=" << intraOpThreads_ + << "; inter_threads=" << interOpThreads_ + << "; profiling=" << (profilingEnabled_ ? "on" : "off"); + + if (selectedModelPath != baseModelPath) { + diagnostics << "; quantized_variant=" << selectedModelPath.filename().string(); + } + +#if AUTOMIX_HAS_NATIVE_ORT + try { + nativeAvailable_ = true; + const auto runtimeProviders = discoverAvailableRuntimeProviders(); + if (!runtimeProviders.empty()) { + availableExecutionProviders_ = runtimeProviders; + activeExecutionProvider_ = resolveExecutionProvider(); + } + + auto nativeState = std::make_shared(); + nativeState->env = std::make_unique(ORT_LOGGING_LEVEL_WARNING, "AutoMixMaster"); + nativeState->sessionOptions = std::make_unique(); + + const int hardwareThreads = static_cast(std::max(1u, std::thread::hardware_concurrency())); + auto tuning = tuningForProvider(activeExecutionProvider_, hardwareThreads); + if (intraOpThreads_ > 0) { + tuning.intraOpThreads = intraOpThreads_; + } + if (interOpThreads_ > 0) { + tuning.interOpThreads = interOpThreads_; + } + + nativeState->sessionOptions->SetIntraOpNumThreads(std::max(1, tuning.intraOpThreads)); + nativeState->sessionOptions->SetInterOpNumThreads(std::max(1, tuning.interOpThreads)); + nativeState->sessionOptions->SetExecutionMode( + tuning.sequentialExecution ? ExecutionMode::ORT_SEQUENTIAL : ExecutionMode::ORT_PARALLEL); + nativeState->sessionOptions->SetGraphOptimizationLevel( + graphOptimizationEnabled_ ? GraphOptimizationLevel::ORT_ENABLE_ALL : GraphOptimizationLevel::ORT_DISABLE_ALL); + + if (!tuning.memPattern) { + nativeState->sessionOptions->DisableMemPattern(); + } + if (!tuning.cpuArena) { + nativeState->sessionOptions->DisableCpuMemArena(); + } + + if (profilingEnabled_) { + nativeState->profilingPrefix = makeProfilePrefix(modelPath_); + nativeState->sessionOptions->EnableProfiling(nativeState->profilingPrefix.string().c_str()); + } + + try { + appendExecutionProvider(*nativeState->sessionOptions, activeExecutionProvider_); + } catch (const std::exception&) { + providerFallbacks_.fetch_add(1); + activeExecutionProvider_ = "cpu"; + } + +#if defined(_WIN32) + nativeState->session = std::make_unique(*nativeState->env, + modelPath_.wstring().c_str(), + *nativeState->sessionOptions); +#else + nativeState->session = std::make_unique(*nativeState->env, + modelPath_.string().c_str(), + *nativeState->sessionOptions); +#endif + + Ort::AllocatorWithDefaultOptions allocator; + const auto inputCount = nativeState->session->GetInputCount(); + nativeState->inputNames.reserve(inputCount); + for (size_t i = 0; i < inputCount; ++i) { + auto name = nativeState->session->GetInputNameAllocated(i, allocator); + nativeState->inputNames.emplace_back(name.get() == nullptr ? std::string() : std::string(name.get())); + } + + const auto outputCount = nativeState->session->GetOutputCount(); + nativeState->outputNames.reserve(outputCount); + for (size_t i = 0; i < outputCount; ++i) { + auto name = nativeState->session->GetOutputNameAllocated(i, allocator); + nativeState->outputNames.emplace_back(name.get() == nullptr ? std::string() : std::string(name.get())); + } + + if (outputNames_.empty()) { + outputNames_ = nativeState->outputNames; + } + if (inputNames_.empty()) { + inputNames_ = nativeState->inputNames; + } + + if (inputCount > 0) { + auto inputInfo = nativeState->session->GetInputTypeInfo(0); + auto shape = inputInfo.GetTensorTypeAndShapeInfo().GetShape(); + if (shape.empty()) { + shape = {1, static_cast(expectedFeatureCount_.value_or(27))}; + } + nativeState->inputShape = shape; + + if (!expectedFeatureCount_.has_value()) { + for (auto it = shape.rbegin(); it != shape.rend(); ++it) { + if (*it > 0) { + expectedFeatureCount_ = static_cast(*it); + break; + } + } + } + } + + nativeState_ = std::move(nativeState); + nativeSessionActive_ = true; + + diagnostics << "; backend=native_onnxruntime" + << "; tuning_hardware_tier=" << tuning.hardwareTier + << "; tuning_mem_pattern=" << (tuning.memPattern ? "on" : "off") + << "; tuning_cpu_arena=" << (tuning.cpuArena ? "on" : "off") + << "; tuning_execution_mode=" << (tuning.sequentialExecution ? "sequential" : "parallel"); + } catch (const std::exception& errorException) { + nativeSessionActive_ = false; + diagnostics << "; backend=native_onnxruntime_unavailable" + << "; native_error=" << errorException.what() + << "; fallback=deterministic_adapter"; + } +#else + diagnostics << "; backend=deterministic_adapter" + << "; reason=onnxruntime_not_linked"; +#endif + + diagnostics_ = diagnostics.str(); + warmupIfNeeded(); return true; } InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { + const auto started = std::chrono::steady_clock::now(); InferenceResult result; + auto finalizeMetrics = [&]() { + const auto elapsed = + std::chrono::duration_cast(std::chrono::steady_clock::now() - started).count(); + inferenceCalls_.fetch_add(1); + cumulativeInferenceMicros_.fetch_add(static_cast(std::max(0, elapsed))); + }; + if (!loaded_) { result.usedModel = false; result.logMessage = "ONNX inference skipped: model not loaded."; + finalizeMetrics(); return result; } + if (!allowedTasks_.empty() && + std::find(allowedTasks_.begin(), allowedTasks_.end(), request.task) == allowedTasks_.end()) { + result.usedModel = false; + result.logMessage = "ONNX task rejected by model metadata: " + request.task; + finalizeMetrics(); + return result; + } + + if (expectedFeatureCount_.has_value() && request.features.size() != expectedFeatureCount_.value()) { + result.usedModel = false; + result.logMessage = "ONNX feature schema mismatch. expected=" + std::to_string(expectedFeatureCount_.value()) + + " got=" + std::to_string(request.features.size()); + finalizeMetrics(); + return result; + } + +#if AUTOMIX_HAS_NATIVE_ORT + if (nativeSessionActive_ && nativeState_ != nullptr && nativeState_->session != nullptr) { + try { + std::scoped_lock lock(nativeMutex_); + std::vector normalized; + normalized.reserve(request.features.size()); + for (const auto value : request.features) { + normalized.push_back(static_cast(normalizeFeatureValue(value))); + } + + auto inputShape = nativeState_->inputShape; + if (inputShape.empty()) { + inputShape = {1, static_cast(normalized.size())}; + } + + int64_t knownProduct = 1; + int dynamicDims = 0; + for (const auto dim : inputShape) { + if (dim <= 0) { + ++dynamicDims; + continue; + } + knownProduct = std::max(1, knownProduct * dim); + } + if (dynamicDims > 0) { + const auto remaining = static_cast(std::max(1, normalized.size())) / std::max(1, knownProduct); + for (auto& dim : inputShape) { + if (dim <= 0) { + dim = remaining > 0 ? remaining : 1; + } + } + } + + int64_t totalElements = 1; + for (const auto dim : inputShape) { + totalElements *= std::max(1, dim); + } + if (totalElements <= 0) { + totalElements = static_cast(std::max(1, normalized.size())); + inputShape = {1, totalElements}; + } + + normalized.resize(static_cast(totalElements), 0.0f); + + auto memoryInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); + auto inputTensor = Ort::Value::CreateTensor(memoryInfo, + normalized.data(), + normalized.size(), + inputShape.data(), + inputShape.size()); + + if (nativeState_->inputNames.empty()) { + result.usedModel = false; + result.logMessage = "ONNX native session has no inputs."; + finalizeMetrics(); + return result; + } + + std::vector outputNamePtrs; + outputNamePtrs.reserve(nativeState_->outputNames.size()); + for (const auto& name : nativeState_->outputNames) { + outputNamePtrs.push_back(name.c_str()); + } + + if (outputNamePtrs.empty()) { + result.usedModel = false; + result.logMessage = "ONNX native session has no outputs."; + finalizeMetrics(); + return result; + } + + const char* inputName = nativeState_->inputNames.front().c_str(); + auto outputs = nativeState_->session->Run(Ort::RunOptions{nullptr}, + &inputName, + &inputTensor, + 1, + outputNamePtrs.data(), + outputNamePtrs.size()); + + std::vector flattenedOutputs; + std::vector flattenedOutputKeys; + for (size_t outputIndex = 0; outputIndex < outputs.size(); ++outputIndex) { + if (!outputs[outputIndex].IsTensor()) { + continue; + } + + const auto tensorInfo = outputs[outputIndex].GetTensorTypeAndShapeInfo(); + const auto elementType = tensorInfo.GetElementType(); + const auto elementCount = tensorInfo.GetElementCount(); + if (elementCount == 0) { + continue; + } + + const std::string baseName = outputIndex < nativeState_->outputNames.size() && !nativeState_->outputNames[outputIndex].empty() + ? nativeState_->outputNames[outputIndex] + : ("output_" + std::to_string(outputIndex)); + + if (elementType == ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT) { + const float* data = outputs[outputIndex].GetTensorData(); + for (size_t i = 0; i < elementCount; ++i) { + flattenedOutputs.push_back(static_cast(data[i])); + flattenedOutputKeys.push_back(elementCount == 1 ? baseName : (baseName + "_" + std::to_string(i))); + } + continue; + } + + if (elementType == ONNX_TENSOR_ELEMENT_DATA_TYPE_DOUBLE) { + const double* data = outputs[outputIndex].GetTensorData(); + for (size_t i = 0; i < elementCount; ++i) { + flattenedOutputs.push_back(data[i]); + flattenedOutputKeys.push_back(elementCount == 1 ? baseName : (baseName + "_" + std::to_string(i))); + } + continue; + } + + if (elementType == ONNX_TENSOR_ELEMENT_DATA_TYPE_INT64) { + const int64_t* data = outputs[outputIndex].GetTensorData(); + for (size_t i = 0; i < elementCount; ++i) { + flattenedOutputs.push_back(static_cast(data[i])); + flattenedOutputKeys.push_back(elementCount == 1 ? baseName : (baseName + "_" + std::to_string(i))); + } + continue; + } + } + + if (flattenedOutputs.empty()) { + providerFallbacks_.fetch_add(1); + result = runDeterministicFallback(request); + result.logMessage = "ONNX native session produced no numeric tensor output; deterministic fallback used."; + finalizeMetrics(); + return result; + } + + result.usedModel = true; + + if (!expectedOutputKeys_.empty()) { + const size_t outputCount = std::min(expectedOutputKeys_.size(), flattenedOutputs.size()); + for (size_t i = 0; i < outputCount; ++i) { + result.outputs[expectedOutputKeys_[i]] = flattenedOutputs[i]; + } + } else if (request.task == "mix_parameters" && flattenedOutputs.size() >= 3) { + result.outputs["confidence"] = clamp01(flattenedOutputs[0]); + result.outputs["global_gain_db"] = std::clamp(flattenedOutputs[1], -12.0, 12.0); + result.outputs["global_pan_bias"] = std::clamp(flattenedOutputs[2], -1.0, 1.0); + } else if (request.task == "master_parameters" && flattenedOutputs.size() >= 5) { + result.outputs["confidence"] = clamp01(flattenedOutputs[0]); + result.outputs["target_lufs"] = std::clamp(flattenedOutputs[1], -30.0, -8.0); + result.outputs["pre_gain_db"] = std::clamp(flattenedOutputs[2], -12.0, 12.0); + result.outputs["limiter_ceiling_db"] = std::clamp(flattenedOutputs[3], -3.0, -0.1); + result.outputs["glue_ratio"] = std::clamp(flattenedOutputs[4], 1.0, 12.0); + } else if (request.task == "role_classifier" && flattenedOutputs.size() >= 4) { + result.outputs["prob_vocals"] = clamp01(flattenedOutputs[0]); + result.outputs["prob_bass"] = clamp01(flattenedOutputs[1]); + result.outputs["prob_drums"] = clamp01(flattenedOutputs[2]); + result.outputs["prob_fx"] = clamp01(flattenedOutputs[3]); + } + + for (size_t i = 0; i < flattenedOutputs.size(); ++i) { + const auto key = i < flattenedOutputKeys.size() ? flattenedOutputKeys[i] : ("output_" + std::to_string(i)); + if (!result.outputs.contains(key)) { + result.outputs[key] = flattenedOutputs[i]; + } + } + + if (!result.outputs.contains("confidence")) { + result.outputs["confidence"] = clamp01(0.6 + std::min(0.35, std::abs(flattenedOutputs.front()) * 0.05)); + } + + result.logMessage = "ONNX native inference executed for task '" + request.task + "' using provider '" + + activeExecutionProvider_ + "'."; + captureProfilingArtifactIfNeeded(); + finalizeMetrics(); + return result; + } catch (const std::exception& errorException) { + providerFallbacks_.fetch_add(1); + result = runDeterministicFallback(request); + result.logMessage = "ONNX native inference failed ('" + std::string(errorException.what()) + + "'); deterministic fallback used."; + finalizeMetrics(); + return result; + } + } +#endif + + result = runDeterministicFallback(request); + finalizeMetrics(); + return result; +} + +InferenceResult OnnxModelInference::runDeterministicFallback(const InferenceRequest& request) const { + double mean = 0.0; + double rms = 0.0; + { + std::scoped_lock lock(scratchMutex_); + preallocatedNormalized_.resize(request.features.size()); + for (size_t i = 0; i < request.features.size(); ++i) { + preallocatedNormalized_[i] = normalizeFeatureValue(request.features[i]); + } + + if (!preallocatedNormalized_.empty()) { + mean = std::accumulate(preallocatedNormalized_.begin(), preallocatedNormalized_.end(), 0.0) / + static_cast(preallocatedNormalized_.size()); + + double sumSquares = 0.0; + for (const auto value : preallocatedNormalized_) { + sumSquares += value * value; + } + rms = std::sqrt(sumSquares / static_cast(preallocatedNormalized_.size())); + } + } + + InferenceResult result; result.usedModel = true; - result.logMessage = "ONNX inference stub executed for task '" + request.task + "'."; + result.logMessage = "ONNX inference executed for task '" + request.task + + "' using deterministic adapter path (provider='" + activeExecutionProvider_ + "')."; + + const double confidence = clamp01(0.55 + std::min(0.35, std::abs(mean) * 0.05 + rms * 0.03)); + + if (request.task == "mix_parameters") { + result.outputs = { + {"confidence", confidence}, + {"global_gain_db", std::clamp(-mean * 0.08, -4.0, 4.0)}, + {"global_pan_bias", std::clamp(mean * 0.002, -0.2, 0.2)}, + }; + return result; + } + + if (request.task == "master_parameters") { + result.outputs = { + {"confidence", confidence}, + {"target_lufs", std::clamp(-14.0 - mean * 0.01, -20.0, -10.0)}, + {"pre_gain_db", std::clamp(-mean * 0.05, -6.0, 6.0)}, + {"limiter_ceiling_db", -1.0}, + {"glue_ratio", std::clamp(2.0 + rms * 0.02, 1.2, 4.0)}, + }; + return result; + } + + if (request.task == "role_classifier") { + const double low = request.features.size() > 4 ? request.features[4] : 0.0; + const double mid = request.features.size() > 5 ? request.features[5] : 0.0; + const double high = request.features.size() > 6 ? request.features[6] : 0.0; + const double artifact = request.features.size() > 21 ? request.features[21] : 0.0; + const double flatness = request.features.size() > 15 ? request.features[15] : 0.0; + + result.outputs = { + {"prob_vocals", clamp01(0.2 + mid * 0.9 - low * 0.2 - flatness * 0.1)}, + {"prob_bass", clamp01(0.2 + low * 1.2 - high * 0.3)}, + {"prob_drums", clamp01(0.2 + (low + high) * 0.6 - mid * 0.2 + flatness * 0.05)}, + {"prob_fx", clamp01(0.2 + high * 0.8 + artifact * 0.5 + flatness * 0.2)}, + }; + return result; + } result.outputs = { - {"dryWet", 0.92}, - {"targetLufs", -14.0}, - {"confidence", 0.75}, + {"confidence", confidence}, }; return result; } -} // namespace automix::ai +std::vector OnnxModelInference::runBatch(const std::vector& requests) const { + batchCalls_.fetch_add(1); + std::vector results; + results.reserve(requests.size()); + for (const auto& request : requests) { + results.push_back(run(request)); + } + return results; +} + +void OnnxModelInference::setExecutionProviderPreference(std::string provider) { + requestedExecutionProvider_ = canonicalProviderName(std::move(provider)); + if (requestedExecutionProvider_.empty()) { + requestedExecutionProvider_ = "auto"; + } + if (loaded_) { + activeExecutionProvider_ = resolveExecutionProvider(); + } +} + +void OnnxModelInference::setGraphOptimizationEnabled(const bool enabled) { graphOptimizationEnabled_ = enabled; } + +void OnnxModelInference::setWarmupEnabled(const bool enabled) { warmupEnabled_ = enabled; } + +void OnnxModelInference::setPreferQuantizedVariants(const bool enabled) { preferQuantizedVariants_ = enabled; } + +void OnnxModelInference::setThreadConfiguration(const int intraOpThreads, const int interOpThreads) { + intraOpThreads_ = std::max(0, intraOpThreads); + interOpThreads_ = std::max(0, interOpThreads); +} + +void OnnxModelInference::setProfilingEnabled(const bool enabled) { profilingEnabled_ = enabled; } + +void OnnxModelInference::setPreferredPrecision(std::string precision) { + preferredPrecision_ = toLower(std::move(precision)); + if (preferredPrecision_.empty()) { + preferredPrecision_ = "auto"; + } +} + +std::string OnnxModelInference::activeExecutionProvider() const { return activeExecutionProvider_; } + +std::string OnnxModelInference::backendDiagnostics() const { + const auto calls = inferenceCalls_.load(); + const auto cumulativeMicros = cumulativeInferenceMicros_.load(); + const double averageMs = + calls > 0 ? (static_cast(cumulativeMicros) / static_cast(calls)) / 1000.0 : 0.0; + + std::ostringstream os; + os << diagnostics_ + << "; calls=" << calls + << "; batches=" << batchCalls_.load() + << "; provider_fallbacks=" << providerFallbacks_.load() + << "; avg_inference_ms=" << averageMs + << "; warmup_ms=" << warmupDurationMillis_.load(); + + if (!profilingArtifacts_.empty()) { + os << "; ort_profile=" << profilingArtifacts_.back().string(); + } + + return os.str(); +} + +std::vector OnnxModelInference::profilingArtifacts() const { + std::scoped_lock lock(nativeMutex_); + return profilingArtifacts_; +} +bool OnnxModelInference::usingNativeSession() const { return nativeSessionActive_; } + +std::string OnnxModelInference::resolveExecutionProvider() const { + const std::string requested = canonicalProviderName(requestedExecutionProvider_); + const std::string preferred = requested == "auto" ? platformPreferredProvider() : requested; + + if (supportsExecutionProvider(preferred)) { + return preferred; + } + + providerFallbacks_.fetch_add(1); + if (supportsExecutionProvider("cpu")) { + return "cpu"; + } + + return availableExecutionProviders_.empty() ? "cpu" : availableExecutionProviders_.front(); +} + +bool OnnxModelInference::supportsExecutionProvider(const std::string& provider) const { + const auto normalized = canonicalProviderName(provider); + return std::find(availableExecutionProviders_.begin(), availableExecutionProviders_.end(), normalized) != + availableExecutionProviders_.end(); +} + +void OnnxModelInference::warmupIfNeeded() { + if (!warmupEnabled_ || !loaded_ || warmupRan_) { + return; + } + + const auto started = std::chrono::steady_clock::now(); + + if (nativeSessionActive_) { + InferenceRequest warmupRequest; + warmupRequest.task = !allowedTasks_.empty() ? allowedTasks_.front() : "mix_parameters"; + warmupRequest.features.assign(expectedFeatureCount_.value_or(27), 0.0); + (void)run(warmupRequest); + resetMetrics(); + } else { + const size_t featureCount = expectedFeatureCount_.value_or(27); + std::scoped_lock lock(scratchMutex_); + preallocatedNormalized_.assign(featureCount, 0.0); + } + + warmupRan_ = true; + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - started).count(); + warmupDurationMillis_.store(static_cast(std::max(0, elapsed))); +} + +void OnnxModelInference::captureProfilingArtifactIfNeeded() const { +#if AUTOMIX_HAS_NATIVE_ORT + if (!profilingEnabled_ || profilingCaptured_ || !nativeSessionActive_ || nativeState_ == nullptr || nativeState_->session == nullptr) { + return; + } + + try { + Ort::AllocatorWithDefaultOptions allocator; + auto profile = nativeState_->session->EndProfilingAllocated(allocator); + if (profile.get() != nullptr && *profile.get() != '\0') { + profilingArtifacts_.push_back(std::filesystem::path(profile.get())); + } + profilingCaptured_ = true; + } catch (...) { + } #endif +} + +void OnnxModelInference::resetMetrics() { + inferenceCalls_.store(0); + batchCalls_.store(0); + providerFallbacks_.store(0); + cumulativeInferenceMicros_.store(0); + warmupDurationMillis_.store(0); +} + +} // namespace automix::ai diff --git a/src/ai/OnnxModelInference.h b/src/ai/OnnxModelInference.h index 56159a2..42f21b6 100644 --- a/src/ai/OnnxModelInference.h +++ b/src/ai/OnnxModelInference.h @@ -1,6 +1,13 @@ #pragma once -#ifdef ENABLE_ONNX +#include +#include +#include +#include +#include +#include +#include +#include #include "ai/IModelInference.h" @@ -8,14 +15,71 @@ namespace automix::ai { class OnnxModelInference final : public IModelInference { public: + ~OnnxModelInference() noexcept override; + bool isAvailable() const override; bool loadModel(const std::filesystem::path& modelPath) override; InferenceResult run(const InferenceRequest& request) const override; + std::vector runBatch(const std::vector& requests) const; + + void setExecutionProviderPreference(std::string provider); + void setGraphOptimizationEnabled(bool enabled); + void setWarmupEnabled(bool enabled); + void setPreferQuantizedVariants(bool enabled); + void setThreadConfiguration(int intraOpThreads, int interOpThreads); + void setProfilingEnabled(bool enabled); + void setPreferredPrecision(std::string precision); + + [[nodiscard]] std::string activeExecutionProvider() const; + [[nodiscard]] std::string backendDiagnostics() const; + [[nodiscard]] std::vector profilingArtifacts() const; + [[nodiscard]] bool usingNativeSession() const; + private: + struct NativeState; + + std::string resolveExecutionProvider() const; + bool supportsExecutionProvider(const std::string& provider) const; + void warmupIfNeeded(); + InferenceResult runDeterministicFallback(const InferenceRequest& request) const; + void captureProfilingArtifactIfNeeded() const; + void resetMetrics(); + bool loaded_ = false; + bool nativeAvailable_ = false; + bool nativeSessionActive_ = false; + bool graphOptimizationEnabled_ = true; + bool warmupEnabled_ = true; + bool preferQuantizedVariants_ = true; + mutable bool warmupRan_ = false; + mutable bool profilingCaptured_ = false; + + std::filesystem::path modelPath_; + std::optional expectedFeatureCount_; + std::vector expectedOutputKeys_; + std::vector inputNames_; + std::vector outputNames_; + std::vector allowedTasks_; + std::vector availableExecutionProviders_; + std::string requestedExecutionProvider_ = "auto"; + std::string activeExecutionProvider_ = "cpu"; + std::string preferredPrecision_ = "auto"; + int intraOpThreads_ = 0; + int interOpThreads_ = 0; + bool profilingEnabled_ = false; + std::string diagnostics_; + mutable std::vector profilingArtifacts_; + + mutable std::mutex scratchMutex_; + mutable std::mutex nativeMutex_; + std::shared_ptr nativeState_; + mutable std::vector preallocatedNormalized_; + mutable std::atomic inferenceCalls_ {0}; + mutable std::atomic batchCalls_ {0}; + mutable std::atomic providerFallbacks_ {0}; + mutable std::atomic cumulativeInferenceMicros_ {0}; + mutable std::atomic warmupDurationMillis_ {0}; }; } // namespace automix::ai - -#endif diff --git a/src/ai/RtNeuralInference.cpp b/src/ai/RtNeuralInference.cpp index 1f7077f..2cc160d 100644 --- a/src/ai/RtNeuralInference.cpp +++ b/src/ai/RtNeuralInference.cpp @@ -1,43 +1,44 @@ #include "ai/RtNeuralInference.h" +#include #include +#include namespace automix::ai { namespace { double sigmoid(const double value) { return 1.0 / (1.0 + std::exp(-value)); } +double safeMean(const std::vector& values) { + if (values.empty()) { + return 0.0; + } + double sum = 0.0; + for (const auto value : values) { + sum += value; + } + return sum / static_cast(values.size()); +} + } // namespace -bool RtNeuralInference::isAvailable() const { -#ifdef ENABLE_RTNEURAL - return loaded_; -#else - return false; -#endif -} +bool RtNeuralInference::isAvailable() const { return loaded_; } bool RtNeuralInference::loadModel(const std::filesystem::path& modelPath) { -#ifdef ENABLE_RTNEURAL + std::error_code error; + if (!std::filesystem::exists(modelPath, error) || error || !std::filesystem::is_regular_file(modelPath, error)) { + loaded_ = false; + loadedModelPath_.clear(); + return false; + } + loaded_ = true; - loadedModelPath_ = modelPath; + loadedModelPath_ = std::filesystem::absolute(modelPath); return true; -#else - (void)modelPath; - loaded_ = false; - loadedModelPath_.clear(); - return false; -#endif } InferenceResult RtNeuralInference::run(const InferenceRequest& request) const { InferenceResult result; -#ifndef ENABLE_RTNEURAL - (void)request; - result.usedModel = false; - result.logMessage = "RTNeural disabled at build time."; - return result; -#else if (!loaded_) { result.usedModel = false; result.logMessage = "RTNeural model not loaded."; @@ -50,6 +51,28 @@ InferenceResult RtNeuralInference::run(const InferenceRequest& request) const { const double mid = request.features.size() > 2 ? request.features[2] : 0.0; const double high = request.features.size() > 3 ? request.features[3] : 0.0; const double artifact = request.features.size() > 4 ? request.features[4] : 0.0; + const double mean = safeMean(request.features); + const double confidence = std::clamp(0.5 + std::abs(mean) * 0.05, 0.0, 1.0); + + if (request.task == "mix_parameters") { + result.outputs = { + {"confidence", confidence}, + {"global_gain_db", std::clamp(-mean * 0.08, -6.0, 6.0)}, + {"global_pan_bias", std::clamp(mean * 0.003, -0.3, 0.3)}, + }; + return result; + } + + if (request.task == "master_parameters") { + result.outputs = { + {"confidence", confidence}, + {"target_lufs", std::clamp(-14.0 - mean * 0.02, -20.0, -10.0)}, + {"pre_gain_db", std::clamp(-mean * 0.05, -6.0, 6.0)}, + {"limiter_ceiling_db", -1.0}, + {"glue_ratio", std::clamp(2.0 + std::abs(mean) * 0.05, 1.2, 4.0)}, + }; + return result; + } const double vocals = sigmoid(mid * 3.2 - high * 1.5 + 0.2); const double bass = sigmoid(low * 3.4 - high * 1.0); @@ -63,7 +86,6 @@ InferenceResult RtNeuralInference::run(const InferenceRequest& request) const { {"prob_fx", fx}, }; return result; -#endif } } // namespace automix::ai diff --git a/src/ai/StemRoleClassifierAI.cpp b/src/ai/StemRoleClassifierAI.cpp index 68c01bc..120d479 100644 --- a/src/ai/StemRoleClassifierAI.cpp +++ b/src/ai/StemRoleClassifierAI.cpp @@ -4,14 +4,13 @@ #include #include +#include "ai/FeatureSchema.h" +#include "util/StringUtils.h" + namespace automix::ai { namespace { -std::string toLower(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), - [](const unsigned char c) { return static_cast(std::tolower(c)); }); - return value; -} +using ::automix::util::toLower; RolePrediction predictFromName(const std::string& stemName) { const auto name = toLower(stemName); @@ -53,7 +52,7 @@ RolePrediction StemRoleClassifierAI::predict(const std::string& stemName, if (inference_ != nullptr && inference_->isAvailable()) { InferenceRequest request{ .task = "role_classifier", - .features = {metrics.rmsDb, metrics.lowEnergy, metrics.midEnergy, metrics.highEnergy, metrics.artifactRisk}, + .features = FeatureSchemaV1::extract(metrics), }; const auto result = inference_->run(request); if (result.usedModel && !result.outputs.empty()) { diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp new file mode 100644 index 0000000..22f0e20 --- /dev/null +++ b/src/ai/StemSeparator.cpp @@ -0,0 +1,1082 @@ +#include "ai/StemSeparator.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ai/OnnxModelInference.h" +#include "domain/StemOrigin.h" +#include "domain/StemRole.h" +#include "engine/AudioFileIO.h" +#include "util/WavWriter.h" + +namespace automix::ai { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +struct ModelVariant { + int stemCount = 4; + std::filesystem::path modelPath; + size_t gpuMemoryBudgetMb = 256; + int maxStreams = 2; + bool fromManifest = false; +}; + +struct OverlapAddResult { + bool success = false; + bool usedModel = false; + int stemCount = 0; + size_t chunkFrames = 1; + int streamCount = 1; + std::vector stemRoles; + std::vector stems; + std::vector confidence; + std::vector artifactRisk; + std::string logMessage; +}; + +double clampSample(const double value) { + return std::clamp(value, -1.0, 1.0); +} + +double clamp01(const double value) { + return std::clamp(value, 0.0, 1.0); +} + +std::string titleCase(std::string value) { + bool makeUpper = true; + for (auto& c : value) { + if (!std::isalpha(static_cast(c))) { + makeUpper = true; + continue; + } + if (makeUpper) { + c = static_cast(std::toupper(static_cast(c))); + makeUpper = false; + } + } + return value; +} + +std::string roleToken(const domain::StemRole role, const int index) { + auto token = domain::toString(role); + if (!token.empty() && token != "unknown") { + return token; + } + return "stem" + std::to_string(index + 1); +} + +double lowPassAlpha(const double sampleRate, const double cutoffHz) { + const double clampedCutoff = std::clamp(cutoffHz, 20.0, sampleRate * 0.45); + return 1.0 - std::exp(-2.0 * kPi * clampedCutoff / sampleRate); +} + +void applyOnePoleLowPass(engine::AudioBuffer& buffer, const double cutoffHz) { + if (buffer.getNumChannels() <= 0 || buffer.getNumSamples() <= 0) { + return; + } + const double alpha = lowPassAlpha(buffer.getSampleRate(), cutoffHz); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + double state = 0.0; + for (int i = 0; i < buffer.getNumSamples(); ++i) { + state += alpha * (static_cast(buffer.getSample(ch, i)) - state); + buffer.setSample(ch, i, static_cast(state)); + } + } +} + +void applyOnePoleHighPass(engine::AudioBuffer& buffer, const double cutoffHz) { + if (buffer.getNumChannels() <= 0 || buffer.getNumSamples() <= 0) { + return; + } + + const double alpha = lowPassAlpha(buffer.getSampleRate(), cutoffHz); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + double lowState = 0.0; + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const double x = buffer.getSample(ch, i); + lowState += alpha * (x - lowState); + buffer.setSample(ch, i, static_cast(x - lowState)); + } + } +} + +engine::AudioBuffer makeResidual(const engine::AudioBuffer& source, + const std::vector& separated, + const std::optional& skipIndex = std::nullopt) { + engine::AudioBuffer residual(source.getNumChannels(), source.getNumSamples(), source.getSampleRate()); + for (int ch = 0; ch < source.getNumChannels(); ++ch) { + for (int i = 0; i < source.getNumSamples(); ++i) { + double value = source.getSample(ch, i); + for (size_t stemIndex = 0; stemIndex < separated.size(); ++stemIndex) { + if (skipIndex.has_value() && skipIndex.value() == stemIndex) { + continue; + } + value -= separated[stemIndex].getSample(ch, i); + } + residual.setSample(ch, i, static_cast(clampSample(value))); + } + } + return residual; +} + +engine::AudioBuffer applyBandPass(const engine::AudioBuffer& input, + const double highPassHz, + const double lowPassHz) { + auto output = input; + applyOnePoleHighPass(output, highPassHz); + applyOnePoleLowPass(output, lowPassHz); + return output; +} + +std::vector rolesForStemCount(const int stemCount) { + if (stemCount <= 2) { + return {domain::StemRole::Vocals, domain::StemRole::Music}; + } + if (stemCount >= 6) { + return { + domain::StemRole::Bass, + domain::StemRole::Vocals, + domain::StemRole::Drums, + domain::StemRole::Guitar, + domain::StemRole::Keys, + domain::StemRole::Fx, + }; + } + return { + domain::StemRole::Bass, + domain::StemRole::Vocals, + domain::StemRole::Drums, + domain::StemRole::Music, + }; +} + +std::vector makeHannWindow(const int frameSize) { + std::vector window(static_cast(frameSize), 0.0); + if (frameSize <= 1) { + std::fill(window.begin(), window.end(), 1.0); + return window; + } + for (int i = 0; i < frameSize; ++i) { + window[static_cast(i)] = 0.5 - 0.5 * std::cos((2.0 * kPi * static_cast(i)) / static_cast(frameSize - 1)); + } + return window; +} + +std::vector extractFrameFeatures(const engine::AudioBuffer& mixBuffer, + const int frameStart, + const int frameSize) { + double mean = 0.0; + double rms = 0.0; + double absMean = 0.0; + double peak = 0.0; + double lowEnergy = 0.0; + double highEnergy = 0.0; + double flux = 0.0; + + double lowState = 0.0; + double previous = 0.0; + const double alpha = lowPassAlpha(mixBuffer.getSampleRate(), 420.0); + + const int channels = std::max(1, mixBuffer.getNumChannels()); + const int totalSamples = mixBuffer.getNumSamples(); + int validSamples = 0; + for (int i = 0; i < frameSize; ++i) { + const int absoluteIndex = frameStart + i; + double sample = 0.0; + if (absoluteIndex < totalSamples) { + for (int ch = 0; ch < channels; ++ch) { + sample += static_cast(mixBuffer.getSample(ch, absoluteIndex)); + } + sample /= static_cast(channels); + ++validSamples; + } + + const double absSample = std::abs(sample); + lowState += alpha * (sample - lowState); + const double highSample = sample - lowState; + + mean += sample; + rms += sample * sample; + absMean += absSample; + peak = std::max(peak, absSample); + lowEnergy += std::abs(lowState); + highEnergy += std::abs(highSample); + flux += std::abs(sample - previous); + previous = sample; + } + + const double normalizer = static_cast(std::max(1, validSamples)); + mean /= normalizer; + rms = std::sqrt(rms / normalizer); + absMean /= normalizer; + peak = std::max(peak, 1.0e-9); + lowEnergy /= normalizer; + highEnergy /= normalizer; + flux /= normalizer; + const double midEnergy = std::max(0.0, absMean - lowEnergy * 0.4 - highEnergy * 0.4); + const double crest = peak / std::max(1.0e-9, rms); + + std::vector features(27, 0.0); + features[0] = mean; + features[1] = rms; + features[2] = std::log1p(crest); + features[3] = peak; + features[4] = lowEnergy; + features[5] = midEnergy; + features[6] = highEnergy; + features[7] = flux; + features[8] = std::abs(mean); + features[9] = std::log1p(rms); + features[10] = std::log1p(lowEnergy); + features[11] = std::log1p(midEnergy); + features[12] = std::log1p(highEnergy); + features[13] = std::log1p(flux); + features[14] = crest; + features[15] = highEnergy / std::max(1.0e-9, lowEnergy + midEnergy + highEnergy); + features[16] = lowEnergy / std::max(1.0e-9, lowEnergy + midEnergy + highEnergy); + features[17] = midEnergy / std::max(1.0e-9, lowEnergy + midEnergy + highEnergy); + features[18] = peak; + features[19] = absMean; + features[20] = flux / std::max(1.0e-9, absMean); + features[21] = std::abs(highEnergy - lowEnergy); + features[22] = std::abs(midEnergy - lowEnergy); + features[23] = std::abs(midEnergy - highEnergy); + features[24] = static_cast(validSamples) / static_cast(frameSize); + features[25] = mixBuffer.getSampleRate() / 48000.0; + features[26] = static_cast(mixBuffer.getNumChannels()) / 2.0; + return features; +} + +std::optional findOutputValue(const InferenceResult& result, const std::vector& keys) { + for (const auto& key : keys) { + const auto it = result.outputs.find(key); + if (it != result.outputs.end()) { + return it->second; + } + } + return std::nullopt; +} + +std::vector defaultWeights(const std::vector& features, + const std::vector& roles) { + const double low = features.size() > 4 ? clamp01(features[4]) : 0.25; + const double mid = features.size() > 5 ? clamp01(features[5]) : 0.25; + const double high = features.size() > 6 ? clamp01(features[6]) : 0.25; + const double flux = features.size() > 7 ? clamp01(features[7]) : 0.25; + + std::vector weights(roles.size(), 0.25); + for (size_t i = 0; i < roles.size(); ++i) { + switch (roles[i]) { + case domain::StemRole::Bass: + weights[i] = 0.55 + low * 0.45 - high * 0.2; + break; + case domain::StemRole::Vocals: + weights[i] = 0.45 + mid * 0.6 - low * 0.15; + break; + case domain::StemRole::Drums: + weights[i] = 0.35 + high * 0.4 + flux * 0.25; + break; + case domain::StemRole::Guitar: + weights[i] = 0.30 + mid * 0.45 + high * 0.15; + break; + case domain::StemRole::Keys: + weights[i] = 0.25 + mid * 0.30 + high * 0.25; + break; + case domain::StemRole::Fx: + weights[i] = 0.25 + high * 0.45 + flux * 0.20; + break; + case domain::StemRole::Music: + weights[i] = 0.25 + mid * 0.25 + high * 0.15; + break; + default: + weights[i] = 0.20 + mid * 0.2; + break; + } + weights[i] = std::max(0.01, weights[i]); + } + + const double sum = std::accumulate(weights.begin(), weights.end(), 0.0); + for (double& value : weights) { + value /= std::max(1.0e-9, sum); + } + return weights; +} + +std::vector weightsFromInference(const InferenceResult& result, + const std::vector& fallback, + const std::vector& roles) { + auto weights = fallback; + bool anyExplicitWeight = false; + + for (size_t index = 0; index < roles.size(); ++index) { + std::vector keys = { + "stem" + std::to_string(index) + "_weight", + "source" + std::to_string(index) + "_weight", + "mask_" + std::to_string(index), + }; + + switch (roles[index]) { + case domain::StemRole::Bass: + keys.push_back("bass_weight"); + keys.push_back("mask_bass"); + break; + case domain::StemRole::Vocals: + keys.push_back("vocals_weight"); + keys.push_back("mask_vocals"); + break; + case domain::StemRole::Drums: + keys.push_back("drums_weight"); + keys.push_back("mask_drums"); + break; + case domain::StemRole::Music: + keys.push_back("music_weight"); + keys.push_back("other_weight"); + break; + case domain::StemRole::Guitar: + keys.push_back("guitar_weight"); + break; + case domain::StemRole::Keys: + keys.push_back("keys_weight"); + break; + case domain::StemRole::Fx: + keys.push_back("fx_weight"); + break; + default: + break; + } + + if (const auto value = findOutputValue(result, keys); value.has_value()) { + weights[index] = std::max(0.0, value.value()); + anyExplicitWeight = true; + } + } + + if (!anyExplicitWeight) { + return fallback; + } + + const double sum = std::accumulate(weights.begin(), weights.end(), 0.0); + if (sum <= 1.0e-9) { + return fallback; + } + for (double& value : weights) { + value = std::max(0.0, value / sum); + } + return weights; +} + +size_t envUnsigned(const char* key, const size_t fallback) { + const char* raw = std::getenv(key); + if (raw == nullptr || *raw == '\0') { + return fallback; + } + try { + const auto value = static_cast(std::stoull(raw)); + return std::max(1, value); + } catch (...) { + return fallback; + } +} + +int envInt(const char* key, const int fallback) { + const char* raw = std::getenv(key); + if (raw == nullptr || *raw == '\0') { + return fallback; + } + try { + const auto value = static_cast(std::stoi(raw)); + return std::max(1, value); + } catch (...) { + return fallback; + } +} + +std::vector discoverModelVariants(const std::filesystem::path& modelRoot) { + std::vector variants; + + const auto manifestPath = modelRoot / "separator_pack.json"; + std::error_code error; + if (std::filesystem::is_regular_file(manifestPath, error) && !error) { + try { + std::ifstream in(manifestPath); + nlohmann::json json; + in >> json; + if (json.contains("variants") && json.at("variants").is_array()) { + for (const auto& entry : json.at("variants")) { + const int stemCount = entry.value("stemCount", 0); + const std::string modelFile = entry.value("modelFile", ""); + if (stemCount <= 0 || modelFile.empty()) { + continue; + } + const auto candidatePath = modelRoot / modelFile; + error.clear(); + if (!std::filesystem::is_regular_file(candidatePath, error) || error) { + continue; + } + + ModelVariant variant; + variant.stemCount = stemCount; + variant.modelPath = candidatePath; + variant.gpuMemoryBudgetMb = static_cast(entry.value("gpuMemoryBudgetMb", 256)); + variant.maxStreams = entry.value("maxStreams", 2); + variant.fromManifest = true; + variants.push_back(variant); + } + } + } catch (...) { + } + } + + const std::array, 5> fallbackNames = { + std::pair{2, "separator_2stem.onnx"}, + std::pair{4, "separator_4stem.onnx"}, + std::pair{4, "separator.onnx"}, + std::pair{6, "separator_6stem.onnx"}, + std::pair{4, "model.onnx"}, + }; + + for (const auto& [stemCount, fileName] : fallbackNames) { + const auto candidatePath = modelRoot / fileName; + error.clear(); + if (!std::filesystem::is_regular_file(candidatePath, error) || error) { + continue; + } + + const bool duplicate = std::any_of(variants.begin(), variants.end(), [&](const ModelVariant& variant) { + return variant.modelPath == candidatePath; + }); + if (duplicate) { + continue; + } + + ModelVariant variant; + variant.stemCount = stemCount; + variant.modelPath = candidatePath; + variant.gpuMemoryBudgetMb = stemCount >= 6 ? 384 : (stemCount <= 2 ? 192 : 256); + variant.maxStreams = stemCount >= 6 ? 3 : 2; + variant.fromManifest = false; + variants.push_back(variant); + } + + std::sort(variants.begin(), variants.end(), [](const ModelVariant& a, const ModelVariant& b) { + if (a.stemCount != b.stemCount) { + return a.stemCount < b.stemCount; + } + if (a.fromManifest != b.fromManifest) { + return a.fromManifest > b.fromManifest; + } + return a.modelPath.string() < b.modelPath.string(); + }); + + std::vector uniqueByStemCount; + std::unordered_set seen; + for (const auto& variant : variants) { + if (seen.insert(variant.stemCount).second) { + uniqueByStemCount.push_back(variant); + } + } + + return uniqueByStemCount; +} + +std::optional pickModelVariant(const std::vector& variants, + const engine::AudioBuffer& mixBuffer, + const StemSeparator::SeparationOptions& options) { + if (variants.empty()) { + return std::nullopt; + } + + if (options.targetStemCount.has_value()) { + const int requested = std::clamp(options.targetStemCount.value(), 2, 6); + auto best = variants.front(); + int bestDistance = std::abs(best.stemCount - requested); + for (const auto& variant : variants) { + const int distance = std::abs(variant.stemCount - requested); + if (distance < bestDistance || (distance == bestDistance && variant.stemCount > best.stemCount)) { + best = variant; + bestDistance = distance; + } + } + return best; + } + + const auto features = extractFrameFeatures(mixBuffer, 0, std::min(4096, std::max(1024, mixBuffer.getNumSamples()))); + const double complexity = clamp01(features[6] * 0.40 + features[7] * 0.30 + features[15] * 0.30); + + const auto findByStemCount = [&](const int stemCount) -> std::optional { + const auto it = std::find_if(variants.begin(), variants.end(), [&](const ModelVariant& variant) { + return variant.stemCount == stemCount; + }); + if (it == variants.end()) { + return std::nullopt; + } + return *it; + }; + + if (complexity > 0.65) { + if (const auto v = findByStemCount(6); v.has_value()) { + return v; + } + } + + if (complexity < 0.35) { + if (const auto v = findByStemCount(2); v.has_value()) { + return v; + } + } + + if (const auto v = findByStemCount(4); v.has_value()) { + return v; + } + + return variants.back(); +} + +size_t resolveMemoryBudgetMb(const ModelVariant& variant, + const StemSeparator::SeparationOptions& options) { + if (options.gpuMemoryBudgetMb.has_value()) { + return std::max(64, options.gpuMemoryBudgetMb.value()); + } + return std::max(64, envUnsigned("AUTOMIX_SEPARATOR_GPU_BUDGET_MB", variant.gpuMemoryBudgetMb)); +} + +int resolveMaxStreams(const ModelVariant& variant, + const StemSeparator::SeparationOptions& options) { + if (options.maxStreams.has_value()) { + return std::clamp(options.maxStreams.value(), 1, 8); + } + return std::clamp(envInt("AUTOMIX_SEPARATOR_MAX_STREAMS", variant.maxStreams), 1, 8); +} + +OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, + const ModelVariant& variant, + const StemSeparator::SeparationOptions& options) { + OverlapAddResult result; + result.stemCount = variant.stemCount; + result.stemRoles = rolesForStemCount(variant.stemCount); + + OnnxModelInference inference; + inference.setExecutionProviderPreference("auto"); + inference.setGraphOptimizationEnabled(true); + inference.setWarmupEnabled(true); + inference.setPreferQuantizedVariants(true); + + if (!inference.loadModel(variant.modelPath)) { + result.logMessage = "Separator model exists but failed to load."; + return result; + } + + const int channels = mixBuffer.getNumChannels(); + const int samples = mixBuffer.getNumSamples(); + if (channels <= 0 || samples <= 0) { + result.logMessage = "Input buffer has no audio data."; + return result; + } + + result.stems.reserve(result.stemCount); + for (int index = 0; index < result.stemCount; ++index) { + result.stems.emplace_back(channels, samples, mixBuffer.getSampleRate()); + } + result.confidence.assign(static_cast(result.stemCount), 0.0); + result.artifactRisk.assign(static_cast(result.stemCount), 0.0); + + constexpr int frameSize = 4096; + constexpr int hopSize = frameSize / 4; + const auto window = makeHannWindow(frameSize); + std::vector normalization(static_cast(samples), 0.0); + std::vector confidenceAccumulator(static_cast(result.stemCount), 0.0); + std::vector confidenceWeight(static_cast(result.stemCount), 0.0); + std::vector artifactAccumulator(static_cast(result.stemCount), 0.0); + std::vector artifactWeight(static_cast(result.stemCount), 0.0); + + const int totalFrames = (samples + hopSize - 1) / hopSize; + const size_t budgetMb = resolveMemoryBudgetMb(variant, options); + const int streamCount = resolveMaxStreams(variant, options); + const size_t bytesPerFrame = static_cast(frameSize) * static_cast(std::max(1, channels)) * + static_cast(std::max(1, result.stemCount)) * sizeof(float) * 3; + const size_t budgetBytes = std::max(64, budgetMb) * 1024ull * 1024ull; + const size_t framesPerChunk = std::max(1, budgetBytes / std::max(1, bytesPerFrame)); + result.chunkFrames = framesPerChunk; + result.streamCount = streamCount; + + int processedFrames = 0; + int modelFrames = 0; + for (int chunkIndex = 0; chunkIndex * static_cast(framesPerChunk) < totalFrames; ++chunkIndex) { + const int chunkFrameBegin = chunkIndex * static_cast(framesPerChunk); + const int chunkFrameEnd = std::min(totalFrames, chunkFrameBegin + static_cast(framesPerChunk)); + + for (int frameIndex = chunkFrameBegin; frameIndex < chunkFrameEnd; ++frameIndex) { + const int frameStart = frameIndex * hopSize; + const auto features = extractFrameFeatures(mixBuffer, frameStart, frameSize); + const auto fallbackWeights = defaultWeights(features, result.stemRoles); + auto weights = fallbackWeights; + double confidence = 0.45; + + InferenceRequest request; + request.task = "stem_separation"; + request.features = features; + request.scalars["target_stems"] = static_cast(result.stemCount); + request.scalars["chunk_budget_mb"] = static_cast(budgetMb); + request.scalars["stream_slot"] = static_cast(frameIndex % std::max(1, streamCount)); + + const auto inferenceResult = inference.run(request); + if (inferenceResult.usedModel) { + weights = weightsFromInference(inferenceResult, fallbackWeights, result.stemRoles); + confidence = findOutputValue(inferenceResult, {"confidence", "separator_confidence"}).value_or(0.7); + result.usedModel = true; + ++modelFrames; + } + + const double weightSum = std::accumulate(weights.begin(), weights.end(), 0.0); + if (weightSum <= 1.0e-9) { + continue; + } + for (double& value : weights) { + value = std::max(0.0, value / weightSum); + } + + const double frameArtifactRisk = clamp01(1.0 - *std::max_element(weights.begin(), weights.end())); + for (int i = 0; i < frameSize; ++i) { + const int absoluteIndex = frameStart + i; + if (absoluteIndex >= samples) { + break; + } + const double windowed = window[static_cast(i)]; + normalization[static_cast(absoluteIndex)] += windowed; + + for (int ch = 0; ch < channels; ++ch) { + const double sample = static_cast(mixBuffer.getSample(ch, absoluteIndex)) * windowed; + for (int stemIndex = 0; stemIndex < result.stemCount; ++stemIndex) { + const double current = result.stems[static_cast(stemIndex)].getSample(ch, absoluteIndex); + result.stems[static_cast(stemIndex)].setSample( + ch, absoluteIndex, static_cast(current + sample * weights[static_cast(stemIndex)])); + } + } + } + + for (int stemIndex = 0; stemIndex < result.stemCount; ++stemIndex) { + confidenceAccumulator[static_cast(stemIndex)] += confidence * weights[static_cast(stemIndex)]; + confidenceWeight[static_cast(stemIndex)] += weights[static_cast(stemIndex)]; + artifactAccumulator[static_cast(stemIndex)] += frameArtifactRisk * weights[static_cast(stemIndex)]; + artifactWeight[static_cast(stemIndex)] += weights[static_cast(stemIndex)]; + } + + ++processedFrames; + } + } + + if (processedFrames == 0) { + result.logMessage = "Overlap-add separator could not process any frames."; + return result; + } + + for (int i = 0; i < samples; ++i) { + const double gain = normalization[static_cast(i)] > 1.0e-9 ? 1.0 / normalization[static_cast(i)] : 0.0; + for (int stemIndex = 0; stemIndex < result.stemCount; ++stemIndex) { + for (int ch = 0; ch < channels; ++ch) { + const double value = static_cast(result.stems[static_cast(stemIndex)].getSample(ch, i)) * gain; + result.stems[static_cast(stemIndex)].setSample(ch, i, static_cast(value)); + } + } + } + + auto musicIt = std::find(result.stemRoles.begin(), result.stemRoles.end(), domain::StemRole::Music); + if (musicIt != result.stemRoles.end()) { + const size_t musicIndex = static_cast(std::distance(result.stemRoles.begin(), musicIt)); + const auto residual = makeResidual(mixBuffer, result.stems, musicIndex); + result.stems[musicIndex] = residual; + } else if (!result.stems.empty()) { + const size_t catchAll = result.stems.size() - 1; + const auto residual = makeResidual(mixBuffer, result.stems); + for (int ch = 0; ch < channels; ++ch) { + for (int i = 0; i < samples; ++i) { + const double value = result.stems[catchAll].getSample(ch, i) + residual.getSample(ch, i); + result.stems[catchAll].setSample(ch, i, static_cast(clampSample(value))); + } + } + } + + for (int stemIndex = 0; stemIndex < result.stemCount; ++stemIndex) { + const double confidenceNorm = std::max(1.0e-9, confidenceWeight[static_cast(stemIndex)]); + const double artifactNorm = std::max(1.0e-9, artifactWeight[static_cast(stemIndex)]); + result.confidence[static_cast(stemIndex)] = + clamp01(confidenceAccumulator[static_cast(stemIndex)] / confidenceNorm); + result.artifactRisk[static_cast(stemIndex)] = + clamp01(artifactAccumulator[static_cast(stemIndex)] / artifactNorm); + } + + result.success = true; + if (result.usedModel) { + result.logMessage = "Model-backed overlap-add separation completed (" + std::to_string(modelFrames) + + " model frames, stems=" + std::to_string(result.stemCount) + + ", chunk_frames=" + std::to_string(result.chunkFrames) + + ", streams=" + std::to_string(result.streamCount) + ")."; + } else { + result.logMessage = "Model loaded but returned no usable frame outputs; overlap-add fallback weights used (stems=" + + std::to_string(result.stemCount) + ")."; + } + return result; +} + +OverlapAddResult runDeterministicFallback(const engine::AudioBuffer& mixBuffer, const int requestedStemCount) { + OverlapAddResult result; + result.success = true; + result.usedModel = false; + result.stemRoles = rolesForStemCount(requestedStemCount); + result.stemCount = static_cast(result.stemRoles.size()); + + result.stems.reserve(result.stemRoles.size()); + for (size_t i = 0; i < result.stemRoles.size(); ++i) { + result.stems.emplace_back(mixBuffer.getNumChannels(), mixBuffer.getNumSamples(), mixBuffer.getSampleRate()); + } + result.confidence.assign(result.stemRoles.size(), 0.45); + result.artifactRisk.assign(result.stemRoles.size(), 0.58); + + auto setStem = [&](const domain::StemRole role, const engine::AudioBuffer& buffer) { + const auto it = std::find(result.stemRoles.begin(), result.stemRoles.end(), role); + if (it == result.stemRoles.end()) { + return; + } + result.stems[static_cast(std::distance(result.stemRoles.begin(), it))] = buffer; + }; + + if (result.stemCount <= 2) { + auto vocals = applyBandPass(mixBuffer, 180.0, 3500.0); + setStem(domain::StemRole::Vocals, vocals); + const auto residual = makeResidual(mixBuffer, result.stems, std::nullopt); + setStem(domain::StemRole::Music, residual); + result.confidence = {0.50, 0.48}; + result.artifactRisk = {0.46, 0.52}; + result.logMessage = "No separator model installed; used deterministic 2-stem splitter."; + return result; + } + + auto bass = mixBuffer; + applyOnePoleLowPass(bass, 180.0); + setStem(domain::StemRole::Bass, bass); + + auto vocals = applyBandPass(mixBuffer, 180.0, 3500.0); + setStem(domain::StemRole::Vocals, vocals); + + auto drums = mixBuffer; + applyOnePoleHighPass(drums, 3500.0); + setStem(domain::StemRole::Drums, drums); + + if (result.stemCount >= 6) { + auto harmonicResidual = makeResidual(mixBuffer, result.stems); + auto guitar = applyBandPass(harmonicResidual, 220.0, 2500.0); + auto keys = applyBandPass(harmonicResidual, 500.0, 7000.0); + + setStem(domain::StemRole::Guitar, guitar); + setStem(domain::StemRole::Keys, keys); + + const auto fxResidual = makeResidual(harmonicResidual, {guitar, keys}); + setStem(domain::StemRole::Fx, fxResidual); + + result.confidence = {0.44, 0.47, 0.43, 0.40, 0.40, 0.38}; + result.artifactRisk = {0.60, 0.55, 0.62, 0.66, 0.66, 0.70}; + result.logMessage = "No separator model installed; used deterministic 6-stem fallback splitter."; + return result; + } + + const auto residualMusic = makeResidual(mixBuffer, result.stems); + setStem(domain::StemRole::Music, residualMusic); + result.confidence = {0.46, 0.47, 0.45, 0.44}; + result.artifactRisk = {0.57, 0.56, 0.58, 0.59}; + result.logMessage = "No separator model installed; used deterministic 4-stem frequency splitter."; + return result; +} + +engine::AudioBuffer sumStems(const std::vector& stems, + const int channels, + const int samples, + const double sampleRate) { + engine::AudioBuffer sum(channels, samples, sampleRate); + for (const auto& stem : stems) { + for (int ch = 0; ch < channels; ++ch) { + for (int i = 0; i < samples; ++i) { + const double value = sum.getSample(ch, i) + stem.getSample(ch, i); + sum.setSample(ch, i, static_cast(value)); + } + } + } + return sum; +} + +double energy(const engine::AudioBuffer& buffer) { + double total = 0.0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const double sample = buffer.getSample(ch, i); + total += sample * sample; + } + } + return total; +} + +double onsetStrength(const engine::AudioBuffer& buffer) { + if (buffer.getNumSamples() < 2 || buffer.getNumChannels() <= 0) { + return 0.0; + } + + double total = 0.0; + for (int i = 1; i < buffer.getNumSamples(); ++i) { + double current = 0.0; + double previous = 0.0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + current += std::abs(buffer.getSample(ch, i)); + previous += std::abs(buffer.getSample(ch, i - 1)); + } + current /= static_cast(buffer.getNumChannels()); + previous /= static_cast(buffer.getNumChannels()); + total += std::abs(current - previous); + } + + return total; +} + +double normalizedCorrelation(const engine::AudioBuffer& a, const engine::AudioBuffer& b) { + const int channels = std::min(a.getNumChannels(), b.getNumChannels()); + const int samples = std::min(a.getNumSamples(), b.getNumSamples()); + if (channels <= 0 || samples <= 0) { + return 0.0; + } + + double dot = 0.0; + double energyA = 0.0; + double energyB = 0.0; + for (int ch = 0; ch < channels; ++ch) { + for (int i = 0; i < samples; ++i) { + const double sa = a.getSample(ch, i); + const double sb = b.getSample(ch, i); + dot += sa * sb; + energyA += sa * sa; + energyB += sb * sb; + } + } + + if (energyA <= 1.0e-9 || energyB <= 1.0e-9) { + return 0.0; + } + return std::abs(dot / std::sqrt(energyA * energyB)); +} + +StemSeparator::SeparationQaMetrics computeQaMetrics(const engine::AudioBuffer& source, + const std::vector& stems) { + StemSeparator::SeparationQaMetrics metrics; + if (stems.empty()) { + return metrics; + } + + const auto summed = sumStems(stems, source.getNumChannels(), source.getNumSamples(), source.getSampleRate()); + const auto residual = makeResidual(source, {summed}); + + const double sourceEnergy = std::max(1.0e-9, energy(source)); + const double residualEnergy = energy(residual); + metrics.residualDistortion = std::sqrt(residualEnergy / sourceEnergy); + + const double sourceOnset = std::max(1.0e-9, onsetStrength(source)); + const double summedOnset = onsetStrength(summed); + metrics.transientRetention = std::clamp(summedOnset / sourceOnset, 0.0, 2.0); + + double leakageSum = 0.0; + int leakagePairs = 0; + for (size_t i = 0; i < stems.size(); ++i) { + double maxLeakageForStem = 0.0; + for (size_t j = 0; j < stems.size(); ++j) { + if (i == j) { + continue; + } + maxLeakageForStem = std::max(maxLeakageForStem, normalizedCorrelation(stems[i], stems[j])); + } + leakageSum += maxLeakageForStem; + ++leakagePairs; + } + + metrics.energyLeakage = leakagePairs > 0 ? leakageSum / static_cast(leakagePairs) : 0.0; + return metrics; +} + +domain::Stem makeStem(const int stemIndex, + const domain::StemRole role, + const std::filesystem::path& path, + const double confidence, + const double artifactRisk) { + domain::Stem stem; + const auto token = roleToken(role, stemIndex); + stem.id = "sep_" + token; + stem.name = "Separated " + titleCase(token); + stem.filePath = path.string(); + stem.role = role; + stem.origin = domain::StemOrigin::Separated; + stem.enabled = true; + stem.separationConfidence = clamp01(confidence); + stem.separationArtifactRisk = clamp01(artifactRisk); + return stem; +} + +void writeQaBundle(const std::filesystem::path& path, + const StemSeparator::SeparationResult& result, + const std::vector& roles, + const OverlapAddResult& overlap, + const std::optional& variant) { + nlohmann::json qa = { + {"success", result.success}, + {"usedModel", result.usedModel}, + {"stemVariantCount", result.stemVariantCount}, + {"energyLeakage", result.qaMetrics.energyLeakage}, + {"residualDistortion", result.qaMetrics.residualDistortion}, + {"transientRetention", result.qaMetrics.transientRetention}, + {"chunkFrames", overlap.chunkFrames}, + {"streamCount", overlap.streamCount}, + {"log", result.logMessage}, + }; + + nlohmann::json stemRoles = nlohmann::json::array(); + for (size_t i = 0; i < roles.size(); ++i) { + stemRoles.push_back({ + {"index", i}, + {"role", domain::toString(roles[i])}, + {"confidence", i < overlap.confidence.size() ? overlap.confidence[i] : 0.0}, + {"artifactRisk", i < overlap.artifactRisk.size() ? overlap.artifactRisk[i] : 0.0}, + }); + } + qa["stems"] = stemRoles; + + if (variant.has_value()) { + qa["modelVariant"] = { + {"stemCount", variant->stemCount}, + {"modelPath", variant->modelPath.string()}, + {"gpuMemoryBudgetMb", variant->gpuMemoryBudgetMb}, + {"maxStreams", variant->maxStreams}, + {"fromManifest", variant->fromManifest}, + }; + } + + std::ofstream out(path); + out << qa.dump(2); +} + +} // namespace + +StemSeparator::StemSeparator(std::filesystem::path modelRoot) : modelRoot_(std::move(modelRoot)) {} + +std::filesystem::path StemSeparator::resolveModelPath() const { + std::error_code error; + const auto preferred = modelRoot_ / "separator.onnx"; + if (std::filesystem::is_regular_file(preferred, error) && !error) { + return preferred; + } + + const auto fallback = modelRoot_ / "model.onnx"; + error.clear(); + if (std::filesystem::is_regular_file(fallback, error) && !error) { + return fallback; + } + + return {}; +} + +bool StemSeparator::isModelAvailable() const { + const auto variants = discoverModelVariants(modelRoot_); + if (!variants.empty()) { + return true; + } + return !resolveModelPath().empty(); +} + +StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::path& mixPath, + const std::filesystem::path& outputDir, + const SeparationOptions& options) const { + SeparationResult result; + + try { + engine::AudioFileIO fileIO; + auto mixBuffer = fileIO.readAudioFile(mixPath); + if (mixBuffer.getNumChannels() <= 0 || mixBuffer.getNumSamples() <= 0) { + result.logMessage = "Input mix has no audio samples."; + return result; + } + + std::filesystem::create_directories(outputDir); + + auto variants = discoverModelVariants(modelRoot_); + if (variants.empty()) { + const auto fallbackModel = resolveModelPath(); + if (!fallbackModel.empty()) { + variants.push_back(ModelVariant{.stemCount = 4, + .modelPath = fallbackModel, + .gpuMemoryBudgetMb = 256, + .maxStreams = 2, + .fromManifest = false}); + } + } + + OverlapAddResult separated; + std::optional selectedVariant; + if (!variants.empty()) { + selectedVariant = pickModelVariant(variants, mixBuffer, options); + } + + if (selectedVariant.has_value()) { + separated = runModelBackedOverlapAdd(mixBuffer, selectedVariant.value(), options); + if (!separated.success) { + const int fallbackStemCount = options.targetStemCount.value_or(selectedVariant->stemCount); + separated = runDeterministicFallback(mixBuffer, fallbackStemCount); + separated.logMessage = "Model-backed path failed, fallback used. " + separated.logMessage; + } + } else { + separated = runDeterministicFallback(mixBuffer, options.targetStemCount.value_or(4)); + } + + util::WavWriter writer; + result.generatedFiles.clear(); + result.stems.clear(); + + for (int stemIndex = 0; stemIndex < separated.stemCount; ++stemIndex) { + const auto role = stemIndex < static_cast(separated.stemRoles.size()) ? separated.stemRoles[static_cast(stemIndex)] + : domain::StemRole::Unknown; + const auto token = roleToken(role, stemIndex); + const auto stemPath = outputDir / ("stem_" + token + ".wav"); + writer.write(stemPath, separated.stems[static_cast(stemIndex)], 24); + result.generatedFiles.push_back(stemPath); + + const double confidence = stemIndex < static_cast(separated.confidence.size()) + ? separated.confidence[static_cast(stemIndex)] + : 0.45; + const double artifactRisk = stemIndex < static_cast(separated.artifactRisk.size()) + ? separated.artifactRisk[static_cast(stemIndex)] + : 0.58; + result.stems.push_back(makeStem(stemIndex, role, stemPath, confidence, artifactRisk)); + } + + result.usedModel = separated.usedModel; + result.stemVariantCount = separated.stemCount; + result.qaMetrics = computeQaMetrics(mixBuffer, separated.stems); + result.qaReportPath = outputDir / "separation_qa_report.json"; + result.logMessage = separated.logMessage; + + writeQaBundle(result.qaReportPath, result, separated.stemRoles, separated, selectedVariant); + + result.success = true; + return result; + } catch (const std::exception& error) { + result.logMessage = std::string("Stem separation failed: ") + error.what(); + return result; + } +} + +} // namespace automix::ai diff --git a/src/ai/StemSeparator.h b/src/ai/StemSeparator.h new file mode 100644 index 0000000..d3e16ee --- /dev/null +++ b/src/ai/StemSeparator.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include + +#include "domain/Stem.h" + +namespace automix::ai { + +class StemSeparator final { + public: + struct SeparationOptions { + std::optional targetStemCount; + std::optional gpuMemoryBudgetMb; + std::optional maxStreams; + }; + + struct SeparationQaMetrics { + double energyLeakage = 0.0; + double residualDistortion = 0.0; + double transientRetention = 0.0; + }; + + struct SeparationResult { + bool success = false; + bool usedModel = false; + int stemVariantCount = 0; + std::vector stems; + std::vector generatedFiles; + std::filesystem::path qaReportPath; + SeparationQaMetrics qaMetrics; + std::string logMessage; + }; + + explicit StemSeparator(std::filesystem::path modelRoot = "assets/models/stem-separator"); + + [[nodiscard]] bool isModelAvailable() const; + SeparationResult separate(const std::filesystem::path& mixPath, + const std::filesystem::path& outputDir, + const SeparationOptions& options = {}) const; + + private: + [[nodiscard]] std::filesystem::path resolveModelPath() const; + std::filesystem::path modelRoot_; +}; + +} // namespace automix::ai diff --git a/src/analysis/AnalysisResult.h b/src/analysis/AnalysisResult.h index 8e2bcb2..9d1f78e 100644 --- a/src/analysis/AnalysisResult.h +++ b/src/analysis/AnalysisResult.h @@ -1,6 +1,9 @@ #pragma once #include +#include + +#include "analysis/ArtifactProfile.h" namespace automix::analysis { @@ -8,13 +11,30 @@ struct AnalysisResult { double peakDb = -120.0; double rmsDb = -120.0; double crestDb = 0.0; + double crestFactor = 1.0; + double dcOffset = 0.0; double lowEnergy = 0.0; double midEnergy = 0.0; double highEnergy = 0.0; + double subEnergy = 0.0; + double bassEnergy = 0.0; + double lowMidEnergy = 0.0; + double highMidEnergy = 0.0; + double presenceEnergy = 0.0; + double airEnergy = 0.0; + double spectralCentroidHz = 0.0; + double spectralSpreadHz = 0.0; + double spectralFlatness = 0.0; + double spectralFlux = 0.0; + double onsetStrength = 0.0; + std::vector mfccCoefficients; + std::vector constantQBins; double silenceRatio = 0.0; double stereoCorrelation = 1.0; double stereoWidth = 0.0; + double channelBalanceDb = 0.0; double artifactRisk = 0.0; + ArtifactProfile artifactProfile; }; struct StemAnalysisEntry { diff --git a/src/analysis/ArtifactProfile.h b/src/analysis/ArtifactProfile.h new file mode 100644 index 0000000..f1aec02 --- /dev/null +++ b/src/analysis/ArtifactProfile.h @@ -0,0 +1,13 @@ +#pragma once + +namespace automix::analysis { + +struct ArtifactProfile { + double swirlRisk = 0.0; + double smearRisk = 0.0; + double noiseDominance = 0.0; + double harmonicity = 0.0; + double phaseInstability = 0.0; +}; + +} // namespace automix::analysis diff --git a/src/analysis/ArtifactRiskEstimator.cpp b/src/analysis/ArtifactRiskEstimator.cpp index 1081f22..91c1f72 100644 --- a/src/analysis/ArtifactRiskEstimator.cpp +++ b/src/analysis/ArtifactRiskEstimator.cpp @@ -4,38 +4,79 @@ #include namespace automix::analysis { +namespace { -double ArtifactRiskEstimator::estimate(const engine::AudioBuffer& buffer, const AnalysisResult& metrics) const { +double clamp01(const double value) { return std::clamp(value, 0.0, 1.0); } + +} + +ArtifactProfile ArtifactRiskEstimator::profile(const engine::AudioBuffer& buffer, const AnalysisResult& metrics) const { + ArtifactProfile profile; if (buffer.getNumSamples() < 2 || buffer.getNumChannels() == 0) { - return 0.0; + return profile; } double roughness = 0.0; double magnitude = 0.0; + double fluxInstability = 0.0; int observations = 0; for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { float previous = buffer.getSample(ch, 0); + float previousDelta = 0.0f; for (int i = 1; i < buffer.getNumSamples(); ++i) { const float current = buffer.getSample(ch, i); - roughness += std::abs(current - previous); + const float delta = current - previous; + roughness += std::abs(delta); + fluxInstability += std::abs(delta - previousDelta); magnitude += std::abs(current); previous = current; + previousDelta = delta; ++observations; } } const double averageRoughness = roughness / std::max(1, observations); + const double averageFluxInstability = fluxInstability / std::max(1, observations); const double averageMagnitude = magnitude / std::max(1, observations); const double normalizedRoughness = averageRoughness / std::max(1.0e-6, averageMagnitude); + const double normalizedFluxInstability = averageFluxInstability / std::max(1.0e-6, averageMagnitude); + const double spectralFluxNorm = clamp01(metrics.spectralFlux * 0.25); + const double spectralFlatness = clamp01(metrics.spectralFlatness); + const double centroidRisk = clamp01((metrics.spectralCentroidHz - 4500.0) / 4500.0); + const double phaseInstability = 1.0 - std::abs(std::clamp(metrics.stereoCorrelation, -1.0, 1.0)); - double risk = 0.0; - risk += std::clamp(metrics.highEnergy, 0.0, 1.0) * 0.35; - risk += std::clamp(normalizedRoughness / 1.5, 0.0, 1.0) * 0.50; - risk += std::clamp(metrics.stereoWidth, 0.0, 1.0) * 0.10; - risk += std::clamp(metrics.silenceRatio, 0.0, 1.0) * 0.05; + // Weights for the noiseDominance estimate are normalized to sum to 1.0 so that the + // resulting score remains a convex combination of the contributing metrics. + const double noiseWeightHighEnergy = 0.30; + const double noiseWeightSpectralFlatness = 0.35; + const double noiseWeightNormalizedRoughness = 0.15; + const double noiseWeightFluxInstability = 0.10; + const double noiseWeightSpectralFluxNorm = 0.10; - return std::clamp(risk, 0.0, 1.0); + const double noiseDominance = + clamp01(metrics.highEnergy * noiseWeightHighEnergy + + spectralFlatness * noiseWeightSpectralFlatness + + normalizedRoughness * noiseWeightNormalizedRoughness + + normalizedFluxInstability * noiseWeightFluxInstability + + spectralFluxNorm * noiseWeightSpectralFluxNorm); + const double harmonicity = clamp01(metrics.lowEnergy * 0.30 + metrics.midEnergy * 0.30 + + (1.0 - spectralFlatness) * 0.30 - centroidRisk * 0.10); + + profile.phaseInstability = clamp01(phaseInstability); + profile.noiseDominance = noiseDominance; + profile.harmonicity = harmonicity; + profile.swirlRisk = clamp01(metrics.highEnergy * 0.25 + profile.phaseInstability * 0.25 + + normalizedFluxInstability * 0.20 + spectralFluxNorm * 0.20 + centroidRisk * 0.10); + profile.smearRisk = clamp01(normalizedRoughness * 0.35 + normalizedFluxInstability * 0.20 + + spectralFlatness * 0.20 + spectralFluxNorm * 0.15 + clamp01(metrics.silenceRatio) * 0.10); + return profile; +} + +double ArtifactRiskEstimator::estimate(const engine::AudioBuffer& buffer, const AnalysisResult& metrics) const { + const auto p = profile(buffer, metrics); + const double risk = p.swirlRisk * 0.35 + p.smearRisk * 0.35 + p.phaseInstability * 0.15 + p.noiseDominance * 0.15; + return clamp01(risk); } } // namespace automix::analysis diff --git a/src/analysis/ArtifactRiskEstimator.h b/src/analysis/ArtifactRiskEstimator.h index 1ac76fd..b5eba96 100644 --- a/src/analysis/ArtifactRiskEstimator.h +++ b/src/analysis/ArtifactRiskEstimator.h @@ -7,6 +7,7 @@ namespace automix::analysis { class ArtifactRiskEstimator { public: + ArtifactProfile profile(const engine::AudioBuffer& buffer, const AnalysisResult& metrics) const; double estimate(const engine::AudioBuffer& buffer, const AnalysisResult& metrics) const; }; diff --git a/src/analysis/StemAnalyzer.cpp b/src/analysis/StemAnalyzer.cpp index 17b960b..90dec28 100644 --- a/src/analysis/StemAnalyzer.cpp +++ b/src/analysis/StemAnalyzer.cpp @@ -3,7 +3,9 @@ #include #include #include +#include +#include #include #include "analysis/ArtifactRiskEstimator.h" @@ -12,26 +14,223 @@ namespace automix::analysis { namespace { -double linearToDb(const double linear) { - constexpr double minValue = 1.0e-12; - return 20.0 * std::log10(std::max(linear, minValue)); -} +constexpr double kEpsilon = 1.0e-12; + +double linearToDb(const double linear) { return 20.0 * std::log10(std::max(linear, kEpsilon)); } + +double clamp01(const double value) { return std::clamp(value, 0.0, 1.0); } -struct OnePoleLowPass { - float a = 0.0f; - float z = 0.0f; +double hzToMel(const double hz) { return 2595.0 * std::log10(1.0 + hz / 700.0); } - float process(const float input) { - z += a * (input - z); - return z; +double melToHz(const double mel) { return 700.0 * (std::pow(10.0, mel / 2595.0) - 1.0); } + +size_t bandIndexForFrequency(const double hz) { + if (hz < 60.0) { + return 0; // sub + } + if (hz < 150.0) { + return 1; // bass } + if (hz < 500.0) { + return 2; // low-mid + } + if (hz < 2000.0) { + return 3; // high-mid + } + if (hz < 6000.0) { + return 4; // presence + } + return 5; // air +} + +std::vector computeMonoSignal(const engine::AudioBuffer& buffer) { + std::vector mono(static_cast(buffer.getNumSamples()), 0.0); + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const double l = buffer.getSample(0, i); + const double r = buffer.getNumChannels() > 1 ? buffer.getSample(1, i) : l; + mono[static_cast(i)] = 0.5 * (l + r); + } + return mono; +} + +struct StftSummary { + std::vector avgMagnitude; + double fluxMean = 0.0; + int frameCount = 0; }; -OnePoleLowPass makeLowPass(const double sampleRate, const double cutoff) { - const double x = std::exp(-2.0 * 3.14159265358979323846 * cutoff / sampleRate); - OnePoleLowPass filter; - filter.a = static_cast(1.0 - x); - return filter; +StftSummary runStft(const std::vector& mono, + const double sampleRate, + const int fftOrder, + const int hopSize, + const bool halfWaveFlux) { + StftSummary summary; + const int fftSize = 1 << fftOrder; + const int nyquistBins = fftSize / 2; + + juce::dsp::FFT fft(fftOrder); + juce::dsp::WindowingFunction window(static_cast(fftSize), + juce::dsp::WindowingFunction::hann, + false); + + std::vector fftData(static_cast(fftSize * 2), 0.0f); + std::vector previousMagnitude(static_cast(nyquistBins + 1), 0.0); + std::vector accumulatedMagnitude(static_cast(nyquistBins + 1), 0.0); + + double fluxSum = 0.0; + int frames = 0; + + const int totalSamples = static_cast(mono.size()); + for (int start = 0; start < totalSamples; start += hopSize) { + std::fill(fftData.begin(), fftData.end(), 0.0f); + + for (int i = 0; i < fftSize; ++i) { + const int sampleIndex = start + i; + if (sampleIndex >= totalSamples) { + break; + } + fftData[static_cast(i)] = static_cast(mono[static_cast(sampleIndex)]); + } + + window.multiplyWithWindowingTable(fftData.data(), static_cast(fftSize)); + fft.performRealOnlyForwardTransform(fftData.data()); + + double frameFlux = 0.0; + for (int bin = 1; bin < nyquistBins; ++bin) { + const double re = fftData[static_cast(bin * 2)]; + const double im = fftData[static_cast(bin * 2 + 1)]; + const double magnitude = std::sqrt(re * re + im * im); + accumulatedMagnitude[static_cast(bin)] += magnitude; + + if (frames > 0) { + const double delta = magnitude - previousMagnitude[static_cast(bin)]; + const double selected = halfWaveFlux ? std::max(0.0, delta) : std::abs(delta); + frameFlux += selected; + } + previousMagnitude[static_cast(bin)] = magnitude; + } + + fluxSum += frameFlux; + ++frames; + if (start + fftSize >= totalSamples) { + break; + } + } + + if (frames > 0) { + for (double& value : accumulatedMagnitude) { + value /= static_cast(frames); + } + } + + summary.avgMagnitude = std::move(accumulatedMagnitude); + summary.fluxMean = fluxSum / static_cast(std::max(1, frames)); + summary.frameCount = frames; + return summary; +} + +std::vector> buildMelFilterBank(const int numBands, + const int fftSize, + const double sampleRate, + const double minHz, + const double maxHz) { + const int nyquistBins = fftSize / 2; + std::vector> filters(static_cast(numBands), + std::vector(static_cast(nyquistBins + 1), 0.0)); + + const double melMin = hzToMel(minHz); + const double melMax = hzToMel(maxHz); + + std::vector melPoints(static_cast(numBands + 2), 0.0); + for (int i = 0; i < numBands + 2; ++i) { + const double t = static_cast(i) / static_cast(numBands + 1); + melPoints[static_cast(i)] = melMin + t * (melMax - melMin); + } + + std::vector bins(static_cast(numBands + 2), 0); + for (int i = 0; i < numBands + 2; ++i) { + const double hz = melToHz(melPoints[static_cast(i)]); + const double bin = std::floor((static_cast(fftSize) + 1.0) * hz / sampleRate); + bins[static_cast(i)] = std::clamp(static_cast(bin), 0, nyquistBins); + } + + for (int m = 1; m <= numBands; ++m) { + const int left = bins[static_cast(m - 1)]; + const int center = bins[static_cast(m)]; + const int right = bins[static_cast(m + 1)]; + + for (int k = left; k < center; ++k) { + const double denom = std::max(1, center - left); + filters[static_cast(m - 1)][static_cast(k)] = (static_cast(k - left)) / denom; + } + for (int k = center; k < right; ++k) { + const double denom = std::max(1, right - center); + filters[static_cast(m - 1)][static_cast(k)] = (static_cast(right - k)) / denom; + } + } + + return filters; +} + +std::vector dctType2(const std::vector& values, const int coeffCount) { + std::vector output(static_cast(coeffCount), 0.0); + const int n = static_cast(values.size()); + if (n == 0) { + return output; + } + + for (int k = 0; k < coeffCount; ++k) { + double sum = 0.0; + for (int i = 0; i < n; ++i) { + const double angle = (3.14159265358979323846 / static_cast(n)) * (static_cast(i) + 0.5) * + static_cast(k); + sum += values[static_cast(i)] * std::cos(angle); + } + output[static_cast(k)] = sum; + } + + return output; +} + +std::vector computeConstantQ(const std::vector& avgMagnitude, + const double sampleRate, + const int fftSize, + const int binsPerOctave, + const int totalBins, + const double minHz) { + std::vector cqt(static_cast(totalBins), 0.0); + if (avgMagnitude.empty()) { + return cqt; + } + + const int nyquistBin = static_cast(avgMagnitude.size()) - 1; + const double q = std::pow(2.0, 1.0 / static_cast(binsPerOctave)); + const double halfBandwidth = std::sqrt(q); + + for (int i = 0; i < totalBins; ++i) { + const double centerHz = minHz * std::pow(q, static_cast(i)); + const double lowHz = centerHz / halfBandwidth; + const double highHz = centerHz * halfBandwidth; + + const int lowBin = std::clamp(static_cast(std::floor(lowHz * fftSize / sampleRate)), 1, nyquistBin); + const int highBin = std::clamp(static_cast(std::ceil(highHz * fftSize / sampleRate)), lowBin, nyquistBin); + + double sum = 0.0; + for (int bin = lowBin; bin <= highBin; ++bin) { + sum += avgMagnitude[static_cast(bin)]; + } + + cqt[static_cast(i)] = sum / static_cast(std::max(1, highBin - lowBin + 1)); + } + + const double total = std::accumulate(cqt.begin(), cqt.end(), 0.0); + if (total > kEpsilon) { + for (double& value : cqt) { + value /= total; + } + } + + return cqt; } } // namespace @@ -42,53 +241,31 @@ AnalysisResult StemAnalyzer::analyzeBuffer(const engine::AudioBuffer& buffer) co return result; } - double peak = 0.0; - double energy = 0.0; const int totalSamples = buffer.getNumSamples(); + double peak = 0.0; + double monoEnergy = 0.0; + double monoSum = 0.0; int silenceCount = 0; - OnePoleLowPass lowL = makeLowPass(buffer.getSampleRate(), 200.0); - OnePoleLowPass lowR = makeLowPass(buffer.getSampleRate(), 200.0); - OnePoleLowPass midL = makeLowPass(buffer.getSampleRate(), 2000.0); - OnePoleLowPass midR = makeLowPass(buffer.getSampleRate(), 2000.0); - - double lowEnergy = 0.0; - double midEnergy = 0.0; - double highEnergy = 0.0; - double sumL = 0.0; double sumR = 0.0; double sumLL = 0.0; double sumRR = 0.0; double sumLR = 0.0; - for (int i = 0; i < buffer.getNumSamples(); ++i) { - const float l = buffer.getSample(0, i); - const float r = buffer.getNumChannels() > 1 ? buffer.getSample(1, i) : l; - + for (int i = 0; i < totalSamples; ++i) { + const double l = buffer.getSample(0, i); + const double r = buffer.getNumChannels() > 1 ? buffer.getSample(1, i) : l; const double mono = 0.5 * (l + r); const double absMono = std::abs(mono); peak = std::max(peak, absMono); - energy += mono * mono; + monoEnergy += mono * mono; + monoSum += mono; if (absMono < 0.001) { ++silenceCount; } - const float lowSampleL = lowL.process(l); - const float lowSampleR = lowR.process(r); - const float midLowL = midL.process(l); - const float midLowR = midR.process(r); - - const float midBandL = midLowL - lowSampleL; - const float midBandR = midLowR - lowSampleR; - const float highBandL = l - midLowL; - const float highBandR = r - midLowR; - - lowEnergy += lowSampleL * lowSampleL + lowSampleR * lowSampleR; - midEnergy += midBandL * midBandL + midBandR * midBandR; - highEnergy += highBandL * highBandL + highBandR * highBandR; - sumL += l; sumR += r; sumLL += l * l; @@ -96,19 +273,15 @@ AnalysisResult StemAnalyzer::analyzeBuffer(const engine::AudioBuffer& buffer) co sumLR += l * r; } - const double rms = std::sqrt(energy / std::max(1, totalSamples)); + const double monoRms = std::sqrt(monoEnergy / std::max(1, totalSamples)); result.peakDb = linearToDb(peak); - result.rmsDb = linearToDb(rms); + result.rmsDb = linearToDb(monoRms); result.crestDb = result.peakDb - result.rmsDb; + result.crestFactor = peak / std::max(monoRms, kEpsilon); + result.silenceRatio = static_cast(silenceCount) / std::max(1, totalSamples); + result.dcOffset = monoSum / std::max(1, totalSamples); - const double bandTotal = lowEnergy + midEnergy + highEnergy + 1.0e-12; - result.lowEnergy = lowEnergy / bandTotal; - result.midEnergy = midEnergy / bandTotal; - result.highEnergy = highEnergy / bandTotal; - - result.silenceRatio = static_cast(silenceCount) / std::max(1, buffer.getNumSamples()); - - const double n = static_cast(buffer.getNumSamples()); + const double n = static_cast(totalSamples); const double cov = sumLR - (sumL * sumR) / n; const double varL = sumLL - (sumL * sumL) / n; const double varR = sumRR - (sumR * sumR) / n; @@ -117,9 +290,98 @@ AnalysisResult StemAnalyzer::analyzeBuffer(const engine::AudioBuffer& buffer) co } else { result.stereoCorrelation = 1.0; } + result.stereoCorrelation = std::clamp(result.stereoCorrelation, -1.0, 1.0); + result.stereoWidth = clamp01((1.0 - result.stereoCorrelation) * 0.5); + + const double leftRms = std::sqrt(std::max(varL / n, 0.0)); + const double rightRms = std::sqrt(std::max(varR / n, 0.0)); + result.channelBalanceDb = linearToDb(leftRms + kEpsilon) - linearToDb(rightRms + kEpsilon); + + const auto monoSignal = computeMonoSignal(buffer); + + // Base STFT used for spectral-balance, centroid/spread/flatness and flux. + constexpr int kBaseFftOrder = 11; // 2048 + constexpr int kBaseFftSize = 1 << kBaseFftOrder; + const auto baseSummary = runStft(monoSignal, buffer.getSampleRate(), kBaseFftOrder, kBaseFftSize / 2, false); + + std::vector bandEnergy(6, 0.0); + double weightedFreqSum = 0.0; + double weightedFreqSquaredSum = 0.0; + double totalMagnitude = 0.0; + double logMagnitudeSum = 0.0; + int magnitudeBins = 0; + + for (size_t bin = 1; bin < baseSummary.avgMagnitude.size(); ++bin) { + const double magnitude = baseSummary.avgMagnitude[bin]; + const double power = magnitude * magnitude; + const double hz = static_cast(bin) * buffer.getSampleRate() / static_cast(kBaseFftSize); + + totalMagnitude += magnitude; + weightedFreqSum += hz * magnitude; + weightedFreqSquaredSum += hz * hz * magnitude; + logMagnitudeSum += std::log(magnitude + kEpsilon); + bandEnergy[bandIndexForFrequency(hz)] += power; + ++magnitudeBins; + } + + const double bandTotal = std::accumulate(bandEnergy.begin(), bandEnergy.end(), 0.0) + kEpsilon; + result.subEnergy = bandEnergy[0] / bandTotal; + result.bassEnergy = bandEnergy[1] / bandTotal; + result.lowMidEnergy = bandEnergy[2] / bandTotal; + result.highMidEnergy = bandEnergy[3] / bandTotal; + result.presenceEnergy = bandEnergy[4] / bandTotal; + result.airEnergy = bandEnergy[5] / bandTotal; + result.lowEnergy = result.subEnergy + result.bassEnergy; + result.midEnergy = result.lowMidEnergy + result.highMidEnergy; + result.highEnergy = result.presenceEnergy + result.airEnergy; + + if (totalMagnitude > kEpsilon) { + result.spectralCentroidHz = weightedFreqSum / totalMagnitude; + const double meanSquared = weightedFreqSquaredSum / totalMagnitude; + const double variance = std::max(0.0, meanSquared - result.spectralCentroidHz * result.spectralCentroidHz); + result.spectralSpreadHz = std::sqrt(variance); + } + + const double geometricMean = std::exp(logMagnitudeSum / static_cast(std::max(1, magnitudeBins))); + const double arithmeticMean = totalMagnitude / static_cast(std::max(1, magnitudeBins)) + kEpsilon; + result.spectralFlatness = clamp01(geometricMean / arithmeticMean); + result.spectralFlux = baseSummary.fluxMean; + + // Transient-focused STFT for onset strength (short window). + constexpr int kTransientFftOrder = 8; // 256 + constexpr int kTransientFftSize = 1 << kTransientFftOrder; + const auto transientSummary = runStft(monoSignal, buffer.getSampleRate(), kTransientFftOrder, kTransientFftSize / 2, true); + result.onsetStrength = transientSummary.fluxMean; + + // Tonal-focused STFT for MFCC and Constant-Q proxy (long window). + constexpr int kTonalFftOrder = 12; // 4096 + constexpr int kTonalFftSize = 1 << kTonalFftOrder; + const auto tonalSummary = runStft(monoSignal, buffer.getSampleRate(), kTonalFftOrder, kTonalFftSize / 2, false); + + const auto melFilters = buildMelFilterBank(26, + kTonalFftSize, + buffer.getSampleRate(), + 20.0, + std::max(2000.0, buffer.getSampleRate() * 0.5)); + std::vector melEnergies(melFilters.size(), 0.0); + for (size_t m = 0; m < melFilters.size(); ++m) { + double energy = 0.0; + for (size_t bin = 1; bin < tonalSummary.avgMagnitude.size(); ++bin) { + energy += melFilters[m][bin] * tonalSummary.avgMagnitude[bin] * tonalSummary.avgMagnitude[bin]; + } + melEnergies[m] = std::log(energy + kEpsilon); + } + + result.mfccCoefficients = dctType2(melEnergies, 13); + result.constantQBins = computeConstantQ(tonalSummary.avgMagnitude, + buffer.getSampleRate(), + kTonalFftSize, + 12, + 24, + 55.0); - result.stereoWidth = std::clamp((1.0 - result.stereoCorrelation) * 0.5, 0.0, 1.0); ArtifactRiskEstimator riskEstimator; + result.artifactProfile = riskEstimator.profile(buffer, result); result.artifactRisk = riskEstimator.estimate(buffer, result); return result; } @@ -135,9 +397,11 @@ std::vector StemAnalyzer::analyzeSession(const domain::Sessio } engine::AudioBuffer buffer = fileIO.readAudioFile(stem.filePath); - entries.push_back(StemAnalysisEntry{.stemId = stem.id, - .stemName = stem.name, - .metrics = analyzeBuffer(buffer)}); + entries.push_back(StemAnalysisEntry{ + .stemId = stem.id, + .stemName = stem.name, + .metrics = analyzeBuffer(buffer), + }); } return entries; @@ -148,18 +412,41 @@ std::string StemAnalyzer::toJsonReport(const std::vector& ent report["stems"] = nlohmann::json::array(); for (const auto& entry : entries) { - report["stems"].push_back({{"stemId", entry.stemId}, - {"stemName", entry.stemName}, - {"peakDb", entry.metrics.peakDb}, - {"rmsDb", entry.metrics.rmsDb}, - {"crestDb", entry.metrics.crestDb}, - {"lowEnergy", entry.metrics.lowEnergy}, - {"midEnergy", entry.metrics.midEnergy}, - {"highEnergy", entry.metrics.highEnergy}, - {"silenceRatio", entry.metrics.silenceRatio}, - {"stereoCorrelation", entry.metrics.stereoCorrelation}, - {"stereoWidth", entry.metrics.stereoWidth}, - {"artifactRisk", entry.metrics.artifactRisk}}); + report["stems"].push_back( + {{"stemId", entry.stemId}, + {"stemName", entry.stemName}, + {"peakDb", entry.metrics.peakDb}, + {"rmsDb", entry.metrics.rmsDb}, + {"crestDb", entry.metrics.crestDb}, + {"crestFactor", entry.metrics.crestFactor}, + {"dcOffset", entry.metrics.dcOffset}, + {"lowEnergy", entry.metrics.lowEnergy}, + {"midEnergy", entry.metrics.midEnergy}, + {"highEnergy", entry.metrics.highEnergy}, + {"subEnergy", entry.metrics.subEnergy}, + {"bassEnergy", entry.metrics.bassEnergy}, + {"lowMidEnergy", entry.metrics.lowMidEnergy}, + {"highMidEnergy", entry.metrics.highMidEnergy}, + {"presenceEnergy", entry.metrics.presenceEnergy}, + {"airEnergy", entry.metrics.airEnergy}, + {"spectralCentroidHz", entry.metrics.spectralCentroidHz}, + {"spectralSpreadHz", entry.metrics.spectralSpreadHz}, + {"spectralFlatness", entry.metrics.spectralFlatness}, + {"spectralFlux", entry.metrics.spectralFlux}, + {"onsetStrength", entry.metrics.onsetStrength}, + {"mfccCoefficients", entry.metrics.mfccCoefficients}, + {"constantQBins", entry.metrics.constantQBins}, + {"silenceRatio", entry.metrics.silenceRatio}, + {"stereoCorrelation", entry.metrics.stereoCorrelation}, + {"stereoWidth", entry.metrics.stereoWidth}, + {"channelBalanceDb", entry.metrics.channelBalanceDb}, + {"artifactRisk", entry.metrics.artifactRisk}, + {"artifactProfile", + {{"swirlRisk", entry.metrics.artifactProfile.swirlRisk}, + {"smearRisk", entry.metrics.artifactProfile.smearRisk}, + {"noiseDominance", entry.metrics.artifactProfile.noiseDominance}, + {"harmonicity", entry.metrics.artifactProfile.harmonicity}, + {"phaseInstability", entry.metrics.artifactProfile.phaseInstability}}}}); } return report.dump(2); diff --git a/src/analysis/StemHealthAssistant.cpp b/src/analysis/StemHealthAssistant.cpp new file mode 100644 index 0000000..672bd4a --- /dev/null +++ b/src/analysis/StemHealthAssistant.cpp @@ -0,0 +1,183 @@ +#include "analysis/StemHealthAssistant.h" + +#include +#include +#include + +#include + +namespace automix::analysis { +namespace { + +void addIssue(StemHealthReport* report, + const StemAnalysisEntry& entry, + const std::string& code, + const std::string& message, + const StemHealthSeverity severity, + const double score) { + report->issues.push_back(StemHealthIssue{ + .stemId = entry.stemId, + .stemName = entry.stemName, + .code = code, + .message = message, + .severity = severity, + .score = score, + }); + report->overallRisk = std::max(report->overallRisk, score); + report->hasCriticalIssues = report->hasCriticalIssues || severity == StemHealthSeverity::Critical; +} + +} // namespace + +std::string toString(const StemHealthSeverity severity) { + switch (severity) { + case StemHealthSeverity::Info: + return "info"; + case StemHealthSeverity::Warning: + return "warning"; + case StemHealthSeverity::Critical: + return "critical"; + } + return "info"; +} + +StemHealthReport StemHealthAssistant::analyze(const domain::Session& session, + const std::vector& analysisEntries) const { + StemHealthReport report; + if (analysisEntries.empty()) { + return report; + } + + double summedLowEnergy = 0.0; + double summedMidEnergy = 0.0; + double summedHighEnergy = 0.0; + for (const auto& entry : analysisEntries) { + summedLowEnergy += std::max(0.0, entry.metrics.lowEnergy); + summedMidEnergy += std::max(0.0, entry.metrics.midEnergy); + summedHighEnergy += std::max(0.0, entry.metrics.highEnergy); + } + + for (const auto& entry : analysisEntries) { + const auto& metrics = entry.metrics; + + if (metrics.artifactRisk > 0.72) { + addIssue(&report, + entry, + "harshness_risk", + "High artifact risk indicates likely harshness or separation residue.", + StemHealthSeverity::Critical, + std::min(1.0, metrics.artifactRisk)); + } else if (metrics.artifactRisk > 0.55) { + addIssue(&report, + entry, + "harshness_risk", + "Moderate artifact risk; consider de-harsh EQ or transient cleanup.", + StemHealthSeverity::Warning, + std::min(1.0, metrics.artifactRisk)); + } + + if (metrics.stereoCorrelation < 0.10) { + addIssue(&report, + entry, + "mono_risk", + "Low stereo correlation suggests mono-compatibility risk.", + StemHealthSeverity::Warning, + std::clamp(0.7 - metrics.stereoCorrelation, 0.0, 1.0)); + } + + if (metrics.silenceRatio > 0.72) { + addIssue(&report, + entry, + "pumping_risk", + "High silence ratio can trigger audible pumping after mastering compression.", + StemHealthSeverity::Warning, + std::clamp(metrics.silenceRatio, 0.0, 1.0)); + } + + const double lowShare = summedLowEnergy > 1.0e-9 ? metrics.lowEnergy / summedLowEnergy : 0.0; + if (lowShare > 0.70 && entry.metrics.crestDb < 8.0) { + addIssue(&report, + entry, + "masking_conflict", + "Dominant low-band energy may mask kick/bass definition.", + StemHealthSeverity::Warning, + std::clamp(lowShare, 0.0, 1.0)); + } + + const double highShare = summedHighEnergy > 1.0e-9 ? metrics.highEnergy / summedHighEnergy : 0.0; + if (highShare > 0.55 && metrics.spectralFlatness > 0.65) { + addIssue(&report, + entry, + "spectral_masking", + "Dense high-band texture may cause cymbal/vocal masking.", + StemHealthSeverity::Warning, + std::clamp(highShare + metrics.spectralFlatness * 0.2, 0.0, 1.0)); + } + } + + if (session.mixPlan.has_value() && !session.mixPlan->stemDecisions.empty()) { + for (const auto& decision : session.mixPlan->stemDecisions) { + if (decision.enableCompressor && decision.compressorRatio > 4.0 && decision.compressorReleaseMs < 60.0) { + auto issue = StemHealthIssue{}; + issue.stemId = decision.stemId; + issue.stemName = decision.stemId; + issue.code = "pumping_risk"; + issue.message = "Aggressive compression settings may create pumping artifacts."; + issue.severity = StemHealthSeverity::Warning; + issue.score = 0.72; + report.issues.push_back(issue); + report.overallRisk = std::max(report.overallRisk, issue.score); + } + } + } + + std::sort(report.issues.begin(), report.issues.end(), [](const StemHealthIssue& a, const StemHealthIssue& b) { + if (a.score != b.score) { + return a.score > b.score; + } + return a.stemId < b.stemId; + }); + + return report; +} + +nlohmann::json StemHealthAssistant::toJson(const StemHealthReport& report) const { + nlohmann::json issues = nlohmann::json::array(); + for (const auto& issue : report.issues) { + issues.push_back({ + {"stemId", issue.stemId}, + {"stemName", issue.stemName}, + {"code", issue.code}, + {"message", issue.message}, + {"severity", toString(issue.severity)}, + {"score", issue.score}, + }); + } + + return { + {"overallRisk", report.overallRisk}, + {"hasCriticalIssues", report.hasCriticalIssues}, + {"issues", issues}, + }; +} + +std::string StemHealthAssistant::toText(const StemHealthReport& report) const { + std::ostringstream out; + out << "Stem Health Report\n"; + out << "Overall risk: " << std::fixed << std::setprecision(2) << report.overallRisk << "\n"; + out << "Critical issues: " << (report.hasCriticalIssues ? "yes" : "no") << "\n"; + + if (report.issues.empty()) { + out << "No pre-export stem health issues detected."; + return out.str(); + } + + for (const auto& issue : report.issues) { + out << "- [" << toString(issue.severity) << "] " << issue.stemName << " (" << issue.code << ")" + << " score=" << std::fixed << std::setprecision(2) << issue.score + << " :: " << issue.message << "\n"; + } + return out.str(); +} + +} // namespace automix::analysis diff --git a/src/analysis/StemHealthAssistant.h b/src/analysis/StemHealthAssistant.h new file mode 100644 index 0000000..4952dff --- /dev/null +++ b/src/analysis/StemHealthAssistant.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +#include + +#include "analysis/StemAnalyzer.h" +#include "domain/Session.h" + +namespace automix::analysis { + +enum class StemHealthSeverity { + Info, + Warning, + Critical, +}; + +struct StemHealthIssue { + std::string stemId; + std::string stemName; + std::string code; + std::string message; + StemHealthSeverity severity = StemHealthSeverity::Info; + double score = 0.0; +}; + +struct StemHealthReport { + std::vector issues; + double overallRisk = 0.0; + bool hasCriticalIssues = false; +}; + +class StemHealthAssistant { + public: + StemHealthReport analyze(const domain::Session& session, + const std::vector& analysisEntries) const; + + nlohmann::json toJson(const StemHealthReport& report) const; + std::string toText(const StemHealthReport& report) const; +}; + +std::string toString(StemHealthSeverity severity); + +} // namespace automix::analysis diff --git a/src/app/Main.cpp b/src/app/Main.cpp index 8f77755..e07c30b 100644 --- a/src/app/Main.cpp +++ b/src/app/Main.cpp @@ -13,7 +13,8 @@ class MainWindow final : public juce::DocumentWindow { juce::DocumentWindow::allButtons) { setUsingNativeTitleBar(true); setContentOwned(new MainComponent(), true); - centreWithSize(980, 640); + setResizable(true, true); + centreWithSize(1280, 720); setVisible(true); } diff --git a/src/app/MainComponent.cpp b/src/app/MainComponent.cpp index 617bef0..5637381 100644 --- a/src/app/MainComponent.cpp +++ b/src/app/MainComponent.cpp @@ -1,18 +1,47 @@ #include "app/MainComponent.h" +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include +#include + +#include -#include "automaster/OriginalMixReference.h" -#include "engine/AudioFileIO.h" -#include "engine/AudioResampler.h" #include "engine/OfflineRenderPipeline.h" -#include "renderers/PhaseLimiterRenderer.h" -#include "renderers/RendererFactory.h" +#include "renderers/ExternalLimiterRenderer.h" +#include "util/LameDownloader.h" +#include "util/StringUtils.h" +#include "util/WavWriter.h" namespace automix::app { namespace { +using ::automix::util::toLower; +using ::automix::util::extensionForFormat; + +class BackgroundJob final : public juce::ThreadPoolJob { + public: + explicit BackgroundJob(std::function task) + : juce::ThreadPoolJob("BackgroundJob"), task_(std::move(task)) {} + + JobStatus runJob() override { + task_(); + return jobHasFinished; + } + + private: + std::function task_; +}; + juce::String toJuceText(const std::vector& lines) { juce::String output; for (const auto& line : lines) { @@ -22,6 +51,57 @@ juce::String toJuceText(const std::vector& lines) { return output; } +std::vector splitDelimited(const std::string& text, const char delimiter) { + std::vector out; + std::stringstream stream(text); + std::string token; + while (std::getline(stream, token, delimiter)) { + if (!token.empty()) { + out.push_back(token); + } + } + return out; +} + +constexpr const char* kExportSpeedModeFinal = "final"; +constexpr const char* kExportSpeedModeBalanced = "balanced"; +constexpr const char* kExportSpeedModeQuick = "quick"; + +const ai::ModelPack* findPackById(const ai::ModelManager& manager, const std::string& id) { + if (id.empty() || id == "none") { + return nullptr; + } + for (const auto& pack : manager.availablePacks()) { + if (pack.id == id) { + return &pack; + } + } + return nullptr; +} + +std::string formatDuration(const double seconds) { + const auto clamped = std::max(0.0, seconds); + const int total = static_cast(std::lround(clamped)); + const int mins = total / 60; + const int secs = total % 60; + std::ostringstream output; + output << mins << ':'; + if (secs < 10) { + output << '0'; + } + output << secs; + return output.str(); +} + +juce::String modelLabel(const ai::HubModelInfo& model) { + juce::String label = model.repoId + " [" + model.useCase + "]"; + label += " dls=" + juce::String(model.downloads); + if (model.recommended) { + label += " *"; + } + return label; +} + } // namespace void MainComponent::AnalysisTableModel::setEntries(const std::vector* entries) { @@ -90,50 +170,179 @@ void MainComponent::AnalysisTableModel::paintCell(juce::Graphics& g, MainComponent::MainComponent() { addAndMakeVisible(importButton_); addAndMakeVisible(originalMixButton_); + addAndMakeVisible(clearOriginalMixButton_); + addAndMakeVisible(regenerateCacheButton_); + addAndMakeVisible(saveSessionButton_); + addAndMakeVisible(loadSessionButton_); + addAndMakeVisible(modelsMenuButton_); addAndMakeVisible(autoMixButton_); addAndMakeVisible(autoMasterButton_); + addAndMakeVisible(batchImportButton_); + addAndMakeVisible(previewOriginalButton_); + addAndMakeVisible(previewRenderedButton_); + addAndMakeVisible(playPauseButton_); + addAndMakeVisible(stopButton_); + addAndMakeVisible(loopInButton_); + addAndMakeVisible(loopOutButton_); + addAndMakeVisible(clearLoopButton_); + addAndMakeVisible(addExternalRendererButton_); + addAndMakeVisible(prefetchLameButton_); addAndMakeVisible(exportButton_); + addAndMakeVisible(cancelButton_); addAndMakeVisible(separatedStemsToggle_); addAndMakeVisible(residualBlendLabel_); addAndMakeVisible(residualBlendSlider_); addAndMakeVisible(rendererBox_); + addAndMakeVisible(exportFormatLabel_); + addAndMakeVisible(exportFormatBox_); + addAndMakeVisible(exportSpeedModeLabel_); + addAndMakeVisible(exportSpeedModeBox_); + addAndMakeVisible(projectProfileLabel_); + addAndMakeVisible(projectProfileBox_); + addAndMakeVisible(exportBitrateLabel_); + addAndMakeVisible(exportBitrateSlider_); + addAndMakeVisible(mp3ModeLabel_); + addAndMakeVisible(mp3ModeBox_); + addAndMakeVisible(mp3VbrLabel_); + addAndMakeVisible(mp3VbrSlider_); + addAndMakeVisible(gpuProviderLabel_); + addAndMakeVisible(gpuProviderBox_); + addAndMakeVisible(masterPresetLabel_); + addAndMakeVisible(masterPresetBox_); + addAndMakeVisible(platformPresetLabel_); + addAndMakeVisible(platformPresetBox_); + addAndMakeVisible(soloStemLabel_); + addAndMakeVisible(soloStemBox_); + addAndMakeVisible(muteStemLabel_); + addAndMakeVisible(muteStemBox_); + addAndMakeVisible(transportSlider_); + addAndMakeVisible(zoomLabel_); + addAndMakeVisible(zoomSlider_); + addAndMakeVisible(fineScrubToggle_); addAndMakeVisible(aiModelsLabel_); addAndMakeVisible(roleModelBox_); addAndMakeVisible(mixModelBox_); addAndMakeVisible(masterModelBox_); addAndMakeVisible(statusLabel_); + addAndMakeVisible(meterLufsLabel_); + addAndMakeVisible(meterShortTermLabel_); + addAndMakeVisible(meterTruePeakLabel_); + addAndMakeVisible(waveformPreview_); addAndMakeVisible(analysisTable_); addAndMakeVisible(reportEditor_); + addAndMakeVisible(taskCenterLabel_); + addAndMakeVisible(taskCenterEditor_); importButton_.addListener(this); originalMixButton_.addListener(this); + clearOriginalMixButton_.addListener(this); + regenerateCacheButton_.addListener(this); + saveSessionButton_.addListener(this); + loadSessionButton_.addListener(this); + modelsMenuButton_.addListener(this); autoMixButton_.addListener(this); autoMasterButton_.addListener(this); + batchImportButton_.addListener(this); + previewOriginalButton_.addListener(this); + previewRenderedButton_.addListener(this); + playPauseButton_.addListener(this); + stopButton_.addListener(this); + loopInButton_.addListener(this); + loopOutButton_.addListener(this); + clearLoopButton_.addListener(this); + addExternalRendererButton_.addListener(this); + prefetchLameButton_.addListener(this); exportButton_.addListener(this); + cancelButton_.addListener(this); + + rendererBox_.addListener(this); + exportFormatBox_.addListener(this); + exportSpeedModeBox_.addListener(this); + mp3ModeBox_.addListener(this); + projectProfileBox_.addListener(this); + gpuProviderBox_.addListener(this); + masterPresetBox_.addListener(this); + platformPresetBox_.addListener(this); roleModelBox_.addListener(this); mixModelBox_.addListener(this); masterModelBox_.addListener(this); + soloStemBox_.addListener(this); + muteStemBox_.addListener(this); + residualBlendSlider_.addListener(this); + exportBitrateSlider_.addListener(this); + mp3VbrSlider_.addListener(this); + transportSlider_.addListener(this); + zoomSlider_.addListener(this); + fineScrubToggle_.addListener(this); - rendererBox_.addItem("BuiltIn", 1); - renderers::PhaseLimiterRenderer phaseLimiterProbe; - const juce::String phaseLimiterLabel = - phaseLimiterProbe.isAvailable() ? "PhaseLimiter" : "PhaseLimiter (not found in assets)"; - rendererBox_.addItem(phaseLimiterLabel, 2); - rendererBox_.setSelectedId(1); - separatedStemsToggle_.setToggleState(false, juce::dontSendNotification); residualBlendLabel_.setJustificationType(juce::Justification::centredLeft); residualBlendSlider_.setSliderStyle(juce::Slider::LinearHorizontal); residualBlendSlider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 56, 20); residualBlendSlider_.setRange(0.0, 10.0, 0.1); residualBlendSlider_.setValue(0.0, juce::dontSendNotification); + + exportFormatLabel_.setJustificationType(juce::Justification::centredLeft); + exportSpeedModeLabel_.setJustificationType(juce::Justification::centredLeft); + exportSpeedModeBox_.addItem("Final", 1); + exportSpeedModeByComboId_[1] = kExportSpeedModeFinal; + exportSpeedModeBox_.addItem("Balanced", 2); + exportSpeedModeByComboId_[2] = kExportSpeedModeBalanced; + exportSpeedModeBox_.addItem("Quick", 3); + exportSpeedModeByComboId_[3] = kExportSpeedModeQuick; + exportSpeedModeBox_.setSelectedId(1, juce::dontSendNotification); + projectProfileLabel_.setJustificationType(juce::Justification::centredLeft); + + exportBitrateLabel_.setJustificationType(juce::Justification::centredLeft); + exportBitrateSlider_.setSliderStyle(juce::Slider::LinearHorizontal); + exportBitrateSlider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 56, 20); + exportBitrateSlider_.setRange(64.0, 320.0, 1.0); + exportBitrateSlider_.setValue(320.0, juce::dontSendNotification); + mp3ModeLabel_.setJustificationType(juce::Justification::centredLeft); + mp3ModeBox_.addItem("CBR", 1); + mp3ModeBox_.addItem("VBR", 2); + mp3ModeBox_.setSelectedId(1, juce::dontSendNotification); + mp3VbrLabel_.setJustificationType(juce::Justification::centredLeft); + mp3VbrSlider_.setSliderStyle(juce::Slider::LinearHorizontal); + mp3VbrSlider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 48, 20); + mp3VbrSlider_.setRange(0.0, 9.0, 1.0); + mp3VbrSlider_.setValue(4.0, juce::dontSendNotification); + + gpuProviderLabel_.setJustificationType(juce::Justification::centredLeft); + gpuProviderBox_.addItem("Auto", 1); + gpuProviderBox_.addItem("CPU", 2); + gpuProviderBox_.addItem("DirectML", 3); + gpuProviderBox_.addItem("CoreML", 4); + gpuProviderBox_.addItem("CUDA", 5); + gpuProviderBox_.setSelectedId(1, juce::dontSendNotification); + + masterPresetLabel_.setJustificationType(juce::Justification::centredLeft); + platformPresetLabel_.setJustificationType(juce::Justification::centredLeft); + soloStemLabel_.setJustificationType(juce::Justification::centredLeft); + muteStemLabel_.setJustificationType(juce::Justification::centredLeft); + + transportSlider_.setSliderStyle(juce::Slider::LinearHorizontal); + transportSlider_.setTextBoxStyle(juce::Slider::NoTextBox, false, 0, 0); + transportSlider_.setRange(0.0, 1.0, 0.0001); + transportSlider_.setValue(0.0, juce::dontSendNotification); + transportSlider_.setSkewFactor(1.0); + + zoomLabel_.setJustificationType(juce::Justification::centredLeft); + zoomSlider_.setSliderStyle(juce::Slider::LinearHorizontal); + zoomSlider_.setTextBoxStyle(juce::Slider::TextBoxRight, false, 56, 20); + zoomSlider_.setRange(1.0, 32.0, 0.1); + zoomSlider_.setValue(1.0, juce::dontSendNotification); + fineScrubToggle_.setToggleState(false, juce::dontSendNotification); + aiModelsLabel_.setJustificationType(juce::Justification::centredLeft); - roleModelBox_.setTextWhenNothingSelected("Role: none"); - mixModelBox_.setTextWhenNothingSelected("Mix: none"); - masterModelBox_.setTextWhenNothingSelected("Master: none"); - refreshModelPacks(); + roleModelBox_.setTextWhenNothingSelected("Role model"); + mixModelBox_.setTextWhenNothingSelected("Mix model"); + masterModelBox_.setTextWhenNothingSelected("Master model"); statusLabel_.setText("Ready", juce::dontSendNotification); + meterLufsLabel_.setJustificationType(juce::Justification::centredLeft); + meterShortTermLabel_.setJustificationType(juce::Justification::centredLeft); + meterTruePeakLabel_.setJustificationType(juce::Justification::centredLeft); analysisTableModel_.setEntries(&analysisEntries_); analysisTable_.setModel(&analysisTableModel_); @@ -150,45 +359,448 @@ MainComponent::MainComponent() { reportEditor_.setReadOnly(true); reportEditor_.setScrollbarsShown(true); + taskCenterLabel_.setJustificationType(juce::Justification::centredLeft); + taskCenterEditor_.setMultiLine(true); + taskCenterEditor_.setReadOnly(true); + taskCenterEditor_.setScrollbarsShown(true); + taskCenterEditor_.setText("Task history will appear here."); + + cancelButton_.setEnabled(false); + clearOriginalMixButton_.setEnabled(false); session_.sessionName = "Untitled Session"; + session_.renderSettings.exportSpeedMode = kExportSpeedModeFinal; + session_.timeline.zoom = zoomSlider_.getValue(); + session_.timeline.fineScrub = fineScrubToggle_.getToggleState(); + + refreshRenderers(); + refreshCodecAvailability(); + refreshModelPacks(); + populateMasterPresetSelectors(); + refreshProjectProfiles(); + refreshStemRoutingSelectors(); + + { + ModelController::Callbacks modelCallbacks; + modelCallbacks.onStatus = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->statusLabel_.setText(juce::String(msg), juce::dontSendNotification); + } + }); + }; + modelCallbacks.onTaskHistory = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->appendTaskHistory(juce::String(msg)); + } + }); + }; + modelCallbacks.onReport = [this](const std::string& text) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), text]() { + if (safeThis != nullptr) { + safeThis->reportEditor_.setText(juce::String(text)); + } + }); + }; + modelCallbacks.onModelPacksChanged = [this]() { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this)]() { + if (safeThis != nullptr) { + safeThis->refreshModelPacks(); + } + }); + }; + modelCallbacks.onCatalogReady = [this]() { + const auto& models = modelController_->discoveredModels(); + if (models.empty()) { + return; + } + + juce::PopupMenu modelMenu; + int itemId = 1000; + for (const auto& model : models) { + modelMenu.addItem(itemId++, modelLabel(model)); + } + modelMenu.addSeparator(); + modelMenu.addItem(1900, "Refresh"); + + juce::Component::SafePointer safeThis(this); + modelMenu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&modelsMenuButton_), + [safeThis](const int selection) { + if (safeThis == nullptr) { + return; + } + if (selection == 1900) { + safeThis->modelController_->fetchCatalog(); + return; + } + const auto& discovered = safeThis->modelController_->discoveredModels(); + if (selection < 1000 || + selection >= 1000 + static_cast(discovered.size())) { + return; + } + const auto& model = discovered[static_cast(selection - 1000)]; + safeThis->modelController_->installModel(model.repoId); + }); + }; + modelController_ = std::make_unique(modelManager_, backgroundPool_, std::move(modelCallbacks)); + } + + { + ImportController::Callbacks importCallbacks; + importCallbacks.onStatus = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->statusLabel_.setText(juce::String(msg), juce::dontSendNotification); + } + }); + }; + importCallbacks.onTaskHistory = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->appendTaskHistory(juce::String(msg)); + } + }); + }; + importCallbacks.onImportComplete = [this](ImportResult result) { + session_.stems = std::move(result.stems); + statusLabel_.setText( + "Imported " + juce::String(static_cast(session_.stems.size())) + " stems", + juce::dontSendNotification); + appendTaskHistory("Imported " + juce::String(static_cast(session_.stems.size())) + " stems"); + + analysisEntries_.clear(); + analysisTableModel_.setEntries(&analysisEntries_); + analysisTable_.updateContent(); + + refreshStemRoutingSelectors(); + reportEditor_.setText(juce::String("Imported files:\n") + toJuceText(result.logLines)); + rebuildPreviewBuffersAsync(); + }; + importController_ = std::make_unique(backgroundPool_, std::move(importCallbacks)); + } + + { + ExportController::Callbacks exportCallbacks; + exportCallbacks.onStatus = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->statusLabel_.setText(juce::String(msg), juce::dontSendNotification); + } + }); + }; + exportCallbacks.onTaskHistory = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->appendTaskHistory(juce::String(msg)); + } + }); + }; + exportCallbacks.onExportComplete = [this](ExportResult result) { + taskRunning_.store(false); + cancelButton_.setEnabled(false); + + if (!result.analysisEntries.empty()) { + analysisEntries_ = std::move(result.analysisEntries); + analysisTableModel_.setEntries(&analysisEntries_); + analysisTable_.updateContent(); + } + + if (!result.crashMessage.isEmpty()) { + statusLabel_.setText("Export crashed", juce::dontSendNotification); + reportEditor_.setText(result.crashMessage); + appendTaskHistory("Export crashed"); + return; + } + + if (result.cancelled) { + statusLabel_.setText("Export cancelled", juce::dontSendNotification); + appendTaskHistory("Export cancelled"); + return; + } + + const bool quickExportMode = toLower(result.exportSpeedMode) == "quick"; + if (quickExportMode) { + appendTaskHistory("Quick export mode active: stem-health preflight skipped"); + } else if (result.healthIssueCount > 0) { + appendTaskHistory("Stem health check found " + juce::String(static_cast(result.healthIssueCount)) + " issue(s)"); + } else { + appendTaskHistory("Stem health check passed"); + } + + statusLabel_.setText(result.success ? "Export complete" : "Export failed", + juce::dontSendNotification); + if (result.healthHasCriticalIssues && result.success) { + statusLabel_.setText("Export complete with critical stem health warnings", juce::dontSendNotification); + } + appendTaskHistory(result.success ? "Export completed" : "Export failed"); + juce::String report = juce::String("Renderer: ") + juce::String(result.rendererName) + + juce::String("\nExport mode: ") + juce::String(result.exportSpeedMode) + + juce::String("\nOutput: ") + juce::String(result.outputAudioPath) + + juce::String("\nReport: ") + juce::String(result.reportPath) + + juce::String("\n\nLogs:\n") + toJuceText(result.logs); + if (!result.healthText.isEmpty()) { + report += "\n\n"; + report += result.healthText; + } + reportEditor_.setText(report); + }; + exportController_ = std::make_unique(backgroundPool_, std::move(exportCallbacks)); + } + + { + ProcessingController::Callbacks processingCallbacks; + processingCallbacks.onStatus = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->statusLabel_.setText(juce::String(msg), juce::dontSendNotification); + } + }); + }; + processingCallbacks.onTaskHistory = [this](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this), msg]() { + if (safeThis != nullptr) { + safeThis->appendTaskHistory(juce::String(msg)); + } + }); + }; + processingCallbacks.onAutoMixComplete = [this](AutoMixResult result) { + taskRunning_.store(false); + cancelButton_.setEnabled(false); + + if (!result.errorText.isEmpty()) { + statusLabel_.setText("Auto Mix failed", juce::dontSendNotification); + reportEditor_.setText(result.errorText); + appendTaskHistory("Auto Mix failed"); + return; + } + + if (result.cancelled) { + statusLabel_.setText("Auto Mix cancelled", juce::dontSendNotification); + appendTaskHistory("Auto Mix cancelled"); + return; + } + + analysisEntries_ = std::move(result.analysisEntries); + analysisTableModel_.setEntries(&analysisEntries_); + analysisTable_.updateContent(); + + if (result.mixPlan.has_value()) { + session_.mixPlan = result.mixPlan.value(); + } + + if (!result.reportText.isEmpty()) { + reportEditor_.setText(result.reportText); + } + + statusLabel_.setText("Auto Mix plan generated", juce::dontSendNotification); + appendTaskHistory("Auto Mix completed"); + rebuildPreviewBuffersAsync(); + }; + processingCallbacks.onAutoMasterComplete = [this](AutoMasterResult result) { + taskRunning_.store(false); + cancelButton_.setEnabled(false); + + if (!result.errorText.isEmpty()) { + statusLabel_.setText("Auto Master failed", juce::dontSendNotification); + reportEditor_.setText(result.errorText); + appendTaskHistory("Auto Master failed"); + return; + } + + if (result.cancelled) { + statusLabel_.setText("Auto Master cancelled", juce::dontSendNotification); + appendTaskHistory("Auto Master cancelled"); + return; + } + + session_.masterPlan = std::move(result.masterPlan); + previewEngine_.setBuffers(result.rawMixBuffer, result.previewMaster); + previewEngine_.setSource(engine::PreviewSource::OriginalMix); + previewEngine_.stop(); + updateTransportFromBuffer(previewEngine_.buildCrossfadedPreview(1024)); + updateMeterPanel(result.previewReport); + + statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); + appendTaskHistory("Auto Master completed"); + if (!result.reportAppend.isEmpty()) { + reportEditor_.setText(reportEditor_.getText() + result.reportAppend); + } + }; + processingCallbacks.onBatchComplete = [this](BatchResult result) { + taskRunning_.store(false); + cancelButton_.setEnabled(false); + + if (!result.errorText.isEmpty()) { + if (result.summary.isEmpty()) { + statusLabel_.setText("Batch preparation failed", juce::dontSendNotification); + reportEditor_.setText(result.errorText); + appendTaskHistory("Batch preparation failed"); + } else { + statusLabel_.setText("Batch folder has no supported audio files", juce::dontSendNotification); + appendTaskHistory("Batch preparation found no supported files"); + } + return; + } + + statusLabel_.setText("Batch complete", juce::dontSendNotification); + reportEditor_.setText(result.summary); + appendTaskHistory("Batch completed"); + }; + processingController_ = std::make_unique(backgroundPool_, std::move(processingCallbacks)); + } + + const auto deviceError = audioDeviceManager_.initialise(0, 2, nullptr, true); + audioDeviceManager_.addAudioCallback(this); + if (!deviceError.isEmpty()) { + statusLabel_.setText("Audio device init warning: " + deviceError, juce::dontSendNotification); + appendTaskHistory("Audio device init warning: " + deviceError); + } + + transportController_.addChangeListener(this); + startTimerHz(20); + updateTransportLoopAndZoomUI(); + updateTransportDisplay(); } MainComponent::~MainComponent() { + cancelRender_.store(true); + backgroundPool_.removeAllJobs(true, 5000); + + stopTimer(); + audioDeviceManager_.removeAudioCallback(this); + transportController_.removeChangeListener(this); + importButton_.removeListener(this); originalMixButton_.removeListener(this); + clearOriginalMixButton_.removeListener(this); + regenerateCacheButton_.removeListener(this); + saveSessionButton_.removeListener(this); + loadSessionButton_.removeListener(this); + modelsMenuButton_.removeListener(this); autoMixButton_.removeListener(this); autoMasterButton_.removeListener(this); + batchImportButton_.removeListener(this); + previewOriginalButton_.removeListener(this); + previewRenderedButton_.removeListener(this); + playPauseButton_.removeListener(this); + stopButton_.removeListener(this); + loopInButton_.removeListener(this); + loopOutButton_.removeListener(this); + clearLoopButton_.removeListener(this); + addExternalRendererButton_.removeListener(this); + prefetchLameButton_.removeListener(this); exportButton_.removeListener(this); + cancelButton_.removeListener(this); + + rendererBox_.removeListener(this); + exportFormatBox_.removeListener(this); + exportSpeedModeBox_.removeListener(this); + mp3ModeBox_.removeListener(this); + projectProfileBox_.removeListener(this); + gpuProviderBox_.removeListener(this); + masterPresetBox_.removeListener(this); + platformPresetBox_.removeListener(this); roleModelBox_.removeListener(this); mixModelBox_.removeListener(this); masterModelBox_.removeListener(this); + soloStemBox_.removeListener(this); + muteStemBox_.removeListener(this); + residualBlendSlider_.removeListener(this); + exportBitrateSlider_.removeListener(this); + mp3VbrSlider_.removeListener(this); + transportSlider_.removeListener(this); + zoomSlider_.removeListener(this); + fineScrubToggle_.removeListener(this); } void MainComponent::resized() { auto area = getLocalBounds().reduced(8); - auto top = area.removeFromTop(36); + + auto top = area.removeFromTop(34); + auto toolsRow = area.removeFromTop(30); + auto meterRow = area.removeFromTop(24); auto blendRow = area.removeFromTop(28); + auto exportRow = area.removeFromTop(28); + auto presetRow = area.removeFromTop(28); + auto stemRow = area.removeFromTop(28); auto modelRow = area.removeFromTop(30); + auto waveformRow = area.removeFromTop(110); + auto transportControlRow = area.removeFromTop(28); + auto transportRow = area.removeFromTop(24); - importButton_.setBounds(top.removeFromLeft(110).reduced(2)); - originalMixButton_.setBounds(top.removeFromLeft(120).reduced(2)); - autoMixButton_.setBounds(top.removeFromLeft(110).reduced(2)); - autoMasterButton_.setBounds(top.removeFromLeft(120).reduced(2)); - exportButton_.setBounds(top.removeFromLeft(110).reduced(2)); - separatedStemsToggle_.setBounds(top.removeFromLeft(170).reduced(2)); - rendererBox_.setBounds(top.removeFromLeft(160).reduced(2)); + importButton_.setBounds(top.removeFromLeft(84).reduced(2)); + originalMixButton_.setBounds(top.removeFromLeft(96).reduced(2)); + clearOriginalMixButton_.setBounds(top.removeFromLeft(104).reduced(2)); + regenerateCacheButton_.setBounds(top.removeFromLeft(110).reduced(2)); + saveSessionButton_.setBounds(top.removeFromLeft(88).reduced(2)); + loadSessionButton_.setBounds(top.removeFromLeft(88).reduced(2)); + modelsMenuButton_.setBounds(top.removeFromLeft(84).reduced(2)); + autoMixButton_.setBounds(top.removeFromLeft(84).reduced(2)); + autoMasterButton_.setBounds(top.removeFromLeft(96).reduced(2)); + batchImportButton_.setBounds(top.removeFromLeft(96).reduced(2)); + exportButton_.setBounds(top.removeFromLeft(76).reduced(2)); + cancelButton_.setBounds(top.removeFromLeft(70).reduced(2)); - residualBlendLabel_.setBounds(blendRow.removeFromLeft(140).reduced(2)); - residualBlendSlider_.setBounds(blendRow.removeFromLeft(260).reduced(2)); + previewOriginalButton_.setBounds(toolsRow.removeFromLeft(100).reduced(2)); + previewRenderedButton_.setBounds(toolsRow.removeFromLeft(100).reduced(2)); + playPauseButton_.setBounds(toolsRow.removeFromLeft(96).reduced(2)); + stopButton_.setBounds(toolsRow.removeFromLeft(70).reduced(2)); + loopInButton_.setBounds(toolsRow.removeFromLeft(95).reduced(2)); + loopOutButton_.setBounds(toolsRow.removeFromLeft(100).reduced(2)); + clearLoopButton_.setBounds(toolsRow.removeFromLeft(90).reduced(2)); + separatedStemsToggle_.setBounds(toolsRow.removeFromLeft(160).reduced(2)); + rendererBox_.setBounds(toolsRow.removeFromLeft(220).reduced(2)); + addExternalRendererButton_.setBounds(toolsRow.removeFromLeft(170).reduced(2)); + prefetchLameButton_.setBounds(toolsRow.removeFromLeft(130).reduced(2)); - aiModelsLabel_.setBounds(modelRow.removeFromLeft(70).reduced(2)); + meterLufsLabel_.setBounds(meterRow.removeFromLeft(220).reduced(2)); + meterShortTermLabel_.setBounds(meterRow.removeFromLeft(220).reduced(2)); + meterTruePeakLabel_.setBounds(meterRow.removeFromLeft(220).reduced(2)); + + residualBlendLabel_.setBounds(blendRow.removeFromLeft(132).reduced(2)); + residualBlendSlider_.setBounds(blendRow.removeFromLeft(250).reduced(2)); + + exportFormatLabel_.setBounds(exportRow.removeFromLeft(50).reduced(2)); + exportFormatBox_.setBounds(exportRow.removeFromLeft(110).reduced(2)); + exportSpeedModeLabel_.setBounds(exportRow.removeFromLeft(48).reduced(2)); + exportSpeedModeBox_.setBounds(exportRow.removeFromLeft(104).reduced(2)); + projectProfileLabel_.setBounds(exportRow.removeFromLeft(56).reduced(2)); + projectProfileBox_.setBounds(exportRow.removeFromLeft(150).reduced(2)); + exportBitrateLabel_.setBounds(exportRow.removeFromLeft(72).reduced(2)); + exportBitrateSlider_.setBounds(exportRow.removeFromLeft(118).reduced(2)); + mp3ModeLabel_.setBounds(exportRow.removeFromLeft(64).reduced(2)); + mp3ModeBox_.setBounds(exportRow.removeFromLeft(72).reduced(2)); + mp3VbrLabel_.setBounds(exportRow.removeFromLeft(46).reduced(2)); + mp3VbrSlider_.setBounds(exportRow.removeFromLeft(98).reduced(2)); + gpuProviderLabel_.setBounds(exportRow.removeFromLeft(76).reduced(2)); + gpuProviderBox_.setBounds(exportRow.removeFromLeft(116).reduced(2)); + + masterPresetLabel_.setBounds(presetRow.removeFromLeft(96).reduced(2)); + masterPresetBox_.setBounds(presetRow.removeFromLeft(200).reduced(2)); + platformPresetLabel_.setBounds(presetRow.removeFromLeft(70).reduced(2)); + platformPresetBox_.setBounds(presetRow.removeFromLeft(190).reduced(2)); + + soloStemLabel_.setBounds(stemRow.removeFromLeft(40).reduced(2)); + soloStemBox_.setBounds(stemRow.removeFromLeft(220).reduced(2)); + muteStemLabel_.setBounds(stemRow.removeFromLeft(44).reduced(2)); + muteStemBox_.setBounds(stemRow.removeFromLeft(220).reduced(2)); + + aiModelsLabel_.setBounds(modelRow.removeFromLeft(64).reduced(2)); roleModelBox_.setBounds(modelRow.removeFromLeft(250).reduced(2)); mixModelBox_.setBounds(modelRow.removeFromLeft(250).reduced(2)); masterModelBox_.setBounds(modelRow.removeFromLeft(250).reduced(2)); - statusLabel_.setBounds(area.removeFromTop(24)); - analysisTable_.setBounds(area.removeFromTop(200)); + waveformPreview_.setBounds(waveformRow.reduced(2)); + zoomLabel_.setBounds(transportControlRow.removeFromLeft(46).reduced(2)); + zoomSlider_.setBounds(transportControlRow.removeFromLeft(220).reduced(2)); + fineScrubToggle_.setBounds(transportControlRow.removeFromLeft(100).reduced(2)); + transportSlider_.setBounds(transportRow.reduced(2)); + + statusLabel_.setBounds(area.removeFromTop(24).reduced(2)); + taskCenterLabel_.setBounds(area.removeFromTop(22).reduced(2)); + taskCenterEditor_.setBounds(area.removeFromTop(98).reduced(2)); + analysisTable_.setBounds(area.removeFromTop(150)); reportEditor_.setBounds(area.reduced(0, 6)); } @@ -203,6 +815,31 @@ void MainComponent::buttonClicked(juce::Button* button) { return; } + if (button == &clearOriginalMixButton_) { + onClearOriginalMix(); + return; + } + + if (button == ®enerateCacheButton_) { + onRegenerateCachedRenders(); + return; + } + + if (button == &saveSessionButton_) { + onSaveSession(); + return; + } + + if (button == &loadSessionButton_) { + onLoadSession(); + return; + } + + if (button == &modelsMenuButton_) { + onModelsMenu(); + return; + } + if (button == &autoMixButton_) { onAutoMix(); return; @@ -213,31 +850,336 @@ void MainComponent::buttonClicked(juce::Button* button) { return; } + if (button == &batchImportButton_) { + onBatchImport(); + return; + } + + if (button == &previewOriginalButton_) { + onPreviewOriginal(); + return; + } + + if (button == &previewRenderedButton_) { + onPreviewRendered(); + return; + } + + if (button == &playPauseButton_) { + if (transportController_.isPlaying()) { + transportController_.pause(); + previewEngine_.stop(); + } else { + playbackCursorSamples_.store(transportController_.positionSamples()); + transportController_.play(); + previewEngine_.play(); + } + updateTransportDisplay(); + return; + } + + if (button == &stopButton_) { + transportController_.stop(); + previewEngine_.stop(); + playbackCursorSamples_.store(0); + updateTransportDisplay(); + return; + } + + if (button == &loopInButton_) { + session_.timeline.loopInSeconds = transportController_.positionSeconds(); + if (session_.timeline.loopOutSeconds <= session_.timeline.loopInSeconds) { + session_.timeline.loopOutSeconds = std::max(session_.timeline.loopInSeconds + 0.5, transportController_.totalSeconds()); + } + session_.timeline.loopEnabled = session_.timeline.loopOutSeconds > session_.timeline.loopInSeconds; + transportController_.setLoopRangeSeconds(session_.timeline.loopInSeconds, + session_.timeline.loopOutSeconds, + session_.timeline.loopEnabled); + appendTaskHistory("Loop In set at " + juce::String(session_.timeline.loopInSeconds, 2) + "s"); + return; + } + + if (button == &loopOutButton_) { + session_.timeline.loopOutSeconds = std::max(session_.timeline.loopInSeconds + 0.5, transportController_.positionSeconds()); + session_.timeline.loopEnabled = session_.timeline.loopOutSeconds > session_.timeline.loopInSeconds; + transportController_.setLoopRangeSeconds(session_.timeline.loopInSeconds, + session_.timeline.loopOutSeconds, + session_.timeline.loopEnabled); + appendTaskHistory("Loop Out set at " + juce::String(session_.timeline.loopOutSeconds, 2) + "s"); + return; + } + + if (button == &clearLoopButton_) { + session_.timeline.loopEnabled = false; + transportController_.clearLoopRange(); + appendTaskHistory("Loop cleared"); + return; + } + + if (button == &fineScrubToggle_) { + session_.timeline.fineScrub = fineScrubToggle_.getToggleState(); + appendTaskHistory(juce::String("Fine Scrub ") + (session_.timeline.fineScrub ? "enabled" : "disabled")); + return; + } + + if (button == &addExternalRendererButton_) { + onAddExternalRenderer(); + return; + } + + if (button == &prefetchLameButton_) { + onPrefetchLame(); + return; + } + if (button == &exportButton_) { onExport(); + return; + } + + if (button == &cancelButton_) { + onCancel(); } } void MainComponent::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) { if (comboBoxThatHasChanged == &roleModelBox_) { - modelManager_.setActivePackId("role", roleModelBox_.getText().toStdString()); + const auto it = roleModelIdByComboId_.find(roleModelBox_.getSelectedId()); + modelManager_.setActivePackId("role", it != roleModelIdByComboId_.end() ? it->second : "none"); return; } + if (comboBoxThatHasChanged == &mixModelBox_) { - modelManager_.setActivePackId("mix", mixModelBox_.getText().toStdString()); + const auto it = mixModelIdByComboId_.find(mixModelBox_.getSelectedId()); + modelManager_.setActivePackId("mix", it != mixModelIdByComboId_.end() ? it->second : "none"); return; } + if (comboBoxThatHasChanged == &masterModelBox_) { - modelManager_.setActivePackId("master", masterModelBox_.getText().toStdString()); + const auto it = masterModelIdByComboId_.find(masterModelBox_.getSelectedId()); + modelManager_.setActivePackId("master", it != masterModelIdByComboId_.end() ? it->second : "none"); + return; + } + + if (comboBoxThatHasChanged == &projectProfileBox_) { + const auto it = projectProfileIdByComboId_.find(projectProfileBox_.getSelectedId()); + if (it != projectProfileIdByComboId_.end()) { + if (const auto profile = domain::findProjectProfile(projectProfiles_, it->second); profile.has_value()) { + applyProjectProfile(profile.value()); + } + } + return; + } + + if (comboBoxThatHasChanged == &exportSpeedModeBox_) { + session_.renderSettings.exportSpeedMode = selectedExportSpeedMode(); + if (isQuickExportModeSelected()) { + applyQuickExportDefaults(); + appendTaskHistory("Export mode set to Quick (MP3 VBR)"); + } else if (session_.renderSettings.exportSpeedMode == kExportSpeedModeBalanced) { + appendTaskHistory("Export mode set to Balanced"); + } else { + appendTaskHistory("Export mode set to Final"); + } + updateExportCodecControls(); + return; + } + + if (comboBoxThatHasChanged == &gpuProviderBox_) { + switch (gpuProviderBox_.getSelectedId()) { + case 2: + session_.renderSettings.gpuExecutionProvider = "cpu"; + break; + case 3: + session_.renderSettings.gpuExecutionProvider = "directml"; + break; + case 4: + session_.renderSettings.gpuExecutionProvider = "coreml"; + break; + case 5: + session_.renderSettings.gpuExecutionProvider = "cuda"; + break; + default: + session_.renderSettings.gpuExecutionProvider = "auto"; + break; + } + return; + } + + if (comboBoxThatHasChanged == &rendererBox_) { + const auto it = rendererIdByComboId_.find(rendererBox_.getSelectedId()); + if (it != rendererIdByComboId_.end()) { + session_.renderSettings.rendererName = it->second; + } + return; + } + + if (comboBoxThatHasChanged == &exportFormatBox_) { + const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); + const std::string format = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; + const auto availability = util::WavWriter::getAvailableFormats(); + const auto isAvailable = [&availability](const std::string& candidateFormat) { + const auto it = std::find_if(availability.begin(), availability.end(), [&](const auto& entry) { + return toLower(entry.format) == toLower(candidateFormat); + }); + return it != availability.end() && it->available; + }; + + updateExportCodecControls(); + + if (!isAvailable(format)) { + for (const auto& [comboId, formatName] : codecFormatByComboId_) { + if (isAvailable(formatName)) { + exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + + juce::String message = "Selected export format is unavailable"; + const auto detailIt = std::find_if(availability.begin(), availability.end(), [&](const auto& entry) { + return toLower(entry.format) == toLower(format); + }); + if (detailIt != availability.end() && !detailIt->detail.empty()) { + message += ": " + juce::String(detailIt->detail); + } + statusLabel_.setText(message, juce::dontSendNotification); + } + return; + } + + if (comboBoxThatHasChanged == &mp3ModeBox_) { + session_.renderSettings.mp3UseVbr = mp3ModeBox_.getSelectedId() == 2; + updateExportCodecControls(); + return; + } + + if (comboBoxThatHasChanged == &soloStemBox_ || comboBoxThatHasChanged == &muteStemBox_) { + rebuildPreviewBuffers(); + return; } } void MainComponent::sliderValueChanged(juce::Slider* slider) { if (slider == &residualBlendSlider_) { session_.residualBlend = residualBlendSlider_.getValue(); + return; + } + + if (slider == &zoomSlider_) { + session_.timeline.zoom = zoomSlider_.getValue(); + updateTransportLoopAndZoomUI(); + return; + } + + if (slider == &mp3VbrSlider_) { + session_.renderSettings.mp3VbrQuality = + std::clamp(static_cast(std::lround(mp3VbrSlider_.getValue())), 0, 9); + return; + } + + if (slider == &transportSlider_ && !ignoreTransportSliderChange_) { + const double target = transportSlider_.getValue(); + if (fineScrubToggle_.getToggleState()) { + const double current = transportController_.progress(); + const double blended = current + (target - current) * 0.18; + lastFineScrubProgress_ = blended; + transportController_.seekToFraction(blended); + } else { + lastFineScrubProgress_ = target; + transportController_.seekToFraction(target); + } + playbackCursorSamples_.store(transportController_.positionSamples()); + return; + } +} + +void MainComponent::timerCallback() { + if (transportController_.isPlaying()) { + const auto currentCursor = playbackCursorSamples_.load(); + const auto totalSamples = transportController_.totalSamples(); + if (totalSamples > 0) { + transportController_.seekToSample(std::clamp(currentCursor, 0, totalSamples)); + } + } + + updateTransportDisplay(); +} + +void MainComponent::changeListenerCallback(juce::ChangeBroadcaster* source) { + if (source == &transportController_) { + updateTransportDisplay(); + } +} + +void MainComponent::audioDeviceIOCallbackWithContext(const float* const* inputChannelData, + const int numInputChannels, + float* const* outputChannelData, + const int numOutputChannels, + const int numSamples, + const juce::AudioIODeviceCallbackContext& context) { + juce::ignoreUnused(inputChannelData, numInputChannels, context); + + for (int ch = 0; ch < numOutputChannels; ++ch) { + if (outputChannelData[ch] != nullptr) { + juce::FloatVectorOperations::clear(outputChannelData[ch], numSamples); + } + } + + if (!previewEngine_.isPlaying() || !transportController_.isPlaying() || numOutputChannels <= 0 || numSamples <= 0) { + return; + } + + int64_t cursor = playbackCursorSamples_.load(); + bool reachedEnd = false; + + { + std::scoped_lock lock(playbackBufferMutex_); + if (playbackBuffer_.getNumSamples() == 0 || playbackBuffer_.getNumChannels() == 0) { + return; + } + + const int sourceChannels = playbackBuffer_.getNumChannels(); + const int totalSamples = playbackBuffer_.getNumSamples(); + const int64_t clampedStart = std::clamp(cursor, 0, totalSamples); + + for (int sample = 0; sample < numSamples; ++sample) { + const int64_t sourceIndex = clampedStart + sample; + if (sourceIndex >= totalSamples) { + reachedEnd = true; + break; + } + for (int ch = 0; ch < numOutputChannels; ++ch) { + const int sourceChannel = std::min(ch, sourceChannels - 1); + const float value = playbackBuffer_.getSample(sourceChannel, static_cast(sourceIndex)); + if (outputChannelData[ch] != nullptr) { + outputChannelData[ch][sample] = value; + } + } + cursor = sourceIndex + 1; + } + } + + playbackCursorSamples_.store(cursor); + + if (reachedEnd) { + previewEngine_.stop(); + juce::MessageManager::callAsync([safeThis = juce::Component::SafePointer(this)]() { + if (safeThis == nullptr) { + return; + } + safeThis->transportController_.pause(); + safeThis->statusLabel_.setText("Preview reached end", juce::dontSendNotification); + }); } } +void MainComponent::audioDeviceAboutToStart(juce::AudioIODevice* device) { + juce::ignoreUnused(device); + playbackCursorSamples_.store(0); +} + +void MainComponent::audioDeviceStopped() {} + void MainComponent::refreshModelPacks() { modelManager_.setRootPath("ModelPacks"); const auto packs = modelManager_.scan(); @@ -245,159 +1187,1268 @@ void MainComponent::refreshModelPacks() { roleModelBox_.clear(juce::dontSendNotification); mixModelBox_.clear(juce::dontSendNotification); masterModelBox_.clear(juce::dontSendNotification); + roleModelIdByComboId_.clear(); + mixModelIdByComboId_.clear(); + masterModelIdByComboId_.clear(); roleModelBox_.addItem("none", 1); mixModelBox_.addItem("none", 1); masterModelBox_.addItem("none", 1); + roleModelIdByComboId_[1] = "none"; + mixModelIdByComboId_[1] = "none"; + masterModelIdByComboId_[1] = "none"; int itemId = 2; for (const auto& pack : packs) { - const juce::String label = pack.id + " (" + pack.type + ")"; + const juce::String label = pack.id + " [" + pack.engine + "]"; if (pack.type == "role_classifier") { - roleModelBox_.addItem(label, itemId++); + roleModelBox_.addItem(label, itemId); + roleModelIdByComboId_[itemId] = pack.id; + ++itemId; } else if (pack.type == "mix_parameters") { - mixModelBox_.addItem(label, itemId++); + mixModelBox_.addItem(label, itemId); + mixModelIdByComboId_[itemId] = pack.id; + ++itemId; } else if (pack.type == "master_parameters") { - masterModelBox_.addItem(label, itemId++); + masterModelBox_.addItem(label, itemId); + masterModelIdByComboId_[itemId] = pack.id; + ++itemId; } else { - roleModelBox_.addItem(label, itemId++); - mixModelBox_.addItem(label, itemId++); - masterModelBox_.addItem(label, itemId++); + roleModelBox_.addItem(label, itemId); + roleModelIdByComboId_[itemId] = pack.id; + ++itemId; + mixModelBox_.addItem(label, itemId); + mixModelIdByComboId_[itemId] = pack.id; + ++itemId; + masterModelBox_.addItem(label, itemId); + masterModelIdByComboId_[itemId] = pack.id; + ++itemId; } } - roleModelBox_.setSelectedId(1); - mixModelBox_.setSelectedId(1); - masterModelBox_.setSelectedId(1); + roleModelBox_.setSelectedId(1, juce::dontSendNotification); + mixModelBox_.setSelectedId(1, juce::dontSendNotification); + masterModelBox_.setSelectedId(1, juce::dontSendNotification); + modelManager_.setActivePackId("role", "none"); + modelManager_.setActivePackId("mix", "none"); + modelManager_.setActivePackId("master", "none"); } -void MainComponent::onImport() { - importChooser_ = std::make_unique("Select stem files", juce::File(), "*.wav;*.aiff;*.aif;*.flac"); - constexpr int flags = juce::FileBrowserComponent::openMode | - juce::FileBrowserComponent::canSelectFiles | - juce::FileBrowserComponent::canSelectMultipleItems; - - importChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { - session_.stems.clear(); - const auto files = chooser.getResults(); - - for (int i = 0; i < files.size(); ++i) { - const auto& file = files.getReference(i); - - domain::Stem stem; - stem.id = "stem_" + std::to_string(i + 1); - stem.name = file.getFileNameWithoutExtension().toStdString(); - stem.filePath = file.getFullPathName().toStdString(); - stem.origin = separatedStemsToggle_.getToggleState() ? domain::StemOrigin::Separated : domain::StemOrigin::Recorded; - session_.stems.push_back(stem); - } - - statusLabel_.setText("Imported " + juce::String(static_cast(session_.stems.size())) + " stems", - juce::dontSendNotification); - analysisEntries_.clear(); - analysisTableModel_.setEntries(&analysisEntries_); - analysisTable_.updateContent(); - reportEditor_.setText(juce::String("Imported files:\n") + - toJuceText([&]() { - std::vector lines; - lines.reserve(session_.stems.size()); - for (const auto& stem : session_.stems) { - lines.push_back(stem.name + " -> " + stem.filePath); - } - return lines; - }())); +std::vector MainComponent::loadConfiguredExternalRenderers() const { + std::vector configs; +#ifdef ENABLE_EXTERNAL_TOOL_SUPPORT + const char* rawValue = std::getenv("AUTOMIX_EXTERNAL_RENDERERS"); + if (rawValue != nullptr && *rawValue != '\0') { + int index = 1; + for (const auto& item : splitDelimited(rawValue, ';')) { + const auto pieces = splitDelimited(item, '|'); + if (pieces.size() < 2) { + continue; + } - importChooser_.reset(); - }); + renderers::ExternalRendererConfig config; + config.id = "ExternalUser" + std::to_string(index++); + config.name = pieces[0]; + config.binaryPath = pieces[1]; + if (pieces.size() >= 3) { + config.licenseId = pieces[2]; + } + configs.push_back(config); + } + } +#endif + configs.insert(configs.end(), userExternalRendererConfigs_.begin(), userExternalRendererConfigs_.end()); + return configs; } -void MainComponent::onImportOriginalMix() { - originalMixChooser_ = - std::make_unique("Select original stereo mix", juce::File(), "*.wav;*.aiff;*.aif;*.flac"); - constexpr int flags = juce::FileBrowserComponent::openMode | - juce::FileBrowserComponent::canSelectFiles; +void MainComponent::refreshRenderers() { + rendererBox_.clear(juce::dontSendNotification); + rendererIdByComboId_.clear(); - originalMixChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { - const auto selected = chooser.getResult(); - if (selected == juce::File()) { - originalMixChooser_.reset(); - return; + renderers::RendererRegistry registry; + rendererInfos_ = registry.list(loadConfiguredExternalRenderers()); + + int comboId = 1; + int preferredId = 0; + for (const auto& info : rendererInfos_) { + juce::String label = info.name; + if (info.linkMode == renderers::RendererLinkMode::External) { + label += " [external]"; } + if (!info.available) { + label += " (unavailable)"; + } + rendererBox_.addItem(label, comboId); + rendererIdByComboId_[comboId] = info.id; - session_.originalMixPath = selected.getFullPathName().toStdString(); + if (preferredId == 0 && info.available) { + preferredId = comboId; + } + if (info.id == session_.renderSettings.rendererName) { + preferredId = comboId; + } + ++comboId; + } + + if (preferredId == 0 && !rendererIdByComboId_.empty()) { + preferredId = rendererIdByComboId_.begin()->first; + } + if (preferredId != 0) { + rendererBox_.setSelectedId(preferredId, juce::dontSendNotification); + const auto selected = rendererIdByComboId_.find(preferredId); + if (selected != rendererIdByComboId_.end()) { + session_.renderSettings.rendererName = selected->second; + } + } +} + +void MainComponent::refreshCodecAvailability() { + exportFormatBox_.clear(juce::dontSendNotification); + codecFormatByComboId_.clear(); + + const auto availability = util::WavWriter::getAvailableFormats(); + std::vector tooltipLines; + + int selectedId = 0; + int firstAvailableId = 0; + int comboId = 1; + for (const auto& entry : availability) { + juce::String label = juce::String(entry.format).toUpperCase(); + if (!entry.available) { + label += " (unavailable)"; + } + + exportFormatBox_.addItem(label, comboId); + codecFormatByComboId_[comboId] = entry.format; + tooltipLines.push_back(entry.format + ": " + entry.detail); + + if (entry.available && firstAvailableId == 0) { + firstAvailableId = comboId; + } + if (toLower(session_.renderSettings.outputFormat) == toLower(entry.format) && entry.available) { + selectedId = comboId; + } + ++comboId; + } + + if (selectedId == 0) { + selectedId = firstAvailableId > 0 ? firstAvailableId : 1; + } + exportFormatBox_.setSelectedId(selectedId, juce::dontSendNotification); + mp3ModeBox_.setSelectedId(session_.renderSettings.mp3UseVbr ? 2 : 1, juce::dontSendNotification); + mp3VbrSlider_.setValue(session_.renderSettings.mp3VbrQuality, juce::dontSendNotification); + + int exportModeSelectedId = 1; + for (const auto& [comboId, mode] : exportSpeedModeByComboId_) { + if (mode == session_.renderSettings.exportSpeedMode) { + exportModeSelectedId = comboId; + break; + } + } + exportSpeedModeBox_.setSelectedId(exportModeSelectedId, juce::dontSendNotification); + + exportFormatBox_.setTooltip(toJuceText(tooltipLines)); + updateExportCodecControls(); +} + +std::string MainComponent::selectedExportSpeedMode() const { + const auto it = exportSpeedModeByComboId_.find(exportSpeedModeBox_.getSelectedId()); + if (it == exportSpeedModeByComboId_.end()) { + return kExportSpeedModeFinal; + } + return it->second; +} + +bool MainComponent::isQuickExportModeSelected() const { + return selectedExportSpeedMode() == kExportSpeedModeQuick; +} + +void MainComponent::applyQuickExportDefaults() { + const auto availability = util::WavWriter::getAvailableFormats(); + const auto isAvailable = [&availability](const std::string& formatName) { + const auto entry = std::find_if(availability.begin(), availability.end(), [&](const auto& candidate) { + return toLower(candidate.format) == toLower(formatName); + }); + return entry != availability.end() && entry->available; + }; + + int mp3ComboId = 0; + for (const auto& [comboId, format] : codecFormatByComboId_) { + if (toLower(format) == "mp3") { + mp3ComboId = comboId; + break; + } + } + + if (mp3ComboId != 0 && isAvailable("mp3")) { + exportFormatBox_.setSelectedId(mp3ComboId, juce::dontSendNotification); + session_.renderSettings.outputFormat = "mp3"; + } else { + for (const auto& [comboId, formatName] : codecFormatByComboId_) { + if (isAvailable(formatName)) { + exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); + session_.renderSettings.outputFormat = formatName; + break; + } + } + statusLabel_.setText("Quick mode: MP3 unavailable, using fallback codec", juce::dontSendNotification); + appendTaskHistory("Quick mode fallback codec selected (MP3 unavailable)"); + } + + exportBitrateSlider_.setValue(320.0, juce::dontSendNotification); + mp3ModeBox_.setSelectedId(2, juce::dontSendNotification); + mp3VbrSlider_.setValue(0.0, juce::dontSendNotification); + session_.renderSettings.lossyBitrateKbps = 320; + session_.renderSettings.lossyQuality = 8; + session_.renderSettings.mp3UseVbr = true; + session_.renderSettings.mp3VbrQuality = 0; +} + +void MainComponent::updateExportCodecControls() { + const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); + const std::string selectedFormat = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; + const bool lossy = util::WavWriter::isLossyFormat(selectedFormat); + const bool mp3 = toLower(selectedFormat) == "mp3"; + const bool vbr = mp3 && mp3ModeBox_.getSelectedId() == 2; + const bool quickMode = isQuickExportModeSelected(); + + exportFormatBox_.setEnabled(!quickMode); + exportFormatLabel_.setEnabled(!quickMode); + exportBitrateSlider_.setEnabled(!quickMode && lossy && !vbr); + exportBitrateLabel_.setEnabled(!quickMode && lossy && !vbr); + mp3ModeBox_.setEnabled(!quickMode && mp3); + mp3ModeLabel_.setEnabled(!quickMode && mp3); + mp3VbrSlider_.setEnabled(!quickMode && vbr); + mp3VbrLabel_.setEnabled(!quickMode && vbr); +} + +void MainComponent::refreshProjectProfiles() { + projectProfiles_ = domain::loadProjectProfiles(std::filesystem::current_path()); + projectProfileBox_.clear(juce::dontSendNotification); + projectProfileIdByComboId_.clear(); + + int selectedId = 0; + int comboId = 1; + for (const auto& profile : projectProfiles_) { + projectProfileBox_.addItem(profile.name + " [" + profile.id + "]", comboId); + projectProfileIdByComboId_[comboId] = profile.id; + if (profile.id == session_.projectProfileId) { + selectedId = comboId; + } + ++comboId; + } + + if (selectedId == 0 && !projectProfileIdByComboId_.empty()) { + selectedId = projectProfileIdByComboId_.begin()->first; + } + + if (selectedId > 0) { + projectProfileBox_.setSelectedId(selectedId, juce::dontSendNotification); + const auto it = projectProfileIdByComboId_.find(selectedId); + if (it != projectProfileIdByComboId_.end()) { + if (const auto profile = domain::findProjectProfile(projectProfiles_, it->second); profile.has_value()) { + applyProjectProfile(profile.value()); + } + } + } +} + +void MainComponent::applyProjectProfile(const domain::ProjectProfile& profile) { + session_.projectProfileId = profile.id; + session_.safetyPolicyId = profile.safetyPolicyId; + session_.preferredStemCount = profile.preferredStemCount; + + if (profile.gpuProvider == "cpu") { + gpuProviderBox_.setSelectedId(2, juce::dontSendNotification); + } else if (profile.gpuProvider == "directml") { + gpuProviderBox_.setSelectedId(3, juce::dontSendNotification); + } else if (profile.gpuProvider == "coreml") { + gpuProviderBox_.setSelectedId(4, juce::dontSendNotification); + } else if (profile.gpuProvider == "cuda") { + gpuProviderBox_.setSelectedId(5, juce::dontSendNotification); + } else { + gpuProviderBox_.setSelectedId(1, juce::dontSendNotification); + } + session_.renderSettings.gpuExecutionProvider = profile.gpuProvider; + + for (const auto& [comboId, format] : codecFormatByComboId_) { + if (toLower(format) == toLower(profile.outputFormat)) { + exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + exportBitrateSlider_.setValue(profile.lossyBitrateKbps, juce::dontSendNotification); + mp3ModeBox_.setSelectedId(profile.mp3UseVbr ? 2 : 1, juce::dontSendNotification); + mp3VbrSlider_.setValue(profile.mp3VbrQuality, juce::dontSendNotification); + session_.renderSettings.outputFormat = profile.outputFormat; + session_.renderSettings.lossyBitrateKbps = profile.lossyBitrateKbps; + session_.renderSettings.mp3UseVbr = profile.mp3UseVbr; + session_.renderSettings.mp3VbrQuality = profile.mp3VbrQuality; + session_.renderSettings.metadataPolicy = profile.metadataPolicy; + session_.renderSettings.metadataTemplate = profile.metadataTemplate; + session_.renderSettings.exportSpeedMode = selectedExportSpeedMode(); + if (isQuickExportModeSelected()) { + applyQuickExportDefaults(); + } + updateExportCodecControls(); + + for (const auto& [comboId, rendererId] : rendererIdByComboId_) { + if (rendererId == profile.rendererName) { + rendererBox_.setSelectedId(comboId, juce::dontSendNotification); + session_.renderSettings.rendererName = rendererId; + break; + } + } + + const auto selectModelComboById = [](juce::ComboBox& combo, + const std::map& idsByCombo, + const std::string& modelId) { + for (const auto& [comboId, id] : idsByCombo) { + if (id == modelId) { + combo.setSelectedId(comboId, juce::dontSendNotification); + return; + } + } + combo.setSelectedId(1, juce::dontSendNotification); + }; + + selectModelComboById(roleModelBox_, roleModelIdByComboId_, profile.roleModelPackId); + selectModelComboById(mixModelBox_, mixModelIdByComboId_, profile.mixModelPackId); + selectModelComboById(masterModelBox_, masterModelIdByComboId_, profile.masterModelPackId); + + modelManager_.setActivePackId("role", profile.roleModelPackId); + modelManager_.setActivePackId("mix", profile.mixModelPackId); + modelManager_.setActivePackId("master", profile.masterModelPackId); + + const auto normalizedPlatform = toLower(profile.platformPreset); + for (const auto& [comboId, preset] : platformPresetByComboId_) { + if (toLower(domain::toString(preset)) == normalizedPlatform) { + platformPresetBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + + appendTaskHistory("Applied profile " + profile.name + " (safety=" + profile.safetyPolicyId + + ", stems=" + std::to_string(profile.preferredStemCount) + ")"); +} + +void MainComponent::refreshStemRoutingSelectors() { + const auto previousSolo = stemIdBySoloComboId_.count(soloStemBox_.getSelectedId()) > 0 + ? stemIdBySoloComboId_[soloStemBox_.getSelectedId()] + : std::string(); + const auto previousMute = stemIdByMuteComboId_.count(muteStemBox_.getSelectedId()) > 0 + ? stemIdByMuteComboId_[muteStemBox_.getSelectedId()] + : std::string(); + + soloStemBox_.clear(juce::dontSendNotification); + muteStemBox_.clear(juce::dontSendNotification); + stemIdBySoloComboId_.clear(); + stemIdByMuteComboId_.clear(); + + soloStemBox_.addItem("None", 1); + muteStemBox_.addItem("None", 1); + stemIdBySoloComboId_[1] = ""; + stemIdByMuteComboId_[1] = ""; + + int comboId = 2; + int nextSolo = 1; + int nextMute = 1; + for (const auto& stem : session_.stems) { + const auto label = juce::String(stem.name + " [" + stem.id + "]"); + soloStemBox_.addItem(label, comboId); + muteStemBox_.addItem(label, comboId); + stemIdBySoloComboId_[comboId] = stem.id; + stemIdByMuteComboId_[comboId] = stem.id; + + if (stem.id == previousSolo) { + nextSolo = comboId; + } + if (stem.id == previousMute) { + nextMute = comboId; + } + + ++comboId; + } + + soloStemBox_.setSelectedId(nextSolo, juce::dontSendNotification); + muteStemBox_.setSelectedId(nextMute, juce::dontSendNotification); +} + +void MainComponent::rebuildPreviewBuffers() { + rebuildPreviewBuffersAsync(); +} + +void MainComponent::applyLoadedSession(domain::Session loadedSession, const juce::String& sourcePath) { + session_ = std::move(loadedSession); + if (session_.renderSettings.exportSpeedMode != kExportSpeedModeFinal && + session_.renderSettings.exportSpeedMode != kExportSpeedModeBalanced && + session_.renderSettings.exportSpeedMode != kExportSpeedModeQuick) { + session_.renderSettings.exportSpeedMode = kExportSpeedModeFinal; + } + + residualBlendSlider_.setValue(session_.residualBlend, juce::dontSendNotification); + clearOriginalMixButton_.setEnabled(session_.originalMixPath.has_value() && !session_.originalMixPath->empty()); + exportBitrateSlider_.setValue(session_.renderSettings.lossyBitrateKbps, juce::dontSendNotification); + mp3ModeBox_.setSelectedId(session_.renderSettings.mp3UseVbr ? 2 : 1, juce::dontSendNotification); + mp3VbrSlider_.setValue(session_.renderSettings.mp3VbrQuality, juce::dontSendNotification); + + int exportModeSelectedId = 1; + for (const auto& [comboId, mode] : exportSpeedModeByComboId_) { + if (mode == session_.renderSettings.exportSpeedMode) { + exportModeSelectedId = comboId; + break; + } + } + exportSpeedModeBox_.setSelectedId(exportModeSelectedId, juce::dontSendNotification); + + zoomSlider_.setValue(session_.timeline.zoom, juce::dontSendNotification); + fineScrubToggle_.setToggleState(session_.timeline.fineScrub, juce::dontSendNotification); + + if (session_.renderSettings.gpuExecutionProvider == "cpu") { + gpuProviderBox_.setSelectedId(2, juce::dontSendNotification); + } else if (session_.renderSettings.gpuExecutionProvider == "directml") { + gpuProviderBox_.setSelectedId(3, juce::dontSendNotification); + } else if (session_.renderSettings.gpuExecutionProvider == "coreml") { + gpuProviderBox_.setSelectedId(4, juce::dontSendNotification); + } else if (session_.renderSettings.gpuExecutionProvider == "cuda") { + gpuProviderBox_.setSelectedId(5, juce::dontSendNotification); + } else { + gpuProviderBox_.setSelectedId(1, juce::dontSendNotification); + } + + if (rendererIdByComboId_.empty()) { + refreshRenderers(); + } + if (codecFormatByComboId_.empty()) { + refreshCodecAvailability(); + } + if (roleModelIdByComboId_.empty() || mixModelIdByComboId_.empty() || masterModelIdByComboId_.empty()) { + refreshModelPacks(); + } + refreshStemRoutingSelectors(); + + for (const auto& [comboId, rendererId] : rendererIdByComboId_) { + if (rendererId == session_.renderSettings.rendererName) { + rendererBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + + for (const auto& [comboId, format] : codecFormatByComboId_) { + if (toLower(format) == toLower(session_.renderSettings.outputFormat)) { + exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + for (const auto& [comboId, profileId] : projectProfileIdByComboId_) { + if (profileId == session_.projectProfileId) { + projectProfileBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + updateExportCodecControls(); + + analysisEntries_.clear(); + analysisTableModel_.setEntries(&analysisEntries_); + analysisTable_.updateContent(); + transportController_.setLoopRangeSeconds(session_.timeline.loopInSeconds, + session_.timeline.loopOutSeconds, + session_.timeline.loopEnabled); + updateTransportLoopAndZoomUI(); + + statusLabel_.setText("Session loaded", juce::dontSendNotification); + reportEditor_.setText("Loaded session: " + sourcePath); + appendTaskHistory("Session loaded: " + sourcePath); + rebuildPreviewBuffersAsync(); +} + +void MainComponent::rebuildPreviewBuffersAsync() { + if (session_.stems.empty()) { + ++previewBuildGeneration_; + waveformPreview_.setBuffer(engine::AudioBuffer{}); + transportController_.setTimeline(0, 44100.0); + return; + } + + const auto generation = ++previewBuildGeneration_; + auto previewSession = session_; + const auto soloIt = stemIdBySoloComboId_.find(soloStemBox_.getSelectedId()); + const auto muteIt = stemIdByMuteComboId_.find(muteStemBox_.getSelectedId()); + const auto soloStemId = soloIt != stemIdBySoloComboId_.end() ? soloIt->second : std::string(); + const auto muteStemId = muteIt != stemIdByMuteComboId_.end() ? muteIt->second : std::string(); + const auto previousProgress = transportController_.progress(); + + juce::Component::SafePointer safeThis(this); + backgroundPool_.addJob(new BackgroundJob([safeThis, + generation, + previewSession = std::move(previewSession), + soloStemId, + muteStemId, + previousProgress]() mutable { + try { + if (!soloStemId.empty()) { + for (auto& stem : previewSession.stems) { + stem.enabled = stem.id == soloStemId; + } + } + if (!muteStemId.empty()) { + for (auto& stem : previewSession.stems) { + if (stem.id == muteStemId) { + stem.enabled = false; + } + } + } + + auto settings = previewSession.renderSettings; + settings.outputSampleRate = settings.outputSampleRate > 0 ? settings.outputSampleRate : 44100; + settings.blockSize = settings.blockSize > 0 ? settings.blockSize : 1024; + settings.outputBitDepth = std::clamp(settings.outputBitDepth, 16, 32); + settings.rendererName = "BuiltIn"; + settings.outputPath.clear(); + settings.outputFormat = "wav"; + + engine::OfflineRenderPipeline pipeline; + const auto raw = pipeline.renderRawMix(previewSession, settings, {}, nullptr); + if (raw.cancelled || raw.mixBuffer.getNumSamples() == 0) { + return; + } + + auto mastered = raw.mixBuffer; + if (previewSession.masterPlan.has_value()) { + automaster::HeuristicAutoMasterStrategy strategy; + mastered = strategy.applyPlan(raw.mixBuffer, previewSession.masterPlan.value(), nullptr); + } + + if (safeThis == nullptr || generation != safeThis->previewBuildGeneration_.load()) { + return; + } + + engine::AudioPreviewEngine previewEngine; + previewEngine.setBuffers(raw.mixBuffer, mastered); + const auto preview = previewEngine.buildCrossfadedPreview(1024); + + juce::MessageManager::callAsync( + [safeThis, + generation, + rawMix = raw.mixBuffer, + mastered = std::move(mastered), + preview = std::move(preview), + previousProgress]() mutable { + if (safeThis == nullptr) { + return; + } + if (generation != safeThis->previewBuildGeneration_.load()) { + return; + } + + safeThis->previewEngine_.setBuffers(rawMix, mastered); + safeThis->updateTransportFromBuffer(preview); + safeThis->transportController_.seekToFraction(previousProgress); + safeThis->playbackCursorSamples_.store(safeThis->transportController_.positionSamples()); + }); + } catch (const std::exception& error) { + const auto message = juce::String(error.what()); + juce::MessageManager::callAsync([safeThis, generation, message]() { + if (safeThis == nullptr) { + return; + } + if (generation != safeThis->previewBuildGeneration_.load()) { + return; + } + safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + "\nPreview rebuild skipped: " + message); + }); + } + }), true); +} + +void MainComponent::updateTransportFromBuffer(const engine::AudioBuffer& buffer) { + { + std::scoped_lock lock(playbackBufferMutex_); + playbackBuffer_ = buffer; + } + playbackCursorSamples_.store(0); + waveformPreview_.setBuffer(buffer); + waveformPreview_.setPlayheadProgress(0.0); + transportController_.setTimeline(buffer.getNumSamples(), buffer.getSampleRate()); + transportController_.setLoopRangeSeconds(session_.timeline.loopInSeconds, + session_.timeline.loopOutSeconds, + session_.timeline.loopEnabled); + transportController_.stop(); + updateTransportLoopAndZoomUI(); + + ignoreTransportSliderChange_ = true; + transportSlider_.setValue(0.0, juce::dontSendNotification); + ignoreTransportSliderChange_ = false; +} + +void MainComponent::updateTransportDisplay() { + const auto progress = transportController_.progress(); + + ignoreTransportSliderChange_ = true; + transportSlider_.setValue(progress, juce::dontSendNotification); + ignoreTransportSliderChange_ = false; + + waveformPreview_.setPlayheadProgress(progress); + updateTransportLoopAndZoomUI(); + + if (transportController_.state() == engine::TransportController::State::Playing) { + playPauseButton_.setButtonText("Pause"); + } else { + playPauseButton_.setButtonText("Play"); + } + + const auto positionText = formatDuration(transportController_.positionSeconds()); + const auto totalText = formatDuration(transportController_.totalSeconds()); + juce::String tooltip = positionText + " / " + totalText; + if (transportController_.loopEnabled()) { + tooltip += " [Loop " + juce::String(formatDuration(transportController_.loopInSeconds())) + + " - " + juce::String(formatDuration(transportController_.loopOutSeconds())) + "]"; + } + transportSlider_.setTooltip(tooltip); +} + +void MainComponent::updateTransportLoopAndZoomUI() { + const double zoom = std::clamp(session_.timeline.zoom, 1.0, 32.0); + waveformPreview_.setZoom(zoom, transportController_.progress()); + waveformPreview_.setLoopRange(transportController_.loopEnabled(), + transportController_.loopInProgress(), + transportController_.loopOutProgress()); +} + +void MainComponent::appendTaskHistory(const juce::String& line) { + const auto timestamp = juce::Time::getCurrentTime().toString(true, true); + const auto entry = "[" + timestamp + "] " + line; + taskHistoryLines_.push_back(entry); + constexpr size_t kMaxTaskHistory = 120; + bool trimmed = false; + if (taskHistoryLines_.size() > kMaxTaskHistory) { + taskHistoryLines_.erase(taskHistoryLines_.begin(), + taskHistoryLines_.begin() + static_cast(taskHistoryLines_.size() - kMaxTaskHistory)); + trimmed = true; + } + + const auto currentText = taskCenterEditor_.getText(); + if (!trimmed && currentText.isNotEmpty() && currentText != "Task history will appear here.") { + taskCenterEditor_.moveCaretToEnd(false); + taskCenterEditor_.insertTextAtCaret(entry + "\n"); + return; + } + + juce::String rebuiltText; + for (const auto& item : taskHistoryLines_) { + rebuiltText += item; + rebuiltText += "\n"; + } + taskCenterEditor_.setText(rebuiltText, false); +} + +void MainComponent::populateMasterPresetSelectors() { + masterPresetBox_.clear(juce::dontSendNotification); + platformPresetBox_.clear(juce::dontSendNotification); + masterPresetByComboId_.clear(); + platformPresetByComboId_.clear(); + + int masterId = 1; + auto addMasterPreset = [&](const juce::String& label, const domain::MasterPreset preset) { + masterPresetBox_.addItem(label, masterId); + masterPresetByComboId_[masterId] = preset; + ++masterId; + }; + + addMasterPreset("Default Streaming", domain::MasterPreset::DefaultStreaming); + addMasterPreset("Broadcast", domain::MasterPreset::Broadcast); + addMasterPreset("Udio Optimized", domain::MasterPreset::UdioOptimized); + addMasterPreset("Custom", domain::MasterPreset::Custom); + + int platformId = 1; + auto addPlatformPreset = [&](const juce::String& label, const domain::MasterPreset preset) { + platformPresetBox_.addItem(label, platformId); + platformPresetByComboId_[platformId] = preset; + ++platformId; + }; + + addPlatformPreset("Spotify", domain::MasterPreset::Spotify); + addPlatformPreset("Apple Music", domain::MasterPreset::AppleMusic); + addPlatformPreset("YouTube", domain::MasterPreset::YouTube); + addPlatformPreset("Amazon Music", domain::MasterPreset::AmazonMusic); + addPlatformPreset("Tidal", domain::MasterPreset::Tidal); + addPlatformPreset("Broadcast EBU R128", domain::MasterPreset::BroadcastEbuR128); + + masterPresetBox_.setSelectedId(1, juce::dontSendNotification); + platformPresetBox_.setSelectedId(1, juce::dontSendNotification); +} + +domain::MasterPreset MainComponent::selectedMasterPreset() const { + const auto it = masterPresetByComboId_.find(masterPresetBox_.getSelectedId()); + if (it == masterPresetByComboId_.end()) { + return domain::MasterPreset::DefaultStreaming; + } + return it->second; +} + +domain::MasterPreset MainComponent::selectedPlatformPreset() const { + const auto it = platformPresetByComboId_.find(platformPresetBox_.getSelectedId()); + if (it == platformPresetByComboId_.end()) { + return domain::MasterPreset::DefaultStreaming; + } + return it->second; +} + +domain::RenderSettings MainComponent::buildCurrentRenderSettings(const std::string& outputPath) const { + domain::RenderSettings settings; + settings.exportSpeedMode = selectedExportSpeedMode(); + settings.outputSampleRate = 44100; + settings.blockSize = 1024; + settings.outputBitDepth = 24; + if (settings.exportSpeedMode == kExportSpeedModeBalanced) { + settings.blockSize = 2048; + } else if (settings.exportSpeedMode == kExportSpeedModeQuick) { + settings.blockSize = 4096; + settings.outputBitDepth = 16; + } + settings.processingThreads = 0; + settings.preferHardwareAcceleration = true; + settings.metadataPolicy = session_.renderSettings.metadataPolicy.empty() ? "copy_all" : session_.renderSettings.metadataPolicy; + settings.metadataTemplate = session_.renderSettings.metadataTemplate; + + const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); + settings.outputFormat = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; + + settings.lossyBitrateKbps = std::clamp(static_cast(std::lround(exportBitrateSlider_.getValue())), 64, 320); + settings.lossyQuality = + std::clamp(static_cast(std::lround((static_cast(settings.lossyBitrateKbps) - 64.0) / 25.6)), 0, 10); + settings.mp3UseVbr = toLower(settings.outputFormat) == "mp3" && mp3ModeBox_.getSelectedId() == 2; + settings.mp3VbrQuality = std::clamp(static_cast(std::lround(mp3VbrSlider_.getValue())), 0, 9); + + if (settings.exportSpeedMode == kExportSpeedModeQuick) { + const auto availability = util::WavWriter::getAvailableFormats(); + const auto hasAvailableMp3 = std::find_if(availability.begin(), availability.end(), [](const auto& entry) { + return toLower(entry.format) == "mp3" && entry.available; + }) != availability.end(); + + if (hasAvailableMp3) { + settings.outputFormat = "mp3"; + settings.lossyBitrateKbps = 320; + settings.lossyQuality = 8; + settings.mp3UseVbr = true; + settings.mp3VbrQuality = 0; + } + } + + switch (gpuProviderBox_.getSelectedId()) { + case 2: + settings.gpuExecutionProvider = "cpu"; + break; + case 3: + settings.gpuExecutionProvider = "directml"; + break; + case 4: + settings.gpuExecutionProvider = "coreml"; + break; + case 5: + settings.gpuExecutionProvider = "cuda"; + break; + default: + settings.gpuExecutionProvider = "auto"; + break; + } + + if (!outputPath.empty()) { + std::filesystem::path normalizedPath(outputPath); + const auto requiredExtension = extensionForFormat(settings.outputFormat); + if (toLower(normalizedPath.extension().string()) != requiredExtension) { + normalizedPath.replace_extension(requiredExtension); + } + settings.outputPath = normalizedPath.string(); + } + + settings.rendererName = "BuiltIn"; + const auto rendererIt = rendererIdByComboId_.find(rendererBox_.getSelectedId()); + if (rendererIt != rendererIdByComboId_.end()) { + settings.rendererName = rendererIt->second; + for (const auto& info : rendererInfos_) { + if (info.id == settings.rendererName) { + settings.externalRendererPath = info.binaryPath.string(); + break; + } + } + } + + return settings; +} + +void MainComponent::onCancel() { + cancelRender_.store(true); + statusLabel_.setText("Cancelling...", juce::dontSendNotification); + appendTaskHistory("Cancellation requested"); +} + +void MainComponent::onImport() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + + importChooser_ = + std::make_unique("Select stem files", juce::File(), "*.wav;*.aiff;*.aif;*.flac;*.mp3;*.ogg"); + constexpr int flags = juce::FileBrowserComponent::openMode | + juce::FileBrowserComponent::canSelectFiles | + juce::FileBrowserComponent::canSelectMultipleItems; + + importChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { + const auto files = chooser.getResults(); + if (files.isEmpty()) { + importChooser_.reset(); + return; + } + + std::vector selectedFiles; + selectedFiles.reserve(static_cast(files.size())); + for (int i = 0; i < files.size(); ++i) { + selectedFiles.push_back(files.getReference(i)); + } + + importController_->importFiles(std::move(selectedFiles), + separatedStemsToggle_.getToggleState(), + session_.preferredStemCount); + importChooser_.reset(); + }); +} + +void MainComponent::onImportOriginalMix() { + originalMixChooser_ = + std::make_unique("Select original stereo mix", + juce::File(), + "*.wav;*.aiff;*.aif;*.flac;*.mp3;*.ogg"); + constexpr int flags = juce::FileBrowserComponent::openMode | + juce::FileBrowserComponent::canSelectFiles; + + originalMixChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + originalMixChooser_.reset(); + return; + } + + session_.originalMixPath = selected.getFullPathName().toStdString(); + clearOriginalMixButton_.setEnabled(true); statusLabel_.setText("Original mix loaded", juce::dontSendNotification); reportEditor_.setText(reportEditor_.getText() + "\nOriginal mix: " + selected.getFullPathName()); + appendTaskHistory("Original mix loaded: " + selected.getFileName()); originalMixChooser_.reset(); }); } +void MainComponent::onClearOriginalMix() { + if (!session_.originalMixPath.has_value()) { + statusLabel_.setText("No original mix is configured", juce::dontSendNotification); + clearOriginalMixButton_.setEnabled(false); + return; + } + + session_.originalMixPath.reset(); + clearOriginalMixButton_.setEnabled(false); + statusLabel_.setText("Original mix cleared", juce::dontSendNotification); + reportEditor_.setText(reportEditor_.getText() + "\nOriginal mix cleared"); + appendTaskHistory("Original mix configuration cleared"); +} + +void MainComponent::onRegenerateCachedRenders() { + engine::OfflineRenderPipeline::clearCaches(); + ExportController::clearHealthCache(); + + statusLabel_.setText("Render caches cleared", juce::dontSendNotification); + appendTaskHistory("Render caches cleared; next render will regenerate intermediates"); + + if (!session_.stems.empty()) { + rebuildPreviewBuffersAsync(); + } +} + +void MainComponent::onSaveSession() { + saveSessionChooser_ = std::make_unique( + "Save session", juce::File::getSpecialLocation(juce::File::userDocumentsDirectory), "*.json"); + constexpr int flags = juce::FileBrowserComponent::saveMode | + juce::FileBrowserComponent::canSelectFiles | + juce::FileBrowserComponent::warnAboutOverwriting; + + saveSessionChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + saveSessionChooser_.reset(); + return; + } + + try { + session_.renderSettings = buildCurrentRenderSettings(session_.renderSettings.outputPath); + session_.timeline.zoom = zoomSlider_.getValue(); + session_.timeline.fineScrub = fineScrubToggle_.getToggleState(); + session_.timeline.loopEnabled = transportController_.loopEnabled(); + session_.timeline.loopInSeconds = transportController_.loopInSeconds(); + session_.timeline.loopOutSeconds = transportController_.loopOutSeconds(); + sessionRepository_.save(selected.getFullPathName().toStdString(), session_); + statusLabel_.setText("Session saved", juce::dontSendNotification); + appendTaskHistory("Session saved: " + selected.getFullPathName()); + } catch (const std::exception& error) { + statusLabel_.setText("Save failed", juce::dontSendNotification); + reportEditor_.setText("Session save error:\n" + juce::String(error.what())); + appendTaskHistory("Session save failed"); + } + + saveSessionChooser_.reset(); + }); +} + +void MainComponent::onLoadSession() { + loadSessionChooser_ = std::make_unique("Load session", juce::File(), "*.json"); + constexpr int flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles; + + loadSessionChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + loadSessionChooser_.reset(); + return; + } + + const auto selectedPath = selected.getFullPathName().toStdString(); + statusLabel_.setText("Loading session...", juce::dontSendNotification); + appendTaskHistory("Session load started: " + selected.getFullPathName()); + + juce::Component::SafePointer safeThis(this); + backgroundPool_.addJob(new BackgroundJob([safeThis, selectedPath]() { + engine::SessionRepository repository; + std::optional loadedSession; + juce::String errorMessage; + + try { + loadedSession = repository.load(selectedPath); + } catch (const std::exception& error) { + errorMessage = error.what(); + } catch (...) { + errorMessage = "Unknown session load error"; + } + + juce::MessageManager::callAsync([safeThis, selectedPath, loadedSession = std::move(loadedSession), errorMessage]() mutable { + if (safeThis == nullptr) { + return; + } + + if (loadedSession.has_value()) { + safeThis->applyLoadedSession(std::move(loadedSession.value()), selectedPath); + } else { + safeThis->statusLabel_.setText("Load failed", juce::dontSendNotification); + safeThis->reportEditor_.setText("Session load error:\n" + errorMessage); + safeThis->appendTaskHistory("Session load failed"); + } + }); + }), true); + + loadSessionChooser_.reset(); + }); +} + +void MainComponent::onPreviewOriginal() { + if (transportController_.totalSamples() == 0) { + rebuildPreviewBuffersAsync(); + statusLabel_.setText("Building preview...", juce::dontSendNotification); + return; + } + + const auto progress = transportController_.progress(); + previewEngine_.setSource(engine::PreviewSource::OriginalMix); + const auto preview = previewEngine_.buildCrossfadedPreview(1024); + updateTransportFromBuffer(preview); + transportController_.seekToFraction(progress); + playbackCursorSamples_.store(transportController_.positionSamples()); + transportController_.play(); + previewEngine_.play(); + + statusLabel_.setText("Preview A selected", juce::dontSendNotification); + appendTaskHistory("Preview source switched to Original (A)"); +} + +void MainComponent::onPreviewRendered() { + if (transportController_.totalSamples() == 0) { + rebuildPreviewBuffersAsync(); + statusLabel_.setText("Building preview...", juce::dontSendNotification); + return; + } + + const auto progress = transportController_.progress(); + previewEngine_.setSource(engine::PreviewSource::RenderedMix); + const auto preview = previewEngine_.buildCrossfadedPreview(1024); + updateTransportFromBuffer(preview); + transportController_.seekToFraction(progress); + playbackCursorSamples_.store(transportController_.positionSamples()); + transportController_.play(); + previewEngine_.play(); + + statusLabel_.setText("Preview B selected", juce::dontSendNotification); + appendTaskHistory("Preview source switched to Rendered (B)"); +} + +void MainComponent::onAddExternalRenderer() { + externalRendererChooser_ = std::make_unique("Select external limiter binary"); + constexpr int flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles; + + externalRendererChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + externalRendererChooser_.reset(); + return; + } + + const auto selectedPath = selected.getFullPathName().toStdString(); + const auto selectedName = selected.getFileName().toStdString(); + statusLabel_.setText("Validating external renderer...", juce::dontSendNotification); + appendTaskHistory("External renderer validation started: " + juce::String(selectedName)); + + juce::Component::SafePointer safeThis(this); + backgroundPool_.addJob(new BackgroundJob([safeThis, selectedPath, selectedName]() { + const auto validation = renderers::ExternalLimiterRenderer::validateBinary(selectedPath); + juce::MessageManager::callAsync([safeThis, selectedPath, selectedName, validation]() { + if (safeThis == nullptr) { + return; + } + + renderers::ExternalRendererConfig config; + config.id = "ExternalUserUI" + std::to_string(safeThis->userExternalRendererConfigs_.size() + 1); + config.name = selectedName; + config.binaryPath = selectedPath; + config.licenseId = "User-supplied"; + safeThis->userExternalRendererConfigs_.push_back(config); + safeThis->refreshRenderers(); + + const juce::String statusText = + validation.valid ? "External renderer added" : "External renderer added (validation failed)"; + safeThis->statusLabel_.setText(statusText, juce::dontSendNotification); + safeThis->appendTaskHistory(statusText); + safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + + "\nAdded external renderer: " + juce::String(selectedPath) + + "\nValidation: " + juce::String(validation.valid ? "passed" : "failed") + + " (" + juce::String(validation.diagnostics) + ")" + + "\nLicense note: user-supplied tool is not distributed by this app."); + }); + }), true); + + externalRendererChooser_.reset(); + }); +} + +void MainComponent::onPrefetchLame() { + if (!util::LameDownloader::isSupportedOnCurrentPlatform()) { + statusLabel_.setText("LAME downloader is not supported on this platform", juce::dontSendNotification); + return; + } + + prefetchLameButton_.setEnabled(false); + statusLabel_.setText("Prefetching LAME...", juce::dontSendNotification); + + juce::Component::SafePointer safeThis(this); + backgroundPool_.addJob(new BackgroundJob([safeThis]() { + const auto result = util::LameDownloader::ensureAvailable(); + juce::MessageManager::callAsync([safeThis, result]() { + if (safeThis == nullptr) { + return; + } + + safeThis->prefetchLameButton_.setEnabled(true); + safeThis->refreshCodecAvailability(); + + if (result.success) { + safeThis->statusLabel_.setText("LAME ready for MP3 export", juce::dontSendNotification); + safeThis->appendTaskHistory("LAME prefetch completed"); + } else { + safeThis->statusLabel_.setText("LAME prefetch failed", juce::dontSendNotification); + safeThis->appendTaskHistory("LAME prefetch failed"); + } + + juce::String report = safeThis->reportEditor_.getText(); + if (!report.isEmpty()) { + report += "\n"; + } + report += "LAME prefetch: "; + report += result.success ? "success" : "failed"; + if (!result.executablePath.empty()) { + report += "\nPath: " + juce::String(result.executablePath.string()); + } + if (!result.detail.empty()) { + report += "\nDetail: " + juce::String(result.detail); + } + safeThis->reportEditor_.setText(report); + }); + }), true); +} + +void MainComponent::onModelsMenu() { + juce::PopupMenu menu; + menu.addItem(1, "Browse & Download Models"); + menu.addItem(2, "Installed Models"); + menu.addItem(3, "Check Updates"); + menu.addItem(4, "Integrity & Licenses"); + menu.addSeparator(); + menu.addItem(5, "Open Model Hub Folder"); + + juce::Component::SafePointer safeThis(this); + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&modelsMenuButton_), + [safeThis](const int result) { + if (safeThis == nullptr) { + return; + } + switch (result) { + case 1: + safeThis->modelController_->fetchCatalog(); + break; + case 2: + safeThis->modelController_->showInstalled(); + break; + case 3: + safeThis->modelController_->checkUpdates(); + break; + case 4: + safeThis->modelController_->verifyIntegrity(); + break; + case 5: { + const auto folder = juce::File( + (std::filesystem::path("assets") / "modelhub").string()); + folder.createDirectory(); + folder.revealToUser(); + break; + } + default: + break; + } + }); +} + +void MainComponent::updateMeterPanel(const automaster::MasteringReport& report) { + meterLufsLabel_.setText("LUFS: " + juce::String(report.integratedLufs, 2), juce::dontSendNotification); + meterShortTermLabel_.setText("Short-term: " + juce::String(report.shortTermLufs, 2), juce::dontSendNotification); + meterTruePeakLabel_.setText("True Peak: " + juce::String(report.truePeakDbtp, 2) + " dBTP", + juce::dontSendNotification); +} + void MainComponent::onAutoMix() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + if (session_.stems.empty()) { statusLabel_.setText("Import stems first", juce::dontSendNotification); return; } - analysisEntries_ = analyzer_.analyzeSession(session_); - analysisTableModel_.setEntries(&analysisEntries_); - analysisTable_.updateContent(); - session_.mixPlan = autoMixStrategy_.buildPlan(session_, analysisEntries_, 1.0); + cancelRender_.store(false); + taskRunning_.store(true); + cancelButton_.setEnabled(true); + statusLabel_.setText("Auto Mix started", juce::dontSendNotification); + appendTaskHistory("Auto Mix started"); - statusLabel_.setText("Auto Mix plan generated", juce::dontSendNotification); - const juce::String analysisJson = analyzer_.toJsonReport(analysisEntries_); - reportEditor_.setText(juce::String("Analysis report JSON:\n") + analysisJson + - juce::String("\n\nMix decisions:\n") + toJuceText(session_.mixPlan->decisionLog)); + std::optional mixPack; + if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("mix")); selected != nullptr) { + mixPack = *selected; + } + + processingController_->runAutoMix(session_, mixPack, cancelRender_); } void MainComponent::onAutoMaster() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + if (session_.stems.empty()) { statusLabel_.setText("Import stems first", juce::dontSendNotification); return; } - domain::RenderSettings settings; - settings.outputSampleRate = 44100; - settings.blockSize = 1024; + cancelRender_.store(false); + taskRunning_.store(true); + cancelButton_.setEnabled(true); + statusLabel_.setText("Auto Master started", juce::dontSendNotification); + appendTaskHistory("Auto Master started"); + + auto preset = selectedPlatformPreset(); + if (preset == domain::MasterPreset::Custom) { + preset = selectedMasterPreset(); + } + + const auto settings = buildCurrentRenderSettings(""); + std::optional masterPack; + if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("master")); selected != nullptr) { + masterPack = *selected; + } - engine::OfflineRenderPipeline pipeline; - auto rawMix = pipeline.renderRawMix(session_, settings, {}, &cancelRender_); - if (rawMix.cancelled) { - statusLabel_.setText("Auto Master cancelled", juce::dontSendNotification); + processingController_->runAutoMaster(session_, settings, preset, masterPack, cancelRender_); +} + +void MainComponent::onBatchImport() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); return; } - session_.masterPlan = autoMasterStrategy_.buildPlan(domain::MasterPreset::DefaultStreaming, rawMix.mixBuffer); - if (session_.originalMixPath.has_value()) { - try { - engine::AudioFileIO fileIO; - engine::AudioResampler resampler; - auto originalMix = fileIO.readAudioFile(session_.originalMixPath.value()); - if (originalMix.getSampleRate() != rawMix.mixBuffer.getSampleRate()) { - originalMix = resampler.resampleLinear(originalMix, rawMix.mixBuffer.getSampleRate()); - } - - automaster::OriginalMixReference referenceTarget; - session_.masterPlan = referenceTarget.applySoftTarget(session_.masterPlan.value(), - rawMix.mixBuffer, - originalMix, - autoMasterStrategy_, - analyzer_); - } catch (const std::exception& error) { - reportEditor_.setText(reportEditor_.getText() + "\nOriginal mix target skipped: " + juce::String(error.what())); + batchImportChooser_ = std::make_unique("Select folder for batch mastering"); + constexpr int flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectDirectories; + + batchImportChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { + const auto folder = chooser.getResult(); + if (folder == juce::File()) { + batchImportChooser_.reset(); + return; } - } - statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); - reportEditor_.setText(reportEditor_.getText() + "\nMaster decisions:\n" + toJuceText(session_.masterPlan->decisionLog)); + cancelRender_.store(false); + cancelButton_.setEnabled(true); + taskRunning_.store(true); + statusLabel_.setText("Batch preparing...", juce::dontSendNotification); + appendTaskHistory("Batch started: " + folder.getFullPathName()); + + const std::filesystem::path inputFolder(folder.getFullPathName().toStdString()); + const auto baseRenderSettings = buildCurrentRenderSettings(""); + + processingController_->runBatch(inputFolder, baseRenderSettings, cancelRender_); + + batchImportChooser_.reset(); + }); } void MainComponent::onExport() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + if (session_.stems.empty()) { statusLabel_.setText("Import stems first", juce::dontSendNotification); return; } + const auto rendererSelection = rendererIdByComboId_.find(rendererBox_.getSelectedId()); + const auto selectedRendererId = rendererSelection != rendererIdByComboId_.end() ? rendererSelection->second : std::string("BuiltIn"); + if (const auto profile = domain::findProjectProfile(projectProfiles_, session_.projectProfileId); profile.has_value()) { + if (!profile->pinnedRendererIds.empty()) { + const bool pinned = std::find(profile->pinnedRendererIds.begin(), + profile->pinnedRendererIds.end(), + selectedRendererId) != profile->pinnedRendererIds.end(); + if (!pinned) { + const bool strictPolicy = toLower(session_.safetyPolicyId) == "strict"; + appendTaskHistory("Renderer " + juce::String(selectedRendererId) + " not pinned for profile " + profile->id); + if (strictPolicy) { + statusLabel_.setText("Export blocked by strict safety policy: renderer not pinned for selected profile", + juce::dontSendNotification); + return; + } + } + } + } + exportChooser_ = std::make_unique( - "Export master", juce::File::getSpecialLocation(juce::File::userDocumentsDirectory), "*.wav"); + "Export master", + juce::File::getSpecialLocation(juce::File::userDocumentsDirectory), + "*.wav;*.flac;*.aiff;*.ogg;*.mp3"); constexpr int flags = juce::FileBrowserComponent::saveMode | juce::FileBrowserComponent::canSelectFiles | juce::FileBrowserComponent::warnAboutOverwriting; @@ -409,40 +2460,15 @@ void MainComponent::onExport() { return; } - domain::RenderSettings settings; - settings.outputSampleRate = 44100; - settings.blockSize = 1024; - settings.outputBitDepth = 24; - settings.outputPath = selected.getFullPathName().toStdString(); - settings.rendererName = rendererBox_.getSelectedId() == 2 ? "PhaseLimiter" : "BuiltIn"; + auto settings = buildCurrentRenderSettings(selected.getFullPathName().toStdString()); - renderers::RenderResult renderResult; - try { - auto renderer = renderers::createRenderer(settings.rendererName); - renderResult = renderer->render(session_, settings, {}, &cancelRender_); - } catch (const std::exception& error) { - statusLabel_.setText("Export crashed", juce::dontSendNotification); - reportEditor_.setText(juce::String("Export exception:\n") + juce::String(error.what())); - exportChooser_.reset(); - return; - } catch (...) { - statusLabel_.setText("Export crashed", juce::dontSendNotification); - reportEditor_.setText("Export exception:\nUnknown error"); - exportChooser_.reset(); - return; - } - - if (renderResult.cancelled) { - statusLabel_.setText("Export cancelled", juce::dontSendNotification); - exportChooser_.reset(); - return; - } + cancelRender_.store(false); + taskRunning_.store(true); + cancelButton_.setEnabled(true); + statusLabel_.setText("Export started", juce::dontSendNotification); + appendTaskHistory("Export started: " + selected.getFullPathName()); - statusLabel_.setText(renderResult.success ? "Export complete" : "Export failed", juce::dontSendNotification); - reportEditor_.setText(juce::String("Renderer: ") + juce::String(renderResult.rendererName) + - juce::String("\nOutput: ") + juce::String(renderResult.outputAudioPath) + - juce::String("\nReport: ") + juce::String(renderResult.reportPath) + - juce::String("\n\nLogs:\n") + toJuceText(renderResult.logs)); + exportController_->runExport(session_, settings, analysisEntries_, cancelRender_); exportChooser_.reset(); }); diff --git a/src/app/MainComponent.h b/src/app/MainComponent.h index d8e3b1b..a752521 100644 --- a/src/app/MainComponent.h +++ b/src/app/MainComponent.h @@ -1,23 +1,43 @@ #pragma once #include +#include +#include #include +#include +#include #include +#include #include #include "analysis/StemAnalyzer.h" #include "ai/ModelManager.h" +#include "ai/HuggingFaceModelHub.h" +#include "app/WaveformPreviewComponent.h" #include "automaster/HeuristicAutoMasterStrategy.h" #include "automix/HeuristicAutoMixStrategy.h" +#include "domain/MasterPlan.h" +#include "domain/ProjectProfile.h" #include "domain/Session.h" +#include "engine/AudioPreviewEngine.h" +#include "engine/SessionRepository.h" +#include "engine/TransportController.h" +#include "app/controllers/ModelController.h" +#include "app/controllers/ImportController.h" +#include "app/controllers/ExportController.h" +#include "app/controllers/ProcessingController.h" +#include "renderers/RendererRegistry.h" namespace automix::app { class MainComponent final : public juce::Component, private juce::Button::Listener, private juce::ComboBox::Listener, - private juce::Slider::Listener { + private juce::Slider::Listener, + private juce::Timer, + private juce::ChangeListener, + private juce::AudioIODeviceCallback { public: MainComponent(); ~MainComponent() override; @@ -48,42 +68,171 @@ class MainComponent final : public juce::Component, void buttonClicked(juce::Button* button) override; void comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) override; void sliderValueChanged(juce::Slider* slider) override; + void timerCallback() override; + void changeListenerCallback(juce::ChangeBroadcaster* source) override; + void audioDeviceIOCallbackWithContext(const float* const* inputChannelData, + int numInputChannels, + float* const* outputChannelData, + int numOutputChannels, + int numSamples, + const juce::AudioIODeviceCallbackContext& context) override; + void audioDeviceAboutToStart(juce::AudioIODevice* device) override; + void audioDeviceStopped() override; void onImport(); void onImportOriginalMix(); + void onClearOriginalMix(); + void onRegenerateCachedRenders(); void onAutoMix(); void onAutoMaster(); + void onBatchImport(); void onExport(); + void onCancel(); + void onSaveSession(); + void onLoadSession(); + void onPreviewOriginal(); + void onPreviewRendered(); + void onAddExternalRenderer(); + void onPrefetchLame(); + void onModelsMenu(); + + void updateMeterPanel(const automaster::MasteringReport& report); void refreshModelPacks(); + void refreshRenderers(); + void refreshCodecAvailability(); + void updateExportCodecControls(); + std::string selectedExportSpeedMode() const; + bool isQuickExportModeSelected() const; + void applyQuickExportDefaults(); + void refreshProjectProfiles(); + void refreshStemRoutingSelectors(); + void applyLoadedSession(domain::Session loadedSession, const juce::String& sourcePath); + void rebuildPreviewBuffers(); + void rebuildPreviewBuffersAsync(); + void updateTransportFromBuffer(const engine::AudioBuffer& buffer); + void updateTransportDisplay(); + void updateTransportLoopAndZoomUI(); + void appendTaskHistory(const juce::String& line); + void applyProjectProfile(const domain::ProjectProfile& profile); + void populateMasterPresetSelectors(); + + domain::MasterPreset selectedMasterPreset() const; + domain::MasterPreset selectedPlatformPreset() const; + + domain::RenderSettings buildCurrentRenderSettings(const std::string& outputPath) const; + std::vector loadConfiguredExternalRenderers() const; juce::TextButton importButton_ {"Import"}; juce::TextButton originalMixButton_ {"Original Mix"}; + juce::TextButton clearOriginalMixButton_ {"Clear Original"}; + juce::TextButton regenerateCacheButton_ {"Regenerate Cache"}; + juce::TextButton saveSessionButton_ {"Save Session"}; + juce::TextButton loadSessionButton_ {"Load Session"}; + juce::TextButton modelsMenuButton_ {"Models"}; juce::TextButton autoMixButton_ {"Auto Mix"}; juce::TextButton autoMasterButton_ {"Auto Master"}; + juce::TextButton batchImportButton_ {"Batch Folder"}; + juce::TextButton previewOriginalButton_ {"Preview A"}; + juce::TextButton previewRenderedButton_ {"Preview B"}; + juce::TextButton playPauseButton_ {"Play/Pause"}; + juce::TextButton stopButton_ {"Stop"}; + juce::TextButton loopInButton_ {"Set Loop In"}; + juce::TextButton loopOutButton_ {"Set Loop Out"}; + juce::TextButton clearLoopButton_ {"Clear Loop"}; + juce::TextButton addExternalRendererButton_ {"Add External Limiter"}; + juce::TextButton prefetchLameButton_ {"Prefetch LAME"}; juce::TextButton exportButton_ {"Export"}; + juce::TextButton cancelButton_ {"Cancel"}; juce::ToggleButton separatedStemsToggle_ {"AI-separated stems"}; juce::Label residualBlendLabel_ {"residualBlendLabel", "Residual Blend %"}; juce::Slider residualBlendSlider_; juce::ComboBox rendererBox_; + juce::Label exportFormatLabel_ {"exportFormatLabel", "Export"}; + juce::ComboBox exportFormatBox_; + juce::Label exportSpeedModeLabel_ {"exportSpeedModeLabel", "Mode"}; + juce::ComboBox exportSpeedModeBox_; + juce::Label projectProfileLabel_ {"projectProfileLabel", "Profile"}; + juce::ComboBox projectProfileBox_; + juce::Label exportBitrateLabel_ {"exportBitrateLabel", "Lossy kbps"}; + juce::Slider exportBitrateSlider_; + juce::Label mp3ModeLabel_ {"mp3ModeLabel", "MP3 Mode"}; + juce::ComboBox mp3ModeBox_; + juce::Label mp3VbrLabel_ {"mp3VbrLabel", "VBR Q"}; + juce::Slider mp3VbrSlider_; + juce::Label gpuProviderLabel_ {"gpuProviderLabel", "ML Provider"}; + juce::ComboBox gpuProviderBox_; + juce::Label masterPresetLabel_ {"masterPresetLabel", "Master Preset"}; + juce::ComboBox masterPresetBox_; + juce::Label platformPresetLabel_ {"platformPresetLabel", "Platform"}; + juce::ComboBox platformPresetBox_; + juce::Label soloStemLabel_ {"soloStemLabel", "Solo"}; + juce::ComboBox soloStemBox_; + juce::Label muteStemLabel_ {"muteStemLabel", "Mute"}; + juce::ComboBox muteStemBox_; + juce::Slider transportSlider_; + juce::Label zoomLabel_ {"zoomLabel", "Zoom"}; + juce::Slider zoomSlider_; + juce::ToggleButton fineScrubToggle_ {"Fine Scrub"}; juce::Label aiModelsLabel_ {"aiModelsLabel", "AI Models"}; juce::ComboBox roleModelBox_; juce::ComboBox mixModelBox_; juce::ComboBox masterModelBox_; juce::Label statusLabel_; + juce::Label meterLufsLabel_ {"meterLufsLabel", "LUFS: --"}; + juce::Label meterShortTermLabel_ {"meterShortTermLabel", "Short-term: --"}; + juce::Label meterTruePeakLabel_ {"meterTruePeakLabel", "True Peak: --"}; + WaveformPreviewComponent waveformPreview_; AnalysisTableModel analysisTableModel_; juce::TableListBox analysisTable_; juce::TextEditor reportEditor_; + juce::Label taskCenterLabel_ {"taskCenterLabel", "Task Center"}; + juce::TextEditor taskCenterEditor_; domain::Session session_; analysis::StemAnalyzer analyzer_; automix::HeuristicAutoMixStrategy autoMixStrategy_; automaster::HeuristicAutoMasterStrategy autoMasterStrategy_; + engine::SessionRepository sessionRepository_; + engine::AudioPreviewEngine previewEngine_; + engine::TransportController transportController_; ai::ModelManager modelManager_; + juce::AudioDeviceManager audioDeviceManager_; std::vector analysisEntries_; + std::vector rendererInfos_; + std::vector userExternalRendererConfigs_; + std::map rendererIdByComboId_; + std::map roleModelIdByComboId_; + std::map mixModelIdByComboId_; + std::map masterModelIdByComboId_; + std::map masterPresetByComboId_; + std::map platformPresetByComboId_; + std::map codecFormatByComboId_; + std::map exportSpeedModeByComboId_; + std::map stemIdBySoloComboId_; + std::map stemIdByMuteComboId_; + std::map projectProfileIdByComboId_; + juce::ThreadPool backgroundPool_ {3}; std::atomic_bool cancelRender_ {false}; + std::atomic_bool taskRunning_ {false}; + std::atomic_uint64_t previewBuildGeneration_ {0}; + std::atomic playbackCursorSamples_ {0}; + std::mutex playbackBufferMutex_; + engine::AudioBuffer playbackBuffer_; + bool ignoreTransportSliderChange_ = false; + double lastFineScrubProgress_ = 0.0; std::unique_ptr importChooser_; std::unique_ptr originalMixChooser_; std::unique_ptr exportChooser_; + std::unique_ptr batchImportChooser_; + std::unique_ptr saveSessionChooser_; + std::unique_ptr loadSessionChooser_; + std::unique_ptr externalRendererChooser_; + std::vector taskHistoryLines_; + std::vector projectProfiles_; + std::unique_ptr modelController_; + std::unique_ptr importController_; + std::unique_ptr exportController_; + std::unique_ptr processingController_; }; } // namespace automix::app diff --git a/src/app/MainComponentBackend.cpp b/src/app/MainComponentBackend.cpp new file mode 100644 index 0000000..9f1deaf --- /dev/null +++ b/src/app/MainComponentBackend.cpp @@ -0,0 +1,1272 @@ +#include "app/MainComponent.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "engine/OfflineRenderPipeline.h" +#include "util/LameDownloader.h" +#include "util/StringUtils.h" +#include "util/WavWriter.h" + +namespace automix::app { +namespace { + +using ::automix::util::toLower; +using ::automix::util::toJuceText; + +std::vector splitDelimited(const std::string& text, const char delimiter) { + std::vector out; + std::stringstream stream(text); + std::string token; + while (std::getline(stream, token, delimiter)) { + if (!token.empty()) { + out.push_back(token); + } + } + return out; +} + +constexpr const char* kExportSpeedModeFinal = "final"; +constexpr const char* kExportSpeedModeBalanced = "balanced"; +constexpr const char* kExportSpeedModeQuick = "quick"; + +const ai::ModelPack* findPackById(const ai::ModelManager& manager, const std::string& id) { + if (id.empty() || id == "none") { + return nullptr; + } + for (const auto& pack : manager.availablePacks()) { + if (pack.id == id) { + return &pack; + } + } + return nullptr; +} + +std::string formatDuration(const double seconds) { + const auto clamped = std::max(0.0, seconds); + const int total = static_cast(std::lround(clamped)); + const int mins = total / 60; + const int secs = total % 60; + std::ostringstream output; + output << mins << ':'; + if (secs < 10) { + output << '0'; + } + output << secs; + return output.str(); +} + +} // namespace + +void MainComponent::refreshModelPacks() { + modelManager_.setRootPath("ModelPacks"); + const auto packs = modelManager_.scan(); + + roleModelBox_.clear(juce::dontSendNotification); + mixModelBox_.clear(juce::dontSendNotification); + masterModelBox_.clear(juce::dontSendNotification); + roleModelIdByComboId_.clear(); + mixModelIdByComboId_.clear(); + masterModelIdByComboId_.clear(); + + roleModelBox_.addItem("none", 1); + mixModelBox_.addItem("none", 1); + masterModelBox_.addItem("none", 1); + roleModelIdByComboId_[1] = "none"; + mixModelIdByComboId_[1] = "none"; + masterModelIdByComboId_[1] = "none"; + + int itemId = 2; + for (const auto& pack : packs) { + const juce::String label = pack.id + " [" + pack.engine + "]"; + if (pack.type == "role_classifier") { + roleModelBox_.addItem(label, itemId); + roleModelIdByComboId_[itemId] = pack.id; + ++itemId; + } else if (pack.type == "mix_parameters") { + mixModelBox_.addItem(label, itemId); + mixModelIdByComboId_[itemId] = pack.id; + ++itemId; + } else if (pack.type == "master_parameters") { + masterModelBox_.addItem(label, itemId); + masterModelIdByComboId_[itemId] = pack.id; + ++itemId; + } else { + roleModelBox_.addItem(label, itemId); + roleModelIdByComboId_[itemId] = pack.id; + ++itemId; + mixModelBox_.addItem(label, itemId); + mixModelIdByComboId_[itemId] = pack.id; + ++itemId; + masterModelBox_.addItem(label, itemId); + masterModelIdByComboId_[itemId] = pack.id; + ++itemId; + } + } + + roleModelBox_.setSelectedId(1, juce::dontSendNotification); + mixModelBox_.setSelectedId(1, juce::dontSendNotification); + masterModelBox_.setSelectedId(1, juce::dontSendNotification); + modelManager_.setActivePackId("role", "none"); + modelManager_.setActivePackId("mix", "none"); + modelManager_.setActivePackId("master", "none"); +} + +std::vector MainComponent::loadConfiguredExternalRenderers() const { + std::vector configs; +#ifdef ENABLE_EXTERNAL_TOOL_SUPPORT + const char* rawValue = std::getenv("AUTOMIX_EXTERNAL_RENDERERS"); + if (rawValue != nullptr && *rawValue != '\0') { + int index = 1; + for (const auto& item : splitDelimited(rawValue, ';')) { + const auto pieces = splitDelimited(item, '|'); + if (pieces.size() < 2) { + continue; + } + + renderers::ExternalRendererConfig config; + config.id = "ExternalUser" + std::to_string(index++); + config.name = pieces[0]; + config.binaryPath = pieces[1]; + if (pieces.size() >= 3) { + config.licenseId = pieces[2]; + } + configs.push_back(config); + } + } +#endif + configs.insert(configs.end(), userExternalRendererConfigs_.begin(), userExternalRendererConfigs_.end()); + return configs; +} + +void MainComponent::refreshRenderers() { + rendererBox_.clear(juce::dontSendNotification); + rendererIdByComboId_.clear(); + + renderers::RendererRegistry registry; + rendererInfos_ = registry.list(loadConfiguredExternalRenderers()); + + int comboId = 1; + int preferredId = 0; + for (const auto& info : rendererInfos_) { + juce::String label = info.name; + if (info.linkMode == renderers::RendererLinkMode::External) { + label += " [external]"; + } + if (!info.available) { + label += " (unavailable)"; + } + rendererBox_.addItem(label, comboId); + rendererIdByComboId_[comboId] = info.id; + + if (preferredId == 0 && info.available) { + preferredId = comboId; + } + if (info.id == session_.renderSettings.rendererName) { + preferredId = comboId; + } + ++comboId; + } + + if (preferredId == 0 && !rendererIdByComboId_.empty()) { + preferredId = rendererIdByComboId_.begin()->first; + } + if (preferredId != 0) { + rendererBox_.setSelectedId(preferredId, juce::dontSendNotification); + const auto selected = rendererIdByComboId_.find(preferredId); + if (selected != rendererIdByComboId_.end()) { + session_.renderSettings.rendererName = selected->second; + } + } +} + +void MainComponent::refreshCodecAvailability() { + exportFormatBox_.clear(juce::dontSendNotification); + codecFormatByComboId_.clear(); + + const auto availability = exportController_ != nullptr + ? exportController_->listCodecAvailability() + : util::WavWriter::getAvailableFormats(); + std::vector tooltipLines; + + int selectedId = 0; + int firstAvailableId = 0; + int comboId = 1; + for (const auto& entry : availability) { + juce::String label = juce::String(entry.format).toUpperCase(); + if (!entry.available) { + label += " (unavailable)"; + } + + exportFormatBox_.addItem(label, comboId); + codecFormatByComboId_[comboId] = entry.format; + tooltipLines.push_back(entry.format + ": " + entry.detail); + + if (entry.available && firstAvailableId == 0) { + firstAvailableId = comboId; + } + if (toLower(session_.renderSettings.outputFormat) == toLower(entry.format) && entry.available) { + selectedId = comboId; + } + ++comboId; + } + + if (selectedId == 0) { + selectedId = firstAvailableId > 0 ? firstAvailableId : 1; + } + exportFormatBox_.setSelectedId(selectedId, juce::dontSendNotification); + mp3ModeBox_.setSelectedId(session_.renderSettings.mp3UseVbr ? 2 : 1, juce::dontSendNotification); + mp3VbrSlider_.setValue(session_.renderSettings.mp3VbrQuality, juce::dontSendNotification); + + int exportModeSelectedId = 1; + for (const auto& [comboId, mode] : exportSpeedModeByComboId_) { + if (mode == session_.renderSettings.exportSpeedMode) { + exportModeSelectedId = comboId; + break; + } + } + exportSpeedModeBox_.setSelectedId(exportModeSelectedId, juce::dontSendNotification); + + exportFormatBox_.setTooltip(toJuceText(tooltipLines)); + updateExportCodecControls(); +} + +std::string MainComponent::selectedExportSpeedMode() const { + if (exportController_ != nullptr) { + return exportController_->selectedExportSpeedMode(exportSpeedModeBox_.getSelectedId(), exportSpeedModeByComboId_); + } + const auto it = exportSpeedModeByComboId_.find(exportSpeedModeBox_.getSelectedId()); + return it == exportSpeedModeByComboId_.end() ? kExportSpeedModeFinal : it->second; +} + +bool MainComponent::isQuickExportModeSelected() const { + if (exportController_ != nullptr) { + return exportController_->isQuickExportMode(selectedExportSpeedMode()); + } + return selectedExportSpeedMode() == kExportSpeedModeQuick; +} + +void MainComponent::applyQuickExportDefaults() { + if (exportController_ == nullptr) { + return; + } + + const auto defaults = exportController_->quickExportDefaults(codecFormatByComboId_); + for (const auto& [comboId, formatName] : codecFormatByComboId_) { + if (toLower(formatName) == toLower(defaults.outputFormat)) { + exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + if (defaults.usedFallbackCodec) { + statusLabel_.setText("Quick mode: MP3 unavailable, using fallback codec", juce::dontSendNotification); + appendTaskHistory("Quick mode fallback codec selected (MP3 unavailable)"); + } + + exportBitrateSlider_.setValue(static_cast(defaults.lossyBitrateKbps), juce::dontSendNotification); + mp3ModeBox_.setSelectedId(defaults.mp3UseVbr ? 2 : 1, juce::dontSendNotification); + mp3VbrSlider_.setValue(static_cast(defaults.mp3VbrQuality), juce::dontSendNotification); + session_.renderSettings.outputFormat = defaults.outputFormat; + session_.renderSettings.lossyBitrateKbps = defaults.lossyBitrateKbps; + session_.renderSettings.lossyQuality = defaults.lossyQuality; + session_.renderSettings.mp3UseVbr = defaults.mp3UseVbr; + session_.renderSettings.mp3VbrQuality = defaults.mp3VbrQuality; +} + +void MainComponent::updateExportCodecControls() { + if (exportController_ == nullptr) { + return; + } + const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); + const std::string selectedFormat = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; + const bool mp3UseVbr = mp3ModeBox_.getSelectedId() == 2; + const auto controls = + exportController_->codecControlsFor(selectedFormat, mp3UseVbr, selectedExportSpeedMode()); + + exportFormatBox_.setEnabled(controls.formatEnabled); + exportFormatLabel_.setEnabled(controls.formatEnabled); + exportBitrateSlider_.setEnabled(controls.bitrateEnabled); + exportBitrateLabel_.setEnabled(controls.bitrateEnabled); + mp3ModeBox_.setEnabled(controls.mp3ModeEnabled); + mp3ModeLabel_.setEnabled(controls.mp3ModeEnabled); + mp3VbrSlider_.setEnabled(controls.mp3VbrEnabled); + mp3VbrLabel_.setEnabled(controls.mp3VbrEnabled); +} + +void MainComponent::refreshProjectProfiles() { + if (profileController_ != nullptr) { + projectProfiles_ = profileController_->loadProfiles(std::filesystem::current_path()); + } else { + projectProfiles_ = domain::loadProjectProfiles(std::filesystem::current_path()); + } + projectProfileBox_.clear(juce::dontSendNotification); + projectProfileIdByComboId_.clear(); + + std::optional selectedProfile; + if (profileController_ != nullptr) { + selectedProfile = profileController_->selectedProfile(projectProfiles_, session_.projectProfileId); + } else { + selectedProfile = domain::findProjectProfile(projectProfiles_, session_.projectProfileId); + } + + int selectedId = 0; + int comboId = 1; + for (const auto& profile : projectProfiles_) { + projectProfileBox_.addItem(profile.name + " [" + profile.id + "]", comboId); + projectProfileIdByComboId_[comboId] = profile.id; + if (selectedProfile.has_value() && profile.id == selectedProfile->id) { + selectedId = comboId; + } + ++comboId; + } + + if (selectedId == 0 && !projectProfileIdByComboId_.empty()) { + selectedId = projectProfileIdByComboId_.begin()->first; + } + + if (selectedId > 0) { + projectProfileBox_.setSelectedId(selectedId, juce::dontSendNotification); + if (selectedProfile.has_value()) { + applyProjectProfile(selectedProfile.value()); + } else { + const auto it = projectProfileIdByComboId_.find(selectedId); + if (it != projectProfileIdByComboId_.end()) { + if (const auto fallbackProfile = domain::findProjectProfile(projectProfiles_, it->second); + fallbackProfile.has_value()) { + applyProjectProfile(fallbackProfile.value()); + } + } + } + } +} + +void MainComponent::applyProjectProfile(const domain::ProjectProfile& profile) { + const auto applied = profileController_ != nullptr + ? profileController_->applyProfile(session_, profile) + : AppliedProfileSettings {}; + if (profileController_ == nullptr) { + session_.projectProfileId = profile.id; + session_.safetyPolicyId = profile.safetyPolicyId; + session_.preferredStemCount = profile.preferredStemCount; + session_.renderSettings.gpuExecutionProvider = profile.gpuProvider; + session_.renderSettings.outputFormat = profile.outputFormat; + session_.renderSettings.lossyBitrateKbps = profile.lossyBitrateKbps; + session_.renderSettings.mp3UseVbr = profile.mp3UseVbr; + session_.renderSettings.mp3VbrQuality = profile.mp3VbrQuality; + session_.renderSettings.metadataPolicy = profile.metadataPolicy; + session_.renderSettings.metadataTemplate = profile.metadataTemplate; + session_.renderSettings.rendererName = profile.rendererName; + } + + const auto gpuProvider = applied.gpuProvider.empty() ? profile.gpuProvider : applied.gpuProvider; + if (gpuProvider == "cpu") { + gpuProviderBox_.setSelectedId(2, juce::dontSendNotification); + } else if (gpuProvider == "directml") { + gpuProviderBox_.setSelectedId(3, juce::dontSendNotification); + } else if (gpuProvider == "coreml") { + gpuProviderBox_.setSelectedId(4, juce::dontSendNotification); + } else if (gpuProvider == "cuda") { + gpuProviderBox_.setSelectedId(5, juce::dontSendNotification); + } else { + gpuProviderBox_.setSelectedId(1, juce::dontSendNotification); + } + + for (const auto& [comboId, format] : codecFormatByComboId_) { + if (toLower(format) == toLower(session_.renderSettings.outputFormat)) { + exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + exportBitrateSlider_.setValue(session_.renderSettings.lossyBitrateKbps, juce::dontSendNotification); + mp3ModeBox_.setSelectedId(session_.renderSettings.mp3UseVbr ? 2 : 1, juce::dontSendNotification); + mp3VbrSlider_.setValue(session_.renderSettings.mp3VbrQuality, juce::dontSendNotification); + session_.renderSettings.exportSpeedMode = selectedExportSpeedMode(); + if (isQuickExportModeSelected()) { + applyQuickExportDefaults(); + } + updateExportCodecControls(); + + const auto selectedRendererId = applied.rendererName.empty() ? profile.rendererName : applied.rendererName; + for (const auto& [comboId, rendererId] : rendererIdByComboId_) { + if (rendererId == selectedRendererId) { + rendererBox_.setSelectedId(comboId, juce::dontSendNotification); + session_.renderSettings.rendererName = rendererId; + break; + } + } + + const auto selectModelComboById = [](juce::ComboBox& combo, + const std::map& idsByCombo, + const std::string& modelId) { + for (const auto& [comboId, id] : idsByCombo) { + if (id == modelId) { + combo.setSelectedId(comboId, juce::dontSendNotification); + return; + } + } + combo.setSelectedId(1, juce::dontSendNotification); + }; + + const auto roleModelId = applied.roleModelPackId.empty() ? profile.roleModelPackId : applied.roleModelPackId; + const auto mixModelId = applied.mixModelPackId.empty() ? profile.mixModelPackId : applied.mixModelPackId; + const auto masterModelId = applied.masterModelPackId.empty() ? profile.masterModelPackId : applied.masterModelPackId; + selectModelComboById(roleModelBox_, roleModelIdByComboId_, roleModelId); + selectModelComboById(mixModelBox_, mixModelIdByComboId_, mixModelId); + selectModelComboById(masterModelBox_, masterModelIdByComboId_, masterModelId); + + modelManager_.setActivePackId("role", roleModelId); + modelManager_.setActivePackId("mix", mixModelId); + modelManager_.setActivePackId("master", masterModelId); + + const auto platformPreset = applied.platformPreset.empty() ? profile.platformPreset : applied.platformPreset; + const auto normalizedPlatform = toLower(platformPreset); + for (const auto& [comboId, preset] : platformPresetByComboId_) { + if (toLower(domain::toString(preset)) == normalizedPlatform) { + platformPresetBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + + appendTaskHistory("Applied profile " + profile.name + " (safety=" + profile.safetyPolicyId + + ", stems=" + std::to_string(profile.preferredStemCount) + ")"); +} + +void MainComponent::refreshStemRoutingSelectors() { + const auto previousSolo = stemIdBySoloComboId_.count(soloStemBox_.getSelectedId()) > 0 + ? stemIdBySoloComboId_[soloStemBox_.getSelectedId()] + : std::string(); + const auto previousMute = stemIdByMuteComboId_.count(muteStemBox_.getSelectedId()) > 0 + ? stemIdByMuteComboId_[muteStemBox_.getSelectedId()] + : std::string(); + + soloStemBox_.clear(juce::dontSendNotification); + muteStemBox_.clear(juce::dontSendNotification); + stemIdBySoloComboId_.clear(); + stemIdByMuteComboId_.clear(); + + soloStemBox_.addItem("None", 1); + muteStemBox_.addItem("None", 1); + stemIdBySoloComboId_[1] = ""; + stemIdByMuteComboId_[1] = ""; + + int comboId = 2; + int nextSolo = 1; + int nextMute = 1; + for (const auto& stem : session_.stems) { + const auto label = juce::String(stem.name + " [" + stem.id + "]"); + soloStemBox_.addItem(label, comboId); + muteStemBox_.addItem(label, comboId); + stemIdBySoloComboId_[comboId] = stem.id; + stemIdByMuteComboId_[comboId] = stem.id; + + if (stem.id == previousSolo) { + nextSolo = comboId; + } + if (stem.id == previousMute) { + nextMute = comboId; + } + + ++comboId; + } + + soloStemBox_.setSelectedId(nextSolo, juce::dontSendNotification); + muteStemBox_.setSelectedId(nextMute, juce::dontSendNotification); +} + +void MainComponent::rebuildPreviewBuffers() { + rebuildPreviewBuffersAsync(); +} + +void MainComponent::applyLoadedSession(domain::Session loadedSession, const juce::String& sourcePath) { + session_ = std::move(loadedSession); + if (session_.renderSettings.exportSpeedMode != kExportSpeedModeFinal && + session_.renderSettings.exportSpeedMode != kExportSpeedModeBalanced && + session_.renderSettings.exportSpeedMode != kExportSpeedModeQuick) { + session_.renderSettings.exportSpeedMode = kExportSpeedModeFinal; + } + + residualBlendSlider_.setValue(session_.residualBlend, juce::dontSendNotification); + clearOriginalMixButton_.setEnabled(session_.originalMixPath.has_value() && !session_.originalMixPath->empty()); + exportBitrateSlider_.setValue(session_.renderSettings.lossyBitrateKbps, juce::dontSendNotification); + mp3ModeBox_.setSelectedId(session_.renderSettings.mp3UseVbr ? 2 : 1, juce::dontSendNotification); + mp3VbrSlider_.setValue(session_.renderSettings.mp3VbrQuality, juce::dontSendNotification); + + int exportModeSelectedId = 1; + for (const auto& [comboId, mode] : exportSpeedModeByComboId_) { + if (mode == session_.renderSettings.exportSpeedMode) { + exportModeSelectedId = comboId; + break; + } + } + exportSpeedModeBox_.setSelectedId(exportModeSelectedId, juce::dontSendNotification); + + zoomSlider_.setValue(session_.timeline.zoom, juce::dontSendNotification); + fineScrubToggle_.setToggleState(session_.timeline.fineScrub, juce::dontSendNotification); + + if (session_.renderSettings.gpuExecutionProvider == "cpu") { + gpuProviderBox_.setSelectedId(2, juce::dontSendNotification); + } else if (session_.renderSettings.gpuExecutionProvider == "directml") { + gpuProviderBox_.setSelectedId(3, juce::dontSendNotification); + } else if (session_.renderSettings.gpuExecutionProvider == "coreml") { + gpuProviderBox_.setSelectedId(4, juce::dontSendNotification); + } else if (session_.renderSettings.gpuExecutionProvider == "cuda") { + gpuProviderBox_.setSelectedId(5, juce::dontSendNotification); + } else { + gpuProviderBox_.setSelectedId(1, juce::dontSendNotification); + } + + if (rendererIdByComboId_.empty()) { + refreshRenderers(); + } + if (codecFormatByComboId_.empty()) { + refreshCodecAvailability(); + } + if (roleModelIdByComboId_.empty() || mixModelIdByComboId_.empty() || masterModelIdByComboId_.empty()) { + refreshModelPacks(); + } + refreshStemRoutingSelectors(); + + for (const auto& [comboId, rendererId] : rendererIdByComboId_) { + if (rendererId == session_.renderSettings.rendererName) { + rendererBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + + for (const auto& [comboId, format] : codecFormatByComboId_) { + if (toLower(format) == toLower(session_.renderSettings.outputFormat)) { + exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + for (const auto& [comboId, profileId] : projectProfileIdByComboId_) { + if (profileId == session_.projectProfileId) { + projectProfileBox_.setSelectedId(comboId, juce::dontSendNotification); + break; + } + } + updateExportCodecControls(); + + analysisEntries_.clear(); + analysisTableModel_.setEntries(&analysisEntries_); + analysisTable_.updateContent(); + transportController_.setLoopRangeSeconds(session_.timeline.loopInSeconds, + session_.timeline.loopOutSeconds, + session_.timeline.loopEnabled); + updateTransportLoopAndZoomUI(); + + statusLabel_.setText("Session loaded", juce::dontSendNotification); + reportEditor_.setText("Loaded session: " + sourcePath); + appendTaskHistory("Session loaded: " + sourcePath); + rebuildPreviewBuffersAsync(); +} + +void MainComponent::rebuildPreviewBuffersAsync() { + if (session_.stems.empty()) { + ++previewBuildGeneration_; + waveformPreview_.setBuffer(engine::AudioBuffer{}); + PreviewController::applyTransportBuffer(engine::AudioBuffer{}, + session_.timeline, + transportController_, + playbackCursorSamples_); + return; + } + + const auto generation = ++previewBuildGeneration_; + const auto soloIt = stemIdBySoloComboId_.find(soloStemBox_.getSelectedId()); + const auto muteIt = stemIdByMuteComboId_.find(muteStemBox_.getSelectedId()); + const auto soloStemId = soloIt != stemIdBySoloComboId_.end() ? soloIt->second : std::string(); + const auto muteStemId = muteIt != stemIdByMuteComboId_.end() ? muteIt->second : std::string(); + if (previewController_ != nullptr) { + PreviewBuildRequest request; + request.session = session_; + request.soloStemId = soloStemId; + request.muteStemId = muteStemId; + request.generation = generation; + request.previousProgress = transportController_.progress(); + previewController_->rebuildPreview(std::move(request)); + } +} + +void MainComponent::updateTransportFromBuffer(const engine::AudioBuffer& buffer) { + { + std::scoped_lock lock(playbackBufferMutex_); + playbackBuffer_ = buffer; + } + waveformPreview_.setBuffer(buffer); + waveformPreview_.setPlayheadProgress(0.0); + PreviewController::applyTransportBuffer(buffer, + session_.timeline, + transportController_, + playbackCursorSamples_); + updateTransportLoopAndZoomUI(); + + ignoreTransportSliderChange_ = true; + transportSlider_.setValue(0.0, juce::dontSendNotification); + ignoreTransportSliderChange_ = false; +} + +void MainComponent::updateTransportDisplay() { + const auto progress = transportController_.progress(); + + ignoreTransportSliderChange_ = true; + transportSlider_.setValue(progress, juce::dontSendNotification); + ignoreTransportSliderChange_ = false; + + waveformPreview_.setPlayheadProgress(progress); + updateTransportLoopAndZoomUI(); + + if (transportController_.state() == engine::TransportController::State::Playing) { + playPauseButton_.setButtonText("Pause"); + } else { + playPauseButton_.setButtonText("Play"); + } + + const auto positionText = formatDuration(transportController_.positionSeconds()); + const auto totalText = formatDuration(transportController_.totalSeconds()); + juce::String tooltip = positionText + " / " + totalText; + if (transportController_.loopEnabled()) { + tooltip += " [Loop " + juce::String(formatDuration(transportController_.loopInSeconds())) + + " - " + juce::String(formatDuration(transportController_.loopOutSeconds())) + "]"; + } + transportSlider_.setTooltip(tooltip); +} + +void MainComponent::updateTransportLoopAndZoomUI() { + const double zoom = std::clamp(session_.timeline.zoom, 1.0, 32.0); + waveformPreview_.setZoom(zoom, transportController_.progress()); + waveformPreview_.setLoopRange(transportController_.loopEnabled(), + transportController_.loopInProgress(), + transportController_.loopOutProgress()); +} + +void MainComponent::appendTaskHistory(const juce::String& line) { + const auto timestamp = juce::Time::getCurrentTime().toString(true, true); + const auto entry = "[" + timestamp + "] " + line; + taskHistoryLines_.push_back(entry); + constexpr size_t kMaxTaskHistory = 120; + bool trimmed = false; + if (taskHistoryLines_.size() > kMaxTaskHistory) { + taskHistoryLines_.erase(taskHistoryLines_.begin(), + taskHistoryLines_.begin() + static_cast(taskHistoryLines_.size() - kMaxTaskHistory)); + trimmed = true; + } + + const auto currentText = taskCenterEditor_.getText(); + if (!trimmed && currentText.isNotEmpty() && currentText != "Task history will appear here.") { + taskCenterEditor_.moveCaretToEnd(false); + taskCenterEditor_.insertTextAtCaret(entry + "\n"); + return; + } + + juce::String rebuiltText; + for (const auto& item : taskHistoryLines_) { + rebuiltText += item; + rebuiltText += "\n"; + } + taskCenterEditor_.setText(rebuiltText, false); +} + +void MainComponent::populateMasterPresetSelectors() { + masterPresetBox_.clear(juce::dontSendNotification); + platformPresetBox_.clear(juce::dontSendNotification); + masterPresetByComboId_.clear(); + platformPresetByComboId_.clear(); + + int masterId = 1; + auto addMasterPreset = [&](const juce::String& label, const domain::MasterPreset preset) { + masterPresetBox_.addItem(label, masterId); + masterPresetByComboId_[masterId] = preset; + ++masterId; + }; + + addMasterPreset("Default Streaming", domain::MasterPreset::DefaultStreaming); + addMasterPreset("Broadcast", domain::MasterPreset::Broadcast); + addMasterPreset("Udio Optimized", domain::MasterPreset::UdioOptimized); + addMasterPreset("Custom", domain::MasterPreset::Custom); + + int platformId = 1; + auto addPlatformPreset = [&](const juce::String& label, const domain::MasterPreset preset) { + platformPresetBox_.addItem(label, platformId); + platformPresetByComboId_[platformId] = preset; + ++platformId; + }; + + addPlatformPreset("Spotify", domain::MasterPreset::Spotify); + addPlatformPreset("Apple Music", domain::MasterPreset::AppleMusic); + addPlatformPreset("YouTube", domain::MasterPreset::YouTube); + addPlatformPreset("Amazon Music", domain::MasterPreset::AmazonMusic); + addPlatformPreset("Tidal", domain::MasterPreset::Tidal); + addPlatformPreset("Broadcast EBU R128", domain::MasterPreset::BroadcastEbuR128); + + masterPresetBox_.setSelectedId(1, juce::dontSendNotification); + platformPresetBox_.setSelectedId(1, juce::dontSendNotification); +} + +domain::MasterPreset MainComponent::selectedMasterPreset() const { + const auto it = masterPresetByComboId_.find(masterPresetBox_.getSelectedId()); + if (it == masterPresetByComboId_.end()) { + return domain::MasterPreset::DefaultStreaming; + } + return it->second; +} + +domain::MasterPreset MainComponent::selectedPlatformPreset() const { + const auto it = platformPresetByComboId_.find(platformPresetBox_.getSelectedId()); + if (it == platformPresetByComboId_.end()) { + return domain::MasterPreset::DefaultStreaming; + } + return it->second; +} + +domain::RenderSettings MainComponent::buildCurrentRenderSettings(const std::string& outputPath) const { + if (exportController_ == nullptr) { + return session_.renderSettings; + } + + BuildRenderSettingsRequest request; + request.outputPath = outputPath; + request.exportSpeedMode = selectedExportSpeedMode(); + request.metadataPolicy = session_.renderSettings.metadataPolicy; + request.metadataTemplate = session_.renderSettings.metadataTemplate; + const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); + request.outputFormat = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; + request.lossyBitrateKbps = static_cast(std::lround(exportBitrateSlider_.getValue())); + request.mp3UseVbr = mp3ModeBox_.getSelectedId() == 2; + request.mp3VbrQuality = static_cast(std::lround(mp3VbrSlider_.getValue())); + request.gpuProviderSelectionId = gpuProviderBox_.getSelectedId(); + request.rendererInfos = rendererInfos_; + + request.selectedRendererId = "BuiltIn"; + const auto rendererIt = rendererIdByComboId_.find(rendererBox_.getSelectedId()); + if (rendererIt != rendererIdByComboId_.end()) { + request.selectedRendererId = rendererIt->second; + } + return exportController_->buildRenderSettings(request); +} + +void MainComponent::beginCancelableTask(const juce::String& statusText, + const juce::String& historyText, + const ActiveTask activeTask) { + cancelImport_.store(false); + cancelModel_.store(false); + cancelSession_.store(false); + cancelMix_.store(false); + cancelMaster_.store(false); + cancelBatch_.store(false); + cancelExport_.store(false); + activeTask_ = activeTask; + taskRunning_.store(true); + cancelButton_.setEnabled(true); + statusLabel_.setText(statusText, juce::dontSendNotification); + appendTaskHistory(historyText); +} + +void MainComponent::finishCancelableTask() { + taskRunning_.store(false); + cancelButton_.setEnabled(false); + activeTask_ = ActiveTask::None; +} + +void MainComponent::requestCancelForActiveTask() { + switch (activeTask_) { + case ActiveTask::Import: + cancelImport_.store(true); + return; + case ActiveTask::Model: + cancelModel_.store(true); + return; + case ActiveTask::Session: + cancelSession_.store(true); + return; + case ActiveTask::AutoMix: + cancelMix_.store(true); + return; + case ActiveTask::AutoMaster: + cancelMaster_.store(true); + return; + case ActiveTask::Batch: + cancelBatch_.store(true); + return; + case ActiveTask::Export: + cancelExport_.store(true); + return; + case ActiveTask::None: + break; + } + + cancelImport_.store(true); + cancelModel_.store(true); + cancelSession_.store(true); + cancelMix_.store(true); + cancelMaster_.store(true); + cancelBatch_.store(true); + cancelExport_.store(true); +} + +void MainComponent::onCancel() { + requestCancelForActiveTask(); + statusLabel_.setText("Cancelling...", juce::dontSendNotification); + appendTaskHistory("Cancellation requested"); +} + +void MainComponent::onImport() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + + importChooser_ = + std::make_unique("Select stem files", juce::File(), "*.wav;*.aiff;*.aif;*.flac;*.mp3;*.ogg"); + constexpr int flags = juce::FileBrowserComponent::openMode | + juce::FileBrowserComponent::canSelectFiles | + juce::FileBrowserComponent::canSelectMultipleItems; + + const auto safeThis = juce::Component::SafePointer(this); + importChooser_->launchAsync(flags, [safeThis](const juce::FileChooser& chooser) { + if (safeThis == nullptr) { + return; + } + const auto files = chooser.getResults(); + if (files.isEmpty()) { + safeThis->importChooser_.reset(); + return; + } + + std::vector selectedFiles; + selectedFiles.reserve(static_cast(files.size())); + for (int i = 0; i < files.size(); ++i) { + selectedFiles.push_back(files.getReference(i)); + } + + safeThis->beginCancelableTask("Import started", + "Import started", + ActiveTask::Import); + safeThis->importController_->importFiles(std::move(selectedFiles), + safeThis->separatedStemsToggle_.getToggleState(), + safeThis->session_.preferredStemCount, + safeThis->cancelImport_); + safeThis->importChooser_.reset(); + }); +} + +void MainComponent::onImportOriginalMix() { + originalMixChooser_ = + std::make_unique("Select original stereo mix", + juce::File(), + "*.wav;*.aiff;*.aif;*.flac;*.mp3;*.ogg"); + constexpr int flags = juce::FileBrowserComponent::openMode | + juce::FileBrowserComponent::canSelectFiles; + + const auto safeThis = juce::Component::SafePointer(this); + originalMixChooser_->launchAsync(flags, [safeThis](const juce::FileChooser& chooser) { + if (safeThis == nullptr) { + return; + } + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + safeThis->originalMixChooser_.reset(); + return; + } + + const auto result = safeThis->originalMixController_->applySelectedPath(selected.getFullPathName().toStdString(), + selected.getFileName().toStdString()); + if (result.applied) { + safeThis->session_.originalMixPath = result.path; + safeThis->clearOriginalMixButton_.setEnabled(true); + safeThis->statusLabel_.setText(result.statusText, juce::dontSendNotification); + safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + "\n" + result.reportLine); + safeThis->appendTaskHistory(result.taskHistoryLine); + } + safeThis->originalMixChooser_.reset(); + }); +} + +void MainComponent::onClearOriginalMix() { + const auto result = originalMixController_->clear(session_.originalMixPath); + if (!result.cleared) { + statusLabel_.setText(result.statusText, juce::dontSendNotification); + clearOriginalMixButton_.setEnabled(false); + return; + } + + session_.originalMixPath.reset(); + clearOriginalMixButton_.setEnabled(false); + statusLabel_.setText(result.statusText, juce::dontSendNotification); + reportEditor_.setText(reportEditor_.getText() + "\n" + result.reportLine); + appendTaskHistory(result.taskHistoryLine); +} + +void MainComponent::onRegenerateCachedRenders() { + engine::OfflineRenderPipeline::clearCaches(); + ExportController::clearHealthCache(); + + statusLabel_.setText("Render caches cleared", juce::dontSendNotification); + appendTaskHistory("Render caches cleared; next render will regenerate intermediates"); + + if (!session_.stems.empty()) { + rebuildPreviewBuffersAsync(); + } +} + +void MainComponent::onSaveSession() { + if (taskRunning_.load()) { + statusLabel_.setText("A background task is already running", juce::dontSendNotification); + return; + } + + saveSessionChooser_ = std::make_unique( + "Save session", juce::File::getSpecialLocation(juce::File::userDocumentsDirectory), "*.json"); + constexpr int flags = juce::FileBrowserComponent::saveMode | + juce::FileBrowserComponent::canSelectFiles | + juce::FileBrowserComponent::warnAboutOverwriting; + + const auto safeThis = juce::Component::SafePointer(this); + saveSessionChooser_->launchAsync(flags, [safeThis](const juce::FileChooser& chooser) { + if (safeThis == nullptr) { + return; + } + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + safeThis->saveSessionChooser_.reset(); + return; + } + + safeThis->session_.renderSettings = + safeThis->buildCurrentRenderSettings(safeThis->session_.renderSettings.outputPath); + safeThis->session_.timeline.zoom = safeThis->zoomSlider_.getValue(); + safeThis->session_.timeline.fineScrub = safeThis->fineScrubToggle_.getToggleState(); + safeThis->session_.timeline.loopEnabled = safeThis->transportController_.loopEnabled(); + safeThis->session_.timeline.loopInSeconds = safeThis->transportController_.loopInSeconds(); + safeThis->session_.timeline.loopOutSeconds = safeThis->transportController_.loopOutSeconds(); + safeThis->beginCancelableTask("Saving session...", + "Session save started: " + selected.getFullPathName(), + ActiveTask::Session); + safeThis->sessionController_->saveSession(selected.getFullPathName().toStdString(), + safeThis->session_, + safeThis->cancelSession_); + safeThis->saveSessionChooser_.reset(); + }); +} + +void MainComponent::onLoadSession() { + if (taskRunning_.load()) { + statusLabel_.setText("A background task is already running", juce::dontSendNotification); + return; + } + + loadSessionChooser_ = std::make_unique("Load session", juce::File(), "*.json"); + constexpr int flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles; + + const auto safeThis = juce::Component::SafePointer(this); + loadSessionChooser_->launchAsync(flags, [safeThis](const juce::FileChooser& chooser) { + if (safeThis == nullptr) { + return; + } + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + safeThis->loadSessionChooser_.reset(); + return; + } + + safeThis->beginCancelableTask("Loading session...", + "Session load started: " + selected.getFullPathName(), + ActiveTask::Session); + safeThis->sessionController_->loadSession(selected.getFullPathName().toStdString(), + safeThis->cancelSession_); + safeThis->loadSessionChooser_.reset(); + }); +} + +void MainComponent::onPreviewOriginal() { + if (transportController_.totalSamples() == 0) { + rebuildPreviewBuffersAsync(); + statusLabel_.setText("Building preview...", juce::dontSendNotification); + return; + } + + const auto progress = transportController_.progress(); + previewEngine_.setSource(engine::PreviewSource::OriginalMix); + const auto preview = previewEngine_.buildCrossfadedPreview(1024); + updateTransportFromBuffer(preview); + transportController_.seekToFraction(progress); + playbackCursorSamples_.store(transportController_.positionSamples()); + transportController_.play(); + previewEngine_.play(); + + statusLabel_.setText("Preview A selected", juce::dontSendNotification); + appendTaskHistory("Preview source switched to Original (A)"); +} + +void MainComponent::onPreviewRendered() { + if (transportController_.totalSamples() == 0) { + rebuildPreviewBuffersAsync(); + statusLabel_.setText("Building preview...", juce::dontSendNotification); + return; + } + + const auto progress = transportController_.progress(); + previewEngine_.setSource(engine::PreviewSource::RenderedMix); + const auto preview = previewEngine_.buildCrossfadedPreview(1024); + updateTransportFromBuffer(preview); + transportController_.seekToFraction(progress); + playbackCursorSamples_.store(transportController_.positionSamples()); + transportController_.play(); + previewEngine_.play(); + + statusLabel_.setText("Preview B selected", juce::dontSendNotification); + appendTaskHistory("Preview source switched to Rendered (B)"); +} + +void MainComponent::onAddExternalRenderer() { + externalRendererChooser_ = std::make_unique("Select external limiter binary"); + constexpr int flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles; + + const auto safeThis = juce::Component::SafePointer(this); + externalRendererChooser_->launchAsync(flags, [safeThis](const juce::FileChooser& chooser) { + if (safeThis == nullptr) { + return; + } + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + safeThis->externalRendererChooser_.reset(); + return; + } + + const auto selectedPath = selected.getFullPathName().toStdString(); + const auto selectedName = selected.getFileName().toStdString(); + if (safeThis->exportController_ != nullptr) { + safeThis->exportController_->validateExternalRenderer(selectedPath, selectedName); + } + + safeThis->externalRendererChooser_.reset(); + }); +} + +void MainComponent::onPrefetchLame() { + if (!util::LameDownloader::isSupportedOnCurrentPlatform()) { + statusLabel_.setText("LAME downloader is not supported on this platform", juce::dontSendNotification); + return; + } + + prefetchLameButton_.setEnabled(false); + statusLabel_.setText("Prefetching LAME...", juce::dontSendNotification); + if (exportController_ != nullptr) { + exportController_->prefetchLame(); + } else { + prefetchLameButton_.setEnabled(true); + } +} + +void MainComponent::onModelsMenu() { + juce::PopupMenu menu; + menu.addItem(1, "Browse & Download Models"); + menu.addItem(2, "Installed Models"); + menu.addItem(3, "Check Updates"); + menu.addItem(4, "Integrity & Licenses"); + menu.addSeparator(); + menu.addItem(5, "Open Model Hub Folder"); + + juce::Component::SafePointer safeThis(this); + menu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&modelsMenuButton_), + [safeThis](const int result) { + if (safeThis == nullptr) { + return; + } + switch (result) { + case 1: + if (safeThis->taskRunning_.load()) { + safeThis->statusLabel_.setText("Another background task is running", juce::dontSendNotification); + break; + } + safeThis->beginCancelableTask("Models: fetching Hugging Face catalog...", + "Models catalog fetch started", + ActiveTask::Model); + safeThis->modelController_->dispatchMenuAction(ModelMenuAction::BrowseAndDownload, + &safeThis->cancelModel_); + break; + case 2: + safeThis->modelController_->dispatchMenuAction(ModelMenuAction::ShowInstalled); + break; + case 3: + if (safeThis->taskRunning_.load()) { + safeThis->statusLabel_.setText("Another background task is running", juce::dontSendNotification); + break; + } + safeThis->beginCancelableTask("Models: checking updates...", + "Model update check started", + ActiveTask::Model); + safeThis->modelController_->dispatchMenuAction(ModelMenuAction::CheckUpdates, + &safeThis->cancelModel_); + break; + case 4: + safeThis->modelController_->dispatchMenuAction(ModelMenuAction::VerifyIntegrity); + break; + case 5: + safeThis->modelController_->dispatchMenuAction(ModelMenuAction::OpenHubFolder); + break; + default: + break; + } + }); +} + +void MainComponent::updateMeterPanel(const automaster::MasteringReport& report) { + meterLufsLabel_.setText("LUFS: " + juce::String(report.integratedLufs, 2), juce::dontSendNotification); + meterShortTermLabel_.setText("Short-term: " + juce::String(report.shortTermLufs, 2), juce::dontSendNotification); + meterTruePeakLabel_.setText("True Peak: " + juce::String(report.truePeakDbtp, 2) + " dBTP", + juce::dontSendNotification); +} + +void MainComponent::onAutoMix() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + + if (session_.stems.empty()) { + statusLabel_.setText("Import stems first", juce::dontSendNotification); + return; + } + + beginCancelableTask("Auto Mix started", "Auto Mix started", ActiveTask::AutoMix); + + std::optional mixPack; + if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("mix")); selected != nullptr) { + mixPack = *selected; + } + + processingController_->runAutoMix(session_, mixPack, cancelMix_); +} + +void MainComponent::onAutoMaster() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + + if (session_.stems.empty()) { + statusLabel_.setText("Import stems first", juce::dontSendNotification); + return; + } + + beginCancelableTask("Auto Master started", "Auto Master started", ActiveTask::AutoMaster); + + auto preset = selectedPlatformPreset(); + if (preset == domain::MasterPreset::Custom) { + preset = selectedMasterPreset(); + } + + const auto settings = buildCurrentRenderSettings(""); + std::optional masterPack; + if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("master")); selected != nullptr) { + masterPack = *selected; + } + + processingController_->runAutoMaster(session_, settings, preset, masterPack, cancelMaster_); +} + +void MainComponent::onBatchImport() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + + batchImportChooser_ = std::make_unique("Select folder for batch mastering"); + constexpr int flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectDirectories; + + const auto safeThis = juce::Component::SafePointer(this); + batchImportChooser_->launchAsync(flags, [safeThis](const juce::FileChooser& chooser) { + if (safeThis == nullptr) { + return; + } + const auto folder = chooser.getResult(); + if (folder == juce::File()) { + safeThis->batchImportChooser_.reset(); + return; + } + + safeThis->beginCancelableTask("Batch preparing...", + "Batch started: " + folder.getFullPathName(), + ActiveTask::Batch); + + const std::filesystem::path inputFolder(folder.getFullPathName().toStdString()); + const auto baseRenderSettings = safeThis->buildCurrentRenderSettings(""); + + safeThis->processingController_->runBatch(inputFolder, baseRenderSettings, safeThis->cancelBatch_); + + safeThis->batchImportChooser_.reset(); + }); +} + +void MainComponent::onExport() { + if (taskRunning_.load()) { + statusLabel_.setText("A render task is already running", juce::dontSendNotification); + return; + } + + if (session_.stems.empty()) { + statusLabel_.setText("Import stems first", juce::dontSendNotification); + return; + } + + const auto rendererSelection = rendererIdByComboId_.find(rendererBox_.getSelectedId()); + const auto selectedRendererId = rendererSelection != rendererIdByComboId_.end() ? rendererSelection->second : std::string("BuiltIn"); + ExportPreflightRequest preflightRequest; + preflightRequest.selectedRendererId = selectedRendererId; + preflightRequest.safetyPolicyId = session_.safetyPolicyId; + preflightRequest.projectProfileId = session_.projectProfileId; + preflightRequest.projectProfiles = projectProfiles_; + const auto preflight = exportController_->preflight(preflightRequest); + if (preflight.taskHistoryText.isNotEmpty()) { + appendTaskHistory(preflight.taskHistoryText); + } + if (!preflight.allowed) { + statusLabel_.setText(preflight.statusText, juce::dontSendNotification); + return; + } + + exportChooser_ = std::make_unique( + "Export master", + juce::File::getSpecialLocation(juce::File::userDocumentsDirectory), + "*.wav;*.flac;*.aiff;*.ogg;*.mp3"); + constexpr int flags = juce::FileBrowserComponent::saveMode | + juce::FileBrowserComponent::canSelectFiles | + juce::FileBrowserComponent::warnAboutOverwriting; + + const auto safeThis = juce::Component::SafePointer(this); + exportChooser_->launchAsync(flags, [safeThis](const juce::FileChooser& chooser) { + if (safeThis == nullptr) { + return; + } + const auto selected = chooser.getResult(); + if (selected == juce::File()) { + safeThis->exportChooser_.reset(); + return; + } + + auto settings = safeThis->buildCurrentRenderSettings(selected.getFullPathName().toStdString()); + + safeThis->beginCancelableTask("Export started", + "Export started: " + selected.getFullPathName(), + ActiveTask::Export); + + safeThis->exportController_->runExport(safeThis->session_, + settings, + safeThis->analysisEntries_, + safeThis->cancelExport_); + + safeThis->exportChooser_.reset(); + }); +} + + +} // namespace automix::app diff --git a/src/app/MainComponentControllers.cpp b/src/app/MainComponentControllers.cpp new file mode 100644 index 0000000..e5f12fb --- /dev/null +++ b/src/app/MainComponentControllers.cpp @@ -0,0 +1,455 @@ +#include "app/MainComponent.h" + +#include "util/StringUtils.h" + +namespace automix::app { +namespace { + +using ::automix::util::toLower; +using ::automix::util::toJuceText; + +juce::String modelLabel(const ai::HubModelInfo& model) { + juce::String label = model.repoId + " [" + model.useCase + "]"; + label += " dls=" + juce::String(model.downloads); + if (model.recommended) { + label += " *"; + } + return label; +} + +} // namespace + +void MainComponent::initializeControllers() { + const auto makeStatusCallback = [](juce::Component::SafePointer safeMain) + -> std::function { + return [safeMain](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = safeMain, msg]() { + if (safeThis != nullptr) { + safeThis->statusLabel_.setText(juce::String(msg), juce::dontSendNotification); + } + }); + }; + }; + + const auto makeTaskHistoryCallback = [](juce::Component::SafePointer safeMain) + -> std::function { + return [safeMain](const std::string& msg) { + juce::MessageManager::callAsync([safeThis = safeMain, msg]() { + if (safeThis != nullptr) { + safeThis->appendTaskHistory(juce::String(msg)); + } + }); + }; + }; + + { + ModelController::Callbacks modelCallbacks; + const auto safeMain = juce::Component::SafePointer(this); + modelCallbacks.onStatus = makeStatusCallback(safeMain); + modelCallbacks.onTaskHistory = makeTaskHistoryCallback(safeMain); + modelCallbacks.onReport = [safeMain](const std::string& text) { + juce::MessageManager::callAsync([safeThis = safeMain, text]() { + if (safeThis != nullptr) { + safeThis->reportEditor_.setText(juce::String(text)); + } + }); + }; + modelCallbacks.onModelPacksChanged = [safeMain]() { + juce::MessageManager::callAsync([safeThis = safeMain]() { + if (safeThis != nullptr) { + safeThis->refreshModelPacks(); + } + }); + }; + modelCallbacks.onCatalogReady = [safeMain]() { + if (safeMain == nullptr || safeMain->modelController_ == nullptr) { + return; + } + + const auto& models = safeMain->modelController_->discoveredModels(); + if (models.empty()) { + return; + } + + juce::PopupMenu modelMenu; + int itemId = 1000; + for (const auto& model : models) { + modelMenu.addItem(itemId++, modelLabel(model)); + } + modelMenu.addSeparator(); + modelMenu.addItem(1900, "Refresh"); + const auto safeThis = safeMain; + + modelMenu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&safeMain->modelsMenuButton_), + [safeThis](const int selection) { + if (safeThis == nullptr) { + return; + } + if (selection == 1900) { + if (safeThis->taskRunning_.load()) { + safeThis->statusLabel_.setText("Another background task is running", juce::dontSendNotification); + return; + } + safeThis->beginCancelableTask("Models: fetching Hugging Face catalog...", + "Models catalog fetch started", + ActiveTask::Model); + safeThis->modelController_->fetchCatalog(safeThis->cancelModel_); + return; + } + const auto& discovered = safeThis->modelController_->discoveredModels(); + if (selection < 1000 || + selection >= 1000 + static_cast(discovered.size())) { + return; + } + const auto& model = discovered[static_cast(selection - 1000)]; + if (safeThis->taskRunning_.load()) { + safeThis->statusLabel_.setText("Another background task is running", juce::dontSendNotification); + return; + } + safeThis->beginCancelableTask("Models: installing " + model.repoId, + "Model install started: " + model.repoId, + ActiveTask::Model); + safeThis->modelController_->installModel(model.repoId, safeThis->cancelModel_); + }); + }; + modelCallbacks.onRevealModelHubFolder = [safeMain](const std::filesystem::path& folderPath) { + juce::MessageManager::callAsync([safeThis = safeMain, folderPath]() { + if (safeThis == nullptr) { + return; + } + juce::File(folderPath.string()).revealToUser(); + }); + }; + modelCallbacks.onAsyncTaskComplete = [safeMain]() { + juce::MessageManager::callAsync([safeThis = safeMain]() { + if (safeThis != nullptr) { + safeThis->finishCancelableTask(); + } + }); + }; + modelController_ = std::make_unique(modelManager_, backgroundPool_, std::move(modelCallbacks)); + } + + { + ImportController::Callbacks importCallbacks; + const auto safeMain = juce::Component::SafePointer(this); + importCallbacks.onStatus = makeStatusCallback(safeMain); + importCallbacks.onTaskHistory = makeTaskHistoryCallback(safeMain); + importCallbacks.onImportComplete = [safeMain](ImportResult result) { + if (safeMain == nullptr) { + return; + } + safeMain->finishCancelableTask(); + if (result.cancelled) { + safeMain->statusLabel_.setText("Import cancelled", juce::dontSendNotification); + safeMain->appendTaskHistory("Import cancelled"); + return; + } + safeMain->session_.stems = std::move(result.stems); + safeMain->statusLabel_.setText( + "Imported " + juce::String(static_cast(safeMain->session_.stems.size())) + " stems", + juce::dontSendNotification); + safeMain->appendTaskHistory("Imported " + juce::String(static_cast(safeMain->session_.stems.size())) + " stems"); + + safeMain->analysisEntries_.clear(); + safeMain->analysisTableModel_.setEntries(&safeMain->analysisEntries_); + safeMain->analysisTable_.updateContent(); + + safeMain->refreshStemRoutingSelectors(); + safeMain->reportEditor_.setText(juce::String("Imported files:\n") + toJuceText(result.logLines)); + safeMain->rebuildPreviewBuffersAsync(); + }; + importController_ = std::make_unique(backgroundPool_, std::move(importCallbacks)); + } + + { + ExportController::Callbacks exportCallbacks; + const auto safeMain = juce::Component::SafePointer(this); + exportCallbacks.onStatus = makeStatusCallback(safeMain); + exportCallbacks.onTaskHistory = makeTaskHistoryCallback(safeMain); + exportCallbacks.onExportComplete = [safeMain](ExportResult result) { + if (safeMain == nullptr) { + return; + } + + safeMain->finishCancelableTask(); + + if (!result.analysisEntries.empty()) { + safeMain->analysisEntries_ = std::move(result.analysisEntries); + safeMain->analysisTableModel_.setEntries(&safeMain->analysisEntries_); + safeMain->analysisTable_.updateContent(); + } + + if (!result.crashMessage.isEmpty()) { + safeMain->statusLabel_.setText("Export crashed", juce::dontSendNotification); + safeMain->reportEditor_.setText(result.crashMessage); + safeMain->appendTaskHistory("Export crashed"); + return; + } + + if (result.cancelled) { + safeMain->statusLabel_.setText("Export cancelled", juce::dontSendNotification); + safeMain->appendTaskHistory("Export cancelled"); + return; + } + + const bool quickExportMode = toLower(result.exportSpeedMode) == "quick"; + if (quickExportMode) { + safeMain->appendTaskHistory("Quick export mode active: stem-health preflight skipped"); + } else if (result.healthIssueCount > 0) { + safeMain->appendTaskHistory( + "Stem health check found " + juce::String(static_cast(result.healthIssueCount)) + " issue(s)"); + } else { + safeMain->appendTaskHistory("Stem health check passed"); + } + + safeMain->statusLabel_.setText(result.success ? "Export complete" : "Export failed", + juce::dontSendNotification); + if (result.healthHasCriticalIssues && result.success) { + safeMain->statusLabel_.setText("Export complete with critical stem health warnings", juce::dontSendNotification); + } + safeMain->appendTaskHistory(result.success ? "Export completed" : "Export failed"); + juce::String report = juce::String("Renderer: ") + juce::String(result.rendererName) + + juce::String("\nExport mode: ") + juce::String(result.exportSpeedMode) + + juce::String("\nOutput: ") + juce::String(result.outputAudioPath) + + juce::String("\nReport: ") + juce::String(result.reportPath) + + juce::String("\n\nLogs:\n") + toJuceText(result.logs); + if (!result.healthText.isEmpty()) { + report += "\n\n"; + report += result.healthText; + } + safeMain->reportEditor_.setText(report); + }; + exportCallbacks.onExternalRendererValidated = [safeMain](ExternalRendererValidationResult result) { + if (safeMain == nullptr) { + return; + } + + renderers::ExternalRendererConfig config; + config.id = "ExternalUserUI" + std::to_string(safeMain->userExternalRendererConfigs_.size() + 1); + config.name = result.selectedName; + config.binaryPath = result.selectedPath; + config.licenseId = "User-supplied"; + safeMain->userExternalRendererConfigs_.push_back(config); + safeMain->refreshRenderers(); + + const juce::String statusText = + result.valid ? "External renderer added" : "External renderer added (validation failed)"; + safeMain->statusLabel_.setText(statusText, juce::dontSendNotification); + safeMain->appendTaskHistory(statusText); + safeMain->reportEditor_.setText(safeMain->reportEditor_.getText() + + "\nAdded external renderer: " + juce::String(result.selectedPath) + + "\nValidation: " + juce::String(result.valid ? "passed" : "failed") + + " (" + juce::String(result.diagnostics) + ")" + + "\nLicense note: user-supplied tool is not distributed by this app."); + }; + exportCallbacks.onLamePrefetchComplete = [safeMain](LamePrefetchResult result) { + if (safeMain == nullptr) { + return; + } + safeMain->prefetchLameButton_.setEnabled(true); + safeMain->refreshCodecAvailability(); + + if (result.success) { + safeMain->statusLabel_.setText("LAME ready for MP3 export", juce::dontSendNotification); + safeMain->appendTaskHistory("LAME prefetch completed"); + } else { + safeMain->statusLabel_.setText("LAME prefetch failed", juce::dontSendNotification); + safeMain->appendTaskHistory("LAME prefetch failed"); + } + + juce::String report = safeMain->reportEditor_.getText(); + if (!report.isEmpty()) { + report += "\n"; + } + report += "LAME prefetch: "; + report += result.success ? "success" : "failed"; + if (!result.executablePath.empty()) { + report += "\nPath: " + juce::String(result.executablePath); + } + if (!result.detail.empty()) { + report += "\nDetail: " + juce::String(result.detail); + } + safeMain->reportEditor_.setText(report); + }; + exportController_ = std::make_unique(backgroundPool_, std::move(exportCallbacks)); + } + + profileController_ = std::make_unique(); + + { + PreviewController::Callbacks previewCallbacks; + const auto safeMain = juce::Component::SafePointer(this); + previewCallbacks.onPreviewReady = [safeMain](PreviewBuildResult result) { + if (safeMain == nullptr) { + return; + } + if (result.generation != safeMain->previewBuildGeneration_.load()) { + return; + } + if (!result.errorText.isEmpty()) { + safeMain->reportEditor_.setText(safeMain->reportEditor_.getText() + + "\nPreview rebuild skipped: " + result.errorText); + return; + } + if (!result.success) { + return; + } + + safeMain->previewEngine_.setBuffers(result.rawMix, result.mastered); + safeMain->updateTransportFromBuffer(result.preview); + safeMain->transportController_.seekToFraction(result.previousProgress); + safeMain->playbackCursorSamples_.store(safeMain->transportController_.positionSamples()); + }; + previewController_ = std::make_unique(backgroundPool_, std::move(previewCallbacks)); + } + + { + ProcessingController::Callbacks processingCallbacks; + const auto safeMain = juce::Component::SafePointer(this); + processingCallbacks.onStatus = makeStatusCallback(safeMain); + processingCallbacks.onTaskHistory = makeTaskHistoryCallback(safeMain); + processingCallbacks.onAutoMixComplete = [safeMain](AutoMixResult result) { + if (safeMain == nullptr) { + return; + } + + safeMain->finishCancelableTask(); + + if (!result.errorText.isEmpty()) { + safeMain->statusLabel_.setText("Auto Mix failed", juce::dontSendNotification); + safeMain->reportEditor_.setText(result.errorText); + safeMain->appendTaskHistory("Auto Mix failed"); + return; + } + + if (result.cancelled) { + safeMain->statusLabel_.setText("Auto Mix cancelled", juce::dontSendNotification); + safeMain->appendTaskHistory("Auto Mix cancelled"); + return; + } + + safeMain->analysisEntries_ = std::move(result.analysisEntries); + safeMain->analysisTableModel_.setEntries(&safeMain->analysisEntries_); + safeMain->analysisTable_.updateContent(); + + if (result.mixPlan.has_value()) { + safeMain->session_.mixPlan = result.mixPlan.value(); + } + + if (!result.reportText.isEmpty()) { + safeMain->reportEditor_.setText(result.reportText); + } + + safeMain->statusLabel_.setText("Auto Mix plan generated", juce::dontSendNotification); + safeMain->appendTaskHistory("Auto Mix completed"); + safeMain->rebuildPreviewBuffersAsync(); + }; + processingCallbacks.onAutoMasterComplete = [safeMain](AutoMasterResult result) { + if (safeMain == nullptr) { + return; + } + + safeMain->finishCancelableTask(); + + if (!result.errorText.isEmpty()) { + safeMain->statusLabel_.setText("Auto Master failed", juce::dontSendNotification); + safeMain->reportEditor_.setText(result.errorText); + safeMain->appendTaskHistory("Auto Master failed"); + return; + } + + if (result.cancelled) { + safeMain->statusLabel_.setText("Auto Master cancelled", juce::dontSendNotification); + safeMain->appendTaskHistory("Auto Master cancelled"); + return; + } + + safeMain->session_.masterPlan = std::move(result.masterPlan); + safeMain->previewEngine_.setBuffers(result.rawMixBuffer, result.previewMaster); + safeMain->previewEngine_.setSource(engine::PreviewSource::OriginalMix); + safeMain->previewEngine_.stop(); + safeMain->updateTransportFromBuffer(safeMain->previewEngine_.buildCrossfadedPreview(1024)); + safeMain->updateMeterPanel(result.previewReport); + + safeMain->statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); + safeMain->appendTaskHistory("Auto Master completed"); + if (!result.reportAppend.isEmpty()) { + safeMain->reportEditor_.setText(safeMain->reportEditor_.getText() + result.reportAppend); + } + }; + processingCallbacks.onBatchComplete = [safeMain](BatchResult result) { + if (safeMain == nullptr) { + return; + } + + safeMain->finishCancelableTask(); + + if (!result.errorText.isEmpty()) { + if (result.summary.isEmpty()) { + safeMain->statusLabel_.setText("Batch preparation failed", juce::dontSendNotification); + safeMain->reportEditor_.setText(result.errorText); + safeMain->appendTaskHistory("Batch preparation failed"); + } else { + safeMain->statusLabel_.setText("Batch folder has no supported audio files", juce::dontSendNotification); + safeMain->appendTaskHistory("Batch preparation found no supported files"); + } + return; + } + + safeMain->statusLabel_.setText("Batch complete", juce::dontSendNotification); + safeMain->reportEditor_.setText(result.summary); + safeMain->appendTaskHistory("Batch completed"); + }; + processingController_ = std::make_unique(backgroundPool_, std::move(processingCallbacks)); + } + + { + SessionController::Callbacks sessionCallbacks; + const auto safeMain = juce::Component::SafePointer(this); + sessionCallbacks.onStatus = makeStatusCallback(safeMain); + sessionCallbacks.onTaskHistory = makeTaskHistoryCallback(safeMain); + sessionCallbacks.onSaveComplete = [safeMain](SessionSaveResult result) { + if (safeMain == nullptr) { + return; + } + safeMain->finishCancelableTask(); + if (result.cancelled) { + safeMain->statusLabel_.setText("Session save cancelled", juce::dontSendNotification); + safeMain->appendTaskHistory("Session save cancelled"); + return; + } + if (result.success) { + safeMain->statusLabel_.setText("Session saved", juce::dontSendNotification); + safeMain->appendTaskHistory("Session saved: " + juce::String(result.path)); + } else { + safeMain->statusLabel_.setText("Save failed", juce::dontSendNotification); + safeMain->reportEditor_.setText("Session save error:\n" + result.errorText); + safeMain->appendTaskHistory("Session save failed"); + } + }; + sessionCallbacks.onLoadComplete = [safeMain](SessionLoadResult result) { + if (safeMain == nullptr) { + return; + } + safeMain->finishCancelableTask(); + if (result.cancelled) { + safeMain->statusLabel_.setText("Session load cancelled", juce::dontSendNotification); + safeMain->appendTaskHistory("Session load cancelled"); + return; + } + if (result.session.has_value()) { + safeMain->applyLoadedSession(std::move(result.session.value()), result.path); + } else { + safeMain->statusLabel_.setText("Load failed", juce::dontSendNotification); + safeMain->reportEditor_.setText("Session load error:\n" + result.errorText); + safeMain->appendTaskHistory("Session load failed"); + } + }; + sessionController_ = std::make_unique(backgroundPool_, std::move(sessionCallbacks)); + } + + originalMixController_ = std::make_unique(); +} + +} // namespace automix::app diff --git a/src/app/WaveformPreviewComponent.cpp b/src/app/WaveformPreviewComponent.cpp new file mode 100644 index 0000000..09f0389 --- /dev/null +++ b/src/app/WaveformPreviewComponent.cpp @@ -0,0 +1,137 @@ +#include "app/WaveformPreviewComponent.h" + +#include +#include + +namespace automix::app { + +void WaveformPreviewComponent::setBuffer(const engine::AudioBuffer& buffer) { + waveform_.clear(); + + const int samples = buffer.getNumSamples(); + if (buffer.getNumChannels() <= 0 || samples <= 0) { + repaint(); + return; + } + + const int columns = std::max(512, getWidth() > 0 ? getWidth() * 4 : 2048); + waveform_.assign(static_cast(columns), 0.0f); + + const int blockSamples = std::max(1, samples / columns); + for (int x = 0; x < columns; ++x) { + const int start = x * blockSamples; + const int end = std::min(samples, start + blockSamples); + + float peak = 0.0f; + for (int i = start; i < end; ++i) { + float mono = 0.0f; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + mono += buffer.getSample(ch, i); + } + mono /= static_cast(buffer.getNumChannels()); + peak = std::max(peak, std::abs(mono)); + } + waveform_[static_cast(x)] = peak; + } + + repaint(); +} + +void WaveformPreviewComponent::setPlayheadProgress(const double progress) { + playheadProgress_ = std::clamp(progress, 0.0, 1.0); + repaint(); +} + +void WaveformPreviewComponent::setZoom(const double zoomFactor, const double centerProgress) { + zoomFactor_ = std::clamp(zoomFactor, 1.0, 64.0); + zoomCenterProgress_ = std::clamp(centerProgress, 0.0, 1.0); + repaint(); +} + +void WaveformPreviewComponent::setLoopRange(const bool enabled, + const double loopStartProgress, + const double loopEndProgress) { + loopEnabled_ = enabled; + loopStartProgress_ = std::clamp(loopStartProgress, 0.0, 1.0); + loopEndProgress_ = std::clamp(loopEndProgress, 0.0, 1.0); + if (loopEndProgress_ <= loopStartProgress_) { + loopEnabled_ = false; + } + repaint(); +} + +void WaveformPreviewComponent::paint(juce::Graphics& g) { + auto area = getLocalBounds(); + g.fillAll(juce::Colour::fromRGB(16, 18, 24)); + + if (waveform_.empty()) { + g.setColour(juce::Colours::white.withAlpha(0.35f)); + g.drawFittedText("Waveform preview", area, juce::Justification::centred, 1); + return; + } + + const float centerY = static_cast(area.getCentreY()); + const float halfHeight = static_cast(area.getHeight()) * 0.42f; + const int width = std::max(1, area.getWidth()); + + const double windowFraction = 1.0 / std::max(1.0, zoomFactor_); + const double maxStart = std::max(0.0, 1.0 - windowFraction); + const double visibleStart = std::clamp(zoomCenterProgress_ - windowFraction * 0.5, 0.0, maxStart); + const double visibleEnd = std::min(1.0, visibleStart + windowFraction); + + const int totalColumns = static_cast(waveform_.size()); + const int startIndex = std::clamp(static_cast(std::floor(visibleStart * static_cast(totalColumns - 1))), 0, totalColumns - 1); + const int endIndex = std::clamp(static_cast(std::ceil(visibleEnd * static_cast(totalColumns - 1))), + startIndex + 1, + totalColumns - 1); + const int visibleColumns = std::max(1, endIndex - startIndex); + + if (loopEnabled_) { + const auto toVisibleX = [&](const double progress) { + if (progress < visibleStart || progress > visibleEnd) { + return -1; + } + const double normalized = (progress - visibleStart) / std::max(1.0e-9, (visibleEnd - visibleStart)); + return static_cast(std::round(normalized * static_cast(width - 1))); + }; + + const int loopStartX = toVisibleX(loopStartProgress_); + const int loopEndX = toVisibleX(loopEndProgress_); + if (loopStartX >= 0 && loopEndX >= 0 && loopEndX > loopStartX) { + g.setColour(juce::Colour::fromRGB(90, 130, 210).withAlpha(0.18f)); + g.fillRect(loopStartX, area.getY(), loopEndX - loopStartX, area.getHeight()); + g.setColour(juce::Colour::fromRGB(140, 180, 255).withAlpha(0.65f)); + g.drawVerticalLine(loopStartX, static_cast(area.getY()), static_cast(area.getBottom())); + g.drawVerticalLine(loopEndX, static_cast(area.getY()), static_cast(area.getBottom())); + } + } + + g.setColour(juce::Colour::fromRGB(65, 180, 255).withAlpha(0.8f)); + for (int x = 0; x < width; ++x) { + const double interp = static_cast(x) / static_cast(std::max(1, width - 1)); + const int sampleIndex = startIndex + static_cast(std::round(interp * static_cast(visibleColumns))); + const int clampedIndex = std::clamp(sampleIndex, 0, totalColumns - 1); + const float value = std::clamp(waveform_[static_cast(clampedIndex)], 0.0f, 1.0f); + const float h = value * halfHeight; + g.drawVerticalLine(area.getX() + x, centerY - h, centerY + h); + } + + int playheadX = 0; + if (playheadProgress_ <= visibleStart) { + playheadX = area.getX(); + } else if (playheadProgress_ >= visibleEnd) { + playheadX = area.getRight() - 1; + } else { + const double normalized = (playheadProgress_ - visibleStart) / std::max(1.0e-9, (visibleEnd - visibleStart)); + playheadX = area.getX() + static_cast(std::round(normalized * static_cast(width - 1))); + } + + g.setColour(juce::Colour::fromRGB(255, 190, 64)); + g.drawLine(static_cast(playheadX), static_cast(area.getY()), static_cast(playheadX), + static_cast(area.getBottom()), 2.0f); + + g.setColour(juce::Colours::white.withAlpha(0.2f)); + g.drawRect(area); +} + +} // namespace automix::app diff --git a/src/app/WaveformPreviewComponent.h b/src/app/WaveformPreviewComponent.h new file mode 100644 index 0000000..84ab8ff --- /dev/null +++ b/src/app/WaveformPreviewComponent.h @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include + +#include "engine/AudioBuffer.h" + +namespace automix::app { + +class WaveformPreviewComponent final : public juce::Component { + public: + void setBuffer(const engine::AudioBuffer& buffer); + void setPlayheadProgress(double progress); + void setZoom(double zoomFactor, double centerProgress); + void setLoopRange(bool enabled, double loopStartProgress, double loopEndProgress); + + void paint(juce::Graphics& g) override; + + private: + std::vector waveform_; + double playheadProgress_ = 0.0; + double zoomFactor_ = 1.0; + double zoomCenterProgress_ = 0.5; + bool loopEnabled_ = false; + double loopStartProgress_ = 0.0; + double loopEndProgress_ = 0.0; +}; + +} // namespace automix::app diff --git a/src/app/controllers/ExportController.cpp b/src/app/controllers/ExportController.cpp new file mode 100644 index 0000000..2db466f --- /dev/null +++ b/src/app/controllers/ExportController.cpp @@ -0,0 +1,245 @@ +#include "app/controllers/ExportController.h" + +#include +#include +#include +#include +#include + +#include "analysis/StemHealthAssistant.h" +#include "renderers/RendererFactory.h" +#include "util/StringUtils.h" + +namespace automix::app { +namespace { + +using ::automix::util::toLower; + +struct ExportHealthCacheEntry { + std::string key; + juce::String text; + bool hasCriticalIssues = false; + size_t issueCount = 0; +}; + +std::mutex& exportHealthCacheMutex() { + static std::mutex mutex; + return mutex; +} + +std::optional& exportHealthCache() { + static std::optional cache; + return cache; +} + +std::string buildHealthCacheKey(const domain::Session& session, + const std::vector& analysisEntries) { + std::ostringstream key; + key << "stems=" << session.stems.size() << "|entries=" << analysisEntries.size() << '|'; + + for (const auto& stem : session.stems) { + key << stem.id << ':' << stem.filePath << ':' << stem.enabled; + if (stem.busId.has_value()) { + key << ':' << stem.busId.value(); + } + + std::error_code error; + const auto writeTime = std::filesystem::last_write_time(stem.filePath, error); + if (!error) { + const auto ticks = std::chrono::duration_cast(writeTime.time_since_epoch()).count(); + key << ':' << ticks; + } + const auto size = std::filesystem::file_size(stem.filePath, error); + if (!error) { + key << ':' << size; + } + key << '|'; + } + + if (session.mixPlan.has_value()) { + key << "mixPlan:" << session.mixPlan->dryWet + << ':' << session.mixPlan->mixBusHeadroomDb + << ':' << session.mixPlan->stemDecisions.size(); + } else { + key << "mixPlan:none"; + } + + return key.str(); +} + +} // namespace + +void ExportController::clearHealthCache() { + std::scoped_lock lock(exportHealthCacheMutex()); + exportHealthCache().reset(); +} + +ExportController::ExportController(juce::ThreadPool& threadPool, Callbacks callbacks) + : threadPool_(threadPool), callbacks_(std::move(callbacks)) {} + +void ExportController::runExport(const domain::Session& session, + const domain::RenderSettings& settings, + const std::vector& analysisEntries, + std::atomic_bool& cancelFlag) { + const bool quickExportMode = toLower(settings.exportSpeedMode) == "quick"; + + struct RenderJob final : juce::ThreadPoolJob { + domain::Session session; + domain::RenderSettings settings; + std::vector analysisEntries; + std::atomic_bool* cancelFlag; + bool quickExportMode; + Callbacks callbacks; + + RenderJob(domain::Session sess, domain::RenderSettings sett, + std::vector entries, + std::atomic_bool* cancel, bool quick, Callbacks cb) + : juce::ThreadPoolJob("ExportJob"), + session(std::move(sess)), + settings(std::move(sett)), + analysisEntries(std::move(entries)), + cancelFlag(cancel), + quickExportMode(quick), + callbacks(std::move(cb)) {} + + JobStatus runJob() override { + renderers::RenderResult renderResult; + juce::String crashMessage; + juce::String healthText; + bool healthHasCriticalIssues = false; + size_t healthIssueCount = 0; + + try { + if (quickExportMode) { + healthText = "Quick export mode: stem-health preflight skipped for faster turnaround."; + } else { + if (analysisEntries.empty()) { + if (callbacks.onStatus) { + callbacks.onStatus("Export: analyzing stems"); + } + + analysis::StemAnalyzer analyzer; + analysisEntries = analyzer.analyzeSession(session); + } + const auto healthCacheKey = buildHealthCacheKey(session, analysisEntries); + bool healthCacheHit = false; + { + std::scoped_lock lock(exportHealthCacheMutex()); + const auto& cached = exportHealthCache(); + if (cached.has_value() && cached->key == healthCacheKey) { + healthText = cached->text; + healthHasCriticalIssues = cached->hasCriticalIssues; + healthIssueCount = cached->issueCount; + healthCacheHit = true; + } + } + + if (!healthCacheHit) { + analysis::StemHealthAssistant healthAssistant; + const auto healthReport = healthAssistant.analyze(session, analysisEntries); + healthText = juce::String(healthAssistant.toText(healthReport)); + healthHasCriticalIssues = healthReport.hasCriticalIssues; + healthIssueCount = healthReport.issues.size(); + + std::scoped_lock lock(exportHealthCacheMutex()); + exportHealthCache() = ExportHealthCacheEntry{ + .key = healthCacheKey, + .text = healthText, + .hasCriticalIssues = healthHasCriticalIssues, + .issueCount = healthIssueCount, + }; + } + } + + auto renderer = renderers::createRenderer(settings.rendererName); + + std::mutex progressMutex; + auto lastProgressEmit = std::chrono::steady_clock::time_point {}; + double lastProgressFraction = -1.0; + std::string lastProgressStage; + auto capturedCallbacks = callbacks; + + renderResult = renderer->render( + session, + settings, + [capturedCallbacks, &progressMutex, &lastProgressEmit, &lastProgressFraction, &lastProgressStage]( + const double progress, const std::string& stage) { + bool emit = false; + { + std::scoped_lock lock(progressMutex); + const auto now = std::chrono::steady_clock::now(); + const bool stageChanged = stage != lastProgressStage; + const bool finalProgress = progress >= 0.999; + const bool timeGateOpen = + lastProgressEmit.time_since_epoch().count() == 0 || + now - lastProgressEmit >= std::chrono::milliseconds(180); + const bool deltaGateOpen = std::abs(progress - lastProgressFraction) >= 0.02; + emit = stageChanged || finalProgress || (timeGateOpen && deltaGateOpen); + if (emit) { + lastProgressEmit = now; + lastProgressFraction = progress; + lastProgressStage = stage; + } + } + if (!emit) { + return; + } + + if (stage == "Mix render cache hit") { + if (capturedCallbacks.onStatus) { + capturedCallbacks.onStatus("Export: Using cached mix render (fast path)"); + } + if (capturedCallbacks.onTaskHistory) { + capturedCallbacks.onTaskHistory("Export using cached mix render"); + } + return; + } + if (capturedCallbacks.onStatus) { + capturedCallbacks.onStatus("Export: " + stage + " (" + + std::to_string(static_cast(progress * 100.0)) + "%)"); + } + if (progress >= 0.999 || stage != "Summing stem buses") { + if (capturedCallbacks.onTaskHistory) { + capturedCallbacks.onTaskHistory("Export " + stage + " " + + std::to_string(static_cast(progress * 100.0)) + "%"); + } + } + }, + cancelFlag); + } catch (const std::exception& error) { + crashMessage = "Export exception:\n" + juce::String(error.what()); + } catch (...) { + crashMessage = "Export exception:\nUnknown error"; + } + + ExportResult result; + result.success = renderResult.success; + result.cancelled = renderResult.cancelled; + result.rendererName = renderResult.rendererName; + result.outputAudioPath = renderResult.outputAudioPath; + result.reportPath = renderResult.reportPath; + result.exportSpeedMode = settings.exportSpeedMode; + result.logs = std::move(renderResult.logs); + result.analysisEntries = std::move(analysisEntries); + result.healthText = healthText; + result.healthHasCriticalIssues = healthHasCriticalIssues; + result.healthIssueCount = healthIssueCount; + result.crashMessage = crashMessage; + + auto capturedCallbacks = callbacks; + juce::MessageManager::callAsync([capturedCallbacks, result = std::move(result)]() mutable { + if (capturedCallbacks.onExportComplete) { + capturedCallbacks.onExportComplete(std::move(result)); + } + }); + + return jobHasFinished; + } + }; + + threadPool_.addJob( + new RenderJob(session, settings, analysisEntries, &cancelFlag, quickExportMode, callbacks_), + true); +} + +} // namespace automix::app diff --git a/src/app/controllers/ExportController.h b/src/app/controllers/ExportController.h new file mode 100644 index 0000000..f8e91b3 --- /dev/null +++ b/src/app/controllers/ExportController.h @@ -0,0 +1,54 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "analysis/StemAnalyzer.h" +#include "domain/Session.h" +#include "renderers/RendererRegistry.h" + +namespace automix::app { + +struct ExportResult { + bool success = false; + bool cancelled = false; + std::string rendererName; + std::string outputAudioPath; + std::string reportPath; + std::string exportSpeedMode; + std::vector logs; + std::vector analysisEntries; + juce::String healthText; + bool healthHasCriticalIssues = false; + size_t healthIssueCount = 0; + juce::String crashMessage; +}; + +class ExportController { + public: + struct Callbacks { + std::function onStatus; + std::function onTaskHistory; + std::function onExportComplete; + }; + + ExportController(juce::ThreadPool& threadPool, Callbacks callbacks); + + void runExport(const domain::Session& session, + const domain::RenderSettings& settings, + const std::vector& analysisEntries, + std::atomic_bool& cancelFlag); + + static void clearHealthCache(); + + private: + juce::ThreadPool& threadPool_; + Callbacks callbacks_; +}; + +} // namespace automix::app diff --git a/src/app/controllers/ImportController.cpp b/src/app/controllers/ImportController.cpp new file mode 100644 index 0000000..e24c3de --- /dev/null +++ b/src/app/controllers/ImportController.cpp @@ -0,0 +1,115 @@ +#include "app/controllers/ImportController.h" + +#include + +#include "ai/StemSeparator.h" + +namespace automix::app { + +ImportController::ImportController(juce::ThreadPool& threadPool, Callbacks callbacks) + : threadPool_(threadPool), callbacks_(std::move(callbacks)) {} + +void ImportController::importFiles(std::vector files, + const bool useSeparation, + const int preferredStemCount) { + if (files.empty()) { + return; + } + + if (callbacks_.onStatus) { + callbacks_.onStatus("Importing files..."); + } + if (callbacks_.onTaskHistory) { + callbacks_.onTaskHistory("Import started"); + } + + struct ImportJob final : juce::ThreadPoolJob { + std::vector files; + bool useSeparation; + int preferredStemCount; + Callbacks callbacks; + + ImportJob(std::vector f, bool sep, int stemCount, Callbacks cb) + : juce::ThreadPoolJob("ImportJob"), + files(std::move(f)), + useSeparation(sep), + preferredStemCount(stemCount), + callbacks(std::move(cb)) {} + + JobStatus runJob() override { + std::vector importedStems; + std::vector importLines; + + if (files.size() == 1 && useSeparation) { + try { + const auto mixPath = std::filesystem::path(files.front().getFullPathName().toStdString()); + const auto outputDir = mixPath.parent_path() / (mixPath.stem().string() + "_separated"); + + ai::StemSeparator separator; + ai::StemSeparator::SeparationOptions separationOptions; + separationOptions.targetStemCount = preferredStemCount; + const auto separationResult = separator.separate(mixPath, outputDir, separationOptions); + if (separationResult.success) { + importedStems = separationResult.stems; + importLines.push_back("Separated import from: " + mixPath.string()); + importLines.push_back("Variant stems: " + std::to_string(separationResult.stemVariantCount)); + for (const auto& stem : separationResult.stems) { + std::string line = " stem -> " + stem.filePath + " role=" + domain::toString(stem.role); + if (stem.separationConfidence.has_value()) { + line += " confidence=" + std::to_string(stem.separationConfidence.value()); + } + if (stem.separationArtifactRisk.has_value()) { + line += " artifactRisk=" + std::to_string(stem.separationArtifactRisk.value()); + } + importLines.push_back(line); + } + if (!separationResult.qaReportPath.empty()) { + importLines.push_back("Separation QA report: " + separationResult.qaReportPath.string()); + } + importLines.push_back("QA energyLeakage=" + std::to_string(separationResult.qaMetrics.energyLeakage) + + " residualDistortion=" + std::to_string(separationResult.qaMetrics.residualDistortion) + + " transientRetention=" + std::to_string(separationResult.qaMetrics.transientRetention)); + importLines.push_back(separationResult.logMessage); + } else { + importLines.push_back("Separation failed, importing original mix file as stem."); + importLines.push_back(separationResult.logMessage); + } + } catch (const std::exception& error) { + importLines.push_back("Separation error: " + std::string(error.what())); + } catch (...) { + importLines.push_back("Separation error: unknown failure"); + } + } + + if (importedStems.empty()) { + for (size_t i = 0; i < files.size(); ++i) { + const auto& file = files[i]; + + domain::Stem stem; + stem.id = "stem_" + std::to_string(i + 1); + stem.name = file.getFileNameWithoutExtension().toStdString(); + stem.filePath = file.getFullPathName().toStdString(); + stem.origin = useSeparation ? domain::StemOrigin::Separated : domain::StemOrigin::Recorded; + stem.enabled = true; + importedStems.push_back(stem); + + importLines.push_back(stem.name + " -> " + stem.filePath); + } + } + + auto capturedCallbacks = callbacks; + ImportResult result{std::move(importedStems), std::move(importLines)}; + juce::MessageManager::callAsync([capturedCallbacks, result = std::move(result)]() mutable { + if (capturedCallbacks.onImportComplete) { + capturedCallbacks.onImportComplete(std::move(result)); + } + }); + + return jobHasFinished; + } + }; + + threadPool_.addJob(new ImportJob(std::move(files), useSeparation, preferredStemCount, callbacks_), true); +} + +} // namespace automix::app diff --git a/src/app/controllers/ImportController.h b/src/app/controllers/ImportController.h new file mode 100644 index 0000000..fedcbb1 --- /dev/null +++ b/src/app/controllers/ImportController.h @@ -0,0 +1,38 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "domain/Session.h" + +namespace automix::app { + +struct ImportResult { + std::vector stems; + std::vector logLines; +}; + +class ImportController { + public: + struct Callbacks { + std::function onStatus; + std::function onTaskHistory; + std::function onImportComplete; + }; + + ImportController(juce::ThreadPool& threadPool, Callbacks callbacks); + + void importFiles(std::vector files, bool useSeparation, int preferredStemCount); + + private: + juce::ThreadPool& threadPool_; + Callbacks callbacks_; +}; + +} // namespace automix::app diff --git a/src/app/controllers/ModelController.cpp b/src/app/controllers/ModelController.cpp new file mode 100644 index 0000000..44d4295 --- /dev/null +++ b/src/app/controllers/ModelController.cpp @@ -0,0 +1,307 @@ +#include "app/controllers/ModelController.h" + +#include + +#include + +namespace automix::app { +namespace { + +std::filesystem::path defaultModelHubRoot() { + return std::filesystem::path("assets") / "modelhub"; +} + +nlohmann::json loadJsonIfPresent(const std::filesystem::path& path) { + try { + std::ifstream in(path); + if (!in.is_open()) { + return nlohmann::json::array(); + } + nlohmann::json parsed; + in >> parsed; + return parsed; + } catch (...) { + return nlohmann::json::array(); + } +} + +} // namespace + +ModelController::ModelController(ai::ModelManager& modelManager, + juce::ThreadPool& threadPool, + Callbacks callbacks) + : modelManager_(modelManager), + threadPool_(threadPool), + callbacks_(std::move(callbacks)), + modelHubRoot_(defaultModelHubRoot()) {} + +void ModelController::setModelHubRoot(const std::filesystem::path& root) { + modelHubRoot_ = root; +} + +const std::vector& ModelController::discoveredModels() const { + return discoveredModels_; +} + +void ModelController::fetchCatalog() { + callbacks_.onStatus("Models: fetching Hugging Face catalog..."); + callbacks_.onTaskHistory("Models catalog fetch started"); + + struct CatalogJob final : juce::ThreadPoolJob { + Callbacks callbacks; + std::vector* modelsOut; + + CatalogJob(Callbacks cb, std::vector* out) + : juce::ThreadPoolJob("FetchCatalog"), callbacks(std::move(cb)), modelsOut(out) {} + + JobStatus runJob() override { + ai::HuggingFaceModelHub hub; + ai::HubModelQueryOptions options; + options.maxResultsPerQuery = 6; + auto models = hub.discoverRecommended(options); + if (models.size() > 20) { + models.resize(20); + } + + auto capturedCallbacks = callbacks; + auto capturedModelsOut = modelsOut; + auto capturedModels = std::move(models); + + juce::MessageManager::callAsync( + [capturedCallbacks, capturedModelsOut, models = std::move(capturedModels)]() mutable { + *capturedModelsOut = std::move(models); + + if (capturedModelsOut->empty()) { + capturedCallbacks.onStatus("Models: no catalog results"); + capturedCallbacks.onTaskHistory("Models catalog returned no results"); + capturedCallbacks.onReport( + "Model browser: no public compatible entries returned by Hugging Face queries."); + return; + } + + capturedCallbacks.onStatus("Models: select model to download"); + capturedCallbacks.onTaskHistory( + "Models catalog loaded (" + std::to_string(capturedModelsOut->size()) + " entries)"); + if (capturedCallbacks.onCatalogReady) { + capturedCallbacks.onCatalogReady(); + } + }); + return jobHasFinished; + } + }; + + threadPool_.addJob(new CatalogJob(callbacks_, &discoveredModels_), true); +} + +void ModelController::installModel(const std::string& repoId) { + if (repoId.empty()) { + return; + } + + callbacks_.onStatus("Models: installing " + repoId); + callbacks_.onTaskHistory("Model install started: " + repoId); + + struct InstallJob final : juce::ThreadPoolJob { + std::string repoId; + std::filesystem::path hubRoot; + Callbacks callbacks; + + InstallJob(std::string id, std::filesystem::path root, Callbacks cb) + : juce::ThreadPoolJob("InstallModel"), + repoId(std::move(id)), + hubRoot(std::move(root)), + callbacks(std::move(cb)) {} + + JobStatus runJob() override { + ai::HuggingFaceModelHub hub; + ai::HubInstallOptions installOptions; + installOptions.destinationRoot = hubRoot; + const auto install = hub.installModel(repoId, installOptions); + + auto capturedCallbacks = callbacks; + auto capturedRepoId = repoId; + auto capturedInstall = install; + + juce::MessageManager::callAsync( + [capturedCallbacks, capturedRepoId, capturedInstall]() { + if (capturedInstall.success) { + capturedCallbacks.onStatus("Model installed: " + capturedRepoId); + capturedCallbacks.onTaskHistory("Model installed: " + capturedRepoId); + } else { + capturedCallbacks.onStatus("Model install failed"); + capturedCallbacks.onTaskHistory("Model install failed: " + capturedRepoId); + } + + std::string report; + report += "Model install: " + capturedRepoId + "\n"; + report += "Revision: " + capturedInstall.revision + "\n"; + report += "Result: " + std::string(capturedInstall.success ? "success" : "failed") + "\n"; + report += "Detail: " + capturedInstall.message + "\n"; + if (!capturedInstall.installPath.empty()) { + report += "Path: " + capturedInstall.installPath.string() + "\n"; + } + report += "Token usage: env-based token is supported via AUTOMIX_HF_TOKEN/HF_TOKEN/HUGGINGFACE_TOKEN.\n"; + capturedCallbacks.onReport(report); + + if (capturedInstall.success && capturedCallbacks.onModelPacksChanged) { + capturedCallbacks.onModelPacksChanged(); + } + }); + return jobHasFinished; + } + }; + + threadPool_.addJob(new InstallJob(repoId, modelHubRoot_, callbacks_), true); +} + +void ModelController::showInstalled() { + const auto registryPath = modelHubRoot_ / "install_registry.json"; + const auto registry = loadJsonIfPresent(registryPath); + if (!registry.is_array() || registry.empty()) { + callbacks_.onStatus("Models: no installed hub models"); + callbacks_.onReport("No installed modelhub entries found at " + registryPath.string()); + return; + } + + std::string report = "Installed Models\n"; + report += "Registry: " + registryPath.string() + "\n\n"; + for (const auto& item : registry) { + if (!item.is_object()) { + continue; + } + report += "- " + item.value("repoId", "") + + " rev=" + item.value("revision", "") + + " useCase=" + item.value("useCase", "") + + " license=" + item.value("license", "") + "\n"; + } + + callbacks_.onStatus("Models: installed list loaded"); + callbacks_.onTaskHistory("Loaded installed model list"); + callbacks_.onReport(report); +} + +void ModelController::checkUpdates() { + const auto registryPath = modelHubRoot_ / "install_registry.json"; + const auto registry = loadJsonIfPresent(registryPath); + if (!registry.is_array() || registry.empty()) { + callbacks_.onStatus("Models: no installed hub models"); + return; + } + + callbacks_.onStatus("Models: checking updates..."); + callbacks_.onTaskHistory("Model update check started"); + + std::vector repoIds; + repoIds.reserve(registry.size()); + for (const auto& item : registry) { + if (item.is_object()) { + const auto repoId = item.value("repoId", ""); + if (!repoId.empty()) { + repoIds.push_back(repoId); + } + } + } + + struct UpdateCheckJob final : juce::ThreadPoolJob { + std::vector repoIds; + std::filesystem::path registryPath; + Callbacks callbacks; + + UpdateCheckJob(std::vector ids, std::filesystem::path regPath, Callbacks cb) + : juce::ThreadPoolJob("CheckUpdates"), + repoIds(std::move(ids)), + registryPath(std::move(regPath)), + callbacks(std::move(cb)) {} + + JobStatus runJob() override { + ai::HuggingFaceModelHub hub; + std::string report = "Model Update Check\n"; + report += "Registry: " + registryPath.string() + "\n\n"; + int updatesAvailable = 0; + + for (const auto& repoId : repoIds) { + const auto localRegistry = loadJsonIfPresent(registryPath); + std::string localRevision; + if (localRegistry.is_array()) { + for (const auto& item : localRegistry) { + if (item.is_object() && item.value("repoId", "") == repoId) { + localRevision = item.value("revision", ""); + break; + } + } + } + + const auto remote = hub.modelInfo(repoId); + if (!remote.has_value()) { + report += "- " + repoId + ": unable to fetch remote metadata\n"; + continue; + } + + const bool changed = !localRevision.empty() && localRevision != remote->revision; + if (changed) { + ++updatesAvailable; + } + report += "- " + repoId + + " local=" + localRevision + + " remote=" + remote->revision + + " status=" + std::string(changed ? "update-available" : "up-to-date") + "\n"; + } + + auto capturedCallbacks = callbacks; + auto capturedReport = std::move(report); + auto capturedUpdates = updatesAvailable; + + juce::MessageManager::callAsync( + [capturedCallbacks, capturedReport, capturedUpdates]() { + capturedCallbacks.onStatus( + "Models: update check complete (" + std::to_string(capturedUpdates) + " updates)"); + capturedCallbacks.onTaskHistory("Model update check completed"); + capturedCallbacks.onReport(capturedReport); + }); + return jobHasFinished; + } + }; + + threadPool_.addJob(new UpdateCheckJob(std::move(repoIds), registryPath, callbacks_), true); +} + +void ModelController::verifyIntegrity() { + const auto registryPath = modelHubRoot_ / "install_registry.json"; + const auto registry = loadJsonIfPresent(registryPath); + if (!registry.is_array() || registry.empty()) { + callbacks_.onStatus("Models: no installed hub models"); + return; + } + + std::string report = "Model Integrity & Licenses\n"; + report += "Registry: " + registryPath.string() + "\n\n"; + int validCount = 0; + int missingCount = 0; + + for (const auto& item : registry) { + if (!item.is_object()) { + continue; + } + const std::string repoId = item.value("repoId", ""); + const std::filesystem::path installPath(item.value("installPath", "")); + const std::filesystem::path primaryFile = installPath / item.value("primaryFile", ""); + std::error_code error; + const bool present = std::filesystem::is_regular_file(primaryFile, error) && !error; + if (present) { + ++validCount; + } else { + ++missingCount; + } + report += "- " + repoId + + " integrity=" + std::string(present ? "ok" : "missing") + + " license=" + item.value("license", "unknown") + + " source=" + item.value("sourceUrl", "") + "\n"; + } + + callbacks_.onStatus("Models: integrity check complete"); + callbacks_.onTaskHistory("Model integrity check completed"); + report += "\nSummary: ok=" + std::to_string(validCount) + " missing=" + std::to_string(missingCount) + "\n"; + callbacks_.onReport(report); +} + +} // namespace automix::app diff --git a/src/app/controllers/ModelController.h b/src/app/controllers/ModelController.h new file mode 100644 index 0000000..e4c1c73 --- /dev/null +++ b/src/app/controllers/ModelController.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include + +#include "ai/HuggingFaceModelHub.h" +#include "ai/ModelManager.h" + +namespace automix::app { + +class ModelController { + public: + struct Callbacks { + std::function onStatus; + std::function onTaskHistory; + std::function onReport; + std::function onModelPacksChanged; + std::function onCatalogReady; + }; + + ModelController(ai::ModelManager& modelManager, + juce::ThreadPool& threadPool, + Callbacks callbacks); + + void fetchCatalog(); + void installModel(const std::string& repoId); + void showInstalled(); + void checkUpdates(); + void verifyIntegrity(); + + const std::vector& discoveredModels() const; + void setModelHubRoot(const std::filesystem::path& root); + + private: + ai::ModelManager& modelManager_; + juce::ThreadPool& threadPool_; + Callbacks callbacks_; + std::filesystem::path modelHubRoot_; + std::vector discoveredModels_; +}; + +} // namespace automix::app diff --git a/src/app/controllers/OriginalMixController.cpp b/src/app/controllers/OriginalMixController.cpp new file mode 100644 index 0000000..d61b395 --- /dev/null +++ b/src/app/controllers/OriginalMixController.cpp @@ -0,0 +1,34 @@ +#include "app/controllers/OriginalMixController.h" + +namespace automix::app { + +OriginalMixSelectionResult OriginalMixController::applySelectedPath(const std::string& path, + const std::string& displayName) const { + OriginalMixSelectionResult result; + if (path.empty()) { + return result; + } + + result.applied = true; + result.path = path; + result.statusText = "Original mix loaded"; + result.reportLine = "Original mix: " + juce::String(path); + result.taskHistoryLine = "Original mix loaded: " + juce::String(displayName); + return result; +} + +OriginalMixClearResult OriginalMixController::clear(const std::optional& currentPath) const { + OriginalMixClearResult result; + if (!currentPath.has_value()) { + result.statusText = "No original mix is configured"; + return result; + } + + result.cleared = true; + result.statusText = "Original mix cleared"; + result.reportLine = "Original mix cleared"; + result.taskHistoryLine = "Original mix configuration cleared"; + return result; +} + +} // namespace automix::app diff --git a/src/app/controllers/OriginalMixController.h b/src/app/controllers/OriginalMixController.h new file mode 100644 index 0000000..2272f59 --- /dev/null +++ b/src/app/controllers/OriginalMixController.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include + +#include + +namespace automix::app { + +struct OriginalMixSelectionResult { + bool applied = false; + std::string path; + juce::String statusText; + juce::String reportLine; + juce::String taskHistoryLine; +}; + +struct OriginalMixClearResult { + bool cleared = false; + juce::String statusText; + juce::String reportLine; + juce::String taskHistoryLine; +}; + +class OriginalMixController { + public: + OriginalMixSelectionResult applySelectedPath(const std::string& path, + const std::string& displayName) const; + OriginalMixClearResult clear(const std::optional& currentPath) const; +}; + +} // namespace automix::app diff --git a/src/app/controllers/PreviewController.cpp b/src/app/controllers/PreviewController.cpp new file mode 100644 index 0000000..d2ec898 --- /dev/null +++ b/src/app/controllers/PreviewController.cpp @@ -0,0 +1,98 @@ +#include "app/controllers/PreviewController.h" + +#include +#include + +#include "automaster/HeuristicAutoMasterStrategy.h" +#include "engine/AudioPreviewEngine.h" +#include "engine/OfflineRenderPipeline.h" +#include "util/BackgroundJob.h" +#include "util/CallbackDispatch.h" + +namespace automix::app { + +PreviewController::PreviewController(juce::ThreadPool& threadPool, Callbacks callbacks) + : threadPool_(threadPool), callbacks_(std::move(callbacks)) {} + +void PreviewController::rebuildPreview(PreviewBuildRequest request) { + if (request.session.stems.empty()) { + return; + } + + threadPool_.addJob(new util::BackgroundJob( + [callbacks = callbacks_, request = std::move(request)]() mutable { + PreviewBuildResult result; + result.generation = request.generation; + result.previousProgress = request.previousProgress; + + try { + if (!request.soloStemId.empty()) { + for (auto& stem : request.session.stems) { + stem.enabled = stem.id == request.soloStemId; + } + } + if (!request.muteStemId.empty()) { + for (auto& stem : request.session.stems) { + if (stem.id == request.muteStemId) { + stem.enabled = false; + } + } + } + + auto settings = request.session.renderSettings; + settings.outputSampleRate = settings.outputSampleRate > 0 ? settings.outputSampleRate : 44100; + settings.blockSize = settings.blockSize > 0 ? settings.blockSize : 1024; + settings.outputBitDepth = std::clamp(settings.outputBitDepth, 16, 32); + settings.rendererName = "BuiltIn"; + settings.outputPath.clear(); + settings.outputFormat = "wav"; + + engine::OfflineRenderPipeline pipeline; + const auto raw = pipeline.renderRawMix(request.session, settings, {}, nullptr); + if (raw.cancelled || raw.mixBuffer.getNumSamples() == 0) { + return; + } + + result.rawMix = raw.mixBuffer; + result.mastered = raw.mixBuffer; + if (request.session.masterPlan.has_value()) { + automaster::HeuristicAutoMasterStrategy strategy; + result.mastered = strategy.applyPlan(raw.mixBuffer, request.session.masterPlan.value(), nullptr); + } + + engine::AudioPreviewEngine previewEngine; + previewEngine.setBuffers(result.rawMix, result.mastered); + result.preview = previewEngine.buildCrossfadedPreview(1024); + result.success = true; + } catch (const std::exception& error) { + result.errorText = error.what(); + } catch (...) { + result.errorText = "Preview rebuild skipped: unknown error"; + } + + if (!result.success && result.errorText.isEmpty()) { + return; + } + + util::dispatchCallback([callbacks, result = std::move(result)]() mutable { + if (callbacks.onPreviewReady) { + callbacks.onPreviewReady(std::move(result)); + } + }); + }), + true); +} + +void PreviewController::applyTransportBuffer(const engine::AudioBuffer& buffer, + const domain::TimelineState& timeline, + engine::TransportController& transportController, + std::atomic& playbackCursorSamples) { + playbackCursorSamples.store(0); + transportController.setTimeline(buffer.getNumSamples(), buffer.getSampleRate()); + transportController.setLoopRangeSeconds(timeline.loopInSeconds, + timeline.loopOutSeconds, + timeline.loopEnabled); + transportController.stop(); +} + +} // namespace automix::app diff --git a/src/app/controllers/PreviewController.h b/src/app/controllers/PreviewController.h new file mode 100644 index 0000000..9e68458 --- /dev/null +++ b/src/app/controllers/PreviewController.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "domain/Session.h" +#include "engine/AudioBuffer.h" +#include "engine/TransportController.h" + +namespace automix::app { + +struct PreviewBuildRequest { + domain::Session session; + std::string soloStemId; + std::string muteStemId; + uint64_t generation = 0; + double previousProgress = 0.0; +}; + +struct PreviewBuildResult { + uint64_t generation = 0; + double previousProgress = 0.0; + bool success = false; + juce::String errorText; + engine::AudioBuffer rawMix; + engine::AudioBuffer mastered; + engine::AudioBuffer preview; +}; + +class PreviewController { + public: + struct Callbacks { + std::function onPreviewReady; + }; + + PreviewController(juce::ThreadPool& threadPool, Callbacks callbacks); + + void rebuildPreview(PreviewBuildRequest request); + + static void applyTransportBuffer(const engine::AudioBuffer& buffer, + const domain::TimelineState& timeline, + engine::TransportController& transportController, + std::atomic& playbackCursorSamples); + + private: + juce::ThreadPool& threadPool_; + Callbacks callbacks_; +}; + +} // namespace automix::app diff --git a/src/app/controllers/ProcessingController.cpp b/src/app/controllers/ProcessingController.cpp new file mode 100644 index 0000000..bc29995 --- /dev/null +++ b/src/app/controllers/ProcessingController.cpp @@ -0,0 +1,507 @@ +#include "app/controllers/ProcessingController.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "ai/AutoMasterStrategyAI.h" +#include "ai/AutoMixStrategyAI.h" +#include "ai/IModelInference.h" +#include "ai/OnnxModelInference.h" +#include "ai/RtNeuralInference.h" +#include "automaster/OriginalMixReference.h" +#include "automix/HeuristicAutoMixStrategy.h" +#include "domain/BatchTypes.h" +#include "engine/AudioFileIO.h" +#include "engine/AudioResampler.h" +#include "engine/BatchQueueRunner.h" +#include "engine/OfflineRenderPipeline.h" +#include "util/StringUtils.h" + +namespace automix::app { +namespace { + +using ::automix::util::toLower; + +juce::String toJuceText(const std::vector& lines) { + juce::String output; + for (const auto& line : lines) { + output += juce::String(line); + output += "\n"; + } + return output; +} + +std::unique_ptr createInferenceBackend(const ai::ModelPack* pack, + const std::string& providerPreference, + std::string* diagnosticsOut) { + if (pack == nullptr) { + return nullptr; + } + + std::unique_ptr backend; + const auto engine = toLower(pack->engine); + if (engine.find("onnx") != std::string::npos || engine == "unknown") { + auto onnx = std::make_unique(); + + auto resolvedProvider = providerPreference; + if ((resolvedProvider.empty() || toLower(resolvedProvider) == "auto") && !pack->providerAffinity.empty()) { + resolvedProvider = pack->providerAffinity.front(); + } + onnx->setExecutionProviderPreference(resolvedProvider); + onnx->setGraphOptimizationEnabled(true); + onnx->setWarmupEnabled(true); + onnx->setPreferQuantizedVariants(toLower(pack->preferredPrecision) != "fp32"); + onnx->setPreferredPrecision(pack->preferredPrecision.empty() ? "auto" : pack->preferredPrecision); + onnx->setThreadConfiguration(pack->defaultIntraOpThreads.value_or(0), pack->defaultInterOpThreads.value_or(0)); + onnx->setProfilingEnabled(pack->enableProfiling); + backend = std::move(onnx); + } + if (!backend && engine.find("rtneural") != std::string::npos) { + backend = std::make_unique(); + } + if (!backend) { + backend = std::make_unique(); + } + + const auto modelPath = pack->rootPath / pack->modelFile; + if (!backend->loadModel(modelPath)) { + return nullptr; + } + + if (diagnosticsOut != nullptr) { + if (const auto* onnx = dynamic_cast(backend.get()); onnx != nullptr) { + *diagnosticsOut = onnx->backendDiagnostics(); + } + } + + return backend; +} + +} // namespace + +ProcessingController::ProcessingController(juce::ThreadPool& threadPool, Callbacks callbacks) + : threadPool_(threadPool), callbacks_(std::move(callbacks)) {} + +void ProcessingController::runAutoMix(const domain::Session& session, + const std::optional& mixPack, + std::atomic_bool& cancelFlag) { + struct AutoMixJob final : juce::ThreadPoolJob { + domain::Session session; + std::optional mixPack; + std::atomic_bool* cancelFlag; + Callbacks callbacks; + + AutoMixJob(domain::Session sess, std::optional pack, + std::atomic_bool* cancel, Callbacks cb) + : juce::ThreadPoolJob("AutoMixJob"), + session(std::move(sess)), + mixPack(std::move(pack)), + cancelFlag(cancel), + callbacks(std::move(cb)) {} + + JobStatus runJob() override { + std::vector analysisEntries; + std::optional plan; + juce::String reportText; + juce::String errorText; + bool cancelled = false; + + try { + if (callbacks.onStatus) { + callbacks.onStatus("Auto Mix: analyzing..."); + } + analysis::StemAnalyzer analyzer; + analysisEntries = analyzer.analyzeSession(session); + + if (cancelFlag != nullptr && cancelFlag->load()) { + cancelled = true; + } + + if (!cancelled) { + if (callbacks.onStatus) { + callbacks.onStatus("Auto Mix: building plan..."); + } + automix::HeuristicAutoMixStrategy heuristicMix; + const auto heuristicPlan = heuristicMix.buildPlan(session, analysisEntries, 1.0); + plan = heuristicPlan; + + ai::AutoMixStrategyAI aiMix; + std::string backendDiagnostics; + std::unique_ptr inference; + if (mixPack.has_value()) { + inference = createInferenceBackend(&mixPack.value(), session.renderSettings.gpuExecutionProvider, &backendDiagnostics); + } + + if (inference != nullptr) { + auto aiPlan = aiMix.buildPlan(session, analysisEntries, heuristicPlan, inference.get()); + if (mixPack.has_value()) { + aiPlan.decisionLog.push_back("AI pack: " + mixPack->id + " license=" + mixPack->licenseId); + } + if (!backendDiagnostics.empty()) { + aiPlan.decisionLog.push_back("Inference backend: " + backendDiagnostics); + } + plan = std::move(aiPlan); + } + + reportText = juce::String("Analysis report JSON:\n") + analyzer.toJsonReport(analysisEntries); + if (plan.has_value()) { + reportText += juce::String("\n\nMix decisions:\n") + toJuceText(plan->decisionLog); + } + } + } catch (const std::exception& error) { + errorText = "Auto Mix failed:\n" + juce::String(error.what()); + } catch (...) { + errorText = "Auto Mix failed:\nUnknown error"; + } + + AutoMixResult result; + result.cancelled = cancelled || (cancelFlag != nullptr && cancelFlag->load()); + result.analysisEntries = std::move(analysisEntries); + result.mixPlan = std::move(plan); + result.reportText = reportText; + result.errorText = errorText; + + auto capturedCallbacks = callbacks; + juce::MessageManager::callAsync([capturedCallbacks, result = std::move(result)]() mutable { + if (capturedCallbacks.onAutoMixComplete) { + capturedCallbacks.onAutoMixComplete(std::move(result)); + } + }); + + return jobHasFinished; + } + }; + + threadPool_.addJob( + new AutoMixJob(session, mixPack, &cancelFlag, callbacks_), + true); +} + +void ProcessingController::runAutoMaster(const domain::Session& session, + const domain::RenderSettings& settings, + const domain::MasterPreset preset, + const std::optional& masterPack, + std::atomic_bool& cancelFlag) { + struct AutoMasterJob final : juce::ThreadPoolJob { + domain::Session session; + domain::RenderSettings settings; + domain::MasterPreset preset; + std::optional masterPack; + std::atomic_bool* cancelFlag; + Callbacks callbacks; + + AutoMasterJob(domain::Session sess, domain::RenderSettings sett, + domain::MasterPreset pres, std::optional pack, + std::atomic_bool* cancel, Callbacks cb) + : juce::ThreadPoolJob("AutoMasterJob"), + session(std::move(sess)), + settings(std::move(sett)), + preset(pres), + masterPack(std::move(pack)), + cancelFlag(cancel), + callbacks(std::move(cb)) {} + + JobStatus runJob() override { + domain::MasterPlan masterPlan; + engine::AudioBuffer rawMixBuffer; + engine::AudioBuffer previewMaster; + automaster::MasteringReport previewReport; + juce::String reportAppend; + juce::String errorText; + bool cancelled = false; + + try { + engine::OfflineRenderPipeline pipeline; + + std::mutex progressMutex; + auto lastProgressEmit = std::chrono::steady_clock::time_point {}; + double lastProgressFraction = -1.0; + std::string lastProgressStage; + auto capturedCallbacks = callbacks; + + const auto rawMix = pipeline.renderRawMix( + session, + settings, + [capturedCallbacks, &progressMutex, &lastProgressEmit, &lastProgressFraction, &lastProgressStage]( + const engine::RenderProgress& progress) { + bool emit = false; + bool stageChanged = false; + { + std::scoped_lock lock(progressMutex); + const auto now = std::chrono::steady_clock::now(); + stageChanged = progress.stage != lastProgressStage; + const bool finalProgress = progress.fraction >= 0.999; + const bool timeGateOpen = + lastProgressEmit.time_since_epoch().count() == 0 || + now - lastProgressEmit >= std::chrono::milliseconds(160); + const bool deltaGateOpen = std::abs(progress.fraction - lastProgressFraction) >= 0.02; + emit = stageChanged || finalProgress || (timeGateOpen && deltaGateOpen); + if (emit) { + lastProgressEmit = now; + lastProgressFraction = progress.fraction; + lastProgressStage = progress.stage; + } + } + if (!emit) { + return; + } + + if (progress.stage == "Mix render cache hit") { + if (capturedCallbacks.onStatus) { + capturedCallbacks.onStatus("Auto Master: Using cached mix render (fast path)"); + } + if (capturedCallbacks.onTaskHistory) { + capturedCallbacks.onTaskHistory("Auto Master using cached mix render"); + } + return; + } + if (capturedCallbacks.onStatus) { + capturedCallbacks.onStatus("Auto Master: " + progress.stage + " " + + std::to_string(static_cast(progress.fraction * 100.0)) + "%"); + } + if (progress.fraction >= 0.999 || progress.stage != "Summing stem buses") { + if (capturedCallbacks.onTaskHistory) { + capturedCallbacks.onTaskHistory("Auto Master " + progress.stage + " " + + std::to_string(static_cast(progress.fraction * 100.0)) + "%"); + } + } + }, + cancelFlag); + + if (rawMix.cancelled) { + cancelled = true; + } else { + rawMixBuffer = rawMix.mixBuffer; + } + + if (!cancelled) { + automaster::HeuristicAutoMasterStrategy autoMasterStrategy; + analysis::StemAnalyzer analyzer; + masterPlan = autoMasterStrategy.buildPlan(preset, rawMixBuffer); + + if (session.originalMixPath.has_value()) { + try { + engine::AudioFileIO fileIO; + engine::AudioResampler resampler; + auto originalMix = fileIO.readAudioFile(session.originalMixPath.value()); + if (originalMix.getSampleRate() != rawMixBuffer.getSampleRate()) { + originalMix = resampler.resampleLinear(originalMix, rawMixBuffer.getSampleRate()); + } + + automaster::OriginalMixReference referenceTarget; + masterPlan = referenceTarget.applySoftTarget(masterPlan, + rawMixBuffer, + originalMix, + autoMasterStrategy, + analyzer); + } catch (const std::exception& error) { + reportAppend += "\nOriginal mix target skipped: " + juce::String(error.what()); + } + } + + std::string backendDiagnostics; + std::unique_ptr masterInference; + if (masterPack.has_value()) { + masterInference = createInferenceBackend(&masterPack.value(), settings.gpuExecutionProvider, &backendDiagnostics); + } + + ai::AutoMasterStrategyAI aiMaster; + if (masterInference != nullptr) { + const auto mixMetrics = analyzer.analyzeBuffer(rawMixBuffer); + masterPlan = aiMaster.buildPlan(mixMetrics, masterPlan, masterInference.get()); + if (masterPack.has_value()) { + masterPlan.decisionLog.push_back("AI pack: " + masterPack->id + " license=" + masterPack->licenseId); + } + if (!backendDiagnostics.empty()) { + masterPlan.decisionLog.push_back("Inference backend: " + backendDiagnostics); + } + } + + if (masterInference != nullptr) { + previewMaster = aiMaster.applyPlan(rawMixBuffer, masterPlan, autoMasterStrategy, &previewReport); + } else { + previewMaster = autoMasterStrategy.applyPlan(rawMixBuffer, masterPlan, &previewReport); + } + + reportAppend += "\nMaster decisions:\n" + toJuceText(masterPlan.decisionLog); + } + } catch (const std::exception& error) { + errorText = "Auto Master failed:\n" + juce::String(error.what()); + } catch (...) { + errorText = "Auto Master failed:\nUnknown error"; + } + + AutoMasterResult result; + result.cancelled = cancelled; + result.masterPlan = std::move(masterPlan); + result.rawMixBuffer = std::move(rawMixBuffer); + result.previewMaster = std::move(previewMaster); + result.previewReport = previewReport; + result.reportAppend = reportAppend; + result.errorText = errorText; + + auto capturedCb = callbacks; + juce::MessageManager::callAsync([capturedCb, result = std::move(result)]() mutable { + if (capturedCb.onAutoMasterComplete) { + capturedCb.onAutoMasterComplete(std::move(result)); + } + }); + + return jobHasFinished; + } + }; + + threadPool_.addJob( + new AutoMasterJob(session, settings, preset, masterPack, &cancelFlag, callbacks_), + true); +} + +void ProcessingController::runBatch(const std::filesystem::path& inputFolder, + const domain::RenderSettings& baseSettings, + std::atomic_bool& cancelFlag) { + struct BatchJob final : juce::ThreadPoolJob { + std::filesystem::path inputFolder; + domain::RenderSettings baseSettings; + std::atomic_bool* cancelFlag; + Callbacks callbacks; + + BatchJob(std::filesystem::path folder, domain::RenderSettings sett, + std::atomic_bool* cancel, Callbacks cb) + : juce::ThreadPoolJob("BatchJob"), + inputFolder(std::move(folder)), + baseSettings(std::move(sett)), + cancelFlag(cancel), + callbacks(std::move(cb)) {} + + JobStatus runJob() override { + const std::filesystem::path outputFolder = inputFolder / "automix_batch_exports"; + + std::vector items; + juce::String prepError; + try { + std::filesystem::create_directories(outputFolder); + engine::BatchQueueRunner batchQueueRunner; + items = batchQueueRunner.buildItemsFromFolder(inputFolder, outputFolder); + } catch (const std::exception& error) { + prepError = error.what(); + } catch (...) { + prepError = "Unknown batch preparation error"; + } + + if (!prepError.isEmpty() || items.empty()) { + BatchResult result; + if (!prepError.isEmpty()) { + result.errorText = "Batch preparation error:\n" + prepError; + } else { + result.errorText = "Batch folder has no supported audio files"; + } + + auto capturedCallbacks = callbacks; + juce::MessageManager::callAsync([capturedCallbacks, result = std::move(result)]() mutable { + if (capturedCallbacks.onBatchComplete) { + capturedCallbacks.onBatchComplete(std::move(result)); + } + }); + return jobHasFinished; + } + + if (callbacks.onStatus) { + callbacks.onStatus("Batch started"); + } + + domain::BatchJob job; + job.items = std::move(items); + job.settings.outputFolder = outputFolder; + job.settings.parallelAnalysis = true; + const int hardwareThreads = static_cast(std::max(1u, std::thread::hardware_concurrency())); + job.settings.analysisThreads = std::max(1, hardwareThreads / 2); + job.settings.renderParallelism = std::max(1, hardwareThreads / 2); + job.settings.renderSettings = baseSettings; + + engine::BatchQueueRunner runner; + std::mutex progressMutex; + auto lastProgressEmit = std::chrono::steady_clock::time_point {}; + size_t lastItemIndex = std::numeric_limits::max(); + double lastProgress = -1.0; + std::string lastStage; + auto capturedCallbacks = callbacks; + + const auto batchResult = runner.process( + job, + [capturedCallbacks, &progressMutex, &lastProgressEmit, &lastItemIndex, &lastProgress, &lastStage]( + const size_t itemIndex, const double progress, const std::string& stage) { + bool emit = false; + { + std::scoped_lock lock(progressMutex); + const auto now = std::chrono::steady_clock::now(); + const bool itemChanged = itemIndex != lastItemIndex; + const bool stageChanged = stage != lastStage; + const bool finalProgress = progress >= 0.999; + const bool timeGateOpen = + lastProgressEmit.time_since_epoch().count() == 0 || + now - lastProgressEmit >= std::chrono::milliseconds(220); + const bool deltaGateOpen = std::abs(progress - lastProgress) >= 0.03; + emit = itemChanged || stageChanged || finalProgress || (timeGateOpen && deltaGateOpen); + if (emit) { + lastProgressEmit = now; + lastItemIndex = itemIndex; + lastProgress = progress; + lastStage = stage; + } + } + if (!emit) { + return; + } + + const auto statusMsg = "Batch item " + std::to_string(itemIndex + 1) + + " " + stage + " (" + std::to_string(static_cast(progress * 100.0)) + "%)"; + if (capturedCallbacks.onStatus) { + capturedCallbacks.onStatus(statusMsg); + } + if (capturedCallbacks.onTaskHistory) { + capturedCallbacks.onTaskHistory("Batch item " + std::to_string(itemIndex + 1) + + " " + stage + " " + std::to_string(static_cast(progress * 100.0)) + "%"); + } + }, + cancelFlag); + + juce::String summary; + summary << "Batch completed\n"; + summary << "Completed: " << batchResult.completed << "\n"; + summary << "Failed: " << batchResult.failed << "\n"; + summary << "Cancelled: " << batchResult.cancelled << "\n"; + + for (const auto& item : job.items) { + summary << item.session.sessionName << " -> " << juce::String(item.outputPath.string()) << " [" + << juce::String(domain::toString(item.status)) << "]"; + if (!item.error.empty()) { + summary << " error=" << juce::String(item.error); + } + summary << "\n"; + } + + BatchResult result; + result.summary = summary; + + auto finalCallbacks = callbacks; + juce::MessageManager::callAsync([finalCallbacks, result = std::move(result)]() mutable { + if (finalCallbacks.onBatchComplete) { + finalCallbacks.onBatchComplete(std::move(result)); + } + }); + + return jobHasFinished; + } + }; + + threadPool_.addJob( + new BatchJob(inputFolder, baseSettings, &cancelFlag, callbacks_), + true); +} + +} // namespace automix::app diff --git a/src/app/controllers/ProcessingController.h b/src/app/controllers/ProcessingController.h new file mode 100644 index 0000000..2f5a619 --- /dev/null +++ b/src/app/controllers/ProcessingController.h @@ -0,0 +1,77 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ai/ModelManager.h" +#include "analysis/StemAnalyzer.h" +#include "automaster/HeuristicAutoMasterStrategy.h" +#include "domain/MasterPlan.h" +#include "domain/MixPlan.h" +#include "domain/Session.h" +#include "engine/AudioBuffer.h" + +namespace automix::app { + +struct AutoMixResult { + bool cancelled = false; + std::vector analysisEntries; + std::optional mixPlan; + juce::String reportText; + juce::String errorText; +}; + +struct AutoMasterResult { + bool cancelled = false; + domain::MasterPlan masterPlan; + engine::AudioBuffer rawMixBuffer; + engine::AudioBuffer previewMaster; + automaster::MasteringReport previewReport; + juce::String reportAppend; + juce::String errorText; +}; + +struct BatchResult { + juce::String summary; + juce::String errorText; +}; + +class ProcessingController { + public: + struct Callbacks { + std::function onStatus; + std::function onTaskHistory; + std::function onAutoMixComplete; + std::function onAutoMasterComplete; + std::function onBatchComplete; + }; + + ProcessingController(juce::ThreadPool& threadPool, Callbacks callbacks); + + void runAutoMix(const domain::Session& session, + const std::optional& mixPack, + std::atomic_bool& cancelFlag); + + void runAutoMaster(const domain::Session& session, + const domain::RenderSettings& settings, + domain::MasterPreset preset, + const std::optional& masterPack, + std::atomic_bool& cancelFlag); + + void runBatch(const std::filesystem::path& inputFolder, + const domain::RenderSettings& baseSettings, + std::atomic_bool& cancelFlag); + + private: + juce::ThreadPool& threadPool_; + Callbacks callbacks_; +}; + +} // namespace automix::app diff --git a/src/app/controllers/ProfileController.cpp b/src/app/controllers/ProfileController.cpp new file mode 100644 index 0000000..1b970c3 --- /dev/null +++ b/src/app/controllers/ProfileController.cpp @@ -0,0 +1,47 @@ +#include "app/controllers/ProfileController.h" + +namespace automix::app { + +std::vector ProfileController::loadProfiles( + const std::filesystem::path& repositoryRoot) const { + return domain::loadProjectProfiles(repositoryRoot); +} + +std::optional ProfileController::selectedProfile( + const std::vector& profiles, + const std::string& preferredProfileId) const { + if (const auto profile = domain::findProjectProfile(profiles, preferredProfileId); profile.has_value()) { + return profile; + } + if (!profiles.empty()) { + return profiles.front(); + } + return std::nullopt; +} + +AppliedProfileSettings ProfileController::applyProfile(domain::Session& session, + const domain::ProjectProfile& profile) const { + session.projectProfileId = profile.id; + session.safetyPolicyId = profile.safetyPolicyId; + session.preferredStemCount = profile.preferredStemCount; + + session.renderSettings.gpuExecutionProvider = profile.gpuProvider; + session.renderSettings.outputFormat = profile.outputFormat; + session.renderSettings.lossyBitrateKbps = profile.lossyBitrateKbps; + session.renderSettings.mp3UseVbr = profile.mp3UseVbr; + session.renderSettings.mp3VbrQuality = profile.mp3VbrQuality; + session.renderSettings.metadataPolicy = profile.metadataPolicy; + session.renderSettings.metadataTemplate = profile.metadataTemplate; + session.renderSettings.rendererName = profile.rendererName; + + AppliedProfileSettings settings; + settings.gpuProvider = profile.gpuProvider; + settings.roleModelPackId = profile.roleModelPackId; + settings.mixModelPackId = profile.mixModelPackId; + settings.masterModelPackId = profile.masterModelPackId; + settings.rendererName = profile.rendererName; + settings.platformPreset = profile.platformPreset; + return settings; +} + +} // namespace automix::app diff --git a/src/app/controllers/ProfileController.h b/src/app/controllers/ProfileController.h new file mode 100644 index 0000000..c3c1d2f --- /dev/null +++ b/src/app/controllers/ProfileController.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include +#include +#include + +#include "domain/ProjectProfile.h" +#include "domain/Session.h" + +namespace automix::app { + +struct AppliedProfileSettings { + std::string gpuProvider; + std::string roleModelPackId; + std::string mixModelPackId; + std::string masterModelPackId; + std::string rendererName; + std::string platformPreset; +}; + +class ProfileController { + public: + std::vector loadProfiles(const std::filesystem::path& repositoryRoot) const; + std::optional selectedProfile(const std::vector& profiles, + const std::string& preferredProfileId) const; + AppliedProfileSettings applyProfile(domain::Session& session, const domain::ProjectProfile& profile) const; +}; + +} // namespace automix::app diff --git a/src/app/controllers/SessionController.cpp b/src/app/controllers/SessionController.cpp new file mode 100644 index 0000000..f16dacf --- /dev/null +++ b/src/app/controllers/SessionController.cpp @@ -0,0 +1,182 @@ +#include "app/controllers/SessionController.h" + +#include +#include + +#include "engine/SessionRepository.h" +#include "util/CallbackDispatch.h" + +namespace automix::app { + +SessionController::SessionController(juce::ThreadPool& threadPool, Callbacks callbacks) + : threadPool_(threadPool), callbacks_(std::move(callbacks)) {} + +void SessionController::saveSession(std::string path, + domain::Session session, + std::atomic_bool& cancelFlag) { + if (path.empty()) { + return; + } + + if (callbacks_.onStatus) { + callbacks_.onStatus("Saving session..."); + } + if (callbacks_.onTaskHistory) { + callbacks_.onTaskHistory("Session save started: " + path); + } + + struct SaveSessionJob final : juce::ThreadPoolJob { + std::string path; + domain::Session session; + std::atomic_bool* cancelFlag; + Callbacks callbacks; + + SaveSessionJob(std::string outputPath, + domain::Session sourceSession, + std::atomic_bool* cancel, + Callbacks cb) + : juce::ThreadPoolJob("SaveSessionJob"), + path(std::move(outputPath)), + session(std::move(sourceSession)), + cancelFlag(cancel), + callbacks(std::move(cb)) {} + + bool isCancellationRequested() const { + return shouldExit() || (cancelFlag != nullptr && cancelFlag->load()); + } + + void requestCancellation() const { + if (cancelFlag != nullptr) { + cancelFlag->store(true); + } + } + + JobStatus runJob() override { + SessionSaveResult result; + result.path = path; + if (isCancellationRequested()) { + requestCancellation(); + result.cancelled = true; + } + + try { + if (!result.cancelled) { + engine::SessionRepository repository; + repository.save(path, session); + result.success = true; + } + if (isCancellationRequested()) { + requestCancellation(); + result.cancelled = true; + result.success = false; + } + } catch (const std::exception& error) { + result.errorText = error.what(); + } catch (...) { + result.errorText = "Unknown session save error"; + } + + if (result.cancelled) { + if (callbacks.onStatus) { + callbacks.onStatus("Session save cancelled"); + } + if (callbacks.onTaskHistory) { + callbacks.onTaskHistory("Session save cancelled: " + path); + } + } + + auto capturedCallbacks = callbacks; + util::dispatchCallback([capturedCallbacks, result = std::move(result)]() mutable { + if (capturedCallbacks.onSaveComplete) { + capturedCallbacks.onSaveComplete(std::move(result)); + } + }); + + return jobHasFinished; + } + }; + + threadPool_.addJob(new SaveSessionJob(std::move(path), std::move(session), &cancelFlag, callbacks_), true); +} + +void SessionController::loadSession(std::string path, std::atomic_bool& cancelFlag) { + if (path.empty()) { + return; + } + + if (callbacks_.onStatus) { + callbacks_.onStatus("Loading session..."); + } + if (callbacks_.onTaskHistory) { + callbacks_.onTaskHistory("Session load started: " + path); + } + + struct LoadSessionJob final : juce::ThreadPoolJob { + std::string path; + std::atomic_bool* cancelFlag; + Callbacks callbacks; + + LoadSessionJob(std::string inputPath, std::atomic_bool* cancel, Callbacks cb) + : juce::ThreadPoolJob("LoadSessionJob"), + path(std::move(inputPath)), + cancelFlag(cancel), + callbacks(std::move(cb)) {} + + bool isCancellationRequested() const { + return shouldExit() || (cancelFlag != nullptr && cancelFlag->load()); + } + + void requestCancellation() const { + if (cancelFlag != nullptr) { + cancelFlag->store(true); + } + } + + JobStatus runJob() override { + SessionLoadResult result; + result.path = path; + if (isCancellationRequested()) { + requestCancellation(); + result.cancelled = true; + } + + try { + if (!result.cancelled) { + engine::SessionRepository repository; + result.session = repository.load(path); + } + if (isCancellationRequested()) { + requestCancellation(); + result.cancelled = true; + result.session.reset(); + } + } catch (const std::exception& error) { + result.errorText = error.what(); + } catch (...) { + result.errorText = "Unknown session load error"; + } + + if (result.cancelled) { + if (callbacks.onStatus) { + callbacks.onStatus("Session load cancelled"); + } + if (callbacks.onTaskHistory) { + callbacks.onTaskHistory("Session load cancelled: " + path); + } + } + + auto capturedCallbacks = callbacks; + util::dispatchCallback([capturedCallbacks, result = std::move(result)]() mutable { + if (capturedCallbacks.onLoadComplete) { + capturedCallbacks.onLoadComplete(std::move(result)); + } + }); + + return jobHasFinished; + } + }; + + threadPool_.addJob(new LoadSessionJob(std::move(path), &cancelFlag, callbacks_), true); +} + +} // namespace automix::app diff --git a/src/app/controllers/SessionController.h b/src/app/controllers/SessionController.h new file mode 100644 index 0000000..3b39c93 --- /dev/null +++ b/src/app/controllers/SessionController.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include +#include +#include + +#include +#include + +#include "domain/Session.h" + +namespace automix::app { + +struct SessionSaveResult { + bool success = false; + bool cancelled = false; + std::string path; + juce::String errorText; +}; + +struct SessionLoadResult { + bool cancelled = false; + std::optional session; + std::string path; + juce::String errorText; +}; + +class SessionController { + public: + struct Callbacks { + std::function onStatus; + std::function onTaskHistory; + std::function onSaveComplete; + std::function onLoadComplete; + }; + + SessionController(juce::ThreadPool& threadPool, Callbacks callbacks); + + void saveSession(std::string path, domain::Session session, std::atomic_bool& cancelFlag); + void loadSession(std::string path, std::atomic_bool& cancelFlag); + + private: + juce::ThreadPool& threadPool_; + Callbacks callbacks_; +}; + +} // namespace automix::app diff --git a/src/automaster/HeuristicAutoMasterStrategy.cpp b/src/automaster/HeuristicAutoMasterStrategy.cpp index 3f39b8c..7806fb4 100644 --- a/src/automaster/HeuristicAutoMasterStrategy.cpp +++ b/src/automaster/HeuristicAutoMasterStrategy.cpp @@ -2,50 +2,104 @@ #include #include +#include +#include +#include #include +#include +#include + +#include + +#include "analysis/StemAnalyzer.h" +#include "dsp/DeEsser.h" +#include "dsp/DynamicDeHarshEq.h" +#include "dsp/LookaheadLimiter.h" +#include "dsp/MidSideProcessor.h" +#include "dsp/MultibandProcessor.h" +#include "dsp/SoftClipper.h" +#include "dsp/TruePeakDetector.h" +#include "engine/LoudnessMeter.h" namespace automix::automaster { namespace { -double dbToLinear(const double db) { return std::pow(10.0, db / 20.0); } +constexpr double kPi = 3.14159265358979323846; +constexpr double kDcHighPassHz = 20.0; +constexpr double kTonalTiltDb = 1.0; +constexpr double kLoudnessToleranceLu = 0.5; +constexpr int kMaxLoudnessIterations = 5; -double linearToDb(const double value) { - constexpr double minValue = 1.0e-12; - return 20.0 * std::log10(std::max(value, minValue)); -} +struct PlatformPresetTarget { + double targetLufs = -14.0; + double truePeakDbtp = -1.0; + bool enableMultiband = false; +}; + +double dbToLinear(const double db) { return std::pow(10.0, db / 20.0); } void applyGain(engine::AudioBuffer& buffer, const double gainDb) { buffer.applyGain(static_cast(dbToLinear(gainDb))); } -void applyTonalTilt(engine::AudioBuffer& buffer) { - // Gentle tilt: trim lows very slightly and lift highs slightly. - float lowL = 0.0f; - float lowR = 0.0f; - constexpr float a = 0.01f; +float lowPassCoefficient(const double sampleRate, const double cutoffHz) { + const double nyquistSafeSampleRate = std::max(8000.0, sampleRate); + const double normalized = -2.0 * kPi * std::max(1.0, cutoffHz) / nyquistSafeSampleRate; + return static_cast(std::exp(normalized)); +} +void applyDcHighPass(engine::AudioBuffer& buffer, const double cutoffHz) { + if (buffer.getNumSamples() == 0 || buffer.getNumChannels() == 0) { + return; + } + + const float a = lowPassCoefficient(buffer.getSampleRate(), cutoffHz); + std::vector lowState(static_cast(buffer.getNumChannels()), 0.0f); for (int i = 0; i < buffer.getNumSamples(); ++i) { - const float inL = buffer.getSample(0, i); - const float inR = buffer.getNumChannels() > 1 ? buffer.getSample(1, i) : inL; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + const float x = buffer.getSample(ch, i); + lowState[static_cast(ch)] = a * lowState[static_cast(ch)] + (1.0f - a) * x; + buffer.setSample(ch, i, x - lowState[static_cast(ch)]); + } + } +} - lowL += a * (inL - lowL); - lowR += a * (inR - lowR); +void applyTonalTilt(engine::AudioBuffer& buffer, const double tiltDb) { + if (buffer.getNumSamples() == 0 || buffer.getNumChannels() == 0 || std::abs(tiltDb) < 1.0e-6) { + return; + } - const float highL = inL - lowL; - const float highR = inR - lowR; + const float lowGain = static_cast(dbToLinear(-0.5 * tiltDb)); + const float highGain = static_cast(dbToLinear(0.5 * tiltDb)); + const float a = lowPassCoefficient(buffer.getSampleRate(), 950.0); + std::vector lowState(static_cast(buffer.getNumChannels()), 0.0f); - buffer.setSample(0, i, lowL * 0.98f + highL * 1.02f); - if (buffer.getNumChannels() > 1) { - buffer.setSample(1, i, lowR * 0.98f + highR * 1.02f); + for (int i = 0; i < buffer.getNumSamples(); ++i) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + const float x = buffer.getSample(ch, i); + lowState[static_cast(ch)] = a * lowState[static_cast(ch)] + (1.0f - a) * x; + const float low = lowState[static_cast(ch)]; + const float high = x - low; + buffer.setSample(ch, i, low * lowGain + high * highGain); } } } void applyGlueCompressor(engine::AudioBuffer& buffer, const double thresholdDb, const double ratio) { + if (buffer.getNumSamples() == 0 || buffer.getNumChannels() == 0) { + return; + } + const float threshold = static_cast(dbToLinear(thresholdDb)); + const float ratioClamped = static_cast(std::clamp(ratio, 1.05, 8.0)); float envelope = 0.0f; - constexpr float attack = 0.015f; - constexpr float release = 0.001f; + const float attackCoeff = static_cast(std::exp(-1.0 / std::max(1.0, buffer.getSampleRate() * 0.020))); + const float releaseCoeff = static_cast(std::exp(-1.0 / std::max(1.0, buffer.getSampleRate() * 0.180))); + + const int windowSamples = std::max(1, static_cast(buffer.getSampleRate() * 0.030)); + std::vector window(static_cast(windowSamples), 0.0f); + int windowWrite = 0; + float sumSquares = 0.0f; for (int i = 0; i < buffer.getNumSamples(); ++i) { float detector = 0.0f; @@ -53,16 +107,22 @@ void applyGlueCompressor(engine::AudioBuffer& buffer, const double thresholdDb, detector = std::max(detector, std::abs(buffer.getSample(ch, i))); } - if (detector > envelope) { - envelope += (detector - envelope) * attack; + const float detectorSq = detector * detector; + sumSquares += detectorSq - window[static_cast(windowWrite)]; + window[static_cast(windowWrite)] = detectorSq; + windowWrite = (windowWrite + 1) % windowSamples; + const float rmsDetector = std::sqrt(std::max(0.0f, sumSquares / static_cast(windowSamples))); + + if (rmsDetector > envelope) { + envelope = rmsDetector + attackCoeff * (envelope - rmsDetector); } else { - envelope += (detector - envelope) * release; + envelope = rmsDetector + releaseCoeff * (envelope - rmsDetector); } float gain = 1.0f; if (envelope > threshold) { const float over = envelope / threshold; - const float compressed = std::pow(over, static_cast(1.0 / std::max(1.0, ratio))); + const float compressed = std::pow(over, 1.0f / ratioClamped); gain = 1.0f / std::max(compressed, 1.0e-6f); } @@ -70,21 +130,13 @@ void applyGlueCompressor(engine::AudioBuffer& buffer, const double thresholdDb, buffer.setSample(ch, i, buffer.getSample(ch, i) * gain); } } -} -void applyLimiter(engine::AudioBuffer& buffer, const double ceilingDb) { - const float ceiling = static_cast(dbToLinear(ceilingDb)); - for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { - for (int i = 0; i < buffer.getNumSamples(); ++i) { - const float sample = buffer.getSample(ch, i); - const float limited = std::clamp(sample, -ceiling, ceiling); - buffer.setSample(ch, i, limited); - } - } + const double makeupDb = std::clamp((ratioClamped - 1.0f) * 0.8, 0.0, 2.5); + buffer.applyGain(static_cast(dbToLinear(makeupDb))); } -void applyDither(engine::AudioBuffer& buffer, const int bitDepth) { - if (bitDepth >= 24) { +void applyDither(engine::AudioBuffer& buffer, const int bitDepth, const float clampCeilingLinear) { + if (bitDepth <= 0 || bitDepth >= 32) { return; } @@ -95,9 +147,122 @@ void applyDither(engine::AudioBuffer& buffer, const int bitDepth) { for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { for (int i = 0; i < buffer.getNumSamples(); ++i) { const float dither = (distribution(generator) + distribution(generator)) * 0.5f * lsb; - buffer.setSample(ch, i, buffer.getSample(ch, i) + dither); + const float output = buffer.getSample(ch, i) + dither; + buffer.setSample(ch, i, std::clamp(output, -clampCeilingLinear, clampCeilingLinear)); + } + } +} + +double computeMonoCorrelation(const engine::AudioBuffer& buffer) { + if (buffer.getNumChannels() < 2 || buffer.getNumSamples() == 0) { + return 1.0; + } + + double sumL = 0.0; + double sumR = 0.0; + double sumLL = 0.0; + double sumRR = 0.0; + double sumLR = 0.0; + const double n = static_cast(buffer.getNumSamples()); + + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const double l = buffer.getSample(0, i); + const double r = buffer.getSample(1, i); + sumL += l; + sumR += r; + sumLL += l * l; + sumRR += r * r; + sumLR += l * r; + } + + const double cov = sumLR - (sumL * sumR) / n; + const double varL = sumLL - (sumL * sumL) / n; + const double varR = sumRR - (sumR * sumR) / n; + if (varL < 1.0e-9 || varR < 1.0e-9) { + return 1.0; + } + return std::clamp(cov / std::sqrt(varL * varR), -1.0, 1.0); +} + +std::vector presetConfigPaths() { + std::vector paths; + std::error_code error; + auto current = std::filesystem::absolute(std::filesystem::current_path(error), error); + if (error) { + return paths; + } + + for (int depth = 0; depth < 6; ++depth) { + paths.push_back(current / "assets" / "mastering" / "platform_presets.json"); + if (!current.has_parent_path()) { + break; + } + const auto parent = current.parent_path(); + if (parent == current) { + break; } + current = parent; + } + return paths; +} + +const std::unordered_map& platformPresetTargets() { + static const std::unordered_map cache = [] { + std::unordered_map targets; + + for (const auto& candidate : presetConfigPaths()) { + std::error_code error; + if (!std::filesystem::is_regular_file(candidate, error) || error) { + continue; + } + + std::ifstream in(candidate); + if (!in.is_open()) { + continue; + } + + try { + nlohmann::json json; + in >> json; + + if (!json.contains("presets") || !json.at("presets").is_object()) { + continue; + } + + for (const auto& [presetKey, value] : json.at("presets").items()) { + if (!value.is_object()) { + continue; + } + + PlatformPresetTarget target; + target.targetLufs = value.value("targetLufs", -14.0); + target.truePeakDbtp = value.value("truePeakDbtp", -1.0); + target.enableMultiband = value.value("enableMultiband", false); + targets[presetKey] = target; + } + + if (!targets.empty()) { + break; + } + } catch (...) { + continue; + } + } + + return targets; + }(); + + return cache; +} + +std::optional dataDrivenTarget(const domain::MasterPreset preset) { + const auto& targets = platformPresetTargets(); + const auto key = domain::toString(preset); + const auto it = targets.find(key); + if (it == targets.end()) { + return std::nullopt; } + return it->second; } } // namespace @@ -106,6 +271,7 @@ domain::MasterPlan HeuristicAutoMasterStrategy::buildPlan(const domain::MasterPr const engine::AudioBuffer& mixBuffer) const { domain::MasterPlan plan; plan.preset = preset; + plan.presetName = domain::toString(preset); switch (preset) { case domain::MasterPreset::DefaultStreaming: @@ -116,18 +282,87 @@ domain::MasterPlan HeuristicAutoMasterStrategy::buildPlan(const domain::MasterPr plan.targetLufs = -23.0; plan.truePeakDbtp = -1.0; break; + case domain::MasterPreset::UdioOptimized: + plan.targetLufs = -14.0; + plan.truePeakDbtp = -1.0; + plan.applyEq = true; + plan.enableDeEsser = true; + plan.enableDeHarshEq = true; + plan.enableLowMono = true; + plan.lowMonoHz = 120.0; + plan.stereoWidth = 0.95; + plan.enableSoftClipper = true; + plan.softClipDrive = 1.2; + plan.enableMultibandCompressor = true; + break; + case domain::MasterPreset::Spotify: + plan.targetLufs = -14.0; + plan.truePeakDbtp = -1.0; + plan.enableMultibandCompressor = true; + break; + case domain::MasterPreset::AppleMusic: + plan.targetLufs = -16.0; + plan.truePeakDbtp = -1.0; + plan.enableMultibandCompressor = true; + break; + case domain::MasterPreset::YouTube: + plan.targetLufs = -14.0; + plan.truePeakDbtp = -1.0; + plan.enableMultibandCompressor = true; + break; + case domain::MasterPreset::AmazonMusic: + plan.targetLufs = -14.0; + plan.truePeakDbtp = -2.0; + plan.enableMultibandCompressor = true; + break; + case domain::MasterPreset::Tidal: + plan.targetLufs = -14.0; + plan.truePeakDbtp = -1.0; + plan.enableMultibandCompressor = true; + break; + case domain::MasterPreset::BroadcastEbuR128: + plan.targetLufs = -23.0; + plan.truePeakDbtp = -1.0; + break; case domain::MasterPreset::Custom: + plan.targetLufs = -14.0; + plan.truePeakDbtp = -1.0; break; } + if (const auto target = dataDrivenTarget(preset); target.has_value()) { + plan.targetLufs = target->targetLufs; + plan.truePeakDbtp = target->truePeakDbtp; + plan.enableMultibandCompressor = plan.enableMultibandCompressor || target->enableMultiband; + plan.decisionLog.push_back("Applied data-driven platform target override."); + } + const double currentLufs = measureIntegratedLufs(mixBuffer); - plan.preGainDb = std::clamp((plan.targetLufs - currentLufs) * 0.7, -6.0, 6.0); + analysis::StemAnalyzer analyzer; + const auto mixMetrics = analyzer.analyzeBuffer(mixBuffer); + plan.preGainDb = std::clamp(plan.targetLufs - currentLufs, -9.0, 9.0); plan.limiterCeilingDb = plan.truePeakDbtp; - plan.decisionLog.push_back("Master preset selected: " + domain::toString(plan.preset)); - plan.decisionLog.push_back("Estimated current LUFS: " + std::to_string(currentLufs)); + if (mixMetrics.artifactProfile.noiseDominance > 0.55) { + plan.enableDeEsser = true; + plan.deEsserStrength = std::max(plan.deEsserStrength, 0.45); + } + if (mixMetrics.artifactProfile.swirlRisk > 0.55) { + plan.enableDeHarshEq = true; + plan.deHarshStrength = std::max(plan.deHarshStrength, 0.45); + } + if (mixMetrics.artifactProfile.phaseInstability > 0.45) { + plan.enableLowMono = true; + plan.stereoWidth = std::min(plan.stereoWidth, 0.9); + } + + plan.decisionLog.push_back("Master preset selected: " + plan.presetName); + plan.decisionLog.push_back("Measured integrated LUFS: " + std::to_string(currentLufs)); plan.decisionLog.push_back("Applied pre-gain: " + std::to_string(plan.preGainDb) + " dB"); plan.decisionLog.push_back("Limiter ceiling set to: " + std::to_string(plan.limiterCeilingDb) + " dBTP"); + if (plan.enableMultibandCompressor) { + plan.decisionLog.push_back("Multiband compression enabled."); + } return plan; } @@ -136,81 +371,119 @@ engine::AudioBuffer HeuristicAutoMasterStrategy::applyPlan(const engine::AudioBu const domain::MasterPlan& plan, MasteringReport* reportOut) const { engine::AudioBuffer mastered = mixBuffer; + std::vector activeModules; + + applyDcHighPass(mastered, kDcHighPassHz); + activeModules.push_back("DcHighPass"); applyGain(mastered, plan.preGainDb); + + if (plan.enableMultibandCompressor) { + dsp::MultibandProcessor multiband; + multiband.process(mastered, plan.multibandSettings); + activeModules.push_back("MultibandProcessor"); + } + if (plan.applyEq) { - applyTonalTilt(mastered); + applyTonalTilt(mastered, kTonalTiltDb); + activeModules.push_back("TonalTiltEq"); + } + + if (plan.enableDeEsser) { + dsp::DeEsser deEsser; + deEsser.process(mastered, plan.deEsserStrength); + activeModules.push_back("DeEsser"); + } + + if (plan.enableDeHarshEq) { + dsp::DynamicDeHarshEq deHarsh; + deHarsh.process(mastered, plan.deHarshStrength); + activeModules.push_back("DynamicDeHarshEq"); + } + + if (plan.enableLowMono || std::abs(plan.stereoWidth - 1.0) > 1.0e-3) { + dsp::MidSideProcessor midSide; + midSide.process(mastered, plan.lowMonoHz, plan.stereoWidth); + activeModules.push_back("MidSideProcessor"); } applyGlueCompressor(mastered, plan.glueThresholdDb, plan.glueRatio); - applyLimiter(mastered, plan.limiterCeilingDb); + activeModules.push_back("GlueCompressor"); - for (int i = 0; i < 2; ++i) { + if (plan.enableSoftClipper) { + dsp::SoftClipper softClipper; + softClipper.process(mastered, plan.softClipDrive); + activeModules.push_back("SoftClipper"); + } + + dsp::LookaheadLimiterSettings limiterSettings; + limiterSettings.ceilingDb = plan.limiterCeilingDb; + limiterSettings.lookaheadMs = plan.limiterLookaheadMs; + limiterSettings.attackMs = plan.limiterAttackMs; + limiterSettings.releaseMs = plan.limiterReleaseMs; + limiterSettings.truePeakEnabled = plan.limiterTruePeakEnabled; + limiterSettings.truePeakOversampleFactor = 4; + limiterSettings.softClipEnabled = false; + + dsp::LookaheadLimiter limiter; + limiter.prepare(mastered.getSampleRate(), mastered.getNumChannels(), limiterSettings); + limiter.process(mastered); + activeModules.push_back("LookaheadLimiter"); + + for (int i = 0; i < kMaxLoudnessIterations; ++i) { const double loudness = measureIntegratedLufs(mastered); - const double correctionDb = plan.targetLufs - loudness; - applyGain(mastered, correctionDb); + const double error = plan.targetLufs - loudness; + if (std::abs(error) <= kLoudnessToleranceLu) { + break; + } + applyGain(mastered, std::clamp(error, -2.5, 2.5)); const double truePeakDbtp = estimateTruePeakDbtp(mastered, 4); if (truePeakDbtp > plan.truePeakDbtp) { applyGain(mastered, plan.truePeakDbtp - truePeakDbtp); } + + limiter.process(mastered); + } + + if (mastered.getNumChannels() > 1) { + const double monoCorr = computeMonoCorrelation(mastered); + if (monoCorr < 0.0) { + dsp::MidSideProcessor monoSafety; + monoSafety.process(mastered, plan.lowMonoHz, 0.9); + limiter.process(mastered); + activeModules.push_back("MonoCompatibilitySafety"); + } } - applyLimiter(mastered, plan.limiterCeilingDb); - applyDither(mastered, plan.ditherBitDepth); + const float ceilingLinear = static_cast(dbToLinear(plan.truePeakDbtp)); + applyDither(mastered, plan.ditherBitDepth, ceilingLinear); if (reportOut != nullptr) { - reportOut->integratedLufs = measureIntegratedLufs(mastered); + engine::LoudnessMeter meter; + const auto metrics = meter.analyze(mastered); + reportOut->integratedLufs = metrics.integratedLufs; + reportOut->shortTermLufs = metrics.shortTermLufs; + reportOut->loudnessRange = metrics.loudnessRange; + reportOut->samplePeakDbfs = metrics.samplePeakDbfs; reportOut->truePeakDbtp = estimateTruePeakDbtp(mastered, 4); + reportOut->crestDb = reportOut->samplePeakDbfs - metrics.integratedLufs; + reportOut->monoCorrelation = computeMonoCorrelation(mastered); + reportOut->activeModules = activeModules; } return mastered; } double HeuristicAutoMasterStrategy::measureIntegratedLufs(const engine::AudioBuffer& buffer) const { - if (buffer.getNumSamples() == 0) { - return -120.0; - } - - double energy = 0.0; - const int channels = std::max(1, buffer.getNumChannels()); - - for (int i = 0; i < buffer.getNumSamples(); ++i) { - double mono = 0.0; - for (int ch = 0; ch < channels; ++ch) { - mono += buffer.getSample(ch, i); - } - mono /= channels; - energy += mono * mono; - } - - const double meanSquare = energy / static_cast(buffer.getNumSamples()); - // Approximation of LUFS from mean square power. - return -0.691 + 10.0 * std::log10(std::max(meanSquare, 1.0e-12)); + engine::LoudnessMeter meter; + return meter.computeIntegratedLufs(buffer); } double HeuristicAutoMasterStrategy::estimateTruePeakDbtp(const engine::AudioBuffer& buffer, const int oversampleFactor) const { - if (buffer.getNumSamples() <= 1) { - return -120.0; - } - - double peak = 0.0; - for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { - for (int i = 0; i < buffer.getNumSamples() - 1; ++i) { - const float a = buffer.getSample(ch, i); - const float b = buffer.getSample(ch, i + 1); - - peak = std::max(peak, static_cast(std::abs(a))); - for (int k = 1; k < oversampleFactor; ++k) { - const float alpha = static_cast(k) / static_cast(oversampleFactor); - const float interpolated = a + (b - a) * alpha; - peak = std::max(peak, static_cast(std::abs(interpolated))); - } - } - } - - return linearToDb(peak); + dsp::TruePeakDetector detector(std::max(2, oversampleFactor)); + return detector.computeTruePeakDbtp(buffer); } } // namespace automix::automaster diff --git a/src/automaster/IAutoMasterStrategy.h b/src/automaster/IAutoMasterStrategy.h index 893efae..89dbcbd 100644 --- a/src/automaster/IAutoMasterStrategy.h +++ b/src/automaster/IAutoMasterStrategy.h @@ -1,5 +1,7 @@ #pragma once +#include + #include "domain/MasterPlan.h" #include "engine/AudioBuffer.h" @@ -7,7 +9,13 @@ namespace automix::automaster { struct MasteringReport { double integratedLufs = -120.0; + double shortTermLufs = -120.0; + double loudnessRange = 0.0; + double samplePeakDbfs = -120.0; double truePeakDbtp = -120.0; + double crestDb = 0.0; + double monoCorrelation = 1.0; + std::vector activeModules; }; class IAutoMasterStrategy { diff --git a/src/automix/HeuristicAutoMixStrategy.cpp b/src/automix/HeuristicAutoMixStrategy.cpp index 5a22bcd..7b9ab2f 100644 --- a/src/automix/HeuristicAutoMixStrategy.cpp +++ b/src/automix/HeuristicAutoMixStrategy.cpp @@ -1,6 +1,8 @@ #include "automix/HeuristicAutoMixStrategy.h" #include +#include +#include #include #include "ai/StemRoleClassifierAI.h" @@ -55,6 +57,46 @@ double highPassForRole(const domain::StemRole role) { } } +int dominantBand(const analysis::AnalysisResult& metrics) { + if (metrics.lowEnergy >= metrics.midEnergy && metrics.lowEnergy >= metrics.highEnergy) { + return 0; + } + if (metrics.midEnergy >= metrics.lowEnergy && metrics.midEnergy >= metrics.highEnergy) { + return 1; + } + return 2; +} + +double safeMean(const std::vector& values, const size_t count) { + if (values.empty() || count == 0) { + return 0.0; + } + const auto limit = std::min(count, values.size()); + return std::accumulate(values.begin(), values.begin() + static_cast(limit), 0.0) / + static_cast(limit); +} + +double normalizedTransientScore(const analysis::AnalysisResult& metrics) { + const double onset = std::log1p(std::max(0.0, metrics.onsetStrength)); + const double flux = std::log1p(std::max(0.0, metrics.spectralFlux)); + return std::clamp((onset + flux) * 0.35, 0.0, 1.0); +} + +double lowFrequencyFocus(const analysis::AnalysisResult& metrics) { + if (metrics.constantQBins.empty()) { + return metrics.lowEnergy; + } + const double lowCqt = safeMean(metrics.constantQBins, 4); + return std::clamp((metrics.lowEnergy + lowCqt) * 0.5, 0.0, 1.0); +} + +double brightnessHint(const analysis::AnalysisResult& metrics) { + const double mfcc1 = metrics.mfccCoefficients.size() > 1 ? metrics.mfccCoefficients[1] : 0.0; + const double mfcc2 = metrics.mfccCoefficients.size() > 2 ? metrics.mfccCoefficients[2] : 0.0; + const double score = 0.5 + 0.05 * (mfcc1 + mfcc2) + metrics.highEnergy * 0.25 - metrics.lowEnergy * 0.15; + return std::clamp(score, 0.0, 1.0); +} + } // namespace domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& session, @@ -65,8 +107,10 @@ domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& sessi plan.mixBusHeadroomDb = 6.0; std::unordered_map analysisByStem; + std::unordered_map dominantBandCounts; for (const auto& entry : analysisEntries) { analysisByStem.emplace(entry.stemId, entry.metrics); + dominantBandCounts[dominantBand(entry.metrics)] += 1; } int stereoOrdinal = 0; @@ -88,23 +132,52 @@ domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& sessi role = prediction.role; } - const double targetRms = targetRmsForRole(role); + const double transientScore = normalizedTransientScore(metrics); + const double lowFocus = lowFrequencyFocus(metrics); + const double brightness = brightnessHint(metrics); + const double crestFactor = std::clamp(metrics.crestFactor, 1.0, 8.0); + + const double targetRms = targetRmsForRole(role) + (transientScore > 0.6 ? -0.4 : 0.0); const bool separatedStem = stem.origin == domain::StemOrigin::Separated; const double artifactRisk = std::clamp(metrics.artifactRisk, 0.0, 1.0); const double baseGainLimit = separatedStem ? 8.0 : 12.0; const double gainLimit = std::max(3.0, baseGainLimit - artifactRisk * 4.0); - const double gainDb = std::clamp(targetRms - metrics.rmsDb, -gainLimit, gainLimit); + double gainDb = std::clamp(targetRms - metrics.rmsDb, -gainLimit, gainLimit); + + const int overlapCount = dominantBandCounts[dominantBand(metrics)]; + if (overlapCount > 1) { + gainDb -= std::min(3.0, static_cast(overlapCount - 1) * 0.75); + gainDb = std::clamp(gainDb, -gainLimit, gainLimit); + } + + if (metrics.artifactProfile.noiseDominance > 0.55 && metrics.highEnergy > 0.5 && gainDb > 0.0) { + gainDb = std::min(gainDb, 0.0); + } domain::StemMixDecision decision; decision.stemId = stem.id; decision.gainDb = gainDb; decision.pan = panForRole(role, stereoOrdinal++); decision.highPassHz = highPassForRole(role); + if (role != domain::StemRole::Bass && role != domain::StemRole::Kick && lowFocus > 0.65) { + decision.highPassHz = std::max(decision.highPassHz, 140.0); + } + decision.mudCutDb = (metrics.lowEnergy > 0.5 && metrics.midEnergy > 0.4) ? -1.5 : 0.0; - decision.enableCompressor = role == domain::StemRole::Vocals; + if (brightness < 0.35 && decision.mudCutDb < 0.0) { + decision.mudCutDb = -2.0; + } + + decision.enableCompressor = role == domain::StemRole::Vocals || role == domain::StemRole::Drums; decision.compressorThresholdDb = separatedStem ? -12.0 : -18.0; decision.compressorRatio = separatedStem ? 1.6 : 2.5; decision.compressorReleaseMs = separatedStem ? 170.0 : 80.0; + + if (transientScore > 0.6 || crestFactor > 4.0) { + decision.compressorRatio = std::max(1.2, decision.compressorRatio - 0.8); + decision.compressorReleaseMs = std::max(60.0, decision.compressorReleaseMs - 20.0); + } + decision.enableExpander = separatedStem && artifactRisk > 0.30; decision.expanderThresholdDb = -44.0 + artifactRisk * 8.0; decision.expanderRatio = 1.2 + artifactRisk * 0.8; @@ -112,10 +185,21 @@ domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& sessi if (separatedStem && decision.enableCompressor && artifactRisk > 0.65) { decision.enableCompressor = false; } + if (metrics.artifactProfile.phaseInstability > 0.45) { + decision.pan *= 0.35; + } + if (metrics.artifactProfile.swirlRisk > 0.6 && decision.gainDb > 1.5) { + decision.gainDb = 1.5; + } plan.decisionLog.push_back("Stem '" + stem.name + "' role=" + domain::toString(role) + " origin=" + domain::toString(stem.origin) + " artifactRisk=" + std::to_string(artifactRisk) + + " transient=" + std::to_string(transientScore) + + " crestFactor=" + std::to_string(crestFactor) + + " overlapCount=" + std::to_string(overlapCount) + + " swirl=" + std::to_string(metrics.artifactProfile.swirlRisk) + + " phaseInstability=" + std::to_string(metrics.artifactProfile.phaseInstability) + " gainDb=" + std::to_string(decision.gainDb) + " pan=" + std::to_string(decision.pan) + " hpHz=" + std::to_string(decision.highPassHz)); diff --git a/src/domain/BatchTypes.cpp b/src/domain/BatchTypes.cpp new file mode 100644 index 0000000..d1bbfa8 --- /dev/null +++ b/src/domain/BatchTypes.cpp @@ -0,0 +1,23 @@ +#include "domain/BatchTypes.h" + +namespace automix::domain { + +std::string toString(const BatchItemStatus status) { + switch (status) { + case BatchItemStatus::Pending: + return "pending"; + case BatchItemStatus::Analyzing: + return "analyzing"; + case BatchItemStatus::Rendering: + return "rendering"; + case BatchItemStatus::Completed: + return "completed"; + case BatchItemStatus::Failed: + return "failed"; + case BatchItemStatus::Cancelled: + return "cancelled"; + } + return "pending"; +} + +} // namespace automix::domain diff --git a/src/domain/BatchTypes.h b/src/domain/BatchTypes.h new file mode 100644 index 0000000..fd63be9 --- /dev/null +++ b/src/domain/BatchTypes.h @@ -0,0 +1,51 @@ +#pragma once + +#include +#include +#include + +#include "domain/RenderSettings.h" +#include "domain/Session.h" + +namespace automix::domain { + +enum class BatchItemStatus { + Pending, + Analyzing, + Rendering, + Completed, + Failed, + Cancelled, +}; + +struct BatchItem { + Session session; + std::filesystem::path sourcePath; + std::filesystem::path outputPath; + BatchItemStatus status = BatchItemStatus::Pending; + std::string error; + std::string reportPath; +}; + +struct BatchSettings { + std::filesystem::path outputFolder; + int analysisThreads = 1; + int renderParallelism = 1; + bool parallelAnalysis = true; + RenderSettings renderSettings; +}; + +struct BatchJob { + std::vector items; + BatchSettings settings; +}; + +struct BatchResult { + int completed = 0; + int failed = 0; + int cancelled = 0; +}; + +std::string toString(BatchItemStatus status); + +} // namespace automix::domain diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index 0f274e4..71849c1 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -2,6 +2,8 @@ #include +#include "domain/ProjectProfile.h" + namespace automix::domain { void to_json(Json& j, const Stem& value) { @@ -15,6 +17,12 @@ void to_json(Json& j, const Stem& value) { if (value.busId.has_value()) { j["busId"] = value.busId.value(); } + if (value.separationConfidence.has_value()) { + j["separationConfidence"] = value.separationConfidence.value(); + } + if (value.separationArtifactRisk.has_value()) { + j["separationArtifactRisk"] = value.separationArtifactRisk.value(); + } } void from_json(const Json& j, Stem& value) { @@ -30,6 +38,18 @@ void from_json(const Json& j, Stem& value) { } else { value.busId.reset(); } + + if (j.contains("separationConfidence") && !j.at("separationConfidence").is_null()) { + value.separationConfidence = j.at("separationConfidence").get(); + } else { + value.separationConfidence.reset(); + } + + if (j.contains("separationArtifactRisk") && !j.at("separationArtifactRisk").is_null()) { + value.separationArtifactRisk = j.at("separationArtifactRisk").get(); + } else { + value.separationArtifactRisk.reset(); + } } void to_json(Json& j, const Bus& value) { @@ -51,7 +71,20 @@ void to_json(Json& j, const RenderSettings& value) { {"blockSize", value.blockSize}, {"outputBitDepth", value.outputBitDepth}, {"outputPath", value.outputPath}, - {"rendererName", value.rendererName}}; + {"outputFormat", value.outputFormat}, + {"exportSpeedMode", value.exportSpeedMode}, + {"gpuExecutionProvider", value.gpuExecutionProvider}, + {"lossyBitrateKbps", value.lossyBitrateKbps}, + {"lossyQuality", value.lossyQuality}, + {"mp3UseVbr", value.mp3UseVbr}, + {"mp3VbrQuality", value.mp3VbrQuality}, + {"processingThreads", value.processingThreads}, + {"preferHardwareAcceleration", value.preferHardwareAcceleration}, + {"metadataPolicy", value.metadataPolicy}, + {"metadataTemplate", value.metadataTemplate}, + {"rendererName", value.rendererName}, + {"externalRendererPath", value.externalRendererPath}, + {"externalRendererTimeoutMs", value.externalRendererTimeoutMs}}; } void from_json(const Json& j, RenderSettings& value) { @@ -59,7 +92,32 @@ void from_json(const Json& j, RenderSettings& value) { value.blockSize = j.value("blockSize", 1024); value.outputBitDepth = j.value("outputBitDepth", 24); value.outputPath = j.value("outputPath", ""); + value.outputFormat = j.value("outputFormat", "auto"); + value.exportSpeedMode = j.value("exportSpeedMode", "final"); + if (value.exportSpeedMode != "final" && + value.exportSpeedMode != "balanced" && + value.exportSpeedMode != "quick") { + value.exportSpeedMode = "final"; + } + value.gpuExecutionProvider = j.value("gpuExecutionProvider", "auto"); + value.lossyBitrateKbps = std::clamp(j.value("lossyBitrateKbps", 320), 48, 512); + value.lossyQuality = std::clamp(j.value("lossyQuality", 7), 0, 10); + value.mp3UseVbr = j.value("mp3UseVbr", false); + value.mp3VbrQuality = std::clamp(j.value("mp3VbrQuality", 4), 0, 9); + value.processingThreads = std::max(0, j.value("processingThreads", 0)); + value.preferHardwareAcceleration = j.value("preferHardwareAcceleration", true); + value.metadataPolicy = j.value("metadataPolicy", "copy_all"); + if (value.metadataPolicy != "copy_all" && + value.metadataPolicy != "copy_common" && + value.metadataPolicy != "copy_common_only" && + value.metadataPolicy != "strip" && + value.metadataPolicy != "override_template") { + value.metadataPolicy = "copy_all"; + } + value.metadataTemplate = j.value("metadataTemplate", std::map{}); value.rendererName = j.value("rendererName", "BuiltIn"); + value.externalRendererPath = j.value("externalRendererPath", ""); + value.externalRendererTimeoutMs = j.value("externalRendererTimeoutMs", 300000); } void to_json(Json& j, const StemMixDecision& value) { @@ -106,8 +164,43 @@ void from_json(const Json& j, MixPlan& value) { value.decisionLog = j.value("decisionLog", std::vector{}); } +void to_json(Json& j, const MultibandBandSettings& value) { + j = Json{{"enabled", value.enabled}, + {"thresholdDb", value.thresholdDb}, + {"ratio", value.ratio}, + {"makeupGainDb", value.makeupGainDb}, + {"width", value.width}}; +} + +void from_json(const Json& j, MultibandBandSettings& value) { + value.enabled = j.value("enabled", true); + value.thresholdDb = j.value("thresholdDb", -18.0); + value.ratio = j.value("ratio", 2.0); + value.makeupGainDb = j.value("makeupGainDb", 0.0); + value.width = j.value("width", 1.0); +} + +void to_json(Json& j, const MultibandSettings& value) { + j = Json{{"crossoverHz", value.crossoverHz}, + {"bands", value.bands}, + {"linearPhase", value.linearPhase}}; +} + +void from_json(const Json& j, MultibandSettings& value) { + value.crossoverHz = j.value("crossoverHz", std::vector{120.0, 500.0, 2000.0, 8000.0}); + value.bands = j.value("bands", std::vector{ + MultibandBandSettings{}, + MultibandBandSettings{}, + MultibandBandSettings{}, + MultibandBandSettings{}, + MultibandBandSettings{}, + }); + value.linearPhase = j.value("linearPhase", false); +} + void to_json(Json& j, const MasterPlan& value) { j = Json{{"preset", toString(value.preset)}, + {"presetName", value.presetName}, {"targetLufs", value.targetLufs}, {"truePeakDbtp", value.truePeakDbtp}, {"preGainDb", value.preGainDb}, @@ -115,12 +208,28 @@ void to_json(Json& j, const MasterPlan& value) { {"glueThresholdDb", value.glueThresholdDb}, {"glueRatio", value.glueRatio}, {"limiterCeilingDb", value.limiterCeilingDb}, + {"limiterTruePeakEnabled", value.limiterTruePeakEnabled}, + {"limiterLookaheadMs", value.limiterLookaheadMs}, + {"limiterAttackMs", value.limiterAttackMs}, + {"limiterReleaseMs", value.limiterReleaseMs}, {"ditherBitDepth", value.ditherBitDepth}, + {"enableDeEsser", value.enableDeEsser}, + {"deEsserStrength", value.deEsserStrength}, + {"enableDeHarshEq", value.enableDeHarshEq}, + {"deHarshStrength", value.deHarshStrength}, + {"enableLowMono", value.enableLowMono}, + {"lowMonoHz", value.lowMonoHz}, + {"stereoWidth", value.stereoWidth}, + {"enableSoftClipper", value.enableSoftClipper}, + {"softClipDrive", value.softClipDrive}, + {"enableMultibandCompressor", value.enableMultibandCompressor}, + {"multibandSettings", value.multibandSettings}, {"decisionLog", value.decisionLog}}; } void from_json(const Json& j, MasterPlan& value) { value.preset = masterPresetFromString(j.value("preset", "default_streaming")); + value.presetName = j.value("presetName", "DefaultStreaming"); value.targetLufs = j.value("targetLufs", -14.0); value.truePeakDbtp = j.value("truePeakDbtp", -1.0); value.preGainDb = j.value("preGainDb", 0.0); @@ -128,7 +237,22 @@ void from_json(const Json& j, MasterPlan& value) { value.glueThresholdDb = j.value("glueThresholdDb", -18.0); value.glueRatio = j.value("glueRatio", 2.0); value.limiterCeilingDb = j.value("limiterCeilingDb", -1.0); + value.limiterTruePeakEnabled = j.value("limiterTruePeakEnabled", true); + value.limiterLookaheadMs = j.value("limiterLookaheadMs", 7.0); + value.limiterAttackMs = j.value("limiterAttackMs", 1.0); + value.limiterReleaseMs = j.value("limiterReleaseMs", 80.0); value.ditherBitDepth = j.value("ditherBitDepth", 24); + value.enableDeEsser = j.value("enableDeEsser", false); + value.deEsserStrength = j.value("deEsserStrength", 0.35); + value.enableDeHarshEq = j.value("enableDeHarshEq", false); + value.deHarshStrength = j.value("deHarshStrength", 0.30); + value.enableLowMono = j.value("enableLowMono", false); + value.lowMonoHz = j.value("lowMonoHz", 120.0); + value.stereoWidth = j.value("stereoWidth", 1.0); + value.enableSoftClipper = j.value("enableSoftClipper", false); + value.softClipDrive = j.value("softClipDrive", 1.15); + value.enableMultibandCompressor = j.value("enableMultibandCompressor", false); + value.multibandSettings = j.value("multibandSettings", MultibandSettings{}); value.decisionLog = j.value("decisionLog", std::vector{}); } @@ -138,7 +262,18 @@ void to_json(Json& j, const Session& value) { {"residualBlend", value.residualBlend}, {"stems", value.stems}, {"buses", value.buses}, - {"renderSettings", value.renderSettings}}; + {"renderSettings", value.renderSettings}, + {"projectProfileId", value.projectProfileId}, + {"safetyPolicyId", value.safetyPolicyId}, + {"preferredStemCount", value.preferredStemCount}, + {"timeline", + Json{ + {"loopEnabled", value.timeline.loopEnabled}, + {"loopInSeconds", value.timeline.loopInSeconds}, + {"loopOutSeconds", value.timeline.loopOutSeconds}, + {"zoom", value.timeline.zoom}, + {"fineScrub", value.timeline.fineScrub}, + }}}; if (value.originalMixPath.has_value()) { j["originalMixPath"] = value.originalMixPath.value(); @@ -158,6 +293,19 @@ void from_json(const Json& j, Session& value) { value.residualBlend = std::clamp(j.value("residualBlend", 0.0), 0.0, 10.0); value.stems = j.value("stems", std::vector{}); value.buses = j.value("buses", std::vector{}); + value.projectProfileId = j.value("projectProfileId", "default"); + value.safetyPolicyId = j.value("safetyPolicyId", "balanced"); + value.preferredStemCount = std::clamp(j.value("preferredStemCount", 4), kMinPreferredStemCount, kMaxPreferredStemCount); + + const auto timelineJson = j.value("timeline", Json::object()); + value.timeline.loopEnabled = timelineJson.value("loopEnabled", false); + value.timeline.loopInSeconds = std::max(0.0, timelineJson.value("loopInSeconds", 0.0)); + value.timeline.loopOutSeconds = std::max(0.0, timelineJson.value("loopOutSeconds", 0.0)); + value.timeline.zoom = std::clamp(timelineJson.value("zoom", 1.0), 1.0, 64.0); + value.timeline.fineScrub = timelineJson.value("fineScrub", false); + if (value.timeline.loopOutSeconds <= value.timeline.loopInSeconds) { + value.timeline.loopEnabled = false; + } if (j.contains("originalMixPath") && !j.at("originalMixPath").is_null()) { value.originalMixPath = j.at("originalMixPath").get(); diff --git a/src/domain/MasterPlan.cpp b/src/domain/MasterPlan.cpp index ab5d682..21c7b65 100644 --- a/src/domain/MasterPlan.cpp +++ b/src/domain/MasterPlan.cpp @@ -1,6 +1,19 @@ #include "domain/MasterPlan.h" +#include +#include + namespace automix::domain { +namespace { + +std::string normalized(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +} // namespace std::string toString(const MasterPreset preset) { switch (preset) { @@ -8,6 +21,20 @@ std::string toString(const MasterPreset preset) { return "default_streaming"; case MasterPreset::Broadcast: return "broadcast"; + case MasterPreset::UdioOptimized: + return "udio_optimized"; + case MasterPreset::Spotify: + return "spotify"; + case MasterPreset::AppleMusic: + return "apple_music"; + case MasterPreset::YouTube: + return "youtube"; + case MasterPreset::AmazonMusic: + return "amazon_music"; + case MasterPreset::Tidal: + return "tidal"; + case MasterPreset::BroadcastEbuR128: + return "broadcast_ebu_r128"; case MasterPreset::Custom: return "custom"; } @@ -15,10 +42,32 @@ std::string toString(const MasterPreset preset) { } MasterPreset masterPresetFromString(const std::string& value) { - if (value == "broadcast") { + const auto text = normalized(value); + if (text == "broadcast") { return MasterPreset::Broadcast; } - if (value == "custom") { + if (text == "udio_optimized") { + return MasterPreset::UdioOptimized; + } + if (text == "spotify") { + return MasterPreset::Spotify; + } + if (text == "apple_music") { + return MasterPreset::AppleMusic; + } + if (text == "youtube") { + return MasterPreset::YouTube; + } + if (text == "amazon_music") { + return MasterPreset::AmazonMusic; + } + if (text == "tidal") { + return MasterPreset::Tidal; + } + if (text == "broadcast_ebu_r128") { + return MasterPreset::BroadcastEbuR128; + } + if (text == "custom") { return MasterPreset::Custom; } return MasterPreset::DefaultStreaming; diff --git a/src/domain/MasterPlan.h b/src/domain/MasterPlan.h index 3138e03..7195a7f 100644 --- a/src/domain/MasterPlan.h +++ b/src/domain/MasterPlan.h @@ -5,10 +5,42 @@ namespace automix::domain { -enum class MasterPreset { DefaultStreaming, Broadcast, Custom }; +enum class MasterPreset { + DefaultStreaming, + Broadcast, + UdioOptimized, + Spotify, + AppleMusic, + YouTube, + AmazonMusic, + Tidal, + BroadcastEbuR128, + Custom, +}; + +struct MultibandBandSettings { + bool enabled = true; + double thresholdDb = -18.0; + double ratio = 2.0; + double makeupGainDb = 0.0; + double width = 1.0; +}; + +struct MultibandSettings { + std::vector crossoverHz = {120.0, 500.0, 2000.0, 8000.0}; + std::vector bands = { + MultibandBandSettings{}, + MultibandBandSettings{}, + MultibandBandSettings{}, + MultibandBandSettings{}, + MultibandBandSettings{}, + }; + bool linearPhase = false; +}; struct MasterPlan { MasterPreset preset = MasterPreset::DefaultStreaming; + std::string presetName = "DefaultStreaming"; double targetLufs = -14.0; double truePeakDbtp = -1.0; double preGainDb = 0.0; @@ -16,7 +48,22 @@ struct MasterPlan { double glueThresholdDb = -18.0; double glueRatio = 2.0; double limiterCeilingDb = -1.0; + bool limiterTruePeakEnabled = true; + double limiterLookaheadMs = 7.0; + double limiterAttackMs = 1.0; + double limiterReleaseMs = 80.0; int ditherBitDepth = 24; + bool enableDeEsser = false; + double deEsserStrength = 0.35; + bool enableDeHarshEq = false; + double deHarshStrength = 0.30; + bool enableLowMono = false; + double lowMonoHz = 120.0; + double stereoWidth = 1.0; + bool enableSoftClipper = false; + double softClipDrive = 1.15; + bool enableMultibandCompressor = false; + MultibandSettings multibandSettings; std::vector decisionLog; }; diff --git a/src/domain/ProjectProfile.cpp b/src/domain/ProjectProfile.cpp new file mode 100644 index 0000000..c6ad4c7 --- /dev/null +++ b/src/domain/ProjectProfile.cpp @@ -0,0 +1,197 @@ +#include "domain/ProjectProfile.h" + +#include +#include +#include + +#include + +namespace automix::domain { +namespace { + +ProjectProfile profileFromJson(const nlohmann::json& json) { + ProjectProfile profile; + profile.id = json.value("id", ""); + profile.name = json.value("name", profile.id); + profile.platformPreset = json.value("platformPreset", json.value("platform", "spotify")); + profile.rendererName = json.value("rendererName", "BuiltIn"); + profile.outputFormat = json.value("outputFormat", "wav"); + profile.lossyBitrateKbps = std::clamp(json.value("lossyBitrateKbps", 320), 64, 320); + profile.mp3UseVbr = json.value("mp3UseVbr", false); + profile.mp3VbrQuality = std::clamp(json.value("mp3VbrQuality", 4), 0, 9); + profile.gpuProvider = json.value("gpuProvider", "auto"); + profile.roleModelPackId = json.value("roleModelPackId", "none"); + profile.mixModelPackId = json.value("mixModelPackId", "none"); + profile.masterModelPackId = json.value("masterModelPackId", "none"); + profile.safetyPolicyId = json.value("safetyPolicyId", "balanced"); + profile.preferredStemCount = std::clamp(json.value("preferredStemCount", 4), kMinPreferredStemCount, kMaxPreferredStemCount); + profile.metadataPolicy = json.value("metadataPolicy", "copy_common"); + if (profile.metadataPolicy != "copy_all" && + profile.metadataPolicy != "copy_common" && + profile.metadataPolicy != "copy_common_only" && + profile.metadataPolicy != "strip" && + profile.metadataPolicy != "override_template") { + profile.metadataPolicy = "copy_common"; + } + if (json.contains("metadataTemplate") && json.at("metadataTemplate").is_object()) { + profile.metadataTemplate = json.at("metadataTemplate").get>(); + } + if (json.contains("pinnedRendererIds") && json.at("pinnedRendererIds").is_array()) { + profile.pinnedRendererIds = json.at("pinnedRendererIds").get>(); + } + return profile; +} + +void appendUnique(std::vector* profiles, const ProjectProfile& profile) { + if (profile.id.empty() || profile.name.empty()) { + return; + } + const auto exists = std::any_of(profiles->begin(), profiles->end(), [&](const ProjectProfile& existing) { + return existing.id == profile.id; + }); + if (!exists) { + profiles->push_back(profile); + } +} + +std::vector profileFileCandidates(const std::filesystem::path& repositoryRoot) { + std::vector candidates; + std::error_code error; + auto current = repositoryRoot.empty() ? std::filesystem::current_path(error) : repositoryRoot; + if (error) { + return candidates; + } + + for (int depth = 0; depth < 5; ++depth) { + candidates.push_back(current / "assets" / "profiles" / "project_profiles.json"); + candidates.push_back(current / "Assets" / "Profiles" / "project_profiles.json"); + if (!current.has_parent_path()) { + break; + } + const auto parent = current.parent_path(); + if (parent == current) { + break; + } + current = parent; + } + + return candidates; +} + +} // namespace + +std::vector defaultProjectProfiles() { + return { + ProjectProfile{ + .id = "default", + .name = "Default Balanced", + .platformPreset = "spotify", + .rendererName = "BuiltIn", + .outputFormat = "wav", + .lossyBitrateKbps = 320, + .mp3UseVbr = false, + .mp3VbrQuality = 4, + .gpuProvider = "auto", + .roleModelPackId = "none", + .mixModelPackId = "none", + .masterModelPackId = "none", + .safetyPolicyId = "balanced", + .preferredStemCount = 4, + .metadataPolicy = "copy_common", + .metadataTemplate = {}, + .pinnedRendererIds = {"BuiltIn"}, + }, + ProjectProfile{ + .id = "streaming_spotify", + .name = "Streaming Spotify", + .platformPreset = "spotify", + .rendererName = "BuiltIn", + .outputFormat = "mp3", + .lossyBitrateKbps = 256, + .mp3UseVbr = true, + .mp3VbrQuality = 2, + .gpuProvider = "auto", + .roleModelPackId = "demo-role-v1", + .mixModelPackId = "demo-mix-v1", + .masterModelPackId = "demo-master-v1", + .safetyPolicyId = "strict", + .preferredStemCount = 4, + .metadataPolicy = "copy_common", + .metadataTemplate = {}, + .pinnedRendererIds = {"BuiltIn", "PhaseLimiter"}, + }, + ProjectProfile{ + .id = "mobile_fast_turn", + .name = "Mobile Fast Turn", + .platformPreset = "youtube", + .rendererName = "BuiltIn", + .outputFormat = "ogg", + .lossyBitrateKbps = 192, + .mp3UseVbr = false, + .mp3VbrQuality = 4, + .gpuProvider = "cpu", + .roleModelPackId = "none", + .mixModelPackId = "none", + .masterModelPackId = "none", + .safetyPolicyId = "balanced", + .preferredStemCount = 2, + .metadataPolicy = "strip", + .metadataTemplate = {}, + .pinnedRendererIds = {"BuiltIn"}, + }, + }; +} + +std::vector loadProjectProfiles(const std::filesystem::path& repositoryRoot) { + std::vector profiles = defaultProjectProfiles(); + + std::unordered_set loadedFiles; + for (const auto& candidate : profileFileCandidates(repositoryRoot)) { + const auto key = candidate.lexically_normal().string(); + if (!loadedFiles.insert(key).second) { + continue; + } + + std::error_code error; + if (!std::filesystem::is_regular_file(candidate, error) || error) { + continue; + } + + try { + std::ifstream in(candidate); + nlohmann::json json; + in >> json; + if (!json.is_array()) { + continue; + } + for (const auto& entry : json) { + appendUnique(&profiles, profileFromJson(entry)); + } + } catch (...) { + } + } + + std::sort(profiles.begin(), profiles.end(), [](const ProjectProfile& a, const ProjectProfile& b) { + if (a.id == "default") { + return true; + } + if (b.id == "default") { + return false; + } + return a.name < b.name; + }); + + return profiles; +} + +std::optional findProjectProfile(const std::vector& profiles, const std::string& id) { + const auto it = std::find_if(profiles.begin(), profiles.end(), [&](const ProjectProfile& profile) { + return profile.id == id; + }); + if (it == profiles.end()) { + return std::nullopt; + } + return *it; +} + +} // namespace automix::domain diff --git a/src/domain/ProjectProfile.h b/src/domain/ProjectProfile.h new file mode 100644 index 0000000..501dc0f --- /dev/null +++ b/src/domain/ProjectProfile.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace automix::domain { + +// Stem count constraints for audio separation. +// Maximum of 6 stems is based on common separation models (vocals, drums, bass, guitar, keys, other) +// and practical UI/performance limits for real-time mixing workflows. +constexpr int kMinPreferredStemCount = 2; +constexpr int kMaxPreferredStemCount = 6; + +struct ProjectProfile { + std::string id; + std::string name; + std::string platformPreset = "spotify"; + std::string rendererName = "BuiltIn"; + std::string outputFormat = "wav"; + int lossyBitrateKbps = 320; + bool mp3UseVbr = false; + int mp3VbrQuality = 4; + std::string gpuProvider = "auto"; + std::string roleModelPackId = "none"; + std::string mixModelPackId = "none"; + std::string masterModelPackId = "none"; + std::string safetyPolicyId = "balanced"; + int preferredStemCount = 4; + std::string metadataPolicy = "copy_common"; + std::map metadataTemplate; + std::vector pinnedRendererIds; +}; + +std::vector defaultProjectProfiles(); +std::vector loadProjectProfiles(const std::filesystem::path& repositoryRoot); +std::optional findProjectProfile(const std::vector& profiles, const std::string& id); + +} // namespace automix::domain diff --git a/src/domain/RenderSettings.h b/src/domain/RenderSettings.h index bbe4af5..d99696e 100644 --- a/src/domain/RenderSettings.h +++ b/src/domain/RenderSettings.h @@ -1,5 +1,6 @@ #pragma once +#include #include namespace automix::domain { @@ -9,7 +10,20 @@ struct RenderSettings { int blockSize = 1024; int outputBitDepth = 24; std::string outputPath; + std::string outputFormat = "auto"; + std::string exportSpeedMode = "final"; + std::string gpuExecutionProvider = "auto"; + int lossyBitrateKbps = 320; + int lossyQuality = 7; + bool mp3UseVbr = false; + int mp3VbrQuality = 4; + int processingThreads = 0; + bool preferHardwareAcceleration = true; + std::string metadataPolicy = "copy_all"; + std::map metadataTemplate; std::string rendererName = "BuiltIn"; + std::string externalRendererPath; + int externalRendererTimeoutMs = 300000; }; } // namespace automix::domain diff --git a/src/domain/Session.h b/src/domain/Session.h index a7dfe8d..bfc4ec9 100644 --- a/src/domain/Session.h +++ b/src/domain/Session.h @@ -12,6 +12,14 @@ namespace automix::domain { +struct TimelineState { + bool loopEnabled = false; + double loopInSeconds = 0.0; + double loopOutSeconds = 0.0; + double zoom = 1.0; + bool fineScrub = false; +}; + struct Session { int schemaVersion = 2; std::string sessionName; @@ -22,6 +30,10 @@ struct Session { RenderSettings renderSettings; std::optional mixPlan; std::optional masterPlan; + TimelineState timeline; + std::string projectProfileId = "default"; + std::string safetyPolicyId = "balanced"; + int preferredStemCount = 4; }; } // namespace automix::domain diff --git a/src/domain/Stem.h b/src/domain/Stem.h index d4e51aa..cce0ed4 100644 --- a/src/domain/Stem.h +++ b/src/domain/Stem.h @@ -15,6 +15,8 @@ struct Stem { StemRole role = StemRole::Unknown; StemOrigin origin = StemOrigin::Recorded; std::optional busId; + std::optional separationConfidence; + std::optional separationArtifactRisk; bool enabled = true; }; diff --git a/src/dsp/DeEsser.cpp b/src/dsp/DeEsser.cpp new file mode 100644 index 0000000..2a137c4 --- /dev/null +++ b/src/dsp/DeEsser.cpp @@ -0,0 +1,82 @@ +#include "dsp/DeEsser.h" + +#include +#include +#include + +namespace automix::dsp { +namespace { + +struct OnePole { + float a = 0.0f; + float z = 0.0f; + float process(const float input) { + z += a * (input - z); + return z; + } +}; + +OnePole makeLowPass(const double sampleRate, const double cutoffHz) { + const double x = std::exp(-2.0 * 3.14159265358979323846 * cutoffHz / sampleRate); + OnePole filter; + filter.a = static_cast(1.0 - x); + return filter; +} + +} // namespace + +void DeEsser::process(engine::AudioBuffer& buffer, const double strength) const { + if (buffer.getNumChannels() == 0 || buffer.getNumSamples() == 0) { + return; + } + + const float amount = static_cast(std::clamp(strength, 0.0, 1.0)); + if (amount <= 0.0f) { + return; + } + + std::array lp4k; + std::array lp10k; + std::array fullEnv = {0.0f, 0.0f}; + std::array sibEnv = {0.0f, 0.0f}; + + for (int ch = 0; ch < std::min(2, buffer.getNumChannels()); ++ch) { + lp4k[static_cast(ch)] = makeLowPass(buffer.getSampleRate(), 4000.0); + lp10k[static_cast(ch)] = makeLowPass(buffer.getSampleRate(), 10000.0); + } + + constexpr float envelopeAttack = 0.1f; + constexpr float envelopeRelease = 0.01f; + constexpr float ratioThreshold = 0.28f; + + for (int i = 0; i < buffer.getNumSamples(); ++i) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + auto& l4 = lp4k[static_cast(std::min(ch, 1))]; + auto& l10 = lp10k[static_cast(std::min(ch, 1))]; + auto& envFull = fullEnv[static_cast(std::min(ch, 1))]; + auto& envSib = sibEnv[static_cast(std::min(ch, 1))]; + + const float x = buffer.getSample(ch, i); + const float low4 = l4.process(x); + const float low10 = l10.process(x); + const float sibBand = low10 - low4; + + const float absFull = std::abs(x); + const float absSib = std::abs(sibBand); + + const float fullCoeff = absFull > envFull ? envelopeAttack : envelopeRelease; + const float sibCoeff = absSib > envSib ? envelopeAttack : envelopeRelease; + envFull += fullCoeff * (absFull - envFull); + envSib += sibCoeff * (absSib - envSib); + + const float ratio = envSib / std::max(envFull, 1.0e-6f); + const float over = std::max(0.0f, ratio - ratioThreshold); + const float gain = std::clamp(1.0f - over * amount * 2.5f, 0.35f, 1.0f); + + const float out = low4 + sibBand * gain + (x - low10); + buffer.setSample(ch, i, out); + } + } +} + +} // namespace automix::dsp diff --git a/src/dsp/DeEsser.h b/src/dsp/DeEsser.h new file mode 100644 index 0000000..294296f --- /dev/null +++ b/src/dsp/DeEsser.h @@ -0,0 +1,12 @@ +#pragma once + +#include "engine/AudioBuffer.h" + +namespace automix::dsp { + +class DeEsser { + public: + void process(engine::AudioBuffer& buffer, double strength) const; +}; + +} // namespace automix::dsp diff --git a/src/dsp/DynamicDeHarshEq.cpp b/src/dsp/DynamicDeHarshEq.cpp new file mode 100644 index 0000000..8a8e214 --- /dev/null +++ b/src/dsp/DynamicDeHarshEq.cpp @@ -0,0 +1,74 @@ +#include "dsp/DynamicDeHarshEq.h" + +#include +#include +#include + +namespace automix::dsp { +namespace { + +struct OnePole { + float a = 0.0f; + float z = 0.0f; + float process(const float input) { + z += a * (input - z); + return z; + } +}; + +OnePole makeLowPass(const double sampleRate, const double cutoffHz) { + const double x = std::exp(-2.0 * 3.14159265358979323846 * cutoffHz / sampleRate); + OnePole filter; + filter.a = static_cast(1.0 - x); + return filter; +} + +} // namespace + +void DynamicDeHarshEq::process(engine::AudioBuffer& buffer, const double strength) const { + if (buffer.getNumChannels() == 0 || buffer.getNumSamples() == 0) { + return; + } + + const float amount = static_cast(std::clamp(strength, 0.0, 1.0)); + if (amount <= 0.0f) { + return; + } + + std::array lp2k; + std::array lp6k; + std::array harshEnv = {0.0f, 0.0f}; + + for (int ch = 0; ch < std::min(2, buffer.getNumChannels()); ++ch) { + lp2k[static_cast(ch)] = makeLowPass(buffer.getSampleRate(), 2000.0); + lp6k[static_cast(ch)] = makeLowPass(buffer.getSampleRate(), 6000.0); + } + + constexpr float attack = 0.08f; + constexpr float release = 0.01f; + constexpr float threshold = 0.08f; + + for (int i = 0; i < buffer.getNumSamples(); ++i) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + auto& low2 = lp2k[static_cast(std::min(ch, 1))]; + auto& low6 = lp6k[static_cast(std::min(ch, 1))]; + auto& env = harshEnv[static_cast(std::min(ch, 1))]; + + const float x = buffer.getSample(ch, i); + const float b2 = low2.process(x); + const float b6 = low6.process(x); + const float harshBand = b6 - b2; + const float harshAbs = std::abs(harshBand); + + const float coeff = harshAbs > env ? attack : release; + env += coeff * (harshAbs - env); + const float over = std::max(0.0f, env - threshold); + const float gain = std::clamp(1.0f - over * amount * 4.0f, 0.25f, 1.0f); + + const float out = b2 + harshBand * gain + (x - b6); + buffer.setSample(ch, i, out); + } + } +} + +} // namespace automix::dsp diff --git a/src/dsp/DynamicDeHarshEq.h b/src/dsp/DynamicDeHarshEq.h new file mode 100644 index 0000000..d16657c --- /dev/null +++ b/src/dsp/DynamicDeHarshEq.h @@ -0,0 +1,12 @@ +#pragma once + +#include "engine/AudioBuffer.h" + +namespace automix::dsp { + +class DynamicDeHarshEq { + public: + void process(engine::AudioBuffer& buffer, double strength) const; +}; + +} // namespace automix::dsp diff --git a/src/dsp/LookaheadLimiter.cpp b/src/dsp/LookaheadLimiter.cpp new file mode 100644 index 0000000..4bf977b --- /dev/null +++ b/src/dsp/LookaheadLimiter.cpp @@ -0,0 +1,143 @@ +#include "dsp/LookaheadLimiter.h" + +#include +#include + +namespace automix::dsp { +namespace { + +double dbToLinear(const double db) { return std::pow(10.0, db / 20.0); } + +} // namespace + +void LookaheadLimiter::prepare(const double sampleRate, const int channels, const LookaheadLimiterSettings& settings) { + sampleRate_ = std::max(8000.0, sampleRate); + channels_ = std::max(1, channels); + settings_ = settings; + lookaheadSamples_ = std::max(1, static_cast(std::round(sampleRate_ * settings_.lookaheadMs * 0.001))); + + const size_t delaySize = static_cast(lookaheadSamples_ + 1); + delayLines_.assign(static_cast(channels_), std::vector(delaySize, 0.0f)); + delayWriteIndex_.assign(static_cast(channels_), 0u); + detectorLine_.assign(delaySize, 0.0f); + detectorWriteIndex_ = 0u; + smoothedGain_ = 1.0f; + + truePeakDetector_.configure(std::max(2, settings_.truePeakOversampleFactor)); +} + +void LookaheadLimiter::setSettings(const LookaheadLimiterSettings& settings) { + settings_ = settings; + lookaheadSamples_ = std::max(1, static_cast(std::round(sampleRate_ * settings_.lookaheadMs * 0.001))); + truePeakDetector_.configure(std::max(2, settings_.truePeakOversampleFactor)); + + const size_t requiredDelaySize = static_cast(lookaheadSamples_ + 1); + for (auto& delay : delayLines_) { + if (delay.size() != requiredDelaySize) { + delay.assign(requiredDelaySize, 0.0f); + } + } + if (detectorLine_.size() != requiredDelaySize) { + detectorLine_.assign(requiredDelaySize, 0.0f); + } else { + std::fill(detectorLine_.begin(), detectorLine_.end(), 0.0f); + } + delayWriteIndex_.assign(delayLines_.size(), 0u); + detectorWriteIndex_ = 0u; +} + +void LookaheadLimiter::reset() { + for (auto& delay : delayLines_) { + std::fill(delay.begin(), delay.end(), 0.0f); + } + std::fill(delayWriteIndex_.begin(), delayWriteIndex_.end(), 0u); + std::fill(detectorLine_.begin(), detectorLine_.end(), 0.0f); + detectorWriteIndex_ = 0u; + smoothedGain_ = 1.0f; +} + +int LookaheadLimiter::latencySamples() const { return lookaheadSamples_; } + +float LookaheadLimiter::softClipSample(const float input) const { + if (!settings_.softClipEnabled) { + return input; + } + const double drive = std::max(0.1, settings_.softClipDrive); + const double normalizer = std::tanh(drive); + if (std::abs(normalizer) < 1.0e-9) { + return input; + } + const double clipped = std::tanh(static_cast(input) * drive) / normalizer; + return static_cast(clipped); +} + +void LookaheadLimiter::process(engine::AudioBuffer& buffer) { + if (buffer.getNumSamples() == 0 || buffer.getNumChannels() == 0) { + return; + } + if (channels_ <= 0 || delayLines_.empty()) { + prepare(buffer.getSampleRate(), buffer.getNumChannels(), settings_); + } + if (buffer.getNumChannels() != channels_) { + prepare(buffer.getSampleRate(), buffer.getNumChannels(), settings_); + } + + const float ceilingLinear = static_cast(dbToLinear(settings_.ceilingDb)); + const float attackCoeff = + static_cast(std::exp(-1.0 / std::max(1.0, sampleRate_ * settings_.attackMs * 0.001))); + const float releaseCoeff = + static_cast(std::exp(-1.0 / std::max(1.0, sampleRate_ * settings_.releaseMs * 0.001))); + + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) { + float detectorSamplePeak = 0.0f; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + const float input = softClipSample(buffer.getSample(ch, sample)); + buffer.setSample(ch, sample, input); + detectorSamplePeak = std::max(detectorSamplePeak, std::abs(input)); + } + + detectorLine_[detectorWriteIndex_] = detectorSamplePeak; + detectorWriteIndex_ = (detectorWriteIndex_ + 1) % detectorLine_.size(); + + float lookaheadPeak = 0.0f; + for (const float detector : detectorLine_) { + lookaheadPeak = std::max(lookaheadPeak, detector); + } + + const float targetGain = lookaheadPeak > ceilingLinear ? (ceilingLinear / std::max(lookaheadPeak, 1.0e-9f)) : 1.0f; + if (targetGain < smoothedGain_) { + smoothedGain_ = targetGain + attackCoeff * (smoothedGain_ - targetGain); + } else { + smoothedGain_ = targetGain + releaseCoeff * (smoothedGain_ - targetGain); + } + smoothedGain_ = std::clamp(smoothedGain_, 0.0f, 1.0f); + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + auto& delay = delayLines_[static_cast(ch)]; + size_t& writeIndex = delayWriteIndex_[static_cast(ch)]; + const size_t readIndex = (writeIndex + 1) % delay.size(); + + const float delayed = delay[readIndex]; + delay[writeIndex] = buffer.getSample(ch, sample); + writeIndex = (writeIndex + 1) % delay.size(); + + const float limited = std::clamp(delayed * smoothedGain_, -ceilingLinear, ceilingLinear); + buffer.setSample(ch, sample, limited); + } + } + + if (settings_.truePeakEnabled) { + const double truePeakDbtp = truePeakDetector_.computeTruePeakDbtp(buffer); + if (truePeakDbtp > settings_.ceilingDb) { + const float correction = static_cast(dbToLinear(settings_.ceilingDb - truePeakDbtp)); + buffer.applyGain(correction); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + buffer.setSample(ch, i, std::clamp(buffer.getSample(ch, i), -ceilingLinear, ceilingLinear)); + } + } + } + } +} + +} // namespace automix::dsp diff --git a/src/dsp/LookaheadLimiter.h b/src/dsp/LookaheadLimiter.h new file mode 100644 index 0000000..7d1315f --- /dev/null +++ b/src/dsp/LookaheadLimiter.h @@ -0,0 +1,48 @@ +#pragma once + +#include +#include + +#include "dsp/TruePeakDetector.h" +#include "engine/AudioBuffer.h" + +namespace automix::dsp { + +struct LookaheadLimiterSettings { + double ceilingDb = -1.0; + double lookaheadMs = 7.0; + double attackMs = 1.0; + double releaseMs = 80.0; + bool truePeakEnabled = true; + int truePeakOversampleFactor = 4; + bool softClipEnabled = false; + double softClipDrive = 1.0; +}; + +class LookaheadLimiter { + public: + void prepare(double sampleRate, int channels, const LookaheadLimiterSettings& settings); + void setSettings(const LookaheadLimiterSettings& settings); + void reset(); + + int latencySamples() const; + void process(engine::AudioBuffer& buffer); + + private: + float softClipSample(float input) const; + + double sampleRate_ = 44100.0; + int channels_ = 0; + int lookaheadSamples_ = 0; + LookaheadLimiterSettings settings_; + + std::vector> delayLines_; + std::vector delayWriteIndex_; + std::vector detectorLine_; + size_t detectorWriteIndex_ = 0; + float smoothedGain_ = 1.0f; + + TruePeakDetector truePeakDetector_; +}; + +} // namespace automix::dsp diff --git a/src/dsp/MidSideProcessor.cpp b/src/dsp/MidSideProcessor.cpp new file mode 100644 index 0000000..eff95f1 --- /dev/null +++ b/src/dsp/MidSideProcessor.cpp @@ -0,0 +1,36 @@ +#include "dsp/MidSideProcessor.h" + +#include +#include + +namespace automix::dsp { + +void MidSideProcessor::process(engine::AudioBuffer& buffer, const double monoBelowHz, const double width) const { + if (buffer.getNumChannels() < 2 || buffer.getNumSamples() == 0) { + return; + } + + const double cutoff = std::clamp(monoBelowHz, 20.0, 400.0); + const float widthGain = static_cast(std::clamp(width, 0.0, 1.8)); + + const double x = std::exp(-2.0 * 3.14159265358979323846 * cutoff / buffer.getSampleRate()); + const float lpCoeff = static_cast(1.0 - x); + + float lowSide = 0.0f; + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const float left = buffer.getSample(0, i); + const float right = buffer.getSample(1, i); + + const float mid = 0.5f * (left + right); + const float side = 0.5f * (left - right); + + lowSide += lpCoeff * (side - lowSide); + const float highSide = side - lowSide; + const float processedSide = highSide * widthGain; + + buffer.setSample(0, i, mid + processedSide); + buffer.setSample(1, i, mid - processedSide); + } +} + +} // namespace automix::dsp diff --git a/src/dsp/MidSideProcessor.h b/src/dsp/MidSideProcessor.h new file mode 100644 index 0000000..087c760 --- /dev/null +++ b/src/dsp/MidSideProcessor.h @@ -0,0 +1,12 @@ +#pragma once + +#include "engine/AudioBuffer.h" + +namespace automix::dsp { + +class MidSideProcessor { + public: + void process(engine::AudioBuffer& buffer, double monoBelowHz, double width) const; +}; + +} // namespace automix::dsp diff --git a/src/dsp/MultibandProcessor.cpp b/src/dsp/MultibandProcessor.cpp new file mode 100644 index 0000000..1b1e4c2 --- /dev/null +++ b/src/dsp/MultibandProcessor.cpp @@ -0,0 +1,140 @@ +#include "dsp/MultibandProcessor.h" + +#include +#include +#include + +namespace automix::dsp { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +double dbToLinear(const double db) { return std::pow(10.0, db / 20.0); } + +double lowPassAlpha(const double sampleRate, const double cutoffHz) { + const double clampedCutoff = std::clamp(cutoffHz, 20.0, sampleRate * 0.45); + return 1.0 - std::exp(-2.0 * kPi * clampedCutoff / sampleRate); +} + +void applyStereoWidth(engine::AudioBuffer& bandBuffer, const double width) { + if (bandBuffer.getNumChannels() < 2 || std::abs(width - 1.0) < 1.0e-6) { + return; + } + + const float widthGain = static_cast(std::clamp(width, 0.0, 2.0)); + for (int i = 0; i < bandBuffer.getNumSamples(); ++i) { + const float l = bandBuffer.getSample(0, i); + const float r = bandBuffer.getSample(1, i); + const float mid = 0.5f * (l + r); + const float side = 0.5f * (l - r) * widthGain; + bandBuffer.setSample(0, i, mid + side); + bandBuffer.setSample(1, i, mid - side); + } +} + +void applyBandCompressor(engine::AudioBuffer& bandBuffer, const domain::MultibandBandSettings& settings) { + if (!settings.enabled || bandBuffer.getNumSamples() == 0 || bandBuffer.getNumChannels() == 0) { + return; + } + + const float threshold = static_cast(dbToLinear(std::clamp(settings.thresholdDb, -60.0, 0.0))); + const float ratio = static_cast(std::clamp(settings.ratio, 1.0, 20.0)); + const float attackCoeff = static_cast(std::exp(-1.0 / std::max(1.0, bandBuffer.getSampleRate() * 0.008))); + const float releaseCoeff = static_cast(std::exp(-1.0 / std::max(1.0, bandBuffer.getSampleRate() * 0.120))); + const float makeup = static_cast(dbToLinear(std::clamp(settings.makeupGainDb, -18.0, 18.0))); + + float envelope = 0.0f; + for (int i = 0; i < bandBuffer.getNumSamples(); ++i) { + float detector = 0.0f; + for (int ch = 0; ch < bandBuffer.getNumChannels(); ++ch) { + detector = std::max(detector, std::abs(bandBuffer.getSample(ch, i))); + } + + if (detector > envelope) { + envelope = detector + attackCoeff * (envelope - detector); + } else { + envelope = detector + releaseCoeff * (envelope - detector); + } + + float gain = 1.0f; + if (envelope > threshold && threshold > 0.0f) { + const float over = envelope / threshold; + const float compressed = std::pow(over, 1.0f / ratio); + gain = 1.0f / std::max(compressed, 1.0e-6f); + } + + const float finalGain = gain * makeup; + for (int ch = 0; ch < bandBuffer.getNumChannels(); ++ch) { + bandBuffer.setSample(ch, i, bandBuffer.getSample(ch, i) * finalGain); + } + } +} + +} // namespace + +void MultibandProcessor::process(engine::AudioBuffer& buffer, const domain::MultibandSettings& settings) const { + if (buffer.getNumChannels() <= 0 || buffer.getNumSamples() <= 0) { + return; + } + + const int crossoverCount = static_cast(settings.crossoverHz.size()); + if (crossoverCount <= 0) { + return; + } + + const int bandCount = crossoverCount + 1; + std::vector bands; + bands.reserve(static_cast(bandCount)); + for (int i = 0; i < bandCount; ++i) { + bands.emplace_back(buffer.getNumChannels(), buffer.getNumSamples(), buffer.getSampleRate()); + } + + std::vector sortedCrossovers = settings.crossoverHz; + std::sort(sortedCrossovers.begin(), sortedCrossovers.end()); + sortedCrossovers.erase(std::unique(sortedCrossovers.begin(), sortedCrossovers.end()), sortedCrossovers.end()); + + const int effectiveCrossovers = static_cast(sortedCrossovers.size()); + if (effectiveCrossovers <= 0) { + return; + } + + std::vector alphaByBand(static_cast(effectiveCrossovers), 0.0); + for (int i = 0; i < effectiveCrossovers; ++i) { + alphaByBand[static_cast(i)] = lowPassAlpha(buffer.getSampleRate(), sortedCrossovers[static_cast(i)]); + } + + std::vector> lpState(static_cast(effectiveCrossovers), + std::vector(static_cast(buffer.getNumChannels()), 0.0)); + + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + double remainder = buffer.getSample(ch, sample); + for (int crossover = 0; crossover < effectiveCrossovers; ++crossover) { + auto& state = lpState[static_cast(crossover)][static_cast(ch)]; + state += alphaByBand[static_cast(crossover)] * (remainder - state); + const float lowBand = static_cast(state); + bands[static_cast(crossover)].setSample(ch, sample, lowBand); + remainder -= static_cast(lowBand); + } + bands[static_cast(effectiveCrossovers)].setSample(ch, sample, static_cast(remainder)); + } + } + + for (int i = 0; i < static_cast(bands.size()); ++i) { + const domain::MultibandBandSettings bandSettings = + i < static_cast(settings.bands.size()) ? settings.bands[static_cast(i)] : domain::MultibandBandSettings{}; + applyBandCompressor(bands[static_cast(i)], bandSettings); + applyStereoWidth(bands[static_cast(i)], bandSettings.width); + } + + buffer.clear(); + for (const auto& band : bands) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + buffer.setSample(ch, i, buffer.getSample(ch, i) + band.getSample(ch, i)); + } + } + } +} + +} // namespace automix::dsp diff --git a/src/dsp/MultibandProcessor.h b/src/dsp/MultibandProcessor.h new file mode 100644 index 0000000..d3834d3 --- /dev/null +++ b/src/dsp/MultibandProcessor.h @@ -0,0 +1,13 @@ +#pragma once + +#include "domain/MasterPlan.h" +#include "engine/AudioBuffer.h" + +namespace automix::dsp { + +class MultibandProcessor final { + public: + void process(engine::AudioBuffer& buffer, const domain::MultibandSettings& settings) const; +}; + +} // namespace automix::dsp diff --git a/src/dsp/SoftClipper.cpp b/src/dsp/SoftClipper.cpp new file mode 100644 index 0000000..433064d --- /dev/null +++ b/src/dsp/SoftClipper.cpp @@ -0,0 +1,23 @@ +#include "dsp/SoftClipper.h" + +#include +#include + +namespace automix::dsp { + +void SoftClipper::process(engine::AudioBuffer& buffer, const double drive) const { + const double clippedDrive = std::clamp(drive, 0.1, 8.0); + const double normalizer = std::tanh(clippedDrive); + if (std::abs(normalizer) < 1.0e-9) { + return; + } + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const double shaped = std::tanh(static_cast(buffer.getSample(ch, i)) * clippedDrive) / normalizer; + buffer.setSample(ch, i, static_cast(shaped)); + } + } +} + +} // namespace automix::dsp diff --git a/src/dsp/SoftClipper.h b/src/dsp/SoftClipper.h new file mode 100644 index 0000000..df37273 --- /dev/null +++ b/src/dsp/SoftClipper.h @@ -0,0 +1,12 @@ +#pragma once + +#include "engine/AudioBuffer.h" + +namespace automix::dsp { + +class SoftClipper { + public: + void process(engine::AudioBuffer& buffer, double drive) const; +}; + +} // namespace automix::dsp diff --git a/src/dsp/TruePeakDetector.cpp b/src/dsp/TruePeakDetector.cpp new file mode 100644 index 0000000..eac25fe --- /dev/null +++ b/src/dsp/TruePeakDetector.cpp @@ -0,0 +1,89 @@ +#include "dsp/TruePeakDetector.h" + +#include +#include + +namespace automix::dsp { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +double linearToDb(const double linear) { + constexpr double minValue = 1.0e-12; + return 20.0 * std::log10(std::max(linear, minValue)); +} + +} // namespace + +TruePeakDetector::TruePeakDetector(const int oversampleFactor, const int tapsPerPhase) + : oversampleFactor_(std::max(2, oversampleFactor)), + tapsPerPhase_(std::max(8, tapsPerPhase)) { + redesign(); +} + +void TruePeakDetector::configure(const int oversampleFactor, const int tapsPerPhase) { + oversampleFactor_ = std::max(2, oversampleFactor); + tapsPerPhase_ = std::max(8, tapsPerPhase); + redesign(); +} + +void TruePeakDetector::redesign() { + const int totalTaps = oversampleFactor_ * tapsPerPhase_; + prototypeFilter_.assign(static_cast(totalTaps), 0.0); + + const double fc = 0.5 / static_cast(oversampleFactor_) * 0.95; + const double center = 0.5 * static_cast(totalTaps - 1); + + double sum = 0.0; + for (int i = 0; i < totalTaps; ++i) { + const double n = static_cast(i) - center; + const double sinc = std::abs(n) < 1.0e-12 ? 2.0 * fc : std::sin(2.0 * kPi * fc * n) / (kPi * n); + const double window = 0.54 - 0.46 * std::cos((2.0 * kPi * static_cast(i)) / static_cast(totalTaps - 1)); + const double value = sinc * window; + prototypeFilter_[static_cast(i)] = value; + sum += value; + } + + if (std::abs(sum) < 1.0e-12) { + return; + } + for (auto& value : prototypeFilter_) { + value /= sum; + } +} + +double TruePeakDetector::computeTruePeakLinear(const engine::AudioBuffer& buffer) const { + if (buffer.getNumChannels() == 0 || buffer.getNumSamples() == 0) { + return 0.0; + } + + double peak = 0.0; + + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + const float* channel = buffer.getReadPointer(ch); + for (int sample = 0; sample < buffer.getNumSamples(); ++sample) { + peak = std::max(peak, static_cast(std::abs(channel[sample]))); + + for (int phase = 1; phase < oversampleFactor_; ++phase) { + double upsampled = 0.0; + for (int tap = 0; tap < tapsPerPhase_; ++tap) { + const int inputIndex = sample - tap; + if (inputIndex < 0) { + continue; + } + const int coeffIndex = phase + tap * oversampleFactor_; + upsampled += static_cast(channel[inputIndex]) * prototypeFilter_[static_cast(coeffIndex)]; + } + peak = std::max(peak, std::abs(upsampled)); + } + } + } + + return peak; +} + +double TruePeakDetector::computeTruePeakDbtp(const engine::AudioBuffer& buffer) const { + return linearToDb(computeTruePeakLinear(buffer)); +} + +} // namespace automix::dsp diff --git a/src/dsp/TruePeakDetector.h b/src/dsp/TruePeakDetector.h new file mode 100644 index 0000000..5ea1a14 --- /dev/null +++ b/src/dsp/TruePeakDetector.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "engine/AudioBuffer.h" + +namespace automix::dsp { + +class TruePeakDetector { + public: + explicit TruePeakDetector(int oversampleFactor = 4, int tapsPerPhase = 16); + + void configure(int oversampleFactor, int tapsPerPhase = 16); + double computeTruePeakLinear(const engine::AudioBuffer& buffer) const; + double computeTruePeakDbtp(const engine::AudioBuffer& buffer) const; + + private: + void redesign(); + + int oversampleFactor_ = 4; + int tapsPerPhase_ = 16; + std::vector prototypeFilter_; +}; + +} // namespace automix::dsp diff --git a/src/engine/AudioFileIO.cpp b/src/engine/AudioFileIO.cpp index 35886e8..173c774 100644 --- a/src/engine/AudioFileIO.cpp +++ b/src/engine/AudioFileIO.cpp @@ -1,18 +1,28 @@ #include "engine/AudioFileIO.h" #include +#include #include +#include #include namespace automix::engine { -AudioBuffer AudioFileIO::readAudioFile(const std::filesystem::path& filePath) const { - juce::AudioFormatManager manager; - manager.registerBasicFormats(); +namespace { +std::unique_ptr createReader(const std::filesystem::path& filePath, + juce::AudioFormatManager* managerOut) { + managerOut->registerBasicFormats(); const juce::File juceFile(filePath.string()); - std::unique_ptr reader(manager.createReaderFor(juceFile)); + return std::unique_ptr(managerOut->createReaderFor(juceFile)); +} + +} // namespace + +AudioBuffer AudioFileIO::readAudioFile(const std::filesystem::path& filePath) const { + juce::AudioFormatManager manager; + std::unique_ptr reader = createReader(filePath, &manager); if (reader == nullptr) { throw std::runtime_error("Failed to open audio file: " + filePath.string()); } @@ -36,4 +46,22 @@ AudioBuffer AudioFileIO::readAudioFile(const std::filesystem::path& filePath) co return output; } +std::map AudioFileIO::readMetadata(const std::filesystem::path& filePath) const { + juce::AudioFormatManager manager; + std::unique_ptr reader = createReader(filePath, &manager); + if (reader == nullptr) { + throw std::runtime_error("Failed to open audio file metadata: " + filePath.string()); + } + + std::map metadata; + const auto keys = reader->metadataValues.getAllKeys(); + for (const auto& key : keys) { + const auto value = reader->metadataValues.getValue(key, {}); + if (value.isNotEmpty()) { + metadata[key.toStdString()] = value.toStdString(); + } + } + return metadata; +} + } // namespace automix::engine diff --git a/src/engine/AudioFileIO.h b/src/engine/AudioFileIO.h index 4aeece9..57cdcfa 100644 --- a/src/engine/AudioFileIO.h +++ b/src/engine/AudioFileIO.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include "engine/AudioBuffer.h" @@ -9,6 +11,7 @@ namespace automix::engine { class AudioFileIO { public: AudioBuffer readAudioFile(const std::filesystem::path& filePath) const; + std::map readMetadata(const std::filesystem::path& filePath) const; }; } // namespace automix::engine diff --git a/src/engine/AudioPreviewEngine.cpp b/src/engine/AudioPreviewEngine.cpp new file mode 100644 index 0000000..8019b4c --- /dev/null +++ b/src/engine/AudioPreviewEngine.cpp @@ -0,0 +1,57 @@ +#include "engine/AudioPreviewEngine.h" + +#include + +namespace automix::engine { + +void AudioPreviewEngine::setBuffers(const AudioBuffer& originalMix, const AudioBuffer& renderedMix) { + originalMix_ = originalMix; + renderedMix_ = renderedMix; +} + +void AudioPreviewEngine::setSource(const PreviewSource source) { + if (source_ == source) { + return; + } + previousSource_ = source_; + source_ = source; +} + +PreviewSource AudioPreviewEngine::source() const { return source_; } + +void AudioPreviewEngine::play() { playing_ = true; } + +void AudioPreviewEngine::stop() { playing_ = false; } + +bool AudioPreviewEngine::isPlaying() const { return playing_; } + +AudioBuffer AudioPreviewEngine::buildCrossfadedPreview(const int crossfadeSamples) const { + const AudioBuffer* target = source_ == PreviewSource::RenderedMix ? &renderedMix_ : &originalMix_; + const AudioBuffer* previous = previousSource_ == PreviewSource::RenderedMix ? &renderedMix_ : &originalMix_; + + if (target->getNumChannels() == 0 || target->getNumSamples() == 0) { + return *target; + } + + AudioBuffer output = *target; + const int fadeSamples = std::clamp(crossfadeSamples, 0, output.getNumSamples()); + if (fadeSamples <= 0 || previous->getNumSamples() == 0) { + return output; + } + + const int commonChannels = std::min(output.getNumChannels(), previous->getNumChannels()); + const int commonSamples = std::min(output.getNumSamples(), previous->getNumSamples()); + + for (int ch = 0; ch < commonChannels; ++ch) { + for (int i = 0; i < std::min(fadeSamples, commonSamples); ++i) { + const float a = previous->getSample(ch, i); + const float b = output.getSample(ch, i); + const float t = static_cast(i) / static_cast(std::max(1, fadeSamples - 1)); + output.setSample(ch, i, a * (1.0f - t) + b * t); + } + } + + return output; +} + +} // namespace automix::engine diff --git a/src/engine/AudioPreviewEngine.h b/src/engine/AudioPreviewEngine.h new file mode 100644 index 0000000..171c94e --- /dev/null +++ b/src/engine/AudioPreviewEngine.h @@ -0,0 +1,32 @@ +#pragma once + +#include "engine/AudioBuffer.h" + +namespace automix::engine { + +enum class PreviewSource { + OriginalMix = 0, + RenderedMix = 1, +}; + +class AudioPreviewEngine { + public: + void setBuffers(const AudioBuffer& originalMix, const AudioBuffer& renderedMix); + void setSource(PreviewSource source); + PreviewSource source() const; + + void play(); + void stop(); + bool isPlaying() const; + + AudioBuffer buildCrossfadedPreview(int crossfadeSamples) const; + + private: + AudioBuffer originalMix_; + AudioBuffer renderedMix_; + PreviewSource source_ = PreviewSource::OriginalMix; + PreviewSource previousSource_ = PreviewSource::OriginalMix; + bool playing_ = false; +}; + +} // namespace automix::engine diff --git a/src/engine/BatchQueueRunner.cpp b/src/engine/BatchQueueRunner.cpp new file mode 100644 index 0000000..325dae1 --- /dev/null +++ b/src/engine/BatchQueueRunner.cpp @@ -0,0 +1,302 @@ +#include "engine/BatchQueueRunner.h" + +#include +#include +#include +#include +#include +#include + +#include "analysis/StemAnalyzer.h" +#include "automix/HeuristicAutoMixStrategy.h" +#include "renderers/RendererFactory.h" +#include "util/StringUtils.h" +#include "util/WavWriter.h" + +namespace automix::engine { +namespace { + +using ::automix::util::toLower; +using ::automix::util::extensionForFormat; + +bool hasAudioExtension(const std::filesystem::path& path) { + const std::string ext = toLower(path.extension().string()); + return ext == ".wav" || ext == ".aif" || ext == ".aiff" || ext == ".flac" || ext == ".mp3" || ext == ".ogg"; +} + +void runAnalysisForItem(domain::BatchItem& item) { + item.status = domain::BatchItemStatus::Analyzing; + analysis::StemAnalyzer analyzer; + automix::HeuristicAutoMixStrategy autoMix; + const auto analysisEntries = analyzer.analyzeSession(item.session); + item.session.mixPlan = autoMix.buildPlan(item.session, analysisEntries, 1.0); +} + +std::pair splitGroupAndStem(const std::filesystem::path& filePath) { + const std::string stemName = toLower(filePath.stem().string()); + static const std::vector suffixes = { + "_vocals", "-vocals", "_vocal", "-vocal", "_vox", "-vox", + "_bass", "-bass", "_drums", "-drums", "_drum", "-drum", + "_other", "-other", "_music", "-music"}; + + for (const auto& suffix : suffixes) { + if (stemName.size() <= suffix.size()) { + continue; + } + if (stemName.ends_with(suffix)) { + const std::string group = stemName.substr(0, stemName.size() - suffix.size()); + const std::string role = suffix.substr(1); + return {group.empty() ? "song" : group, role}; + } + } + + return {stemName, "mix"}; +} + +domain::StemRole roleFromSuffix(const std::string& suffix) { + if (suffix == "vocals" || suffix == "vocal" || suffix == "vox") { + return domain::StemRole::Vocals; + } + if (suffix == "bass") { + return domain::StemRole::Bass; + } + if (suffix == "drums" || suffix == "drum") { + return domain::StemRole::Drums; + } + if (suffix == "other" || suffix == "music") { + return domain::StemRole::Music; + } + return domain::StemRole::Unknown; +} + +} // namespace + +std::vector BatchQueueRunner::buildItemsFromFolder(const std::filesystem::path& inputFolder, + const std::filesystem::path& outputFolder) const { + std::vector items; + std::error_code error; + if (!std::filesystem::exists(inputFolder, error) || error) { + return items; + } + + std::unordered_map> groupedFiles; + for (const auto& entry : std::filesystem::directory_iterator(inputFolder)) { + if (!entry.is_regular_file()) { + continue; + } + if (!hasAudioExtension(entry.path())) { + continue; + } + + const auto [group, stem] = splitGroupAndStem(entry.path()); + (void)stem; + groupedFiles[group].push_back(entry.path()); + } + + items.reserve(groupedFiles.size()); + for (const auto& [groupName, files] : groupedFiles) { + domain::Session session; + session.sessionName = groupName; + session.stems.reserve(files.size()); + + int stemIndex = 1; + for (const auto& file : files) { + const auto split = splitGroupAndStem(file); + const auto& suffix = split.second; + domain::Stem stem; + stem.id = "stem_" + std::to_string(stemIndex++); + stem.name = file.stem().string(); + stem.filePath = file.string(); + stem.role = roleFromSuffix(suffix); + stem.origin = domain::StemOrigin::Separated; + session.stems.push_back(stem); + } + + domain::BatchItem item; + item.session = session; + item.sourcePath = inputFolder; + item.outputPath = outputFolder / (groupName + "_master.wav"); + item.status = domain::BatchItemStatus::Pending; + items.push_back(item); + } + + std::sort(items.begin(), items.end(), [](const domain::BatchItem& a, const domain::BatchItem& b) { + return a.session.sessionName < b.session.sessionName; + }); + return items; +} + +domain::BatchResult BatchQueueRunner::process(domain::BatchJob& job, + const ProgressCallback& progressCallback, + std::atomic_bool* cancelFlag) const { + domain::BatchResult result; + if (job.items.empty()) { + return result; + } + + const int defaultThreads = static_cast(std::max(1u, std::thread::hardware_concurrency())); + const bool useAcceleration = job.settings.renderSettings.preferHardwareAcceleration; + const int analysisThreads = + useAcceleration ? std::max(1, job.settings.analysisThreads > 0 ? job.settings.analysisThreads : defaultThreads) : 1; + const int renderThreads = useAcceleration + ? std::max(1, + job.settings.renderParallelism > 0 ? job.settings.renderParallelism + : std::max(1, defaultThreads / 2)) + : 1; + + if (job.settings.parallelAnalysis && analysisThreads > 1) { + std::atomic nextIndex{0}; + std::vector workers; + workers.reserve(static_cast(analysisThreads)); + + for (int worker = 0; worker < analysisThreads; ++worker) { + workers.emplace_back([&]() { + for (;;) { + const size_t i = nextIndex.fetch_add(1); + if (i >= job.items.size()) { + break; + } + + auto& item = job.items[i]; + if (cancelFlag != nullptr && cancelFlag->load()) { + item.status = domain::BatchItemStatus::Cancelled; + continue; + } + + if (item.status == domain::BatchItemStatus::Pending) { + try { + runAnalysisForItem(item); + } catch (const std::exception& errorException) { + item.status = domain::BatchItemStatus::Failed; + item.error = errorException.what(); + } catch (...) { + item.status = domain::BatchItemStatus::Failed; + item.error = "Unknown analysis failure."; + } + } + } + }); + } + + for (auto& worker : workers) { + worker.join(); + } + } else { + for (auto& item : job.items) { + if (cancelFlag != nullptr && cancelFlag->load()) { + item.status = domain::BatchItemStatus::Cancelled; + continue; + } + if (item.status == domain::BatchItemStatus::Pending) { + try { + runAnalysisForItem(item); + } catch (const std::exception& errorException) { + item.status = domain::BatchItemStatus::Failed; + item.error = errorException.what(); + } catch (...) { + item.status = domain::BatchItemStatus::Failed; + item.error = "Unknown analysis failure."; + } + } + } + } + + std::atomic completed{0}; + std::atomic failed{0}; + std::atomic cancelled{0}; + std::atomic renderIndex{0}; + std::mutex itemMutex; + + const auto renderWorker = [&]() { + for (;;) { + const size_t i = renderIndex.fetch_add(1); + if (i >= job.items.size()) { + break; + } + + auto& item = job.items[i]; + if (cancelFlag != nullptr && cancelFlag->load()) { + item.status = domain::BatchItemStatus::Cancelled; + item.error = "Cancelled"; + ++cancelled; + continue; + } + + item.status = domain::BatchItemStatus::Rendering; + + auto settings = job.settings.renderSettings; + if (settings.rendererName.empty()) { + settings.rendererName = "BuiltIn"; + } + if (settings.processingThreads <= 0) { + settings.processingThreads = std::max(1, defaultThreads / std::max(1, renderThreads)); + } + + const std::string resolvedFormat = util::WavWriter::resolveFormat(item.outputPath, settings.outputFormat); + const std::string requiredExtension = extensionForFormat(resolvedFormat); + if (item.outputPath.empty()) { + item.outputPath = job.settings.outputFolder / (item.session.sessionName + "_master" + requiredExtension); + } else if (toLower(item.outputPath.extension().string()) != requiredExtension) { + item.outputPath.replace_extension(requiredExtension); + } + + settings.outputFormat = resolvedFormat; + settings.outputPath = item.outputPath.string(); + + try { + auto renderer = renderers::createRenderer(settings.rendererName); + const auto renderResult = renderer->render( + item.session, + settings, + [&](const double stageProgress, const std::string& stage) { + if (!progressCallback) { + return; + } + const double itemWeight = 1.0 / static_cast(job.items.size()); + const double progress = std::clamp(itemWeight * (static_cast(i) + stageProgress), 0.0, 1.0); + progressCallback(i, progress, stage); + }, + cancelFlag); + + if (renderResult.cancelled) { + item.status = domain::BatchItemStatus::Cancelled; + item.error = "Cancelled"; + ++cancelled; + } else if (renderResult.success) { + item.status = domain::BatchItemStatus::Completed; + item.reportPath = renderResult.reportPath; + ++completed; + } else { + item.status = domain::BatchItemStatus::Failed; + item.error = renderResult.logs.empty() ? "Render failed" : renderResult.logs.back(); + ++failed; + } + } catch (const std::exception& error) { + std::scoped_lock lock(itemMutex); + item.status = domain::BatchItemStatus::Failed; + item.error = error.what(); + ++failed; + } + } + }; + + if (renderThreads > 1) { + std::vector workers; + workers.reserve(static_cast(renderThreads)); + for (int worker = 0; worker < renderThreads; ++worker) { + workers.emplace_back(renderWorker); + } + for (auto& worker : workers) { + worker.join(); + } + } else { + renderWorker(); + } + + result.completed = completed.load(); + result.failed = failed.load(); + result.cancelled = cancelled.load(); + return result; +} + +} // namespace automix::engine diff --git a/src/engine/BatchQueueRunner.h b/src/engine/BatchQueueRunner.h new file mode 100644 index 0000000..145e5c1 --- /dev/null +++ b/src/engine/BatchQueueRunner.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include +#include +#include + +#include "domain/BatchTypes.h" + +namespace automix::engine { + +class BatchQueueRunner { + public: + using ProgressCallback = std::function; + + std::vector buildItemsFromFolder(const std::filesystem::path& inputFolder, + const std::filesystem::path& outputFolder) const; + + domain::BatchResult process(domain::BatchJob& job, + const ProgressCallback& progressCallback, + std::atomic_bool* cancelFlag) const; +}; + +} // namespace automix::engine diff --git a/src/engine/LoudnessMeter.cpp b/src/engine/LoudnessMeter.cpp new file mode 100644 index 0000000..8ad3af1 --- /dev/null +++ b/src/engine/LoudnessMeter.cpp @@ -0,0 +1,131 @@ +#include "engine/LoudnessMeter.h" + +#include +#include +#include + +#ifdef ENABLE_LIBEBUR128 +#include +#endif + +namespace automix::engine { +namespace { + +double linearToDb(const double linear) { + constexpr double minValue = 1.0e-12; + return 20.0 * std::log10(std::max(linear, minValue)); +} + +double fallbackIntegratedLufs(const AudioBuffer& buffer) { + if (buffer.getNumSamples() == 0 || buffer.getNumChannels() == 0) { + return -120.0; + } + + double sum = 0.0; + const int channels = buffer.getNumChannels(); + for (int i = 0; i < buffer.getNumSamples(); ++i) { + double mono = 0.0; + for (int ch = 0; ch < channels; ++ch) { + mono += buffer.getSample(ch, i); + } + mono /= static_cast(channels); + sum += mono * mono; + } + + const double meanSquare = sum / static_cast(std::max(1, buffer.getNumSamples())); + return -0.691 + 10.0 * std::log10(std::max(meanSquare, 1.0e-12)); +} + +} // namespace + +LoudnessMetrics LoudnessMeter::analyze(const AudioBuffer& buffer, const size_t chunkSize) const { + LoudnessMetrics metrics; + metrics.samplePeakDbfs = computeSamplePeakDbfs(buffer); + + if (buffer.getNumSamples() == 0 || buffer.getNumChannels() == 0) { + return metrics; + } + +#ifdef ENABLE_LIBEBUR128 + const int channelCount = std::clamp(buffer.getNumChannels(), 1, 2); + ebur128_state* state = + ebur128_init(static_cast(channelCount), + static_cast(std::max(1.0, buffer.getSampleRate())), + EBUR128_MODE_I | EBUR128_MODE_S | EBUR128_MODE_LRA); + if (state == nullptr) { + metrics.integratedLufs = fallbackIntegratedLufs(buffer); + metrics.shortTermLufs = metrics.integratedLufs; + return metrics; + } + + ebur128_set_channel(state, 0, EBUR128_LEFT); + if (channelCount > 1) { + ebur128_set_channel(state, 1, EBUR128_RIGHT); + } + + const size_t framesPerChunk = std::max(1, chunkSize); + std::vector interleaved; + interleaved.resize(framesPerChunk * static_cast(channelCount)); + + for (int offset = 0; offset < buffer.getNumSamples(); offset += static_cast(framesPerChunk)) { + const int frames = std::min(static_cast(framesPerChunk), buffer.getNumSamples() - offset); + for (int frame = 0; frame < frames; ++frame) { + for (int ch = 0; ch < channelCount; ++ch) { + interleaved[static_cast(frame * channelCount + ch)] = buffer.getSample(ch, offset + frame); + } + } + ebur128_add_frames_double(state, interleaved.data(), static_cast(frames)); + } + + double integrated = -120.0; + if (ebur128_loudness_global(state, &integrated) == EBUR128_SUCCESS && std::isfinite(integrated)) { + metrics.integratedLufs = integrated; + } else { + metrics.integratedLufs = fallbackIntegratedLufs(buffer); + } + + double shortTerm = -120.0; + if (ebur128_loudness_shortterm(state, &shortTerm) == EBUR128_SUCCESS && std::isfinite(shortTerm)) { + metrics.shortTermLufs = shortTerm; + } else { + metrics.shortTermLufs = metrics.integratedLufs; + } + + double lra = 0.0; + if (ebur128_loudness_range(state, &lra) == EBUR128_SUCCESS && std::isfinite(lra)) { + metrics.loudnessRange = lra; + } + + ebur128_destroy(&state); + return metrics; +#else + metrics.integratedLufs = fallbackIntegratedLufs(buffer); + metrics.shortTermLufs = metrics.integratedLufs; + metrics.loudnessRange = 0.0; + return metrics; +#endif +} + +double LoudnessMeter::computeIntegratedLufs(const AudioBuffer& buffer, const size_t chunkSize) const { + return analyze(buffer, chunkSize).integratedLufs; +} + +double LoudnessMeter::computeShortTermLufs(const AudioBuffer& buffer, const size_t chunkSize) const { + return analyze(buffer, chunkSize).shortTermLufs; +} + +double LoudnessMeter::computeLoudnessRange(const AudioBuffer& buffer, const size_t chunkSize) const { + return analyze(buffer, chunkSize).loudnessRange; +} + +double LoudnessMeter::computeSamplePeakDbfs(const AudioBuffer& buffer) const { + double peak = 0.0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + peak = std::max(peak, static_cast(std::abs(buffer.getSample(ch, i)))); + } + } + return linearToDb(peak); +} + +} // namespace automix::engine diff --git a/src/engine/LoudnessMeter.h b/src/engine/LoudnessMeter.h new file mode 100644 index 0000000..fca97f5 --- /dev/null +++ b/src/engine/LoudnessMeter.h @@ -0,0 +1,25 @@ +#pragma once + +#include + +#include "engine/AudioBuffer.h" + +namespace automix::engine { + +struct LoudnessMetrics { + double integratedLufs = -120.0; + double shortTermLufs = -120.0; + double loudnessRange = 0.0; + double samplePeakDbfs = -120.0; +}; + +class LoudnessMeter { + public: + LoudnessMetrics analyze(const AudioBuffer& buffer, size_t chunkSize = 1024) const; + double computeIntegratedLufs(const AudioBuffer& buffer, size_t chunkSize = 1024) const; + double computeShortTermLufs(const AudioBuffer& buffer, size_t chunkSize = 1024) const; + double computeLoudnessRange(const AudioBuffer& buffer, size_t chunkSize = 1024) const; + double computeSamplePeakDbfs(const AudioBuffer& buffer) const; +}; + +} // namespace automix::engine diff --git a/src/engine/OfflineRenderPipeline.cpp b/src/engine/OfflineRenderPipeline.cpp index 0bd5a22..90b7f0e 100644 --- a/src/engine/OfflineRenderPipeline.cpp +++ b/src/engine/OfflineRenderPipeline.cpp @@ -1,19 +1,35 @@ #include "engine/OfflineRenderPipeline.h" #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include #include +#include #include "domain/MixPlan.h" #include "engine/AudioFileIO.h" #include "engine/AudioResampler.h" #include "engine/ResidualBlendProcessor.h" +#include "util/StringUtils.h" namespace automix::engine { namespace { +using ::automix::util::toLower; + constexpr double kPi = 3.14159265358979323846; +constexpr double kSqrtHalf = 0.7071067811865476; double dbToLinear(const double db) { return std::pow(10.0, db / 20.0); } @@ -22,45 +38,359 @@ double linearToDb(const double linear) { return 20.0 * std::log10(std::max(linear, minValue)); } -struct HighPassState { - std::vector previousInput; - std::vector previousOutput; +struct BiquadCoefficients { + float b0 = 1.0f; + float b1 = 0.0f; + float b2 = 0.0f; + float a1 = 0.0f; + float a2 = 0.0f; }; -void applyHighPass(AudioBuffer& buffer, const double cutoffHz) { - if (cutoffHz <= 0.0) { - return; +struct StemRenderNode { + std::shared_ptr buffer; + std::string busId; +}; + +AudioBuffer processStemBuffer(const AudioBuffer& input, + const domain::StemMixDecision* decision, + double dryWet, + int outputChannels); + +struct FileStamp { + bool valid = false; + std::uintmax_t size = 0; + std::int64_t writeTicks = 0; +}; + +struct CachedStemAudio { + FileStamp stamp; + std::shared_ptr buffer; +}; + +struct CachedRenderMix { + std::string key; + OfflineRenderResult result; +}; + +std::mutex& stemRawCacheMutex() { + static std::mutex mutex; + return mutex; +} + +std::mutex& stemProcessedCacheMutex() { + static std::mutex mutex; + return mutex; +} + +std::mutex& renderMixCacheMutex() { + static std::mutex mutex; + return mutex; +} + +std::unordered_map& stemRawCache() { + static std::unordered_map cache; + return cache; +} + +std::unordered_map& stemProcessedCache() { + static std::unordered_map cache; + return cache; +} + +std::optional& renderMixCache() { + static std::optional cache; + return cache; +} + +template +void hashCombine(std::size_t& seed, const T& value) { + const auto hash = std::hash{}(value); + seed ^= hash + 0x9e3779b97f4a7c15ULL + (seed << 6U) + (seed >> 2U); +} + +std::string normalizedPathString(const std::filesystem::path& path) { + std::error_code error; + const auto absolute = std::filesystem::absolute(path, error); + if (error) { + return path.lexically_normal().string(); } + return absolute.lexically_normal().string(); +} - const double dt = 1.0 / buffer.getSampleRate(); - const double rc = 1.0 / (2.0 * kPi * cutoffHz); - const float alpha = static_cast(rc / (rc + dt)); +FileStamp fileStampForPath(const std::filesystem::path& path) { + std::error_code error; + const auto fileStatus = std::filesystem::status(path, error); + if (error || !std::filesystem::is_regular_file(fileStatus)) { + return {}; + } - HighPassState state; - state.previousInput.resize(static_cast(buffer.getNumChannels()), 0.0f); - state.previousOutput.resize(static_cast(buffer.getNumChannels()), 0.0f); + const auto size = std::filesystem::file_size(path, error); + if (error) { + return {}; + } + + const auto writeTime = std::filesystem::last_write_time(path, error); + if (error) { + return {}; + } + + const auto ticks = std::chrono::duration_cast(writeTime.time_since_epoch()).count(); + return FileStamp{ + .valid = true, + .size = size, + .writeTicks = static_cast(ticks), + }; +} + +bool sameFileStamp(const FileStamp& left, const FileStamp& right) { + return left.valid == right.valid && + left.size == right.size && + left.writeTicks == right.writeTicks; +} + +std::size_t decisionHash(const domain::StemMixDecision* decision) { + std::size_t seed = 0; + if (decision == nullptr) { + hashCombine(seed, 0); + return seed; + } + + hashCombine(seed, decision->stemId); + hashCombine(seed, decision->gainDb); + hashCombine(seed, decision->pan); + hashCombine(seed, decision->highPassHz); + hashCombine(seed, decision->mudCutDb); + hashCombine(seed, decision->enableCompressor); + hashCombine(seed, decision->compressorThresholdDb); + hashCombine(seed, decision->compressorRatio); + hashCombine(seed, decision->compressorReleaseMs); + hashCombine(seed, decision->enableExpander); + hashCombine(seed, decision->expanderThresholdDb); + hashCombine(seed, decision->expanderRatio); + return seed; +} - for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { - for (int i = 0; i < buffer.getNumSamples(); ++i) { +std::string makeStemRawCacheKey(const std::filesystem::path& stemPath, const int outputSampleRate) { + std::size_t seed = 0; + hashCombine(seed, normalizedPathString(stemPath)); + hashCombine(seed, outputSampleRate); + return std::to_string(seed); +} + +std::string makeStemProcessedCacheKey(const std::filesystem::path& stemPath, + const int outputSampleRate, + const domain::StemMixDecision* decision, + const double dryWet, + const int outputChannels) { + std::size_t seed = 0; + hashCombine(seed, normalizedPathString(stemPath)); + hashCombine(seed, outputSampleRate); + hashCombine(seed, decisionHash(decision)); + hashCombine(seed, dryWet); + hashCombine(seed, outputChannels); + return std::to_string(seed); +} + +std::string makeRenderMixCacheKey(const domain::Session& session, const domain::RenderSettings& settings) { + std::ostringstream out; + out << settings.outputSampleRate << '|' + << settings.blockSize << '|' + << session.residualBlend << '|' + << session.stems.size() << '|' + << session.buses.size(); + + for (const auto& stem : session.stems) { + const auto stamp = fileStampForPath(stem.filePath); + out << "|s:" << stem.id + << ':' << stem.filePath + << ':' << stem.enabled + << ':' << static_cast(stem.role) + << ':' << (stem.busId.has_value() ? stem.busId.value() : "") + << ':' << stamp.valid + << ':' << stamp.size + << ':' << stamp.writeTicks; + } + + for (const auto& bus : session.buses) { + out << "|b:" << bus.id + << ':' << bus.gainDb; + } + + if (session.mixPlan.has_value()) { + out << "|mix:" << session.mixPlan->dryWet + << ':' << session.mixPlan->mixBusHeadroomDb + << ':' << session.mixPlan->stemDecisions.size(); + for (const auto& decision : session.mixPlan->stemDecisions) { + out << ':' << decisionHash(&decision); + } + } else { + out << "|mix:none"; + } + + if (session.residualBlend > 0.0 && session.originalMixPath.has_value()) { + const auto originalStamp = fileStampForPath(session.originalMixPath.value()); + out << "|orig:" << session.originalMixPath.value() + << ':' << originalStamp.valid + << ':' << originalStamp.size + << ':' << originalStamp.writeTicks; + } + + return out.str(); +} + +std::shared_ptr loadStemResampledCached(const std::filesystem::path& stemPath, + const int outputSampleRate, + AudioFileIO& fileIO, + AudioResampler& resampler) { + const auto key = makeStemRawCacheKey(stemPath, outputSampleRate); + const auto stamp = fileStampForPath(stemPath); + { + std::scoped_lock lock(stemRawCacheMutex()); + const auto& cache = stemRawCache(); + const auto cached = cache.find(key); + if (cached != cache.end() && sameFileStamp(cached->second.stamp, stamp)) { + return cached->second.buffer; + } + } + + auto loaded = fileIO.readAudioFile(stemPath); + if (loaded.getSampleRate() != static_cast(outputSampleRate)) { + loaded = resampler.resampleLinear(loaded, static_cast(outputSampleRate)); + } + auto shared = std::make_shared(std::move(loaded)); + + { + std::scoped_lock lock(stemRawCacheMutex()); + auto& cache = stemRawCache(); + cache[key] = CachedStemAudio{ + .stamp = stamp, + .buffer = shared, + }; + if (cache.size() > 192) { + cache.clear(); + } + } + + return shared; +} + +std::shared_ptr processStemCached(const std::filesystem::path& stemPath, + const int outputSampleRate, + const std::shared_ptr& source, + const domain::StemMixDecision* decision, + const double dryWet, + const int outputChannels) { + const auto key = makeStemProcessedCacheKey(stemPath, outputSampleRate, decision, dryWet, outputChannels); + const auto stamp = fileStampForPath(stemPath); + { + std::scoped_lock lock(stemProcessedCacheMutex()); + const auto& cache = stemProcessedCache(); + const auto cached = cache.find(key); + if (cached != cache.end() && sameFileStamp(cached->second.stamp, stamp)) { + return cached->second.buffer; + } + } + + auto processed = std::make_shared(processStemBuffer(*source, decision, dryWet, outputChannels)); + { + std::scoped_lock lock(stemProcessedCacheMutex()); + auto& cache = stemProcessedCache(); + cache[key] = CachedStemAudio{ + .stamp = stamp, + .buffer = processed, + }; + if (cache.size() > 256) { + cache.clear(); + } + } + return processed; +} + +BiquadCoefficients makeHighPass(const double sampleRate, const double cutoffHz, const double q = kSqrtHalf) { + const double sr = std::max(8000.0, sampleRate); + const double safeCutoff = std::clamp(cutoffHz, 10.0, sr * 0.45); + const double w0 = 2.0 * kPi * safeCutoff / sr; + const double cosW0 = std::cos(w0); + const double sinW0 = std::sin(w0); + const double alpha = sinW0 / (2.0 * std::max(0.05, q)); + const double a0 = 1.0 + alpha; + + BiquadCoefficients coeffs; + coeffs.b0 = static_cast(((1.0 + cosW0) * 0.5) / a0); + coeffs.b1 = static_cast((-(1.0 + cosW0)) / a0); + coeffs.b2 = static_cast(((1.0 + cosW0) * 0.5) / a0); + coeffs.a1 = static_cast((-2.0 * cosW0) / a0); + coeffs.a2 = static_cast((1.0 - alpha) / a0); + return coeffs; +} + +BiquadCoefficients makePeakingEq(const double sampleRate, + const double centerHz, + const double q, + const double gainDb) { + const double sr = std::max(8000.0, sampleRate); + const double safeCenter = std::clamp(centerHz, 20.0, sr * 0.45); + const double w0 = 2.0 * kPi * safeCenter / sr; + const double cosW0 = std::cos(w0); + const double sinW0 = std::sin(w0); + const double a = std::pow(10.0, gainDb / 40.0); + const double alpha = sinW0 / (2.0 * std::max(0.1, q)); + const double a0 = 1.0 + alpha / a; + + BiquadCoefficients coeffs; + coeffs.b0 = static_cast((1.0 + alpha * a) / a0); + coeffs.b1 = static_cast((-2.0 * cosW0) / a0); + coeffs.b2 = static_cast((1.0 - alpha * a) / a0); + coeffs.a1 = static_cast((-2.0 * cosW0) / a0); + coeffs.a2 = static_cast((1.0 - alpha / a) / a0); + return coeffs; +} + +void applyBiquad(AudioBuffer& buffer, const BiquadCoefficients& coeffs) { + if (buffer.getNumSamples() == 0 || buffer.getNumChannels() == 0) { + return; + } + + std::vector z1(static_cast(buffer.getNumChannels()), 0.0f); + std::vector z2(static_cast(buffer.getNumChannels()), 0.0f); + + for (int i = 0; i < buffer.getNumSamples(); ++i) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { const float x = buffer.getSample(ch, i); - const float y = alpha * (state.previousOutput[static_cast(ch)] + x - state.previousInput[static_cast(ch)]); + const float y = coeffs.b0 * x + z1[static_cast(ch)]; + z1[static_cast(ch)] = coeffs.b1 * x - coeffs.a1 * y + z2[static_cast(ch)]; + z2[static_cast(ch)] = coeffs.b2 * x - coeffs.a2 * y; buffer.setSample(ch, i, y); - state.previousInput[static_cast(ch)] = x; - state.previousOutput[static_cast(ch)] = y; } } } +void applyHighPass(AudioBuffer& buffer, const double cutoffHz) { + if (cutoffHz <= 0.0) { + return; + } + applyBiquad(buffer, makeHighPass(buffer.getSampleRate(), cutoffHz)); +} + +void applyMudCut(AudioBuffer& buffer, const double cutDb) { + if (std::abs(cutDb) < 1.0e-6) { + return; + } + applyBiquad(buffer, makePeakingEq(buffer.getSampleRate(), 320.0, 0.9, cutDb)); +} + void applySimpleCompressor(AudioBuffer& buffer, const double thresholdDb, const double ratio, + const double attackMs, const double releaseMs) { const float threshold = static_cast(dbToLinear(thresholdDb)); const float ratioClamped = static_cast(std::clamp(ratio, 1.1, 20.0)); float envelope = 0.0f; - constexpr float attack = 0.08f; - const double releaseSamples = std::max(1.0, buffer.getSampleRate() * (releaseMs / 1000.0)); - const float release = static_cast(1.0 / releaseSamples); + const float attackCoeff = + static_cast(std::exp(-1.0 / std::max(1.0, buffer.getSampleRate() * std::max(0.5, attackMs) * 0.001))); + const float releaseCoeff = + static_cast(std::exp(-1.0 / std::max(1.0, buffer.getSampleRate() * std::max(3.0, releaseMs) * 0.001))); for (int i = 0; i < buffer.getNumSamples(); ++i) { float detector = 0.0f; @@ -69,9 +399,9 @@ void applySimpleCompressor(AudioBuffer& buffer, } if (detector > envelope) { - envelope += (detector - envelope) * attack; + envelope = detector + attackCoeff * (envelope - detector); } else { - envelope += (detector - envelope) * release; + envelope = detector + releaseCoeff * (envelope - detector); } float gain = 1.0f; @@ -91,8 +421,8 @@ void applySimpleExpander(AudioBuffer& buffer, const double thresholdDb, const do const float threshold = static_cast(dbToLinear(thresholdDb)); const float ratioClamped = static_cast(std::clamp(ratio, 1.05, 4.0)); float envelope = 0.0f; - constexpr float attack = 0.03f; - constexpr float release = 0.004f; + const float attackCoeff = static_cast(std::exp(-1.0 / std::max(1.0, buffer.getSampleRate() * 0.010))); + const float releaseCoeff = static_cast(std::exp(-1.0 / std::max(1.0, buffer.getSampleRate() * 0.130))); for (int i = 0; i < buffer.getNumSamples(); ++i) { float detector = 0.0f; @@ -101,9 +431,9 @@ void applySimpleExpander(AudioBuffer& buffer, const double thresholdDb, const do } if (detector > envelope) { - envelope += (detector - envelope) * attack; + envelope = detector + attackCoeff * (envelope - detector); } else { - envelope += (detector - envelope) * release; + envelope = detector + releaseCoeff * (envelope - detector); } float gain = 1.0f; @@ -126,10 +456,12 @@ AudioBuffer processStemBuffer(const AudioBuffer& input, if (decision != nullptr) { applyHighPass(processed, decision->highPassHz); + applyMudCut(processed, decision->mudCutDb); if (decision->enableCompressor) { applySimpleCompressor(processed, decision->compressorThresholdDb, decision->compressorRatio, + 12.0, decision->compressorReleaseMs); } if (decision->enableExpander) { @@ -172,17 +504,138 @@ AudioBuffer processStemBuffer(const AudioBuffer& input, return output; } +std::string defaultBusIdForStem(const domain::Stem& stem) { + if (stem.busId.has_value() && !stem.busId->empty()) { + return stem.busId.value(); + } + + switch (stem.role) { + case domain::StemRole::Drums: + case domain::StemRole::Kick: + return "bus_drums"; + case domain::StemRole::Bass: + return "bus_bass"; + case domain::StemRole::Vocals: + return "bus_vocals"; + case domain::StemRole::Fx: + return "bus_fx"; + default: + return "bus_music"; + } +} + +void applyRoleBusProcessing(const std::string& busId, AudioBuffer& buffer) { + const auto name = toLower(busId); + if (name.find("drum") != std::string::npos) { + applySimpleCompressor(buffer, -16.0, 2.0, 15.0, 120.0); + return; + } + if (name.find("vocal") != std::string::npos) { + applySimpleCompressor(buffer, -18.0, 1.8, 20.0, 180.0); + return; + } + if (name.find("music") != std::string::npos || name.find("instrument") != std::string::npos) { + applySimpleCompressor(buffer, -20.0, 1.5, 18.0, 140.0); + } +} + +int effectiveThreadCount(const domain::RenderSettings& settings, const int taskCount) { + if (!settings.preferHardwareAcceleration) { + return 1; + } + const int hardwareThreads = static_cast(std::max(1u, std::thread::hardware_concurrency())); + const int requested = settings.processingThreads > 0 ? settings.processingThreads : std::max(1, hardwareThreads - 1); + const int bounded = std::clamp(requested, 1, std::max(1, taskCount)); + if (taskCount <= 2) { + return 1; + } + return bounded; +} + +int effectiveMixBlockSize(const domain::RenderSettings& settings, const int maxSamples, const int stemCount) { + int blockSize = std::max(1, settings.blockSize); + if (stemCount >= 12 && maxSamples >= blockSize * 1024) { + blockSize = std::min(8192, blockSize * 4); + } else if (stemCount >= 6 && maxSamples >= blockSize * 512) { + blockSize = std::min(4096, blockSize * 2); + } + return blockSize; +} + +void addBlock(AudioBuffer& destination, + const AudioBuffer& source, + const int startSample, + const int numSamples, + const int sourceStartSample = -1) { + const int srcStart = sourceStartSample >= 0 ? sourceStartSample : startSample; + if (numSamples <= 0) { + return; + } + + const int channels = std::min(destination.getNumChannels(), source.getNumChannels()); + for (int ch = 0; ch < channels; ++ch) { + float* dst = destination.getWritePointer(ch); + const float* src = source.getReadPointer(ch); + int i = 0; + for (; i + 3 < numSamples; i += 4) { + dst[startSample + i] += src[srcStart + i]; + dst[startSample + i + 1] += src[srcStart + i + 1]; + dst[startSample + i + 2] += src[srcStart + i + 2]; + dst[startSample + i + 3] += src[srcStart + i + 3]; + } + for (; i < numSamples; ++i) { + dst[startSample + i] += src[srcStart + i]; + } + } +} + } // namespace +void OfflineRenderPipeline::clearCaches() { + { + std::scoped_lock lock(stemRawCacheMutex()); + stemRawCache().clear(); + } + { + std::scoped_lock lock(stemProcessedCacheMutex()); + stemProcessedCache().clear(); + } + { + std::scoped_lock lock(renderMixCacheMutex()); + renderMixCache().reset(); + } +} + OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& session, const domain::RenderSettings& settings, const ProgressCallback& onProgress, const std::atomic_bool* cancelFlag) const { OfflineRenderResult result; + const auto renderCacheKey = makeRenderMixCacheKey(session, settings); + if (cancelFlag == nullptr || !cancelFlag->load()) { + std::optional cached; + { + std::scoped_lock lock(renderMixCacheMutex()); + const auto& cache = renderMixCache(); + if (cache.has_value() && cache->key == renderCacheKey) { + cached = cache->result; + } + } + + if (cached.has_value()) { + cached->logs.emplace_back("Raw mix cache hit."); + if (onProgress) { + onProgress(RenderProgress{.fraction = 1.0, .stage = "Mix render cache hit"}); + } + return cached.value(); + } + } + AudioFileIO fileIO; AudioResampler resampler; std::unordered_map decisions; + std::unordered_map busGainDbById; double dryWet = 1.0; double headroomDb = 6.0; @@ -194,50 +647,137 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s } } - std::vector stemBuffers; - stemBuffers.reserve(session.stems.size()); + for (const auto& bus : session.buses) { + busGainDbById[bus.id] = bus.gainDb; + } + std::vector enabledStemIndices; + enabledStemIndices.reserve(session.stems.size()); for (size_t i = 0; i < session.stems.size(); ++i) { - if (cancelFlag != nullptr && cancelFlag->load()) { - result.cancelled = true; - result.logs.emplace_back("Render cancelled during import stage."); - return result; + if (session.stems[i].enabled) { + enabledStemIndices.push_back(i); } + } + + std::vector> stemNodeSlots(enabledStemIndices.size()); + std::atomic nextStemIndex{0}; + std::atomic processedStemCount{0}; + std::mutex errorMutex; + std::mutex progressMutex; + std::vector importErrors; + const int stemThreads = effectiveThreadCount(settings, static_cast(enabledStemIndices.size())); + + const auto importWorker = [&]() { + AudioFileIO workerFileIO; + AudioResampler workerResampler; + for (;;) { + const size_t slot = nextStemIndex.fetch_add(1); + if (slot >= enabledStemIndices.size()) { + break; + } + + if (cancelFlag != nullptr && cancelFlag->load()) { + continue; + } + + const size_t stemIndex = enabledStemIndices[slot]; + const auto& stem = session.stems[stemIndex]; + + try { + const auto decisionIt = decisions.find(stem.id); + const domain::StemMixDecision* decision = decisionIt != decisions.end() ? &decisionIt->second : nullptr; + const std::string busId = defaultBusIdForStem(stem); + const auto resampled = loadStemResampledCached(stem.filePath, + settings.outputSampleRate, + workerFileIO, + workerResampler); + const auto processed = processStemCached(stem.filePath, + settings.outputSampleRate, + resampled, + decision, + dryWet, + 2); + + stemNodeSlots[slot] = StemRenderNode{ + .buffer = processed, + .busId = busId, + }; + } catch (const std::exception& error) { + std::scoped_lock lock(errorMutex); + importErrors.emplace_back("Failed to import stem '" + stem.name + "': " + error.what()); + } - const auto& stem = session.stems[i]; - if (!stem.enabled) { - continue; + const size_t done = processedStemCount.fetch_add(1) + 1; + if (onProgress) { + const double total = static_cast(std::max(1, enabledStemIndices.size())); + const double fraction = static_cast(done) / total * 0.35; + std::scoped_lock lock(progressMutex); + onProgress(RenderProgress{.fraction = fraction, .stage = "Importing stems"}); + } } + }; - AudioBuffer buffer = fileIO.readAudioFile(stem.filePath); - if (buffer.getSampleRate() != static_cast(settings.outputSampleRate)) { - buffer = resampler.resampleLinear(buffer, static_cast(settings.outputSampleRate)); + if (stemThreads > 1) { + std::vector workers; + workers.reserve(static_cast(stemThreads)); + for (int t = 0; t < stemThreads; ++t) { + workers.emplace_back(importWorker); + } + for (auto& worker : workers) { + worker.join(); } + } else { + importWorker(); + } - const auto decisionIt = decisions.find(stem.id); - const domain::StemMixDecision* decision = decisionIt != decisions.end() ? &decisionIt->second : nullptr; + if (cancelFlag != nullptr && cancelFlag->load()) { + result.cancelled = true; + result.logs.emplace_back("Render cancelled during import stage."); + return result; + } - stemBuffers.emplace_back(processStemBuffer(buffer, decision, dryWet, 2)); + if (!importErrors.empty()) { + for (const auto& error : importErrors) { + result.logs.push_back(error); + } + throw std::runtime_error(importErrors.front()); + } - if (onProgress) { - onProgress(RenderProgress{.fraction = static_cast(i + 1) / std::max(1, session.stems.size()) * 0.5, - .stage = "Importing stems"}); + std::vector stemNodes; + stemNodes.reserve(stemNodeSlots.size()); + for (auto& slot : stemNodeSlots) { + if (slot.has_value()) { + stemNodes.push_back(std::move(slot.value())); } } int maxSamples = 0; - for (const auto& stem : stemBuffers) { - maxSamples = std::max(maxSamples, stem.getNumSamples()); + for (const auto& node : stemNodes) { + maxSamples = std::max(maxSamples, node.buffer->getNumSamples()); } if (maxSamples == 0) { throw std::runtime_error("No stem audio available to render."); } + std::unordered_set busIds; + for (const auto& node : stemNodes) { + busIds.insert(node.busId); + } + if (busIds.empty()) { + busIds.insert("bus_music"); + } + + std::unordered_map busBuffers; + for (const auto& busId : busIds) { + busBuffers.emplace(busId, AudioBuffer(2, maxSamples, static_cast(settings.outputSampleRate))); + } + result.mixBuffer = AudioBuffer(2, maxSamples, static_cast(settings.outputSampleRate)); - const int blockSize = std::max(1, settings.blockSize); + const int blockSize = effectiveMixBlockSize(settings, maxSamples, static_cast(stemNodes.size())); const int totalBlocks = (maxSamples + blockSize - 1) / blockSize; + const int progressStride = std::max(1, totalBlocks / 120); for (int block = 0; block < totalBlocks; ++block) { if (cancelFlag != nullptr && cancelFlag->load()) { @@ -249,24 +789,41 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s const int start = block * blockSize; const int end = std::min(start + blockSize, maxSamples); - for (const auto& stem : stemBuffers) { - for (int sample = start; sample < end; ++sample) { - if (sample >= stem.getNumSamples()) { - continue; - } - - for (int ch = 0; ch < result.mixBuffer.getNumChannels(); ++ch) { - const float mixed = result.mixBuffer.getSample(ch, sample) + stem.getSample(ch, sample); - result.mixBuffer.setSample(ch, sample, mixed); - } + for (const auto& node : stemNodes) { + auto busIt = busBuffers.find(node.busId); + if (busIt == busBuffers.end()) { + continue; } + auto& busBuffer = busIt->second; + if (start >= node.buffer->getNumSamples()) { + continue; + } + const int blockSamples = std::min(end, node.buffer->getNumSamples()) - start; + addBlock(busBuffer, *node.buffer, start, blockSamples); + } + + if (onProgress && (block == totalBlocks - 1 || block % progressStride == 0)) { + const double fraction = 0.35 + (static_cast(block + 1) / std::max(1, totalBlocks) * 0.40); + onProgress(RenderProgress{.fraction = fraction, .stage = "Summing stem buses"}); + } + } + + for (auto& [busId, busBuffer] : busBuffers) { + applyRoleBusProcessing(busId, busBuffer); + + const auto busGainIt = busGainDbById.find(busId); + if (busGainIt != busGainDbById.end() && std::abs(busGainIt->second) > 1.0e-6) { + busBuffer.applyGain(static_cast(dbToLinear(busGainIt->second))); + result.logs.emplace_back("Applied bus gain " + std::to_string(busGainIt->second) + " dB to '" + busId + "'."); } + addBlock(result.mixBuffer, busBuffer, 0, maxSamples, 0); + if (onProgress) { - const double fraction = 0.5 + (static_cast(block + 1) / std::max(1, totalBlocks) * 0.5); - onProgress(RenderProgress{.fraction = fraction, .stage = "Summing mix blocks"}); + onProgress(RenderProgress{.fraction = 0.75, .stage = "Mixing role buses"}); } } + result.logs.emplace_back("Summed stems through " + std::to_string(busBuffers.size()) + " role bus(es)."); double peak = 0.0; for (int ch = 0; ch < result.mixBuffer.getNumChannels(); ++ch) { @@ -316,6 +873,16 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s result.logs.emplace_back("Raw mix render completed."); result.logs.emplace_back("Final peak dBFS: " + std::to_string(linearToDb(finalPeak))); + { + std::scoped_lock lock(renderMixCacheMutex()); + renderMixCache() = CachedRenderMix{ + .key = renderCacheKey, + .result = result, + }; + } + if (onProgress) { + onProgress(RenderProgress{.fraction = 1.0, .stage = "Mix render complete"}); + } return result; } diff --git a/src/engine/OfflineRenderPipeline.h b/src/engine/OfflineRenderPipeline.h index 007f116..5ca323d 100644 --- a/src/engine/OfflineRenderPipeline.h +++ b/src/engine/OfflineRenderPipeline.h @@ -25,6 +25,8 @@ using ProgressCallback = std::function; class OfflineRenderPipeline { public: + static void clearCaches(); + OfflineRenderResult renderRawMix(const domain::Session& session, const domain::RenderSettings& settings, const ProgressCallback& onProgress, diff --git a/src/engine/TransportController.cpp b/src/engine/TransportController.cpp new file mode 100644 index 0000000..b687f27 --- /dev/null +++ b/src/engine/TransportController.cpp @@ -0,0 +1,228 @@ +#include "engine/TransportController.h" + +#include +#include + +namespace automix::engine { + +void TransportController::setTimeline(const int64_t totalSamples, const double sampleRate) { + { + std::scoped_lock lock(mutex_); + totalSamples_ = std::max(0, totalSamples); + sampleRate_ = std::max(8000.0, sampleRate); + positionSamples_ = std::clamp(positionSamples_, int64_t{0}, totalSamples_); + loopStartSamples_ = std::clamp(loopStartSamples_, int64_t{0}, totalSamples_); + loopEndSamples_ = std::clamp(loopEndSamples_, int64_t{0}, totalSamples_); + if (loopEndSamples_ <= loopStartSamples_) { + loopEnabled_ = false; + } + if (totalSamples_ == 0) { + positionSamples_ = 0; + loopStartSamples_ = 0; + loopEndSamples_ = 0; + loopEnabled_ = false; + state_ = State::Stopped; + } + } + sendChangeMessage(); +} + +void TransportController::play() { + { + std::scoped_lock lock(mutex_); + if (totalSamples_ <= 0) { + return; + } + if (positionSamples_ >= totalSamples_) { + positionSamples_ = 0; + } + state_ = State::Playing; + } + sendChangeMessage(); +} + +void TransportController::pause() { + { + std::scoped_lock lock(mutex_); + if (state_ != State::Playing) { + return; + } + state_ = State::Paused; + } + sendChangeMessage(); +} + +void TransportController::stop() { + { + std::scoped_lock lock(mutex_); + state_ = State::Stopped; + positionSamples_ = 0; + } + sendChangeMessage(); +} + +void TransportController::seekToSample(const int64_t samplePosition) { + { + std::scoped_lock lock(mutex_); + positionSamples_ = std::clamp(samplePosition, int64_t{0}, totalSamples_); + if (loopEnabled_ && loopEndSamples_ > loopStartSamples_ && positionSamples_ > loopEndSamples_) { + positionSamples_ = loopEndSamples_; + } + if (positionSamples_ >= totalSamples_ && totalSamples_ > 0 && state_ == State::Playing) { + state_ = State::Paused; + } + } + sendChangeMessage(); +} + +void TransportController::seekToFraction(const double fraction) { + int64_t samplePosition = 0; + { + std::scoped_lock lock(mutex_); + const double clamped = std::clamp(fraction, 0.0, 1.0); + samplePosition = static_cast(clamped * static_cast(totalSamples_)); + } + seekToSample(samplePosition); +} + +void TransportController::advance(const int numSamples) { + bool changed = false; + { + std::scoped_lock lock(mutex_); + if (state_ != State::Playing || totalSamples_ <= 0 || numSamples <= 0) { + return; + } + const auto advanced = positionSamples_ + static_cast(numSamples); + if (loopEnabled_ && loopEndSamples_ > loopStartSamples_) { + if (advanced >= loopEndSamples_) { + const int64_t loopLength = std::max(1, loopEndSamples_ - loopStartSamples_); + const int64_t overshoot = advanced - loopEndSamples_; + positionSamples_ = loopStartSamples_ + (overshoot % loopLength); + } else { + positionSamples_ = advanced; + } + } else { + positionSamples_ = std::min(totalSamples_, advanced); + } + changed = true; + if (!loopEnabled_ && positionSamples_ >= totalSamples_) { + state_ = State::Paused; + } + } + + if (changed) { + sendChangeMessage(); + } +} + +void TransportController::setLoopRangeSeconds(const double loopInSeconds, + const double loopOutSeconds, + const bool enabled) { + { + std::scoped_lock lock(mutex_); + const auto toSample = [this](const double seconds) { + return static_cast(std::llround(std::max(0.0, seconds) * sampleRate_)); + }; + loopStartSamples_ = std::clamp(toSample(loopInSeconds), int64_t{0}, totalSamples_); + loopEndSamples_ = std::clamp(toSample(loopOutSeconds), int64_t{0}, totalSamples_); + loopEnabled_ = enabled && loopEndSamples_ > loopStartSamples_; + + if (loopEnabled_) { + positionSamples_ = std::clamp(positionSamples_, loopStartSamples_, loopEndSamples_); + } + } + sendChangeMessage(); +} + +void TransportController::clearLoopRange() { + { + std::scoped_lock lock(mutex_); + loopEnabled_ = false; + loopStartSamples_ = 0; + loopEndSamples_ = 0; + } + sendChangeMessage(); +} + +TransportController::State TransportController::state() const { + std::scoped_lock lock(mutex_); + return state_; +} + +bool TransportController::isPlaying() const { + std::scoped_lock lock(mutex_); + return state_ == State::Playing; +} + +int64_t TransportController::positionSamples() const { + std::scoped_lock lock(mutex_); + return positionSamples_; +} + +int64_t TransportController::totalSamples() const { + std::scoped_lock lock(mutex_); + return totalSamples_; +} + +double TransportController::positionSeconds() const { + std::scoped_lock lock(mutex_); + if (sampleRate_ <= 0.0) { + return 0.0; + } + return static_cast(positionSamples_) / sampleRate_; +} + +double TransportController::totalSeconds() const { + std::scoped_lock lock(mutex_); + if (sampleRate_ <= 0.0) { + return 0.0; + } + return static_cast(totalSamples_) / sampleRate_; +} + +double TransportController::progress() const { + std::scoped_lock lock(mutex_); + if (totalSamples_ <= 0) { + return 0.0; + } + return std::clamp(static_cast(positionSamples_) / static_cast(totalSamples_), 0.0, 1.0); +} + +bool TransportController::loopEnabled() const { + std::scoped_lock lock(mutex_); + return loopEnabled_; +} + +double TransportController::loopInSeconds() const { + std::scoped_lock lock(mutex_); + if (sampleRate_ <= 0.0) { + return 0.0; + } + return static_cast(loopStartSamples_) / sampleRate_; +} + +double TransportController::loopOutSeconds() const { + std::scoped_lock lock(mutex_); + if (sampleRate_ <= 0.0) { + return 0.0; + } + return static_cast(loopEndSamples_) / sampleRate_; +} + +double TransportController::loopInProgress() const { + std::scoped_lock lock(mutex_); + if (totalSamples_ <= 0) { + return 0.0; + } + return std::clamp(static_cast(loopStartSamples_) / static_cast(totalSamples_), 0.0, 1.0); +} + +double TransportController::loopOutProgress() const { + std::scoped_lock lock(mutex_); + if (totalSamples_ <= 0) { + return 0.0; + } + return std::clamp(static_cast(loopEndSamples_) / static_cast(totalSamples_), 0.0, 1.0); +} + +} // namespace automix::engine diff --git a/src/engine/TransportController.h b/src/engine/TransportController.h new file mode 100644 index 0000000..60711d4 --- /dev/null +++ b/src/engine/TransportController.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include + +#include + +namespace automix::engine { + +class TransportController final : public juce::ChangeBroadcaster { + public: + enum class State { + Stopped, + Playing, + Paused, + }; + + void setTimeline(int64_t totalSamples, double sampleRate); + void play(); + void pause(); + void stop(); + void seekToSample(int64_t samplePosition); + void seekToFraction(double fraction); + void advance(int numSamples); + void setLoopRangeSeconds(double loopInSeconds, double loopOutSeconds, bool enabled); + void clearLoopRange(); + + [[nodiscard]] State state() const; + [[nodiscard]] bool isPlaying() const; + [[nodiscard]] int64_t positionSamples() const; + [[nodiscard]] int64_t totalSamples() const; + [[nodiscard]] double positionSeconds() const; + [[nodiscard]] double totalSeconds() const; + [[nodiscard]] double progress() const; + [[nodiscard]] bool loopEnabled() const; + [[nodiscard]] double loopInSeconds() const; + [[nodiscard]] double loopOutSeconds() const; + [[nodiscard]] double loopInProgress() const; + [[nodiscard]] double loopOutProgress() const; + + private: + mutable std::mutex mutex_; + int64_t totalSamples_ = 0; + int64_t positionSamples_ = 0; + int64_t loopStartSamples_ = 0; + int64_t loopEndSamples_ = 0; + bool loopEnabled_ = false; + double sampleRate_ = 44100.0; + State state_ = State::Stopped; +}; + +} // namespace automix::engine diff --git a/src/platform/ThirdPartyComponentRegistry.cpp b/src/platform/ThirdPartyComponentRegistry.cpp new file mode 100644 index 0000000..a8c5256 --- /dev/null +++ b/src/platform/ThirdPartyComponentRegistry.cpp @@ -0,0 +1,60 @@ +#include "platform/ThirdPartyComponentRegistry.h" + +namespace automix::platform { + +std::vector ThirdPartyComponentRegistry::all() { + return { + { + .name = "JUCE", + .version = "8.0.8", + .licenseId = "AGPL-3.0-or-later OR JUCE-commercial", + .linkMode = ThirdPartyLinkMode::InProcess, + .shipsByDefault = true, + .notes = "Core app framework. Distribution mode controls license obligations.", + }, + { + .name = "nlohmann_json", + .version = "3.11.3", + .licenseId = "MIT", + .linkMode = ThirdPartyLinkMode::InProcess, + .shipsByDefault = true, + .notes = "JSON serialization for sessions, reports, and model manifests.", + }, + { + .name = "libebur128", + .version = "1.2.6", + .licenseId = "MIT", + .linkMode = ThirdPartyLinkMode::InProcess, + .shipsByDefault = true, + .notes = "BS.1770 loudness metering backend when enabled in build config.", + }, + { + .name = "PhaseLimiter", + .version = "external", + .licenseId = "See assets/phaselimiter/licenses", + .linkMode = ThirdPartyLinkMode::External, + .shipsByDefault = false, + .notes = "Optional external renderer selected by user; app degrades to BuiltIn if unavailable.", + }, + { + .name = "ExternalLimiterRenderer", + .version = "internal-wrapper", + .licenseId = "User-supplied tool license", + .linkMode = ThirdPartyLinkMode::External, + .shipsByDefault = false, + .notes = "Generic external renderer wrapper with timeout/cancel and compliance post-check.", + }, + }; +} + +std::string toString(const ThirdPartyLinkMode linkMode) { + switch (linkMode) { + case ThirdPartyLinkMode::InProcess: + return "in-process"; + case ThirdPartyLinkMode::External: + return "external"; + } + return "unknown"; +} + +} // namespace automix::platform diff --git a/src/platform/ThirdPartyComponentRegistry.h b/src/platform/ThirdPartyComponentRegistry.h new file mode 100644 index 0000000..8c0340b --- /dev/null +++ b/src/platform/ThirdPartyComponentRegistry.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +namespace automix::platform { + +enum class ThirdPartyLinkMode { + InProcess, + External, +}; + +struct ThirdPartyComponent { + std::string name; + std::string version; + std::string licenseId; + ThirdPartyLinkMode linkMode = ThirdPartyLinkMode::InProcess; + bool shipsByDefault = false; + std::string notes; +}; + +class ThirdPartyComponentRegistry { + public: + // Keep this list explicit so every integration documents legal/packaging intent. + static std::vector all(); +}; + +std::string toString(ThirdPartyLinkMode linkMode); + +} // namespace automix::platform diff --git a/src/renderers/BuiltInRenderer.cpp b/src/renderers/BuiltInRenderer.cpp index ff6a6cf..06d947e 100644 --- a/src/renderers/BuiltInRenderer.cpp +++ b/src/renderers/BuiltInRenderer.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include @@ -11,9 +13,16 @@ #include "engine/AudioFileIO.h" #include "engine/AudioResampler.h" #include "engine/OfflineRenderPipeline.h" +#include "util/MetadataPolicy.h" +#include "util/MetadataSourceResolver.h" #include "util/WavWriter.h" namespace automix::renderers { +namespace { + +using ::automix::util::metadataSourcePath; + +} // namespace bool BuiltInRenderer::isAvailable() const { return true; } @@ -47,8 +56,9 @@ RenderResult BuiltInRenderer::render(const domain::Session& session, domain::MasterPlan plan = session.masterPlan.has_value() ? session.masterPlan.value() : strategy.buildPlan(domain::MasterPreset::DefaultStreaming, renderState.mixBuffer); + const bool usedSessionMasterPlan = session.masterPlan.has_value(); - if (!session.masterPlan.has_value() && session.originalMixPath.has_value()) { + if (!usedSessionMasterPlan && session.originalMixPath.has_value()) { try { engine::AudioFileIO fileIO; engine::AudioResampler resampler; @@ -69,21 +79,61 @@ RenderResult BuiltInRenderer::render(const domain::Session& session, const auto spectrumMetrics = analyzer.analyzeBuffer(mastered); util::WavWriter writer; + std::map sourceMetadata; + if (const auto sourcePath = metadataSourcePath(session); sourcePath.has_value()) { + try { + engine::AudioFileIO fileIO; + sourceMetadata = fileIO.readMetadata(sourcePath.value()); + } catch (const std::exception& error) { + result.logs.push_back("Metadata copy skipped: " + std::string(error.what())); + } + } + std::vector metadataPolicyNotes; + const auto exportMetadata = + util::applyMetadataPolicy(sourceMetadata, settings.metadataPolicy, settings.metadataTemplate, &metadataPolicyNotes); + for (const auto& note : metadataPolicyNotes) { + result.logs.push_back(note); + } const std::filesystem::path outputPath = settings.outputPath.empty() ? "export_master.wav" : settings.outputPath; - writer.write(outputPath, mastered, settings.outputBitDepth); + writer.write(outputPath, + mastered, + settings.outputBitDepth, + settings.outputFormat, + settings.lossyBitrateKbps, + settings.lossyQuality, + settings.mp3UseVbr, + settings.mp3VbrQuality, + exportMetadata); const std::filesystem::path reportPath = outputPath.string() + ".report.json"; nlohmann::json report = { {"renderer", "BuiltIn"}, {"outputAudioPath", outputPath.string()}, {"integratedLufs", masteringReport.integratedLufs}, + {"shortTermLufs", masteringReport.shortTermLufs}, + {"loudnessRange", masteringReport.loudnessRange}, + {"samplePeakDbfs", masteringReport.samplePeakDbfs}, {"truePeakDbtp", masteringReport.truePeakDbtp}, + {"crestDb", masteringReport.crestDb}, + {"monoCorrelation", masteringReport.monoCorrelation}, {"spectrumLow", spectrumMetrics.lowEnergy}, {"spectrumMid", spectrumMetrics.midEnergy}, {"spectrumHigh", spectrumMetrics.highEnergy}, {"stereoCorrelation", spectrumMetrics.stereoCorrelation}, + {"masterPreset", plan.presetName}, + {"masterPlanSource", usedSessionMasterPlan ? "session" : "heuristic"}, + {"mixPlanSource", session.mixPlan.has_value() ? "session" : "heuristic"}, + {"exportSpeedMode", settings.exportSpeedMode}, + {"outputFormat", settings.outputFormat}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, + {"mp3Mode", settings.mp3UseVbr ? "vbr" : "cbr"}, + {"mp3VbrQuality", settings.mp3VbrQuality}, + {"metadataPolicy", settings.metadataPolicy}, {"targetLufs", plan.targetLufs}, {"targetTruePeakDbtp", plan.truePeakDbtp}, + {"limiterCeilingDb", plan.limiterCeilingDb}, + {"activeModules", masteringReport.activeModules}, {"decisionLog", plan.decisionLog}, {"renderLogs", renderState.logs}, }; diff --git a/src/renderers/ExternalLimiterRenderer.cpp b/src/renderers/ExternalLimiterRenderer.cpp new file mode 100644 index 0000000..425eb22 --- /dev/null +++ b/src/renderers/ExternalLimiterRenderer.cpp @@ -0,0 +1,478 @@ +#include "renderers/ExternalLimiterRenderer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ai/MasteringCompliance.h" +#include "analysis/StemAnalyzer.h" +#include "automaster/HeuristicAutoMasterStrategy.h" +#include "automaster/OriginalMixReference.h" +#include "engine/AudioFileIO.h" +#include "engine/AudioResampler.h" +#include "engine/OfflineRenderPipeline.h" +#include "renderers/BuiltInRenderer.h" +#include "util/MetadataPolicy.h" +#include "util/MetadataSourceResolver.h" +#include "util/WavWriter.h" + +namespace automix::renderers { +namespace { + +using ::automix::util::metadataSourcePath; + +RenderResult fallbackToBuiltIn(const domain::Session& session, + const domain::RenderSettings& settings, + const IRenderer::ProgressCallback& onProgress, + std::atomic_bool* cancelFlag, + const std::string& reason) { + BuiltInRenderer fallback; + auto result = fallback.render(session, settings, onProgress, cancelFlag); + result.rendererName = "ExternalLimiter (fallback BuiltIn)"; + result.logs.push_back("External limiter fallback reason: " + reason); + return result; +} + +std::string captureChildOutput(juce::ChildProcess& process, const size_t maxBytes) { + std::string output; + char buffer[2048]; + while (const int bytes = process.readProcessOutput(buffer, static_cast(sizeof(buffer)))) { + if (bytes <= 0) { + break; + } + if (output.size() >= maxBytes) { + continue; + } + const auto remaining = maxBytes - output.size(); + output.append(buffer, static_cast(std::min(bytes, static_cast(remaining)))); + } + return output; +} + +std::string summarizeOutput(const std::string& text, const size_t limit = 220) { + if (text.size() <= limit) { + return text; + } + return text.substr(0, limit) + "..."; +} + +std::optional parseJsonFromOutput(const std::string& output) { + if (output.empty()) { + return std::nullopt; + } + + try { + return nlohmann::json::parse(output); + } catch (...) { + } + + const auto begin = output.find('{'); + const auto end = output.rfind('}'); + if (begin == std::string::npos || end == std::string::npos || begin >= end) { + return std::nullopt; + } + + try { + return nlohmann::json::parse(output.substr(begin, end - begin + 1)); + } catch (...) { + return std::nullopt; + } +} + +bool isCompatibleSchema(const nlohmann::json& json) { + if (!json.contains("schemaVersion")) { + return false; + } + + if (json.at("schemaVersion").is_string()) { + const auto schema = json.at("schemaVersion").get(); + return schema.rfind("1.", 0) == 0; + } + if (json.at("schemaVersion").is_number_integer()) { + return json.at("schemaVersion").get() == 1; + } + return false; +} + +} // namespace + +ExternalLimiterRenderer::ValidationResult ExternalLimiterRenderer::validateBinary(const std::filesystem::path& binaryPath, + const int timeoutMs) { + ValidationResult result; + result.errorCode = "unknown"; + + std::error_code error; + if (!std::filesystem::is_regular_file(binaryPath, error) || error) { + result.errorCode = "binary_missing"; + result.diagnostics = "Binary path is missing or not a file."; + return result; + } + + const auto nonce = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count()); + const auto outputDir = binaryPath.parent_path().empty() ? std::filesystem::current_path() : binaryPath.parent_path(); + const auto requestPath = outputDir / ("external_limiter_validate_" + nonce + ".json"); + + nlohmann::json request = { + {"validate", true}, + {"schemaVersion", "1.0"}, + {"request", "capabilities"}, + {"inputPath", ""}, + {"outputPath", ""}, + {"sampleRate", 44100}, + {"bitDepth", 24}, + }; + + { + std::ofstream out(requestPath); + out << request.dump(2); + } + + juce::StringArray command; + command.add(binaryPath.string()); + command.add("--validate"); + command.add("--request"); + command.add(requestPath.string()); + + juce::ChildProcess process; + if (!process.start(command)) { + std::filesystem::remove(requestPath, error); + result.errorCode = "launch_failed"; + result.diagnostics = "Failed to launch binary with --validate."; + return result; + } + + auto start = std::chrono::steady_clock::now(); + const int timeout = std::max(500, timeoutMs); + while (process.isRunning()) { + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); + if (elapsed > timeout) { + process.kill(); + std::filesystem::remove(requestPath, error); + result.errorCode = "timeout"; + result.diagnostics = "Validation timed out."; + return result; + } + std::this_thread::sleep_for(std::chrono::milliseconds(30)); + } + + const int exitCode = process.getExitCode(); + const std::string processOutput = captureChildOutput(process, 65536); + std::filesystem::remove(requestPath, error); + + if (exitCode != 0) { + result.errorCode = "exit_code"; + result.diagnostics = "Validation exited with code " + std::to_string(exitCode) + ". Output: " + summarizeOutput(processOutput); + return result; + } + + const auto json = parseJsonFromOutput(processOutput); + if (!json.has_value() || !json->is_object()) { + result.errorCode = "invalid_json"; + result.diagnostics = "Validation output is not valid JSON."; + return result; + } + + if (!json->contains("version") || !json->at("version").is_string()) { + result.errorCode = "missing_version"; + result.diagnostics = "Validation response missing string field 'version'."; + return result; + } + + if (!json->contains("supportedFeatures") || !json->at("supportedFeatures").is_array()) { + result.errorCode = "missing_supported_features"; + result.diagnostics = "Validation response missing array field 'supportedFeatures'."; + return result; + } + + if (!isCompatibleSchema(*json)) { + result.errorCode = "schema_incompatible"; + result.diagnostics = "Validation response has incompatible or missing schemaVersion."; + return result; + } + + result.version = json->at("version").get(); + for (const auto& value : json->at("supportedFeatures")) { + if (value.is_string()) { + result.supportedFeatures.push_back(value.get()); + } + } + + result.valid = true; + result.errorCode = "ok"; + result.diagnostics = "Validation passed."; + return result; +} + +bool ExternalLimiterRenderer::isAvailable() const { return true; } + +RenderResult ExternalLimiterRenderer::render(const domain::Session& session, + const domain::RenderSettings& settings, + const ProgressCallback& onProgress, + std::atomic_bool* cancelFlag) const { + if (settings.externalRendererPath.empty()) { + return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, "No external binary path configured."); + } + + const std::filesystem::path binaryPath(settings.externalRendererPath); + std::error_code error; + if (!std::filesystem::is_regular_file(binaryPath, error) || error) { + return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, "External binary path is invalid."); + } + + const auto validation = validateBinary(binaryPath, std::min(5000, settings.externalRendererTimeoutMs)); + if (!validation.valid) { + return fallbackToBuiltIn(session, + settings, + onProgress, + cancelFlag, + "External binary validation failed [" + validation.errorCode + "]: " + validation.diagnostics); + } + + try { + engine::OfflineRenderPipeline pipeline; + auto rawResult = pipeline.renderRawMix( + session, settings, + [&](const engine::RenderProgress& progress) { + if (onProgress) { + onProgress(progress.fraction * 0.4, progress.stage); + } + }, + cancelFlag); + + if (rawResult.cancelled) { + RenderResult cancelledResult; + cancelledResult.cancelled = true; + cancelledResult.rendererName = "ExternalLimiter"; + cancelledResult.logs = rawResult.logs; + return cancelledResult; + } + + const std::filesystem::path outputPath = settings.outputPath.empty() ? "export_master.wav" : settings.outputPath; + const auto outputFormat = util::WavWriter::resolveFormat(outputPath, settings.outputFormat); + if (outputPath.has_parent_path()) { + std::filesystem::create_directories(outputPath.parent_path()); + } + + const auto nonce = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count()); + const std::filesystem::path outputDir = outputPath.has_parent_path() ? outputPath.parent_path() : std::filesystem::current_path(); + const std::filesystem::path tempInputPath = outputDir / ("external_limiter_input_" + nonce + ".wav"); + const std::filesystem::path tempLimiterOutputPath = outputFormat == "wav" ? outputPath + : (outputDir / ("external_limiter_output_" + nonce + ".wav")); + const std::filesystem::path requestPath = outputDir / ("external_limiter_request_" + nonce + ".json"); + + util::WavWriter writer; + writer.write(tempInputPath, rawResult.mixBuffer, settings.outputBitDepth); + + automaster::HeuristicAutoMasterStrategy strategy; + analysis::StemAnalyzer analyzer; + const bool usedSessionMasterPlan = session.masterPlan.has_value(); + const bool usedSessionMixPlan = session.mixPlan.has_value(); + auto plan = session.masterPlan.has_value() ? session.masterPlan.value() + : strategy.buildPlan(domain::MasterPreset::DefaultStreaming, rawResult.mixBuffer); + if (!usedSessionMasterPlan && session.originalMixPath.has_value()) { + try { + engine::AudioFileIO fileIO; + engine::AudioResampler resampler; + auto originalMix = fileIO.readAudioFile(session.originalMixPath.value()); + if (originalMix.getSampleRate() != rawResult.mixBuffer.getSampleRate()) { + originalMix = resampler.resampleLinear(originalMix, rawResult.mixBuffer.getSampleRate()); + } + automaster::OriginalMixReference referenceTarget; + plan = referenceTarget.applySoftTarget(plan, rawResult.mixBuffer, originalMix, strategy, analyzer); + } catch (const std::exception& errorException) { + rawResult.logs.push_back("Original mix reference skipped: " + std::string(errorException.what())); + } + } + + nlohmann::json request = { + {"inputPath", tempInputPath.string()}, + {"outputPath", tempLimiterOutputPath.string()}, + {"sampleRate", settings.outputSampleRate}, + {"bitDepth", settings.outputBitDepth}, + {"targetLufs", plan.targetLufs}, + {"ceilingDbtp", plan.truePeakDbtp}, + {"limiterCeilingDb", plan.limiterCeilingDb}, + {"limiterTruePeakEnabled", plan.limiterTruePeakEnabled}, + {"limiterLookaheadMs", plan.limiterLookaheadMs}, + {"limiterAttackMs", plan.limiterAttackMs}, + {"limiterReleaseMs", plan.limiterReleaseMs}, + {"preGainDb", plan.preGainDb}, + {"outputFormat", outputFormat}, + {"exportSpeedMode", settings.exportSpeedMode}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, + {"gpuExecutionProvider", settings.gpuExecutionProvider}, + {"preferHardwareAcceleration", settings.preferHardwareAcceleration}, + {"metadataPolicy", settings.metadataPolicy}, + {"metadataTemplate", settings.metadataTemplate}, + {"masterPlanSource", usedSessionMasterPlan ? "session" : "heuristic"}, + {"mixPlanSource", usedSessionMixPlan ? "session" : "heuristic"}, + {"masterDecisionLog", plan.decisionLog}, + {"mixDecisionLog", session.mixPlan.has_value() ? session.mixPlan->decisionLog : std::vector{}}, + }; + { + std::ofstream out(requestPath); + out << request.dump(2); + } + + juce::StringArray command; + command.add(binaryPath.string()); + command.add("--request"); + command.add(requestPath.string()); + + juce::ChildProcess process; + if (!process.start(command)) { + return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, "Failed to start external process."); + } + + if (onProgress) { + onProgress(0.5, "External limiter processing"); + } + + const auto timeoutMs = std::max(1000, settings.externalRendererTimeoutMs); + auto start = std::chrono::steady_clock::now(); + while (process.isRunning()) { + if (cancelFlag != nullptr && cancelFlag->load()) { + process.kill(); + RenderResult cancelledResult; + cancelledResult.cancelled = true; + cancelledResult.rendererName = "ExternalLimiter"; + return cancelledResult; + } + + const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); + if (elapsed > timeoutMs) { + process.kill(); + return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, "External limiter timed out."); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + const int exitCode = process.getExitCode(); + const std::string processOutput = captureChildOutput(process, 65536); + if (exitCode != 0 || !std::filesystem::exists(tempLimiterOutputPath)) { + return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, + "External limiter failed with exit code " + std::to_string(exitCode)); + } + + if (onProgress) { + onProgress(0.8, "Compliance post-check"); + } + + engine::AudioFileIO fileIo; + auto mastered = fileIo.readAudioFile(tempLimiterOutputPath); + ai::MasteringCompliance compliance; + automaster::MasteringReport complianceReport; + std::map sourceMetadata; + if (const auto sourcePath = metadataSourcePath(session); sourcePath.has_value()) { + try { + sourceMetadata = fileIo.readMetadata(sourcePath.value()); + } catch (const std::exception& errorException) { + rawResult.logs.push_back("Metadata copy skipped: " + std::string(errorException.what())); + } + } + std::vector metadataPolicyNotes; + const auto exportMetadata = + util::applyMetadataPolicy(sourceMetadata, settings.metadataPolicy, settings.metadataTemplate, &metadataPolicyNotes); + for (const auto& note : metadataPolicyNotes) { + rawResult.logs.push_back(note); + } + + const auto boundedPlan = compliance.enforcePlanBounds(plan); + const auto checked = compliance.enforceOutput(mastered, boundedPlan, strategy, &complianceReport); + mastered = checked; + writer.write(outputPath, + mastered, + settings.outputBitDepth, + settings.outputFormat, + settings.lossyBitrateKbps, + settings.lossyQuality, + settings.mp3UseVbr, + settings.mp3VbrQuality, + exportMetadata); + + const auto spectrum = analyzer.analyzeBuffer(mastered); + + const std::filesystem::path reportPath = outputPath.string() + ".report.json"; + nlohmann::json report = { + {"renderer", "ExternalLimiter"}, + {"binaryPath", binaryPath.string()}, + {"validatedVersion", validation.version}, + {"validatedFeatures", validation.supportedFeatures}, + {"outputAudioPath", outputPath.string()}, + {"processExitCode", exitCode}, + {"integratedLufs", complianceReport.integratedLufs}, + {"shortTermLufs", complianceReport.shortTermLufs}, + {"loudnessRange", complianceReport.loudnessRange}, + {"truePeakDbtp", complianceReport.truePeakDbtp}, + {"samplePeakDbfs", complianceReport.samplePeakDbfs}, + {"monoCorrelation", complianceReport.monoCorrelation}, + {"spectrumLow", spectrum.lowEnergy}, + {"spectrumMid", spectrum.midEnergy}, + {"spectrumHigh", spectrum.highEnergy}, + {"stereoCorrelation", spectrum.stereoCorrelation}, + {"masterPlanSource", usedSessionMasterPlan ? "session" : "heuristic"}, + {"mixPlanSource", usedSessionMixPlan ? "session" : "heuristic"}, + {"masterDecisionLog", boundedPlan.decisionLog}, + {"mixDecisionLog", session.mixPlan.has_value() ? session.mixPlan->decisionLog : std::vector{}}, + {"exportSpeedMode", settings.exportSpeedMode}, + {"outputFormat", outputFormat}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, + {"mp3Mode", settings.mp3UseVbr ? "vbr" : "cbr"}, + {"mp3VbrQuality", settings.mp3VbrQuality}, + {"metadataPolicy", settings.metadataPolicy}, + {"targetLufs", boundedPlan.targetLufs}, + {"targetTruePeakDbtp", boundedPlan.truePeakDbtp}, + {"limiterCeilingDb", boundedPlan.limiterCeilingDb}, + {"limiterLookaheadMs", boundedPlan.limiterLookaheadMs}, + {"limiterAttackMs", boundedPlan.limiterAttackMs}, + {"limiterReleaseMs", boundedPlan.limiterReleaseMs}, + {"limiterTruePeakEnabled", boundedPlan.limiterTruePeakEnabled}, + {"processOutput", processOutput}, + }; + { + std::ofstream out(reportPath); + out << report.dump(2); + } + + std::filesystem::remove(tempInputPath, error); + std::filesystem::remove(requestPath, error); + if (tempLimiterOutputPath != outputPath) { + std::filesystem::remove(tempLimiterOutputPath, error); + } + + RenderResult result; + result.success = true; + result.rendererName = "ExternalLimiter"; + result.outputAudioPath = outputPath.string(); + result.reportPath = reportPath.string(); + result.logs = rawResult.logs; + result.logs.push_back("External limiter binary: " + binaryPath.string()); + result.logs.push_back("External limiter validation version: " + validation.version); + result.logs.push_back("External process exit code: " + std::to_string(exitCode)); + if (!processOutput.empty()) { + result.logs.push_back("External process output captured."); + } + + if (onProgress) { + onProgress(1.0, "External limiter completed"); + } + return result; + } catch (const std::exception& errorException) { + return fallbackToBuiltIn(session, settings, onProgress, cancelFlag, + "External limiter exception: " + std::string(errorException.what())); + } +} + +} // namespace automix::renderers diff --git a/src/renderers/ExternalLimiterRenderer.h b/src/renderers/ExternalLimiterRenderer.h new file mode 100644 index 0000000..c1f6b2e --- /dev/null +++ b/src/renderers/ExternalLimiterRenderer.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include "renderers/IRenderer.h" + +namespace automix::renderers { + +class ExternalLimiterRenderer final : public IRenderer { + public: + struct ValidationResult { + bool valid = false; + std::string version; + std::vector supportedFeatures; + std::string errorCode; + std::string diagnostics; + }; + + static ValidationResult validateBinary(const std::filesystem::path& binaryPath, int timeoutMs = 5000); + + bool isAvailable() const override; + + RenderResult render(const domain::Session& session, + const domain::RenderSettings& settings, + const ProgressCallback& onProgress, + std::atomic_bool* cancelFlag) const override; +}; + +} // namespace automix::renderers diff --git a/src/renderers/PhaseLimiterDiscovery.cpp b/src/renderers/PhaseLimiterDiscovery.cpp index 8d83bb2..f726b93 100644 --- a/src/renderers/PhaseLimiterDiscovery.cpp +++ b/src/renderers/PhaseLimiterDiscovery.cpp @@ -9,20 +9,14 @@ #include +#include "util/FileUtils.h" +#include "util/StringUtils.h" + namespace automix::renderers { namespace { -bool isRegularFile(const std::filesystem::path& path) { - std::error_code error; - return std::filesystem::is_regular_file(path, error); -} - -std::string toLower(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) { - return static_cast(std::tolower(c)); - }); - return value; -} +using ::automix::util::isRegularFile; +using ::automix::util::toLower; std::vector executableNames() { #if defined(_WIN32) diff --git a/src/renderers/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index 96fc9b3..d354c5c 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -12,18 +13,24 @@ #include #include +#include "ai/MasteringCompliance.h" #include "analysis/StemAnalyzer.h" #include "automaster/HeuristicAutoMasterStrategy.h" +#include "automaster/OriginalMixReference.h" #include "engine/AudioFileIO.h" #include "engine/AudioResampler.h" #include "engine/OfflineRenderPipeline.h" #include "renderers/BuiltInRenderer.h" #include "renderers/PhaseLimiterDiscovery.h" +#include "util/MetadataPolicy.h" +#include "util/MetadataSourceResolver.h" #include "util/WavWriter.h" namespace automix::renderers { namespace { +using ::automix::util::metadataSourcePath; + constexpr int kPhaseLimiterSampleRate = 44100; constexpr int kPhaseLimiterBitDepth = 16; constexpr size_t kMaxProcessOutputCaptureBytes = 32768; @@ -145,7 +152,30 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, rawMix = resampler.resampleLinear(rawMix, static_cast(kPhaseLimiterSampleRate)); } + automaster::HeuristicAutoMasterStrategy strategy; + analysis::StemAnalyzer analyzer; + const bool usedSessionMasterPlan = session.masterPlan.has_value(); + const bool usedSessionMixPlan = session.mixPlan.has_value(); + auto plan = session.masterPlan.has_value() + ? session.masterPlan.value() + : strategy.buildPlan(domain::MasterPreset::DefaultStreaming, rawMix); + if (!usedSessionMasterPlan && session.originalMixPath.has_value()) { + try { + engine::AudioFileIO fileIO; + engine::AudioResampler resampler; + auto originalMix = fileIO.readAudioFile(session.originalMixPath.value()); + if (originalMix.getSampleRate() != rawMix.getSampleRate()) { + originalMix = resampler.resampleLinear(originalMix, rawMix.getSampleRate()); + } + automaster::OriginalMixReference referenceTarget; + plan = referenceTarget.applySoftTarget(plan, rawMix, originalMix, strategy, analyzer); + } catch (const std::exception& errorException) { + renderState.logs.push_back("Original mix reference skipped: " + std::string(errorException.what())); + } + } + const std::filesystem::path outputPath = settings.outputPath.empty() ? "export_master.wav" : settings.outputPath; + const auto outputFormat = util::WavWriter::resolveFormat(outputPath, settings.outputFormat); if (outputPath.has_parent_path()) { std::filesystem::create_directories(outputPath.parent_path()); } @@ -154,6 +184,9 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, const std::filesystem::path tempRoot = binaryInfo->installRoot / "tmp"; const std::filesystem::path tempWorkDir = tempRoot / ("work_" + suffix); const std::filesystem::path tempInputPath = tempRoot / ("input_" + suffix + ".wav"); + const std::filesystem::path tempPhaseOutputPath = outputFormat == "wav" + ? outputPath + : (tempRoot / ("phase_output_" + suffix + ".wav")); const std::filesystem::path relativeTempWorkDir = std::filesystem::path("tmp") / ("work_" + suffix); const std::filesystem::path relativeTempInputPath = std::filesystem::path("tmp") / ("input_" + suffix + ".wav"); std::filesystem::create_directories(tempWorkDir); @@ -166,13 +199,13 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, juce::StringArray command; command.add(binaryInfo->executablePath.generic_string()); command.add("-input=" + relativeTempInputPath.generic_string()); - command.add("-output=" + outputPath.generic_string()); + command.add("-output=" + tempPhaseOutputPath.generic_string()); command.add("-disable_input_encode=true"); command.add("-output_format=wav"); command.add("-sample_rate=44100"); - command.add("-bit_depth=16"); - command.add("-ceiling=-1"); - command.add("-mastering=false"); + command.add("-bit_depth=" + std::to_string(std::clamp(settings.outputBitDepth, 16, 24))); + command.add("-ceiling=" + std::to_string(plan.limiterCeilingDb)); + command.add("-mastering=true"); command.add("-tmp=" + relativeTempWorkDir.generic_string()); juce::ChildProcess process; @@ -199,7 +232,7 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, drainProcessOutput(process, processOutput); const auto exitCode = process.getExitCode(); - if (exitCode != 0 || !pathExists(outputPath)) { + if (exitCode != 0 || !pathExists(tempPhaseOutputPath)) { const std::string outputHint = processOutput.empty() ? "" : (" output=" + processOutput.substr(0, 240)); @@ -212,12 +245,36 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, } engine::AudioFileIO fileIO; - const auto mastered = fileIO.readAudioFile(outputPath); + auto mastered = fileIO.readAudioFile(tempPhaseOutputPath); + std::map sourceMetadata; + if (const auto sourcePath = metadataSourcePath(session); sourcePath.has_value()) { + try { + sourceMetadata = fileIO.readMetadata(sourcePath.value()); + } catch (const std::exception& error) { + renderState.logs.push_back("Metadata copy skipped: " + std::string(error.what())); + } + } + std::vector metadataPolicyNotes; + const auto exportMetadata = + util::applyMetadataPolicy(sourceMetadata, settings.metadataPolicy, settings.metadataTemplate, &metadataPolicyNotes); + for (const auto& note : metadataPolicyNotes) { + renderState.logs.push_back(note); + } + + ai::MasteringCompliance compliance; + const auto boundedPlan = compliance.enforcePlanBounds(plan); + automaster::MasteringReport complianceReport; + mastered = compliance.enforceOutput(mastered, boundedPlan, strategy, &complianceReport); + writer.write(outputPath, + mastered, + settings.outputBitDepth, + settings.outputFormat, + settings.lossyBitrateKbps, + settings.lossyQuality, + settings.mp3UseVbr, + settings.mp3VbrQuality, + exportMetadata); - automaster::HeuristicAutoMasterStrategy strategy; - analysis::StemAnalyzer analyzer; - const double integratedLufs = strategy.measureIntegratedLufs(mastered); - const double truePeakDbtp = strategy.estimateTruePeakDbtp(mastered, 4); const auto spectrumMetrics = analyzer.analyzeBuffer(mastered); const std::filesystem::path reportPath = outputPath.string() + ".report.json"; @@ -225,12 +282,35 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, {"renderer", "PhaseLimiter"}, {"phaseLimiterBinary", binaryInfo->executablePath.string()}, {"outputAudioPath", outputPath.string()}, - {"integratedLufs", integratedLufs}, - {"truePeakDbtp", truePeakDbtp}, + {"integratedLufs", complianceReport.integratedLufs}, + {"shortTermLufs", complianceReport.shortTermLufs}, + {"loudnessRange", complianceReport.loudnessRange}, + {"samplePeakDbfs", complianceReport.samplePeakDbfs}, + {"truePeakDbtp", complianceReport.truePeakDbtp}, + {"monoCorrelation", complianceReport.monoCorrelation}, {"spectrumLow", spectrumMetrics.lowEnergy}, {"spectrumMid", spectrumMetrics.midEnergy}, {"spectrumHigh", spectrumMetrics.highEnergy}, {"stereoCorrelation", spectrumMetrics.stereoCorrelation}, + {"masterPlanSource", usedSessionMasterPlan ? "session" : "heuristic"}, + {"mixPlanSource", usedSessionMixPlan ? "session" : "heuristic"}, + {"masterDecisionLog", boundedPlan.decisionLog}, + {"mixDecisionLog", session.mixPlan.has_value() ? session.mixPlan->decisionLog : std::vector{}}, + {"exportSpeedMode", settings.exportSpeedMode}, + {"outputFormat", outputFormat}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, + {"mp3Mode", settings.mp3UseVbr ? "vbr" : "cbr"}, + {"mp3VbrQuality", settings.mp3VbrQuality}, + {"metadataPolicy", settings.metadataPolicy}, + {"preGainDb", boundedPlan.preGainDb}, + {"targetLufs", boundedPlan.targetLufs}, + {"targetTruePeakDbtp", boundedPlan.truePeakDbtp}, + {"limiterCeilingDb", boundedPlan.limiterCeilingDb}, + {"limiterLookaheadMs", boundedPlan.limiterLookaheadMs}, + {"limiterAttackMs", boundedPlan.limiterAttackMs}, + {"limiterReleaseMs", boundedPlan.limiterReleaseMs}, + {"limiterTruePeakEnabled", boundedPlan.limiterTruePeakEnabled}, {"renderLogs", renderState.logs}, }; @@ -240,6 +320,9 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, std::error_code ignore; std::filesystem::remove(tempInputPath, ignore); std::filesystem::remove_all(tempWorkDir, ignore); + if (tempPhaseOutputPath != outputPath) { + std::filesystem::remove(tempPhaseOutputPath, ignore); + } RenderResult result; result.success = true; diff --git a/src/renderers/RendererFactory.cpp b/src/renderers/RendererFactory.cpp index 381ab17..3a86ff4 100644 --- a/src/renderers/RendererFactory.cpp +++ b/src/renderers/RendererFactory.cpp @@ -1,16 +1,27 @@ #include "renderers/RendererFactory.h" #include "renderers/BuiltInRenderer.h" +#include "renderers/ExternalLimiterRenderer.h" #include "renderers/PhaseLimiterRenderer.h" namespace automix::renderers { std::unique_ptr createRenderer(const std::string& preferredRenderer) { + if (preferredRenderer.empty() || preferredRenderer == "BuiltIn") { + return std::make_unique(); + } + if (preferredRenderer == "PhaseLimiter") { return std::make_unique(); } - return std::make_unique(); + if (preferredRenderer == "ExternalLimiter" || preferredRenderer.rfind("ExternalUser", 0) == 0) { + return std::make_unique(); + } + + // Any non-built-in renderer id discovered from registry descriptors is treated as + // external limiter-compatible to avoid routing unexpected ids to the built-in renderer. + return std::make_unique(); } } // namespace automix::renderers diff --git a/src/renderers/RendererRegistry.cpp b/src/renderers/RendererRegistry.cpp new file mode 100644 index 0000000..1a43c82 --- /dev/null +++ b/src/renderers/RendererRegistry.cpp @@ -0,0 +1,423 @@ +#include "renderers/RendererRegistry.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "renderers/ExternalLimiterRenderer.h" +#include "renderers/PhaseLimiterDiscovery.h" +#include "util/HashUtils.h" + +namespace automix::renderers { +namespace { + +struct TrustPolicy { + bool enforceSignedDescriptors = false; + std::unordered_set trustedSigners; + std::unordered_map> profileRendererPins; +}; + +bool hasBinary(const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error); +} + +std::vector trustPolicyCandidates() { + std::vector candidates; + std::error_code error; + auto current = std::filesystem::absolute(std::filesystem::current_path(error), error); + if (error) { + return candidates; + } + + for (int depth = 0; depth < 5; ++depth) { + candidates.push_back(current / "assets" / "renderers" / "trust_policy.json"); + candidates.push_back(current / "assets" / "limiters" / "trust_policy.json"); + candidates.push_back(current / "Assets" / "Renderers" / "trust_policy.json"); + if (!current.has_parent_path()) { + break; + } + const auto parent = current.parent_path(); + if (parent == current) { + break; + } + current = parent; + } + + return candidates; +} + +TrustPolicy loadTrustPolicy() { + TrustPolicy policy; + + std::set visited; + for (const auto& candidate : trustPolicyCandidates()) { + const auto key = candidate.lexically_normal().string(); + if (!visited.insert(key).second) { + continue; + } + + std::error_code error; + if (!std::filesystem::is_regular_file(candidate, error) || error) { + continue; + } + + try { + std::ifstream in(candidate); + nlohmann::json json; + in >> json; + policy.enforceSignedDescriptors = json.value("enforceSignedDescriptors", false); + + if (json.contains("trustedSigners") && json.at("trustedSigners").is_array()) { + for (const auto& signer : json.at("trustedSigners")) { + if (signer.is_string()) { + policy.trustedSigners.insert(signer.get()); + } + } + } + + if (json.contains("profileRendererPins") && json.at("profileRendererPins").is_object()) { + for (const auto& [profileId, rendererIds] : json.at("profileRendererPins").items()) { + if (!rendererIds.is_array()) { + continue; + } + policy.profileRendererPins[profileId] = rendererIds.get>(); + } + } + + return policy; + } catch (...) { + continue; + } + } + + return policy; +} + +bool verifyDescriptorSignature(const nlohmann::json& descriptor, + const std::string& algorithm, + const std::string& signatureValue) { + if (algorithm.empty() || signatureValue.empty()) { + return false; + } + + if (algorithm != "fnv1a64" && algorithm != "FNV1A64") { + return false; + } + + auto canonical = descriptor; + canonical.erase("signature"); + const auto digest = util::toHex(util::fnv1a64(canonical.dump())); + return digest == signatureValue; +} + +RendererInfo makeBuiltInInfo() { + RendererInfo info; + info.id = "BuiltIn"; + info.name = "BuiltIn"; + info.version = "internal"; + info.licenseId = "Project"; + info.linkMode = RendererLinkMode::InProcess; + info.bundledByDefault = true; + info.available = true; + info.discovery = "Always available (core renderer)."; + info.trustPolicyStatus = "trusted:built-in"; + return info; +} + +RendererInfo makePhaseLimiterInfo() { + PhaseLimiterDiscovery discovery; + const auto binaryInfo = discovery.find(); + + RendererInfo info; + info.id = "PhaseLimiter"; + info.name = "PhaseLimiter"; + info.version = "external"; + info.licenseId = "See assets/phaselimiter/licenses"; + info.linkMode = RendererLinkMode::External; + info.bundledByDefault = false; + info.available = binaryInfo.has_value(); + info.discovery = binaryInfo.has_value() ? "Auto-discovered in assets or PHASELIMITER_BIN." + : "Not found in assets. Set PHASELIMITER_BIN or install under assets."; + info.trustPolicyStatus = "unsigned"; + + if (binaryInfo.has_value()) { + info.binaryPath = binaryInfo->executablePath; + } + + return info; +} + +std::filesystem::path capabilitySnapshotPath(const ExternalRendererConfig& config) { + const auto safeId = config.id.empty() ? std::string("external") : config.id; + return config.binaryPath.parent_path() / (safeId + ".capabilities.snapshot.json"); +} + +void writeCapabilitySnapshot(const ExternalRendererConfig& config, + const ExternalLimiterRenderer::ValidationResult& validation, + const std::filesystem::path& snapshotPath) { + nlohmann::json snapshot = { + {"id", config.id}, + {"name", config.name}, + {"binaryPath", config.binaryPath.string()}, + {"version", validation.version}, + {"supportedFeatures", validation.supportedFeatures}, + {"validated", validation.valid}, + {"errorCode", validation.errorCode}, + {"diagnostics", validation.diagnostics}, + {"signatureValid", config.signatureValid}, + {"signerId", config.signerId}, + {"timestampEpochMs", std::chrono::duration_cast( + std::chrono::high_resolution_clock::now().time_since_epoch()) + .count()}, + }; + std::ofstream out(snapshotPath); + if (out.is_open()) { + out << snapshot.dump(2); + } +} + +RendererInfo makeExternalInfo(const ExternalRendererConfig& config) { + RendererInfo info; + info.id = config.id; + info.name = config.name; + info.version = config.version; + info.licenseId = config.licenseId; + info.linkMode = RendererLinkMode::External; + info.bundledByDefault = config.bundledByDefault; + info.binaryPath = config.binaryPath; + info.pinnedProfileIds = config.pinnedProfileIds; + info.trustPolicyStatus = config.trustPolicyStatus; + + if (!hasBinary(config.binaryPath)) { + info.available = false; + info.discovery = "Configured path is missing or not executable."; + return info; + } + + const auto validation = ExternalLimiterRenderer::validateBinary(config.binaryPath); + info.available = validation.valid; + if (!validation.version.empty() && (info.version.empty() || info.version == "unknown")) { + info.version = validation.version; + } + + const auto snapshotPath = capabilitySnapshotPath(config); + writeCapabilitySnapshot(config, validation, snapshotPath); + info.capabilitySnapshotPath = snapshotPath; + + if (validation.valid) { + info.discovery = "External limiter validated (--validate contract)."; + } else { + info.discovery = "Validation failed [" + validation.errorCode + "]: " + validation.diagnostics; + } + + if (!info.trustPolicyStatus.empty()) { + info.discovery += " Trust=" + info.trustPolicyStatus + "."; + } + + return info; +} + +std::optional loadExternalRendererDescriptor(const std::filesystem::path& descriptorPath, + const TrustPolicy& trustPolicy) { + try { + std::ifstream in(descriptorPath); + if (!in.is_open()) { + return std::nullopt; + } + + nlohmann::json json; + in >> json; + + ExternalRendererConfig config; + config.id = json.value("id", descriptorPath.parent_path().filename().string()); + config.name = json.value("name", config.id); + config.version = json.value("version", "unknown"); + config.licenseId = json.value("licenseId", json.value("license", "unknown")); + config.bundledByDefault = json.value("bundledByDefault", false); + + std::string binaryPath = json.value("binaryPath", json.value("binary", "")); + if (binaryPath.empty()) { + return std::nullopt; + } + + const std::filesystem::path binary(binaryPath); + config.binaryPath = binary.is_absolute() ? binary : (descriptorPath.parent_path() / binary); + + if (json.contains("signature") && json.at("signature").is_object()) { + const auto& signature = json.at("signature"); + config.signerId = signature.value("signer", ""); + config.signatureAlgorithm = signature.value("algorithm", ""); + config.signatureValue = signature.value("value", ""); + config.signatureValid = verifyDescriptorSignature(json, config.signatureAlgorithm, config.signatureValue); + } + + if (json.contains("pinnedProfileIds") && json.at("pinnedProfileIds").is_array()) { + config.pinnedProfileIds = json.at("pinnedProfileIds").get>(); + } + + if (config.id.empty() || config.name.empty()) { + return std::nullopt; + } + + if (!config.signerId.empty() && !trustPolicy.trustedSigners.empty()) { + if (trustPolicy.trustedSigners.find(config.signerId) == trustPolicy.trustedSigners.end()) { + config.trustPolicyStatus = "signer_untrusted"; + } + } + + if (!config.signatureAlgorithm.empty() && !config.signatureValid) { + config.trustPolicyStatus = "signature_invalid"; + } else if (!config.signatureAlgorithm.empty() && config.signatureValid) { + config.trustPolicyStatus = "signature_valid"; + } + + if (trustPolicy.enforceSignedDescriptors) { + if (config.signatureAlgorithm.empty()) { + config.trustPolicyStatus = "signature_missing"; + } + if (!config.signerId.empty() && !trustPolicy.trustedSigners.empty() && + trustPolicy.trustedSigners.find(config.signerId) == trustPolicy.trustedSigners.end()) { + config.trustPolicyStatus = "signer_untrusted"; + } + } + + if (config.trustPolicyStatus.empty()) { + config.trustPolicyStatus = config.signatureAlgorithm.empty() ? "unsigned" : "signature_valid"; + } + + return config; + } catch (...) { + return std::nullopt; + } +} + +std::vector assetLimiterRoots() { + std::vector roots; + std::error_code error; + auto current = std::filesystem::absolute(std::filesystem::current_path(error), error); + if (error) { + return roots; + } + + for (int depth = 0; depth < 5; ++depth) { + roots.push_back(current / "assets" / "limiters"); + roots.push_back(current / "assets" / "renderers"); + roots.push_back(current / "Assets" / "Limiters"); + if (!current.has_parent_path()) { + break; + } + current = current.parent_path(); + } + return roots; +} + +std::vector discoverAssetExternalRenderers(const TrustPolicy& trustPolicy) { + std::vector configs; + std::set seenDescriptors; + std::error_code error; + + for (const auto& root : assetLimiterRoots()) { + if (!std::filesystem::exists(root, error) || error) { + continue; + } + for (const auto& entry : std::filesystem::recursive_directory_iterator( + root, + std::filesystem::directory_options::skip_permission_denied, + error)) { + if (error || !entry.is_regular_file()) { + continue; + } + if (entry.path().filename() != "renderer.json") { + continue; + } + + const auto descriptorKey = entry.path().lexically_normal().string(); + if (!seenDescriptors.insert(descriptorKey).second) { + continue; + } + + const auto config = loadExternalRendererDescriptor(entry.path(), trustPolicy); + if (config.has_value()) { + if (trustPolicy.enforceSignedDescriptors) { + if (config->trustPolicyStatus == "signature_missing" || + config->trustPolicyStatus == "signature_invalid" || + config->trustPolicyStatus == "signer_untrusted") { + continue; + } + } + configs.push_back(config.value()); + } + } + } + + return configs; +} + +} // namespace + +std::vector RendererRegistry::list(const std::vector& externalConfigs) const { + const auto trustPolicy = loadTrustPolicy(); + + std::vector infos; + const auto assetConfigs = discoverAssetExternalRenderers(trustPolicy); + infos.reserve(2 + externalConfigs.size() + assetConfigs.size()); + infos.push_back(makeBuiltInInfo()); + infos.push_back(makePhaseLimiterInfo()); + + std::set seenExternalIds; + auto appendConfig = [&](const ExternalRendererConfig& config) { + if (config.id.empty() || config.name.empty()) { + return; + } + if (!seenExternalIds.insert(config.id).second) { + return; + } + + ExternalRendererConfig effective = config; + if (effective.trustPolicyStatus.empty()) { + effective.trustPolicyStatus = "unsigned"; + } + infos.push_back(makeExternalInfo(effective)); + }; + + for (const auto& config : externalConfigs) { + appendConfig(config); + } + for (const auto& config : assetConfigs) { + appendConfig(config); + } + + std::stable_sort(infos.begin(), infos.end(), [](const RendererInfo& left, const RendererInfo& right) { + if (left.id == "BuiltIn") { + return true; + } + if (right.id == "BuiltIn") { + return false; + } + if (left.available != right.available) { + return left.available > right.available; + } + return left.name < right.name; + }); + return infos; +} + +std::string toString(const RendererLinkMode mode) { + switch (mode) { + case RendererLinkMode::InProcess: + return "in-process"; + case RendererLinkMode::External: + return "external"; + } + + return "unknown"; +} + +} // namespace automix::renderers diff --git a/src/renderers/RendererRegistry.h b/src/renderers/RendererRegistry.h new file mode 100644 index 0000000..94964bd --- /dev/null +++ b/src/renderers/RendererRegistry.h @@ -0,0 +1,52 @@ +#pragma once + +#include +#include +#include + +namespace automix::renderers { + +enum class RendererLinkMode { + InProcess, + External, +}; + +struct RendererInfo { + std::string id; + std::string name; + std::string version; + std::string licenseId; + RendererLinkMode linkMode = RendererLinkMode::InProcess; + bool bundledByDefault = true; + bool available = false; + std::string discovery; + std::filesystem::path binaryPath; + std::string trustPolicyStatus; + std::vector pinnedProfileIds; + std::filesystem::path capabilitySnapshotPath; +}; + +struct ExternalRendererConfig { + std::string id; + std::string name; + std::string version = "unknown"; + std::string licenseId = "unknown"; + std::filesystem::path binaryPath; + bool bundledByDefault = false; + std::string signerId; + std::string signatureAlgorithm; + std::string signatureValue; + bool signatureValid = false; + std::string trustPolicyStatus = "unsigned"; + std::vector pinnedProfileIds; + std::string capabilitySnapshot; +}; + +class RendererRegistry { + public: + std::vector list(const std::vector& externalConfigs = {}) const; +}; + +std::string toString(RendererLinkMode mode); + +} // namespace automix::renderers diff --git a/src/util/BackgroundJob.h b/src/util/BackgroundJob.h new file mode 100644 index 0000000..826083e --- /dev/null +++ b/src/util/BackgroundJob.h @@ -0,0 +1,24 @@ +#pragma once + +#include +#include + +#include + +namespace automix::util { + +class BackgroundJob final : public juce::ThreadPoolJob { + public: + explicit BackgroundJob(std::function task) + : juce::ThreadPoolJob("BackgroundJob"), task_(std::move(task)) {} + + JobStatus runJob() override { + task_(); + return jobHasFinished; + } + + private: + std::function task_; +}; + +} // namespace automix::util diff --git a/src/util/CallbackDispatch.h b/src/util/CallbackDispatch.h new file mode 100644 index 0000000..082adc4 --- /dev/null +++ b/src/util/CallbackDispatch.h @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include + +namespace automix::util { + +template +inline void dispatchCallback(Fn&& callback) { + if (juce::MessageManager::getInstanceWithoutCreating() != nullptr) { + juce::MessageManager::callAsync(std::forward(callback)); + return; + } + std::forward(callback)(); +} + +} // namespace automix::util diff --git a/src/util/FileUtils.h b/src/util/FileUtils.h new file mode 100644 index 0000000..78c764e --- /dev/null +++ b/src/util/FileUtils.h @@ -0,0 +1,13 @@ +#pragma once + +#include +#include + +namespace automix::util { + +inline bool isRegularFile(const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error) && !error; +} + +} // namespace automix::util diff --git a/src/util/HashUtils.h b/src/util/HashUtils.h new file mode 100644 index 0000000..743c9b8 --- /dev/null +++ b/src/util/HashUtils.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace automix::util { + +constexpr uint64_t kFnv1a64OffsetBasis = 14695981039346656037ull; +constexpr uint64_t kFnv1a64Prime = 1099511628211ull; + +inline uint64_t fnv1a64Update(uint64_t hash, const void* data, const size_t size) noexcept { + if (data == nullptr || size == 0) { + return hash; + } + + const auto* bytes = static_cast(data); + for (size_t i = 0; i < size; ++i) { + hash ^= bytes[i]; + hash *= kFnv1a64Prime; + } + return hash; +} + +inline uint64_t fnv1a64(const void* data, const size_t size) noexcept { + return fnv1a64Update(kFnv1a64OffsetBasis, data, size); +} + +inline uint64_t fnv1a64(const std::string_view input) noexcept { + return fnv1a64(input.data(), input.size()); +} + +inline std::string toHex(const uint64_t value) { + std::ostringstream out; + out << std::hex << value; + return out.str(); +} + +} // namespace automix::util diff --git a/src/util/LameDownloader.cpp b/src/util/LameDownloader.cpp new file mode 100644 index 0000000..7ddeade --- /dev/null +++ b/src/util/LameDownloader.cpp @@ -0,0 +1,886 @@ +#include "util/LameDownloader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include "util/FileUtils.h" +#include "util/StringUtils.h" + +namespace automix::util { +namespace { + +using ::automix::util::toLower; +using ::automix::util::trim; +using ::automix::util::isRegularFile; + +constexpr const char* kDefaultLameVersion = "3.100"; +constexpr int kDownloadTimeoutMs = 180000; + +enum class SourceType { + DirectBinary, + Zip, + Debian, + Ghcr, +}; + +struct DownloadSource { + SourceType type = SourceType::Zip; + std::string url; + std::string ghcrOs; + std::string ghcrArch; +}; + +struct TempDirectory { + explicit TempDirectory(const std::string& prefix) { + const auto base = + std::filesystem::path(juce::File::getSpecialLocation(juce::File::tempDirectory).getFullPathName().toStdString()); + const auto nonce = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count()); + path = base / (prefix + "_" + nonce); + + std::error_code error; + std::filesystem::create_directories(path, error); + if (error) { + throw std::runtime_error("Failed to create temp directory: " + path.string()); + } + } + + ~TempDirectory() { + std::error_code error; + std::filesystem::remove_all(path, error); + } + + std::filesystem::path path; +}; + +std::string joinLines(const std::vector& lines) { + std::ostringstream os; + for (size_t i = 0; i < lines.size(); ++i) { + if (i != 0) { + os << '\n'; + } + os << lines[i]; + } + return os.str(); +} + +std::optional readEnvironment(const char* key) { +#if defined(_WIN32) + char* buffer = nullptr; + size_t length = 0; + if (_dupenv_s(&buffer, &length, key) != 0 || buffer == nullptr) { + return std::nullopt; + } + + std::string value(buffer, length > 0 ? length - 1 : 0); + free(buffer); + value = trim(value); + if (value.empty()) { + return std::nullopt; + } + return value; +#else + const char* value = std::getenv(key); + if (value == nullptr || *value == '\0') { + return std::nullopt; + } + const auto trimmed = trim(value); + if (trimmed.empty()) { + return std::nullopt; + } + return trimmed; +#endif +} + +bool flagEnabled(const char* key) { + const auto value = readEnvironment(key); + if (!value.has_value()) { + return false; + } + + const auto lower = toLower(value.value()); + return lower == "1" || lower == "true" || lower == "yes" || lower == "on"; +} + +std::string binaryName() { +#if defined(_WIN32) + return "lame.exe"; +#else + return "lame"; +#endif +} + +std::string platformKey() { +#if defined(_WIN32) +#if defined(_M_ARM64) || defined(__aarch64__) + return "win32-arm64"; +#elif defined(_M_IX86) || defined(__i386__) + return "win32-ia32"; +#else + return "win32-x64"; +#endif +#elif defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) + return "darwin-arm64"; +#else + return "darwin-x64"; +#endif +#elif defined(__linux__) +#if defined(__aarch64__) + return "linux-arm64"; +#elif defined(__arm__) + return "linux-arm"; +#else + return "linux-x64"; +#endif +#else + return ""; +#endif +} + +std::vector platformSources() { + const auto version = readEnvironment("AUTOMIX_LAME_VERSION").value_or(kDefaultLameVersion); + if (const auto manualUrl = readEnvironment("AUTOMIX_LAME_DOWNLOAD_URL"); manualUrl.has_value()) { + const auto lower = toLower(*manualUrl); + if (lower.ends_with(".zip")) { + return {{SourceType::Zip, *manualUrl, "", ""}}; + } + if (lower.ends_with(".deb")) { + return {{SourceType::Debian, *manualUrl, "", ""}}; + } + return {{SourceType::DirectBinary, *manualUrl, "", ""}}; + } + + const auto key = platformKey(); + if (key == "win32-x64") { + return { + {SourceType::Zip, "https://www.rarewares.org/files/mp3/lame" + version + ".1-x64.zip", "", ""}, + }; + } + if (key == "win32-ia32") { + return { + {SourceType::Zip, "https://www.rarewares.org/files/mp3/lame" + version + ".1-win32.zip", "", ""}, + }; + } + if (key == "win32-arm64") { + return { + {SourceType::Zip, "https://www.rarewares.org/files/mp3/LAME-" + version + "-Win-ARM64.zip", "", ""}, + }; + } + if (key == "linux-x64") { + return { + {SourceType::Debian, "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_amd64.deb", "", ""}, + {SourceType::Ghcr, "", "linux", "amd64"}, + }; + } + if (key == "linux-arm64") { + return { + {SourceType::Debian, "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_arm64.deb", "", ""}, + {SourceType::Ghcr, "", "linux", "arm64"}, + }; + } + if (key == "linux-arm") { + return { + {SourceType::Debian, "https://deb.debian.org/debian/pool/main/l/lame/lame_" + version + "-6_armhf.deb", "", ""}, + }; + } + if (key == "darwin-x64") { + return { + {SourceType::Ghcr, "", "darwin", "amd64"}, + }; + } + if (key == "darwin-arm64") { + return { + {SourceType::Ghcr, "", "darwin", "arm64"}, + }; + } + return {}; +} + +bool runProcess(const juce::StringArray& command, + const int timeoutMs, + std::string* processOutput, + std::string* detail) { + juce::ChildProcess process; + if (!process.start(command)) { + if (detail != nullptr) { + *detail = "Failed to start process: " + command.joinIntoString(" ").toStdString(); + } + return false; + } + + if (!process.waitForProcessToFinish(timeoutMs)) { + process.kill(); + if (detail != nullptr) { + *detail = "Timed out waiting for process: " + command.joinIntoString(" ").toStdString(); + } + return false; + } + + const auto output = process.readAllProcessOutput().toStdString(); + if (processOutput != nullptr) { + *processOutput = output; + } + + const auto exitCode = process.getExitCode(); + if (exitCode != 0) { + if (detail != nullptr) { + std::ostringstream os; + os << "Process failed (exit=" << exitCode << "): " << command.joinIntoString(" ").toStdString(); + if (!output.empty()) { + os << " output=" << output; + } + *detail = os.str(); + } + return false; + } + return true; +} + +bool downloadToFile(const std::string& url, + const std::filesystem::path& outputPath, + const std::string& extraHeaders, + std::string* detail) { + int statusCode = 0; + const auto baseOptions = + juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(45000) + .withNumRedirectsToFollow(8) + .withStatusCode(&statusCode); + const auto input = juce::URL(url).createInputStream( + extraHeaders.empty() ? baseOptions : baseOptions.withExtraHeaders(extraHeaders)); + if (input == nullptr) { + if (detail != nullptr) { + *detail = "Download failed (no stream): " + url; + } + return false; + } + if (statusCode >= 400) { + if (detail != nullptr) { + *detail = "Download failed (HTTP " + std::to_string(statusCode) + "): " + url; + } + return false; + } + + std::error_code error; + std::filesystem::create_directories(outputPath.parent_path(), error); + if (error) { + if (detail != nullptr) { + *detail = "Failed creating output folder: " + outputPath.parent_path().string(); + } + return false; + } + + juce::File outFile(outputPath.string()); + std::unique_ptr out(outFile.createOutputStream()); + if (out == nullptr || !out->openedOk()) { + if (detail != nullptr) { + *detail = "Failed opening output file: " + outputPath.string(); + } + return false; + } + + out->writeFromInputStream(*input, -1); + out->flush(); + if (!isRegularFile(outputPath)) { + if (detail != nullptr) { + *detail = "Downloaded file missing after transfer: " + outputPath.string(); + } + return false; + } + + return true; +} + +std::optional fetchJson(const std::string& url, + const std::string& extraHeaders, + std::string* detail) { + int statusCode = 0; + const auto baseOptions = + juce::URL::InputStreamOptions(juce::URL::ParameterHandling::inAddress) + .withConnectionTimeoutMs(45000) + .withNumRedirectsToFollow(8) + .withStatusCode(&statusCode); + const auto input = juce::URL(url).createInputStream( + extraHeaders.empty() ? baseOptions : baseOptions.withExtraHeaders(extraHeaders)); + if (input == nullptr) { + if (detail != nullptr) { + *detail = "JSON fetch failed (no stream): " + url; + } + return std::nullopt; + } + if (statusCode >= 400) { + if (detail != nullptr) { + *detail = "JSON fetch failed (HTTP " + std::to_string(statusCode) + "): " + url; + } + return std::nullopt; + } + + const auto text = input->readEntireStreamAsString().toStdString(); + if (text.empty()) { + if (detail != nullptr) { + *detail = "JSON fetch returned empty body: " + url; + } + return std::nullopt; + } + + try { + return nlohmann::json::parse(text); + } catch (const std::exception& error) { + if (detail != nullptr) { + *detail = "JSON parse failed: " + std::string(error.what()); + } + return std::nullopt; + } +} + +std::optional findFileRecursive(const std::filesystem::path& root, + const std::vector& names) { + std::error_code error; + if (!std::filesystem::exists(root, error) || error) { + return std::nullopt; + } + + std::filesystem::recursive_directory_iterator it(root, std::filesystem::directory_options::skip_permission_denied, error); + std::filesystem::recursive_directory_iterator end; + for (; it != end && !error; it.increment(error)) { + if (!it->is_regular_file(error) || error) { + continue; + } + + const auto fileName = toLower(it->path().filename().string()); + for (const auto& candidateName : names) { + if (fileName == toLower(candidateName)) { + return it->path(); + } + } + } + + return std::nullopt; +} + +bool ensureExecutable(const std::filesystem::path& path, std::string* detail) { + std::error_code error; + if (!isRegularFile(path)) { + if (detail != nullptr) { + *detail = "File is not present: " + path.string(); + } + return false; + } + +#if !defined(_WIN32) + const auto perms = std::filesystem::status(path, error).permissions(); + if (!error) { + std::filesystem::permissions(path, + perms | std::filesystem::perms::owner_exec | std::filesystem::perms::group_exec | + std::filesystem::perms::others_exec, + std::filesystem::perm_options::replace, + error); + } + if (error && detail != nullptr) { + *detail = "Failed to set executable permissions: " + path.string(); + return false; + } +#endif + + juce::StringArray versionCommand; + versionCommand.add(path.string()); + versionCommand.add("--version"); + + std::string output; + std::string versionDetail; + if (!runProcess(versionCommand, 15000, &output, &versionDetail)) { + if (detail != nullptr) { + *detail = "Downloaded binary failed validation. " + versionDetail; + } + return false; + } + + if (toLower(output).find("lame") == std::string::npos && detail != nullptr) { + *detail = "Binary validation succeeded without 'lame' in --version output."; + } + + return true; +} + +bool copyBinaryToCache(const std::filesystem::path& source, const std::filesystem::path& destination, std::string* detail) { + std::error_code error; + std::filesystem::create_directories(destination.parent_path(), error); + if (error) { + if (detail != nullptr) { + *detail = "Failed creating cache directory: " + destination.parent_path().string(); + } + return false; + } + + std::filesystem::copy_file(source, destination, std::filesystem::copy_options::overwrite_existing, error); + if (error) { + if (detail != nullptr) { + *detail = "Failed copying binary to cache: " + destination.string(); + } + return false; + } + + return ensureExecutable(destination, detail); +} + +std::optional extractArMember(const std::filesystem::path& archivePath, + const std::string& memberPrefix, + const std::filesystem::path& outputDirectory, + std::string* detail) { + std::ifstream input(archivePath, std::ios::binary); + if (!input.is_open()) { + if (detail != nullptr) { + *detail = "Unable to open .deb archive: " + archivePath.string(); + } + return std::nullopt; + } + + char magic[8] = {}; + input.read(magic, sizeof(magic)); + if (input.gcount() != static_cast(sizeof(magic)) || std::string(magic, sizeof(magic)) != "!\n") { + if (detail != nullptr) { + *detail = "Invalid ar archive header: " + archivePath.string(); + } + return std::nullopt; + } + + while (true) { + char header[60] = {}; + input.read(header, sizeof(header)); + if (input.eof()) { + break; + } + if (!input.good()) { + if (detail != nullptr) { + *detail = "Failed reading ar header from: " + archivePath.string(); + } + return std::nullopt; + } + + std::string name = trim(std::string(header, 16)); + if (!name.empty() && name.back() == '/') { + name.pop_back(); + } + + const auto sizeText = trim(std::string(header + 48, 10)); + std::int64_t size = 0; + try { + size = std::stoll(sizeText); + } catch (...) { + if (detail != nullptr) { + *detail = "Failed parsing ar member size for: " + name; + } + return std::nullopt; + } + if (size < 0) { + if (detail != nullptr) { + *detail = "Encountered negative ar member size for: " + name; + } + return std::nullopt; + } + + if (name.rfind(memberPrefix, 0) == 0) { + std::vector payload(static_cast(size)); + input.read(payload.data(), static_cast(payload.size())); + if (!input.good()) { + if (detail != nullptr) { + *detail = "Failed reading ar member payload for: " + name; + } + return std::nullopt; + } + + std::error_code error; + std::filesystem::create_directories(outputDirectory, error); + if (error) { + if (detail != nullptr) { + *detail = "Failed creating extraction directory: " + outputDirectory.string(); + } + return std::nullopt; + } + + const auto outputPath = outputDirectory / name; + std::ofstream output(outputPath, std::ios::binary | std::ios::trunc); + if (!output.is_open()) { + if (detail != nullptr) { + *detail = "Failed writing extracted member: " + outputPath.string(); + } + return std::nullopt; + } + output.write(payload.data(), static_cast(payload.size())); + output.flush(); + if (!output.good()) { + if (detail != nullptr) { + *detail = "Failed flushing extracted member: " + outputPath.string(); + } + return std::nullopt; + } + + return outputPath; + } + + input.seekg(size, std::ios::cur); + if (!input.good()) { + if (detail != nullptr) { + *detail = "Failed skipping ar member: " + name; + } + return std::nullopt; + } + + if ((size % 2) != 0) { + input.seekg(1, std::ios::cur); + if (!input.good()) { + if (detail != nullptr) { + *detail = "Failed skipping ar padding byte."; + } + return std::nullopt; + } + } + } + + if (detail != nullptr) { + *detail = "No data.tar member found in archive: " + archivePath.string(); + } + return std::nullopt; +} + +bool installFromZip(const DownloadSource& source, const std::filesystem::path& targetBinary, std::string* detail) { + TempDirectory temp("automix_lame_zip"); + const auto archivePath = temp.path / "lame.zip"; + if (!downloadToFile(source.url, archivePath, "", detail)) { + return false; + } + + juce::ZipFile zip(juce::File(archivePath.string())); + if (zip.getNumEntries() <= 0) { + if (detail != nullptr) { + *detail = "ZIP archive appears empty: " + source.url; + } + return false; + } + + const auto extractPath = temp.path / "extract"; + std::error_code error; + std::filesystem::create_directories(extractPath, error); + if (error) { + if (detail != nullptr) { + *detail = "Failed creating ZIP extract directory: " + extractPath.string(); + } + return false; + } + + const auto unzipResult = zip.uncompressTo(juce::File(extractPath.string()), true); + if (unzipResult.failed()) { + if (detail != nullptr) { + *detail = "Failed extracting ZIP archive: " + unzipResult.getErrorMessage().toStdString(); + } + return false; + } + + const auto foundBinary = findFileRecursive(extractPath, {binaryName(), "lame"}); + if (!foundBinary.has_value()) { + if (detail != nullptr) { + *detail = "No LAME executable found in ZIP archive: " + source.url; + } + return false; + } + + return copyBinaryToCache(*foundBinary, targetBinary, detail); +} + +bool extractTarArchive(const std::filesystem::path& archivePath, + const std::filesystem::path& outputDirectory, + const std::string& compressionFlag, + const std::string& memberHint, + std::string* detail) { + juce::StringArray command; + command.add("tar"); + command.add("-x" + compressionFlag + "f"); + command.add(archivePath.string()); + command.add("-C"); + command.add(outputDirectory.string()); + if (!memberHint.empty()) { + command.add(memberHint); + } + return runProcess(command, kDownloadTimeoutMs, nullptr, detail); +} + +bool installFromDebian(const DownloadSource& source, const std::filesystem::path& targetBinary, std::string* detail) { +#if !defined(__linux__) + (void)source; + (void)targetBinary; + if (detail != nullptr) { + *detail = "Debian package install is only available on Linux."; + } + return false; +#else + TempDirectory temp("automix_lame_deb"); + const auto debPath = temp.path / "lame.deb"; + if (!downloadToFile(source.url, debPath, "", detail)) { + return false; + } + + const auto memberPath = extractArMember(debPath, "data.tar", temp.path, detail); + if (!memberPath.has_value()) { + return false; + } + + const auto lowerName = toLower(memberPath->filename().string()); + std::string compression = "J"; + if (lowerName.ends_with(".tar.gz")) { + compression = "z"; + } else if (lowerName.ends_with(".tar.xz")) { + compression = "J"; + } else if (lowerName.ends_with(".tar")) { + compression.clear(); + } else { + if (detail != nullptr) { + *detail = "Unsupported data tar format in .deb: " + memberPath->filename().string(); + } + return false; + } + + const auto extractDir = temp.path / "extract"; + std::error_code error; + std::filesystem::create_directories(extractDir, error); + if (error) { + if (detail != nullptr) { + *detail = "Failed creating extraction dir: " + extractDir.string(); + } + return false; + } + + if (!extractTarArchive(*memberPath, extractDir, compression, "", detail)) { + return false; + } + + const auto binaryPath = extractDir / "usr" / "bin" / "lame"; + if (!isRegularFile(binaryPath)) { + if (detail != nullptr) { + *detail = "Debian package did not contain usr/bin/lame."; + } + return false; + } + + return copyBinaryToCache(binaryPath, targetBinary, detail); +#endif +} + +bool installFromGhcr(const DownloadSource& source, const std::filesystem::path& targetBinary, std::string* detail) { + const auto version = readEnvironment("AUTOMIX_LAME_VERSION").value_or(kDefaultLameVersion); + + const auto tokenJson = fetchJson("https://ghcr.io/token?service=ghcr.io&scope=repository:homebrew/core/lame:pull", "", detail); + if (!tokenJson.has_value() || !tokenJson->contains("token")) { + if (detail != nullptr && detail->empty()) { + *detail = "Failed to resolve GHCR token."; + } + return false; + } + + const std::string token = tokenJson->value("token", ""); + if (token.empty()) { + if (detail != nullptr) { + *detail = "GHCR token response did not include a token."; + } + return false; + } + + const std::string authHeader = "Authorization: Bearer " + token + "\n"; + const auto manifestList = fetchJson( + "https://ghcr.io/v2/homebrew/core/lame/manifests/" + version, + authHeader + "Accept: application/vnd.oci.image.index.v1+json\n", + detail); + if (!manifestList.has_value() || !manifestList->contains("manifests")) { + if (detail != nullptr && detail->empty()) { + *detail = "Failed to fetch GHCR manifest list."; + } + return false; + } + + std::string manifestDigest; + for (const auto& manifest : (*manifestList)["manifests"]) { + const auto platform = manifest.value("platform", nlohmann::json::object()); + if (platform.value("os", "") == source.ghcrOs && platform.value("architecture", "") == source.ghcrArch) { + manifestDigest = manifest.value("digest", ""); + break; + } + } + if (manifestDigest.empty()) { + if (detail != nullptr) { + *detail = "No GHCR manifest found for " + source.ghcrOs + "/" + source.ghcrArch; + } + return false; + } + + const auto manifest = fetchJson( + "https://ghcr.io/v2/homebrew/core/lame/manifests/" + manifestDigest, + authHeader + "Accept: application/vnd.oci.image.manifest.v1+json\n", + detail); + if (!manifest.has_value() || !manifest->contains("layers")) { + if (detail != nullptr && detail->empty()) { + *detail = "Failed to fetch GHCR image manifest."; + } + return false; + } + + std::string layerDigest; + std::string mediaType; + for (const auto& layer : (*manifest)["layers"]) { + mediaType = layer.value("mediaType", ""); + if (mediaType.find("tar") != std::string::npos) { + layerDigest = layer.value("digest", ""); + break; + } + } + if (layerDigest.empty()) { + if (detail != nullptr) { + *detail = "No tar layer found in GHCR image manifest."; + } + return false; + } + + TempDirectory temp("automix_lame_ghcr"); + const bool gzipLayer = mediaType.find("gzip") != std::string::npos; + const auto layerPath = temp.path / (gzipLayer ? "layer.tar.gz" : "layer.tar"); + if (!downloadToFile("https://ghcr.io/v2/homebrew/core/lame/blobs/" + layerDigest, + layerPath, + authHeader + "Accept: application/octet-stream\n", + detail)) { + return false; + } + + const auto extractDir = temp.path / "extract"; + std::error_code error; + std::filesystem::create_directories(extractDir, error); + if (error) { + if (detail != nullptr) { + *detail = "Failed creating GHCR extract folder: " + extractDir.string(); + } + return false; + } + + if (!extractTarArchive(layerPath, extractDir, gzipLayer ? "z" : "", "", detail)) { + return false; + } + + const auto binaryPath = findFileRecursive(extractDir, {"lame", "lame.exe"}); + if (!binaryPath.has_value()) { + if (detail != nullptr) { + *detail = "Unable to locate LAME binary in GHCR layer."; + } + return false; + } + + return copyBinaryToCache(*binaryPath, targetBinary, detail); +} + +std::filesystem::path internalCacheBinaryPath() { + const auto key = platformKey().empty() ? "unknown" : platformKey(); + const auto appData = + std::filesystem::path(juce::File::getSpecialLocation(juce::File::userApplicationDataDirectory).getFullPathName().toStdString()); + return appData / "AutoMixMaster" / "codecs" / "lame" / key / binaryName(); +} + +} // namespace + +std::filesystem::path LameDownloader::cacheBinaryPath() { return internalCacheBinaryPath(); } + +bool LameDownloader::isSupportedOnCurrentPlatform() { return !platformSources().empty(); } + +LameDownloader::DownloadResult LameDownloader::ensureAvailable(const bool forceDownload) { + static std::mutex mutex; + const std::lock_guard lock(mutex); + + DownloadResult result; + const auto targetBinary = cacheBinaryPath(); + if (!forceDownload && !flagEnabled("AUTOMIX_LAME_FORCE_DOWNLOAD") && isRegularFile(targetBinary)) { + std::string detail; + if (ensureExecutable(targetBinary, &detail)) { + result.success = true; + result.executablePath = targetBinary; + result.detail = "Using cached LAME binary at " + targetBinary.string(); + return result; + } + } + + if (!forceDownload && flagEnabled("AUTOMIX_LAME_SKIP_DOWNLOAD")) { + result.detail = "Skipped LAME download because AUTOMIX_LAME_SKIP_DOWNLOAD is enabled."; + return result; + } + + const auto sources = platformSources(); + if (sources.empty()) { + result.detail = "No fallback LAME downloader source configured for this platform."; + return result; + } + + std::vector failures; + result.attempted = true; + for (const auto& source : sources) { + std::string attemptDetail; + bool installed = false; + switch (source.type) { + case SourceType::DirectBinary: { + TempDirectory temp("automix_lame_direct"); + const auto downloadedPath = temp.path / binaryName(); + if (downloadToFile(source.url, downloadedPath, "", &attemptDetail)) { + installed = copyBinaryToCache(downloadedPath, targetBinary, &attemptDetail); + } + break; + } + case SourceType::Zip: + installed = installFromZip(source, targetBinary, &attemptDetail); + break; + case SourceType::Debian: + installed = installFromDebian(source, targetBinary, &attemptDetail); + break; + case SourceType::Ghcr: + installed = installFromGhcr(source, targetBinary, &attemptDetail); + break; + } + + if (installed) { + result.success = true; + result.executablePath = targetBinary; + result.detail = "Downloaded fallback LAME binary to " + targetBinary.string(); + return result; + } + + std::string sourceLabel; + switch (source.type) { + case SourceType::DirectBinary: + sourceLabel = "direct"; + break; + case SourceType::Zip: + sourceLabel = "zip"; + break; + case SourceType::Debian: + sourceLabel = "debian"; + break; + case SourceType::Ghcr: + sourceLabel = "ghcr"; + break; + } + + failures.push_back("[" + sourceLabel + "] " + (attemptDetail.empty() ? "download attempt failed" : attemptDetail)); + } + + result.detail = failures.empty() ? "LAME download failed." : joinLines(failures); + return result; +} + +} // namespace automix::util diff --git a/src/util/LameDownloader.h b/src/util/LameDownloader.h new file mode 100644 index 0000000..dae5314 --- /dev/null +++ b/src/util/LameDownloader.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +namespace automix::util { + +class LameDownloader { + public: + struct DownloadResult { + bool success = false; + bool attempted = false; + std::filesystem::path executablePath; + std::string detail; + }; + + static std::filesystem::path cacheBinaryPath(); + static bool isSupportedOnCurrentPlatform(); + static DownloadResult ensureAvailable(bool forceDownload = false); +}; + +} // namespace automix::util diff --git a/src/util/MetadataPolicy.cpp b/src/util/MetadataPolicy.cpp new file mode 100644 index 0000000..b628b7e --- /dev/null +++ b/src/util/MetadataPolicy.cpp @@ -0,0 +1,92 @@ +#include "util/MetadataPolicy.h" + +#include +#include +#include + +#include "util/StringUtils.h" + +namespace automix::util { +namespace { + +using ::automix::util::toLower; + +std::string normalizeKey(const std::string& key) { + std::string normalized; + normalized.reserve(key.size()); + for (const auto c : key) { + if (std::isalnum(static_cast(c)) != 0) { + normalized.push_back(static_cast(std::tolower(static_cast(c)))); + } + } + return normalized; +} + +bool isCommonMetadataKey(const std::string& key) { + static const std::set keys = { + "title", "track", "song", "tit2", "artist", "performer", "albumartist", "tpe1", + "album", "talb", "year", "date", "tyer", "tdrc", "tracknumber", "trck", + "genre", "tcon", "comment", "description", "comm", + }; + return keys.find(key) != keys.end(); +} + +} // namespace + +std::map applyMetadataPolicy( + const std::map& sourceMetadata, + const std::string& policy, + const std::map& metadataTemplate, + std::vector* notes) { + const auto normalizedPolicy = toLower(policy.empty() ? "copy_all" : policy); + + if (normalizedPolicy == "strip") { + if (notes != nullptr) { + notes->push_back("Metadata policy=strip: removing all metadata tags."); + } + return {}; + } + + if (normalizedPolicy == "copy_common" || normalizedPolicy == "copy_common_only") { + std::map filtered; + for (const auto& [key, value] : sourceMetadata) { + if (value.empty()) { + continue; + } + if (isCommonMetadataKey(normalizeKey(key))) { + filtered[key] = value; + } + } + if (notes != nullptr) { + notes->push_back("Metadata policy=copy_common: preserving common distribution tags only."); + } + return filtered; + } + + if (normalizedPolicy == "override_template") { + std::map merged; + for (const auto& [key, value] : sourceMetadata) { + if (!value.empty() && isCommonMetadataKey(normalizeKey(key))) { + merged[key] = value; + } + } + + for (const auto& [key, value] : metadataTemplate) { + if (!key.empty() && !value.empty()) { + merged[key] = value; + } + } + + if (notes != nullptr) { + notes->push_back("Metadata policy=override_template: merged common source tags with template overrides."); + } + return merged; + } + + if (notes != nullptr) { + notes->push_back("Metadata policy=copy_all: preserving all source metadata tags."); + } + return sourceMetadata; +} + +} // namespace automix::util diff --git a/src/util/MetadataPolicy.h b/src/util/MetadataPolicy.h new file mode 100644 index 0000000..db6e641 --- /dev/null +++ b/src/util/MetadataPolicy.h @@ -0,0 +1,15 @@ +#pragma once + +#include +#include +#include + +namespace automix::util { + +std::map applyMetadataPolicy( + const std::map& sourceMetadata, + const std::string& policy, + const std::map& metadataTemplate = {}, + std::vector* notes = nullptr); + +} // namespace automix::util diff --git a/src/util/MetadataSourceResolver.h b/src/util/MetadataSourceResolver.h new file mode 100644 index 0000000..a7e3982 --- /dev/null +++ b/src/util/MetadataSourceResolver.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include + +#include "domain/Session.h" + +namespace automix::util { + +inline std::optional metadataSourcePath(const domain::Session& session) { + if (session.originalMixPath.has_value()) { + const std::filesystem::path originalPath(session.originalMixPath.value()); + std::error_code error; + if (std::filesystem::is_regular_file(originalPath, error) && !error) { + return originalPath; + } + } + + for (const auto& stem : session.stems) { + if (!stem.enabled || stem.filePath.empty()) { + continue; + } + const std::filesystem::path stemPath(stem.filePath); + std::error_code error; + if (std::filesystem::is_regular_file(stemPath, error) && !error) { + return stemPath; + } + } + + for (const auto& stem : session.stems) { + if (stem.filePath.empty()) { + continue; + } + const std::filesystem::path stemPath(stem.filePath); + std::error_code error; + if (std::filesystem::is_regular_file(stemPath, error) && !error) { + return stemPath; + } + } + + return std::nullopt; +} + +} // namespace automix::util diff --git a/src/util/StringUtils.h b/src/util/StringUtils.h new file mode 100644 index 0000000..48da473 --- /dev/null +++ b/src/util/StringUtils.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +namespace automix::util { + +inline std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +inline std::string trim(std::string value) { + const auto first = std::find_if_not(value.begin(), value.end(), [](const unsigned char c) { + return std::isspace(c) != 0; + }); + const auto last = std::find_if_not(value.rbegin(), value.rend(), [](const unsigned char c) { + return std::isspace(c) != 0; + }).base(); + + if (first >= last) { + return ""; + } + + value = std::string(first, last); + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') { + value = value.substr(1, value.size() - 2); + } + return value; +} + +inline std::string extensionForFormat(const std::string& format) { + const auto normalized = toLower(format); + if (normalized == "wav") { + return ".wav"; + } + if (normalized == "flac") { + return ".flac"; + } + if (normalized == "aiff" || normalized == "aif") { + return ".aiff"; + } + if (normalized == "ogg" || normalized == "vorbis") { + return ".ogg"; + } + if (normalized == "mp3") { + return ".mp3"; + } + return ".wav"; +} + +} // namespace automix::util diff --git a/src/util/WavWriter.cpp b/src/util/WavWriter.cpp index dc1eaab..765569b 100644 --- a/src/util/WavWriter.cpp +++ b/src/util/WavWriter.cpp @@ -1,35 +1,469 @@ #include "util/WavWriter.h" #include +#include +#include +#include #include +#include +#include +#include #include +#include +#include +#include #include +#include + +#include "util/FileUtils.h" +#include "util/LameDownloader.h" +#include "util/StringUtils.h" namespace automix::util { +namespace { -void WavWriter::write(const std::filesystem::path& path, const engine::AudioBuffer& buffer, const int bitDepth) const { - juce::File outputFile(path.string()); - outputFile.deleteFile(); +using ::automix::util::toLower; +using ::automix::util::isRegularFile; - std::unique_ptr stream(outputFile.createOutputStream()); - if (stream == nullptr || !stream->openedOk()) { - throw std::runtime_error("Failed to open output WAV file: " + path.string()); +int qualityIndexFromRequested(const juce::StringArray& options, const int requestedQuality) { + if (options.isEmpty()) { + return 0; } + return std::clamp(requestedQuality, 0, options.size() - 1); +} - juce::WavAudioFormat format; - std::unique_ptr writer( - format.createWriterFor(stream.get(), - buffer.getSampleRate(), - static_cast(buffer.getNumChannels()), - bitDepth, - {}, - 0)); - if (writer == nullptr) { - throw std::runtime_error("Failed to create WAV writer for: " + path.string()); +std::string normalizeMetadataKey(std::string key) { + std::string normalized; + normalized.reserve(key.size()); + for (const unsigned char c : key) { + if (std::isalnum(c) != 0) { + normalized.push_back(static_cast(std::tolower(c))); + } } - stream.release(); + return normalized; +} + +std::map buildNormalizedMetadataLookup( + const std::map& sourceMetadata) { + std::map normalized; + for (const auto& [key, value] : sourceMetadata) { + if (value.empty()) { + continue; + } + const auto normalizedKey = normalizeMetadataKey(key); + if (normalizedKey.empty()) { + continue; + } + if (normalized.find(normalizedKey) == normalized.end()) { + normalized[normalizedKey] = value; + } + } + return normalized; +} + +std::string findFirstMetadataValue(const std::map& normalizedMetadata, + std::initializer_list candidateKeys) { + for (const auto* candidate : candidateKeys) { + const auto it = normalizedMetadata.find(candidate); + if (it != normalizedMetadata.end() && !it->second.empty()) { + return it->second; + } + } + return ""; +} + +juce::StringPairArray buildWriterMetadata(const int bitrate, const std::map& sourceMetadata) { + juce::StringPairArray metadata; + metadata.set("bitrate", juce::String(bitrate)); + + for (const auto& [key, value] : sourceMetadata) { + if (!key.empty() && !value.empty()) { + metadata.set(juce::String(key), juce::String(value)); + } + } + + const auto normalized = buildNormalizedMetadataLookup(sourceMetadata); + const auto title = findFirstMetadataValue(normalized, {"title", "track", "song", "tit2"}); + const auto artist = findFirstMetadataValue(normalized, {"artist", "performer", "albumartist", "tpe1"}); + const auto album = findFirstMetadataValue(normalized, {"album", "talb"}); + const auto genre = findFirstMetadataValue(normalized, {"genre", "tcon"}); + const auto year = findFirstMetadataValue(normalized, {"year", "date", "tyer", "tdrc"}); + const auto track = findFirstMetadataValue(normalized, {"track", "tracknumber", "trck"}); + const auto comment = findFirstMetadataValue(normalized, {"comment", "description", "comm"}); + + if (!title.empty()) { + metadata.set("title", juce::String(title)); + } + if (!artist.empty()) { + metadata.set("artist", juce::String(artist)); + } + if (!album.empty()) { + metadata.set("album", juce::String(album)); + } + if (!genre.empty()) { + metadata.set("genre", juce::String(genre)); + } + if (!year.empty()) { + metadata.set("year", juce::String(year)); + metadata.set("date", juce::String(year)); + } + if (!track.empty()) { + metadata.set("track", juce::String(track)); + } + if (!comment.empty()) { + metadata.set("comment", juce::String(comment)); + } + + return metadata; +} + +std::vector lameExecutableNames() { +#if defined(_WIN32) + return {"lame.exe", "lame"}; +#else + return {"lame"}; +#endif +} + +std::string trimPathToken(std::string token) { + const auto first = std::find_if_not(token.begin(), token.end(), [](const unsigned char c) { + return std::isspace(c) != 0; + }); + const auto last = std::find_if_not(token.rbegin(), token.rend(), [](const unsigned char c) { + return std::isspace(c) != 0; + }).base(); + + if (first >= last) { + return ""; + } + + token = std::string(first, last); + if (token.size() >= 2 && token.front() == '"' && token.back() == '"') { + token = token.substr(1, token.size() - 2); + } + return token; +} + +void appendAncestorPaths(const std::filesystem::path& seed, + std::vector& roots, + std::unordered_set& seen) { + if (seed.empty()) { + return; + } + + std::error_code error; + auto current = std::filesystem::absolute(seed, error); + if (error) { + return; + } + + for (int depth = 0; depth < 8; ++depth) { + const auto key = toLower(current.lexically_normal().string()); + if (seen.insert(key).second) { + roots.push_back(current); + } + + if (!current.has_parent_path()) { + break; + } + const auto parent = current.parent_path(); + if (parent == current) { + break; + } + current = parent; + } +} + +std::vector ancestorPaths() { + std::vector roots; + std::unordered_set seen; + roots.reserve(24); + + std::error_code error; + appendAncestorPaths(std::filesystem::current_path(error), roots, seen); + + const juce::File executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + if (executable.existsAsFile()) { + const auto executableDir = std::filesystem::path(executable.getParentDirectory().getFullPathName().toStdString()); + appendAncestorPaths(executableDir, roots, seen); + appendAncestorPaths(executableDir / ".." / "Resources", roots, seen); + } + + return roots; +} + +std::optional findLameInDirectory(const std::filesystem::path& directory) { + if (directory.empty()) { + return std::nullopt; + } + + for (const auto& name : lameExecutableNames()) { + const auto candidate = directory / name; + if (isRegularFile(candidate)) { + return candidate; + } + } + return std::nullopt; +} + +std::optional resolveBundledLameExecutable() { +#if defined(ENABLE_BUNDLED_LAME) && ENABLE_BUNDLED_LAME + const auto names = lameExecutableNames(); +#if defined(_WIN32) + const std::vector dirs = { + "assets/lame", + "assets/lame/bin", + "assets/lame/windows", + "assets/codecs/lame", + "third_party/lame/bin", + }; +#elif defined(__APPLE__) + const std::vector dirs = { + "assets/lame", + "assets/lame/bin", + "assets/lame/mac", + "assets/codecs/lame", + "third_party/lame/bin", + }; +#else + const std::vector dirs = { + "assets/lame", + "assets/lame/bin", + "assets/lame/linux", + "assets/codecs/lame", + "third_party/lame/bin", + }; +#endif + + for (const auto& root : ancestorPaths()) { + for (const auto& dir : dirs) { + for (const auto& name : names) { + const auto candidate = root / dir / name; + if (isRegularFile(candidate)) { + return candidate; + } + } + } + } +#endif + + return std::nullopt; +} + +std::optional findLameExecutable() { + if (const auto downloaded = LameDownloader::cacheBinaryPath(); isRegularFile(downloaded)) { + return downloaded; + } + + if (const auto bundled = resolveBundledLameExecutable(); bundled.has_value()) { + return bundled; + } + + const juce::File executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + if (executable.existsAsFile()) { + const auto executableDir = std::filesystem::path(executable.getParentDirectory().getFullPathName().toStdString()); + if (const auto besideExecutable = findLameInDirectory(executableDir); besideExecutable.has_value()) { + return besideExecutable; + } + if (const auto inExecutableBin = findLameInDirectory(executableDir / "bin"); inExecutableBin.has_value()) { + return inExecutableBin; + } + if (const auto inExecutableLame = findLameInDirectory(executableDir / "lame"); inExecutableLame.has_value()) { + return inExecutableLame; + } + } + + if (const char* env = std::getenv("LAME_BIN"); env != nullptr && *env != '\0') { + const std::filesystem::path candidate(trimPathToken(env)); + if (isRegularFile(candidate)) { + return candidate; + } + } + + const char* rawPath = std::getenv("PATH"); + if (rawPath == nullptr || *rawPath == '\0') { + return std::nullopt; + } + +#if defined(_WIN32) + constexpr char delimiter = ';'; +#else + constexpr char delimiter = ':'; +#endif + const auto names = lameExecutableNames(); + + std::stringstream stream(rawPath); + std::string token; + while (std::getline(stream, token, delimiter)) { + const auto entry = trimPathToken(token); + if (entry.empty()) { + continue; + } + for (const auto& name : names) { + const auto candidate = std::filesystem::path(entry) / name; + if (isRegularFile(candidate)) { + return candidate; + } + } + } + + return std::nullopt; +} + +std::string extensionFormat(const std::filesystem::path& path) { + const auto ext = toLower(path.extension().string()); + if (ext == ".wav" || ext == ".wave") { + return "wav"; + } + if (ext == ".aif" || ext == ".aiff") { + return "aiff"; + } + if (ext == ".flac") { + return "flac"; + } + if (ext == ".ogg" || ext == ".vorbis") { + return "ogg"; + } + if (ext == ".mp3") { + return "mp3"; + } + return ""; +} +bool formatExistsInManager(const juce::AudioFormatManager& manager, const std::string& extension) { + for (int i = 0; i < manager.getNumKnownFormats(); ++i) { + if (const auto* format = manager.getKnownFormat(i); format != nullptr) { + const auto exts = format->getFileExtensions(); + for (const auto& ext : exts) { + if (toLower(ext.toStdString()) == toLower(extension)) { + return true; + } + } + } + } + return false; +} + +std::unique_ptr createWriterForFormat(const std::string& format, + juce::OutputStream* stream, + const double sampleRate, + const int channels, + const int outputBitDepth, + const int bitrate, + const int quality, + const std::map& sourceMetadata, + std::string* detail) { + const auto metadata = buildWriterMetadata(bitrate, sourceMetadata); + + std::unique_ptr writer; + const auto normalized = toLower(format); + + if (normalized == "wav") { + juce::WavAudioFormat wav; + writer.reset(wav.createWriterFor(stream, + sampleRate, + static_cast(channels), + outputBitDepth, + metadata, + 0)); + if (writer == nullptr && detail != nullptr) { + *detail = "WAV writer creation failed."; + } + return writer; + } + + if (normalized == "aiff") { + juce::AiffAudioFormat aiff; + writer.reset(aiff.createWriterFor(stream, + sampleRate, + static_cast(channels), + outputBitDepth, + metadata, + 0)); + if (writer == nullptr && detail != nullptr) { + *detail = "AIFF writer creation failed."; + } + return writer; + } + + if (normalized == "flac") { + juce::FlacAudioFormat flac; + writer.reset(flac.createWriterFor(stream, + sampleRate, + static_cast(channels), + std::clamp(outputBitDepth, 16, 24), + metadata, + 0)); + if (writer == nullptr && detail != nullptr) { + *detail = "FLAC writer creation failed."; + } + return writer; + } + + if (normalized == "ogg") { +#if defined(JUCE_USE_OGGVORBIS) && JUCE_USE_OGGVORBIS + juce::OggVorbisAudioFormat ogg; + writer.reset(ogg.createWriterFor(stream, + sampleRate, + static_cast(channels), + 0, + metadata, + qualityIndexFromRequested(ogg.getQualityOptions(), quality))); + if (writer == nullptr && detail != nullptr) { + *detail = "OGG writer creation failed."; + } +#else + if (detail != nullptr) { + *detail = "JUCE OGG/Vorbis codec support is disabled in this build."; + } +#endif + return writer; + } + + if (normalized == "mp3") { +#if defined(JUCE_USE_MP3AUDIOFORMAT) && JUCE_USE_MP3AUDIOFORMAT + juce::MP3AudioFormat mp3; + writer.reset(mp3.createWriterFor(stream, + sampleRate, + static_cast(channels), + 0, + metadata, + qualityIndexFromRequested(mp3.getQualityOptions(), quality))); + + if (writer == nullptr) { +#if defined(JUCE_USE_LAME_AUDIO_FORMAT) && JUCE_USE_LAME_AUDIO_FORMAT + const auto lamePath = findLameExecutable(); + if (lamePath.has_value()) { + juce::LAMEEncoderAudioFormat lame(mp3, juce::File(lamePath->string())); + writer.reset(lame.createWriterFor(stream, + sampleRate, + static_cast(channels), + outputBitDepth, + metadata, + qualityIndexFromRequested(lame.getQualityOptions(), quality))); + } +#endif + } + + if (writer == nullptr && detail != nullptr) { + *detail = "MP3 writer unavailable (JUCE MP3 codec or LAME fallback not found)."; + } +#else + if (detail != nullptr) { + *detail = "JUCE MP3 codec support is disabled in this build."; + } +#endif + return writer; + } + + if (detail != nullptr) { + *detail = "Unsupported format '" + format + "'."; + } + return writer; +} + +void writeAudioBufferToWriter(juce::AudioFormatWriter& writer, const engine::AudioBuffer& buffer) { juce::AudioBuffer juceBuffer(buffer.getNumChannels(), buffer.getNumSamples()); for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { float* dst = juceBuffer.getWritePointer(ch); @@ -37,7 +471,305 @@ void WavWriter::write(const std::filesystem::path& path, const engine::AudioBuff std::copy(src, src + buffer.getNumSamples(), dst); } - writer->writeFromAudioSampleBuffer(juceBuffer, 0, juceBuffer.getNumSamples()); + writer.writeFromAudioSampleBuffer(juceBuffer, 0, juceBuffer.getNumSamples()); +} + +int lameQualityPreset(const int quality) { + const int clamped = std::clamp(quality, 0, 10); + return std::clamp(9 - clamped, 0, 9); +} + +bool encodeMp3WithExternalLame(const std::filesystem::path& lamePath, + const std::filesystem::path& inputWavPath, + const std::filesystem::path& outputMp3Path, + const int bitrateKbps, + const int quality, + const bool useVbr, + const int vbrQuality, + const std::map& sourceMetadata, + std::string* detail) { + const auto normalizedMetadata = buildNormalizedMetadataLookup(sourceMetadata); + + juce::StringArray command; + command.add(lamePath.string()); + command.add("--silent"); + if (useVbr) { + command.add("-V"); + command.add(std::to_string(std::clamp(vbrQuality, 0, 9))); + } else { + command.add("-b"); + command.add(std::to_string(std::clamp(bitrateKbps, 48, 320))); + } + command.add("-q"); + command.add(std::to_string(lameQualityPreset(quality))); + + const auto title = findFirstMetadataValue(normalizedMetadata, {"title", "track", "song", "tit2"}); + const auto artist = findFirstMetadataValue(normalizedMetadata, {"artist", "performer", "albumartist", "tpe1"}); + const auto album = findFirstMetadataValue(normalizedMetadata, {"album", "talb"}); + const auto year = findFirstMetadataValue(normalizedMetadata, {"year", "date", "tyer", "tdrc"}); + const auto track = findFirstMetadataValue(normalizedMetadata, {"track", "tracknumber", "trck"}); + const auto genre = findFirstMetadataValue(normalizedMetadata, {"genre", "tcon"}); + const auto comment = findFirstMetadataValue(normalizedMetadata, {"comment", "description", "comm"}); + + if (!title.empty()) { + command.add("--tt"); + command.add(title); + } + if (!artist.empty()) { + command.add("--ta"); + command.add(artist); + } + if (!album.empty()) { + command.add("--tl"); + command.add(album); + } + if (!year.empty()) { + command.add("--ty"); + command.add(year); + } + if (!track.empty()) { + command.add("--tn"); + command.add(track); + } + if (!genre.empty()) { + command.add("--tg"); + command.add(genre); + } + if (!comment.empty()) { + command.add("--tc"); + command.add(comment); + } + + command.add(inputWavPath.string()); + command.add(outputMp3Path.string()); + + juce::ChildProcess process; + if (!process.start(command)) { + if (detail != nullptr) { + *detail = "Failed to start external LAME process."; + } + return false; + } + + if (!process.waitForProcessToFinish(120000)) { + process.kill(); + if (detail != nullptr) { + *detail = "External LAME process timed out."; + } + return false; + } + + const int exitCode = process.getExitCode(); + const auto output = process.readAllProcessOutput().toStdString(); + std::error_code error; + const bool hasOutput = std::filesystem::is_regular_file(outputMp3Path, error) && !error; + if (exitCode != 0 || !hasOutput) { + if (detail != nullptr) { + std::ostringstream os; + os << "External LAME failed (exit=" << exitCode << ")"; + if (!output.empty()) { + os << ": " << output; + } + *detail = os.str(); + } + return false; + } + + if (detail != nullptr) { + *detail = "External LAME encode succeeded."; + } + return true; +} + +} // namespace + +std::string WavWriter::resolveFormat(const std::filesystem::path& path, const std::string& preferredFormat) { + const auto preferred = toLower(preferredFormat); + if (!preferred.empty() && preferred != "auto") { + if (preferred == "aif") { + return "aiff"; + } + if (preferred == "vorbis") { + return "ogg"; + } + return preferred; + } + + const auto fromExtension = extensionFormat(path); + return fromExtension.empty() ? "wav" : fromExtension; +} + +bool WavWriter::isLossyFormat(const std::string& format) { + const auto normalized = toLower(format); + return normalized == "mp3" || normalized == "ogg"; +} + +std::vector WavWriter::getAvailableFormats() { + juce::AudioFormatManager manager; + manager.registerBasicFormats(); + + const std::vector> formatDescriptors = { + {"wav", ".wav"}, + {"aiff", ".aiff"}, + {"flac", ".flac"}, + {"ogg", ".ogg"}, + {"mp3", ".mp3"}, + }; + + std::vector availability; + availability.reserve(formatDescriptors.size()); + + for (const auto& [format, extension] : formatDescriptors) { + auto stream = std::make_unique(); + std::string detail; + auto writer = createWriterForFormat(format, stream.get(), 44100.0, 2, 24, 192, 7, {}, &detail); + const bool knownByManager = formatExistsInManager(manager, extension); + + bool available = writer != nullptr; + if (!available && format == "mp3") { + if (const auto lamePath = findLameExecutable(); lamePath.has_value()) { + available = true; + detail = "MP3 available via external LAME binary: " + lamePath->string(); + } else if (LameDownloader::isSupportedOnCurrentPlatform()) { + available = true; + detail = "MP3 available via on-demand LAME downloader (attempted during export)."; + } + } + + if (available) { + if (writer != nullptr) { + stream.release(); + detail = knownByManager ? "Writer and AudioFormatManager support are available." + : "Writer available; not listed by AudioFormatManager::getKnownFormats()."; + } else if (detail.empty()) { + detail = "Format available through external encoder."; + } + } else if (detail.empty()) { + detail = knownByManager ? "Format known by AudioFormatManager but writer creation failed." + : "Format not available in this build."; + } + if (!available && format == "mp3") { + const std::string guidance = + "Set LAME_BIN or add 'lame' to PATH. Automatic downloader uses AUTOMIX_LAME_DOWNLOAD_URL/AUTOMIX_LAME_VERSION."; + detail = detail.empty() ? guidance : (detail + " " + guidance); + } + + availability.push_back(FormatAvailability{ + .format = format, + .available = available, + .detail = detail, + }); + } + + return availability; +} + +bool WavWriter::isFormatAvailable(const std::string& format) { + const auto normalized = toLower(format); + const auto availability = getAvailableFormats(); + const auto it = std::find_if(availability.begin(), availability.end(), [&](const FormatAvailability& entry) { + return entry.format == normalized; + }); + return it != availability.end() && it->available; +} + +void WavWriter::write(const std::filesystem::path& path, + const engine::AudioBuffer& buffer, + const int bitDepth, + const std::string& preferredFormat, + const int lossyBitrateKbps, + const int lossyQuality, + const bool mp3UseVbr, + const int mp3VbrQuality, + const std::map& sourceMetadata) const { + const auto format = resolveFormat(path, preferredFormat); + const auto normalizedFormat = toLower(format); + const int outputBitDepth = std::clamp(bitDepth, 16, 32); + const int bitrate = std::clamp(lossyBitrateKbps, 48, 512); + const int quality = std::clamp(lossyQuality, 0, 10); + + juce::File outputFile(path.string()); + outputFile.deleteFile(); + + std::unique_ptr stream(outputFile.createOutputStream()); + if (stream == nullptr || !stream->openedOk()) { + throw std::runtime_error("Failed to open output audio file: " + path.string()); + } + + std::string detail; + std::unique_ptr writer; + if (!(normalizedFormat == "mp3" && mp3UseVbr)) { + writer = createWriterForFormat(format, + stream.get(), + buffer.getSampleRate(), + buffer.getNumChannels(), + outputBitDepth, + bitrate, + quality, + sourceMetadata, + &detail); + } else { + detail = "MP3 VBR selected: using external LAME encoder path."; + } + + if (writer == nullptr && normalizedFormat == "mp3") { + stream.reset(); + outputFile.deleteFile(); + auto lamePath = findLameExecutable(); + if (!lamePath.has_value()) { + const auto download = LameDownloader::ensureAvailable(); + if (download.success && !download.executablePath.empty()) { + lamePath = download.executablePath; + } else if (!download.detail.empty()) { + detail = detail.empty() ? download.detail : (detail + " " + download.detail); + } + } + if (lamePath.has_value()) { + const auto nonce = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count()); + const auto tempWavPath = path.parent_path() / (path.stem().string() + "_lame_input_" + nonce + ".wav"); + std::string lameDetail; + std::error_code error; + + try { + write(tempWavPath, buffer, outputBitDepth, "wav", bitrate, quality); + if (encodeMp3WithExternalLame(*lamePath, + tempWavPath, + path, + bitrate, + quality, + mp3UseVbr, + mp3VbrQuality, + sourceMetadata, + &lameDetail)) { + std::filesystem::remove(tempWavPath, error); + return; + } + } catch (const std::exception& errorException) { + lameDetail = errorException.what(); + } + + std::filesystem::remove(tempWavPath, error); + if (!lameDetail.empty()) { + detail = detail.empty() ? lameDetail : detail + " " + lameDetail; + } + } + } + + if (writer == nullptr) { + const auto availability = getAvailableFormats(); + std::ostringstream os; + os << "Failed to create audio writer (format=" << format << ") for: " << path.string(); + if (!detail.empty()) { + os << " [" << detail << "]"; + } + os << " Available formats:"; + for (const auto& entry : availability) { + os << ' ' << entry.format << '=' << (entry.available ? "yes" : "no"); + } + throw std::runtime_error(os.str()); + } + stream.release(); + writeAudioBufferToWriter(*writer, buffer); } } // namespace automix::util diff --git a/src/util/WavWriter.h b/src/util/WavWriter.h index 3e24acb..87ccca8 100644 --- a/src/util/WavWriter.h +++ b/src/util/WavWriter.h @@ -1,6 +1,9 @@ #pragma once #include +#include +#include +#include #include "engine/AudioBuffer.h" @@ -8,9 +11,26 @@ namespace automix::util { class WavWriter { public: + struct FormatAvailability { + std::string format; + bool available = false; + std::string detail; + }; + void write(const std::filesystem::path& path, const engine::AudioBuffer& buffer, - int bitDepth) const; + int bitDepth, + const std::string& preferredFormat = "auto", + int lossyBitrateKbps = 320, + int lossyQuality = 7, + bool mp3UseVbr = false, + int mp3VbrQuality = 4, + const std::map& sourceMetadata = {}) const; + + static std::string resolveFormat(const std::filesystem::path& path, const std::string& preferredFormat); + static bool isLossyFormat(const std::string& format); + static std::vector getAvailableFormats(); + static bool isFormatAvailable(const std::string& format); }; } // namespace automix::util diff --git a/tests/python/test_batch_studio_api.py b/tests/python/test_batch_studio_api.py new file mode 100644 index 0000000..e210708 --- /dev/null +++ b/tests/python/test_batch_studio_api.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Unit tests for batch_studio_api path-containment helpers.""" + +from __future__ import annotations + +import importlib.util +import pathlib +import tempfile +import unittest + + +def _load_module(): + repo_root = pathlib.Path(__file__).resolve().parents[2] + module_path = repo_root / "tools" / "batch_studio_api.py" + spec = importlib.util.spec_from_file_location("batch_studio_api", module_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Unable to load module: {module_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class BatchStudioApiPathContainmentTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.module = _load_module() + + def test_allows_root_and_descendants(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = pathlib.Path(temp_dir) / "allowed" + nested = root / "nested" / "folder" + nested.mkdir(parents=True) + + self.assertTrue(self.module._is_path_contained(str(root), root)) + self.assertTrue(self.module._is_path_contained(str(nested), root)) + + def test_rejects_path_escape_via_parent_traversal(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + workspace = pathlib.Path(temp_dir) + root = workspace / "allowed" + outside = workspace / "outside" + root.mkdir(parents=True) + outside.mkdir(parents=True) + + escaped_path = root / ".." / "outside" + self.assertFalse(self.module._is_path_contained(str(escaped_path), root)) + + def test_allows_nonexistent_child_path_inside_root(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = pathlib.Path(temp_dir) / "allowed" + root.mkdir(parents=True) + future_path = root / "future" / "file.json" + + self.assertTrue(self.module._is_path_contained(str(future_path), root)) + + def test_rejects_invalid_path_input(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = pathlib.Path(temp_dir) / "allowed" + root.mkdir(parents=True) + + self.assertFalse(self.module._is_path_contained("bad\0path", root)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/regression/RegressionHarness.cpp b/tests/regression/RegressionHarness.cpp index 784d788..e37b81c 100644 --- a/tests/regression/RegressionHarness.cpp +++ b/tests/regression/RegressionHarness.cpp @@ -130,6 +130,7 @@ RenderMetrics metricsFromReport(const std::filesystem::path& reportPath) { RenderMetrics metrics; metrics.integratedLufs = reportJson.value("integratedLufs", -120.0); metrics.truePeakDbtp = reportJson.value("truePeakDbtp", 0.0); + metrics.monoCorrelation = reportJson.value("monoCorrelation", 1.0); metrics.spectrumLow = reportJson.value("spectrumLow", 0.0); metrics.spectrumMid = reportJson.value("spectrumMid", 0.0); metrics.spectrumHigh = reportJson.value("spectrumHigh", 0.0); @@ -144,6 +145,9 @@ double defaultToleranceFor(const std::string& metricName) { if (metricName == "truePeakDbtp") { return 0.35; } + if (metricName == "monoCorrelation") { + return 0.15; + } if (metricName == "stereoCorrelation") { return 0.20; } @@ -181,6 +185,9 @@ double metricValue(const RenderMetrics& metrics, const std::string& metricName) if (metricName == "truePeakDbtp") { return metrics.truePeakDbtp; } + if (metricName == "monoCorrelation") { + return metrics.monoCorrelation; + } if (metricName == "spectrumLow") { return metrics.spectrumLow; } @@ -250,9 +257,10 @@ void comparePipelineMetrics(const nlohmann::json& fixture, const PipelineMetrics& actual, std::vector* failures) { const auto& expectedMetrics = fixture.at("pipelines").at(actual.pipelineName); - constexpr std::array metricNames = { + constexpr std::array metricNames = { "integratedLufs", "truePeakDbtp", + "monoCorrelation", "spectrumLow", "spectrumMid", "spectrumHigh", @@ -261,6 +269,9 @@ void comparePipelineMetrics(const nlohmann::json& fixture, for (const auto* metric : metricNames) { const std::string metricName(metric); + if (!expectedMetrics.contains(metricName)) { + continue; + } const double expected = expectedMetrics.at(metricName).get(); const double observed = metricValue(actual.metrics, metricName); const double tolerance = toleranceForMetric(fixture, actual.pipelineName, metricName); diff --git a/tests/regression/RegressionHarness.h b/tests/regression/RegressionHarness.h index 7bf4942..cd1f889 100644 --- a/tests/regression/RegressionHarness.h +++ b/tests/regression/RegressionHarness.h @@ -9,6 +9,7 @@ namespace automix::regression { struct RenderMetrics { double integratedLufs = -120.0; double truePeakDbtp = 0.0; + double monoCorrelation = 1.0; double spectrumLow = 0.0; double spectrumMid = 0.0; double spectrumHigh = 0.0; diff --git a/tests/regression/baselines.json b/tests/regression/baselines.json index 31b6888..632ae09 100644 --- a/tests/regression/baselines.json +++ b/tests/regression/baselines.json @@ -4,29 +4,32 @@ "name": "synthetic_dual_tone", "pipelines": { "heuristic": { - "integratedLufs": -14.0, - "truePeakDbtp": -3.96, - "spectrumLow": 0.55, - "spectrumMid": 0.25, + "integratedLufs": -14.23, + "truePeakDbtp": -7.69, + "monoCorrelation": 1.0, + "spectrumLow": 0.00037, + "spectrumMid": 0.99962, "spectrumHigh": 0.20, "stereoCorrelation": 1.0 }, "ai": { "integratedLufs": -13.51, - "truePeakDbtp": -5.57, - "spectrumLow": 0.55, - "spectrumMid": 0.25, + "truePeakDbtp": -9.69, + "monoCorrelation": 1.0, + "spectrumLow": 0.00068, + "spectrumMid": 0.99930, "spectrumHigh": 0.20, "stereoCorrelation": 1.0 } }, "tolerances": { - "integratedLufs": 1.5, - "truePeakDbtp": 0.35, - "spectrumLow": 0.35, - "spectrumMid": 0.35, - "spectrumHigh": 0.35, - "stereoCorrelation": 0.20 + "integratedLufs": 1.0, + "truePeakDbtp": 2.5, + "monoCorrelation": 0.20, + "spectrumLow": 0.15, + "spectrumMid": 0.15, + "spectrumHigh": 0.50, + "stereoCorrelation": 0.25 }, "overrides": { "ai": { diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index f0b115d..d60d1fc 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -5,6 +5,7 @@ #include #include "ai/IModelInference.h" +#include "ai/FeatureSchema.h" #include "ai/ModelManager.h" #include "ai/ModelPackLoader.h" #include "ai/ModelStrategy.h" @@ -52,7 +53,21 @@ TEST_CASE("Model pack loader parses schema and defaults", "[ai]") { { std::ofstream meta(tempDir / "model.json"); - meta << R"({"schema_version":1,"id":"mix-v1","name":"Mix V1","type":"mix_parameters","engine":"onnxruntime","version":"1.0.0","model_file":"model.onnx"})"; + meta << R"({ + "schema_version": 1, + "id": "mix-v1", + "name": "Mix V1", + "type": "mix_parameters", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "MIT", + "source": "unit-test", + "feature_schema_version": "1.0.0", + "output_schema": { + "target_lufs": "float" + } +})"; } const auto pack = loader.load(tempDir); @@ -75,19 +90,48 @@ TEST_CASE("Model manager scans packs and stores active selections", "[ai]") { std::ofstream model(roleDir / "model.onnx", std::ios::binary); model << "role"; std::ofstream meta(roleDir / "model.json"); - meta << R"({"id":"role-classifier-v1","type":"role_classifier","model_file":"model.onnx"})"; + meta << R"({ + "id": "role-classifier-v1", + "type": "role_classifier", + "model_file": "model.onnx", + "license": "MIT", + "source": "unit-test", + "feature_schema_version": "1.0.0", + "output_schema": { + "prob_vocals": "float" + } +})"; } { std::ofstream model(mixDir / "model.onnx", std::ios::binary); model << "mix"; std::ofstream meta(mixDir / "model.json"); - meta << R"({"id":"mix-params-v1","type":"mix_parameters","model_file":"model.onnx"})"; + meta << R"({ + "id": "mix-params-v1", + "type": "mix_parameters", + "model_file": "model.onnx", + "license": "MIT", + "source": "unit-test", + "feature_schema_version": "1.0.0", + "output_schema": { + "target_lufs": "float" + } +})"; } automix::ai::ModelManager manager(root); const auto packs = manager.scan(); - REQUIRE(packs.size() == 2); - REQUIRE(manager.packsForType("role_classifier").size() == 1); + REQUIRE(packs.size() >= 2); + bool foundRole = false; + bool foundMix = false; + for (const auto& pack : packs) { + foundRole = foundRole || pack.id == "role-classifier-v1"; + foundMix = foundMix || pack.id == "mix-params-v1"; + } + REQUIRE(foundRole); + REQUIRE(foundMix); + const auto rolePacks = manager.packsForType("role_classifier"); + REQUIRE(rolePacks.empty() == false); manager.setActivePackId("role", "role-classifier-v1"); REQUIRE(manager.activePackId("role") == "role-classifier-v1"); @@ -95,6 +139,31 @@ TEST_CASE("Model manager scans packs and stores active selections", "[ai]") { std::filesystem::remove_all(root); } +TEST_CASE("Model pack loader rejects packs missing licensing metadata", "[ai]") { + const auto root = std::filesystem::temp_directory_path() / "automix_model_pack_invalid_meta"; + std::filesystem::remove_all(root); + std::filesystem::create_directories(root); + + { + std::ofstream model(root / "model.onnx", std::ios::binary); + model << "dummy"; + std::ofstream meta(root / "model.json"); + meta << R"({ + "id": "invalid-meta-pack", + "type": "mix_parameters", + "engine": "onnxruntime", + "model_file": "model.onnx", + "feature_schema_version": "1.0.0" +})"; + } + + automix::ai::ModelPackLoader loader; + const auto pack = loader.load(root); + REQUIRE_FALSE(pack.has_value()); + + std::filesystem::remove_all(root); +} + TEST_CASE("Null model inference returns clear run log", "[ai]") { automix::ai::NullModelInference inference; const auto loaded = inference.loadModel("unused.onnx"); @@ -127,3 +196,59 @@ TEST_CASE("Model strategy applies overrides when model inference is available", REQUIRE(mixOut.dryWet == Catch::Approx(0.77)); REQUIRE(masterOut.targetLufs == Catch::Approx(-12.5)); } + +TEST_CASE("Feature schema exposes rich feature vector for AI plans", "[ai]") { + REQUIRE(automix::ai::FeatureSchemaV1::featureCount() >= 20); +} + +TEST_CASE("Feature schema version compatibility uses semantic versioning", "[ai]") { + // Exact version match should be compatible + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.0")); + + // Patch version updates should be compatible (backward compatible) + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.1")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.2")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0.99")); + + // Minor version updates should be compatible (backward compatible) + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.1.0")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.2.0")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.99.0")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.1.5")); + + // Different major version should be incompatible (breaking changes) + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("0.9.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("2.0.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("2.1.0")); + + // Invalid or malformed versions should be incompatible + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("invalid")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1.x.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("a.b.c")); + + // Partial versions (missing components) should be rejected per strict semver + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1.0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1.1")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("0")); + REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("2")); +} + +TEST_CASE("Model manager scans demo packs from assets roots", "[ai]") { + automix::ai::ModelManager manager("missing_root_for_test"); + const auto packs = manager.scan(); + + bool foundDemoRole = false; + bool foundDemoMix = false; + bool foundDemoMaster = false; + for (const auto& pack : packs) { + foundDemoRole = foundDemoRole || pack.id == "demo-role-v1"; + foundDemoMix = foundDemoMix || pack.id == "demo-mix-v1"; + foundDemoMaster = foundDemoMaster || pack.id == "demo-master-v1"; + } + + REQUIRE(foundDemoRole); + REQUIRE(foundDemoMix); + REQUIRE(foundDemoMaster); +} diff --git a/tests/unit/AnalysisTests.cpp b/tests/unit/AnalysisTests.cpp index e00680b..df1fc34 100644 --- a/tests/unit/AnalysisTests.cpp +++ b/tests/unit/AnalysisTests.cpp @@ -1,4 +1,6 @@ #include +#include +#include #include #include @@ -17,6 +19,19 @@ automix::engine::AudioBuffer makeSine(const double freq, const double amplitude, return buffer; } +automix::engine::AudioBuffer makeNoise(const double sampleRate, const int samples, const float amplitude) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + uint32_t state = 0xA5B3571Du; + for (int i = 0; i < samples; ++i) { + state = state * 1664525u + 1013904223u; + const float normalized = static_cast((state >> 9) & 0x7FFFFF) / static_cast(0x7FFFFF); + const float value = (normalized * 2.0f - 1.0f) * amplitude; + buffer.setSample(0, i, value); + buffer.setSample(1, i, value); + } + return buffer; +} + } // namespace TEST_CASE("Analysis computes expected peak and RMS for sine", "[analysis]") { @@ -28,6 +43,8 @@ TEST_CASE("Analysis computes expected peak and RMS for sine", "[analysis]") { REQUIRE(metrics.peakDb == Catch::Approx(-6.02).margin(0.5)); REQUIRE(metrics.rmsDb == Catch::Approx(-9.03).margin(0.7)); REQUIRE(metrics.crestDb == Catch::Approx(3.0).margin(0.7)); + REQUIRE(metrics.spectralCentroidHz == Catch::Approx(440.0).margin(180.0)); + REQUIRE(metrics.spectralFlatness < 0.4); } TEST_CASE("Analysis silence detection catches mostly silent buffers", "[analysis]") { @@ -41,6 +58,8 @@ TEST_CASE("Analysis silence detection catches mostly silent buffers", "[analysis const auto metrics = analyzer.analyzeBuffer(buffer); REQUIRE(metrics.silenceRatio > 0.95); + REQUIRE(std::isfinite(metrics.spectralFlux)); + REQUIRE(metrics.spectralFlux >= 0.0); } TEST_CASE("Analysis stereo width distinguishes decorrelated channels", "[analysis]") { @@ -57,4 +76,36 @@ TEST_CASE("Analysis stereo width distinguishes decorrelated channels", "[analysi REQUIRE(metrics.stereoCorrelation < -0.8); REQUIRE(metrics.stereoWidth > 0.9); + REQUIRE(metrics.artifactProfile.phaseInstability >= 0.0); + REQUIRE(metrics.artifactProfile.phaseInstability <= 1.0); +} + +TEST_CASE("Analysis spectral flatness is higher for noise than tone", "[analysis]") { + automix::analysis::StemAnalyzer analyzer; + + const auto tone = makeSine(700.0, 0.35, 44100.0, 44100); + const auto noise = makeNoise(44100.0, 44100, 0.35f); + + const auto toneMetrics = analyzer.analyzeBuffer(tone); + const auto noiseMetrics = analyzer.analyzeBuffer(noise); + + REQUIRE(noiseMetrics.spectralFlatness > toneMetrics.spectralFlatness); + REQUIRE(noiseMetrics.artifactRisk >= toneMetrics.artifactRisk); +} + +TEST_CASE("Analysis handles short and full-scale buffers", "[analysis][edge]") { + automix::engine::AudioBuffer shortBuffer(1, 32, 48000.0); + for (int i = 0; i < shortBuffer.getNumSamples(); ++i) { + const float value = (i % 2 == 0) ? 1.0f : -1.0f; + shortBuffer.setSample(0, i, value); + } + + automix::analysis::StemAnalyzer analyzer; + const auto metrics = analyzer.analyzeBuffer(shortBuffer); + + REQUIRE(std::isfinite(metrics.rmsDb)); + REQUIRE(std::isfinite(metrics.peakDb)); + REQUIRE(metrics.peakDb <= 0.1); + REQUIRE(metrics.peakDb >= -0.2); + REQUIRE(metrics.silenceRatio < 0.2); } diff --git a/tests/unit/AudioIoTests.cpp b/tests/unit/AudioIoTests.cpp index 197d468..22996c7 100644 --- a/tests/unit/AudioIoTests.cpp +++ b/tests/unit/AudioIoTests.cpp @@ -68,3 +68,11 @@ TEST_CASE("Resampling preserves tone behavior approximately", "[audioio]") { REQUIRE(ratio > 0.95); REQUIRE(ratio < 1.05); } + +TEST_CASE("Audio writer format resolution supports lossy formats", "[audioio]") { + REQUIRE(automix::util::WavWriter::resolveFormat("mix.wav", "mp3") == "mp3"); + REQUIRE(automix::util::WavWriter::resolveFormat("mix.ogg", "auto") == "ogg"); + REQUIRE(automix::util::WavWriter::isLossyFormat("mp3")); + REQUIRE(automix::util::WavWriter::isLossyFormat("ogg")); + REQUIRE_FALSE(automix::util::WavWriter::isLossyFormat("wav")); +} diff --git a/tests/unit/AudioPreviewEngineTests.cpp b/tests/unit/AudioPreviewEngineTests.cpp new file mode 100644 index 0000000..76989c1 --- /dev/null +++ b/tests/unit/AudioPreviewEngineTests.cpp @@ -0,0 +1,34 @@ +#include + +#include "engine/AudioBuffer.h" +#include "engine/AudioPreviewEngine.h" + +namespace { + +automix::engine::AudioBuffer makeConstantBuffer(const float value) { + automix::engine::AudioBuffer buffer(2, 512, 48000.0); + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + buffer.setSample(ch, i, value); + } + } + return buffer; +} + +} // namespace + +TEST_CASE("AudioPreviewEngine crossfades between A/B sources", "[preview]") { + automix::engine::AudioPreviewEngine engine; + const auto original = makeConstantBuffer(0.1f); + const auto rendered = makeConstantBuffer(0.9f); + engine.setBuffers(original, rendered); + + engine.setSource(automix::engine::PreviewSource::OriginalMix); + auto a = engine.buildCrossfadedPreview(64); + REQUIRE(a.getSample(0, 0) == 0.1f); + + engine.setSource(automix::engine::PreviewSource::RenderedMix); + auto b = engine.buildCrossfadedPreview(64); + REQUIRE(b.getSample(0, 0) == 0.1f); + REQUIRE(b.getSample(0, 63) > 0.5f); +} diff --git a/tests/unit/AutoMasterTests.cpp b/tests/unit/AutoMasterTests.cpp index 876d9d0..5d51d6f 100644 --- a/tests/unit/AutoMasterTests.cpp +++ b/tests/unit/AutoMasterTests.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -21,6 +22,16 @@ automix::engine::AudioBuffer makeBusySignal() { return buffer; } +double peakLinear(const automix::engine::AudioBuffer& buffer) { + double peak = 0.0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + peak = std::max(peak, static_cast(std::abs(buffer.getSample(ch, i)))); + } + } + return peak; +} + } // namespace TEST_CASE("Mastering enforces true peak ceiling", "[master]") { @@ -35,6 +46,8 @@ TEST_CASE("Mastering enforces true peak ceiling", "[master]") { REQUIRE(output.getNumSamples() == input.getNumSamples()); REQUIRE(report.truePeakDbtp <= -0.8); + REQUIRE(report.monoCorrelation <= 1.0); + REQUIRE(report.monoCorrelation >= -1.0); } TEST_CASE("Mastering lands near target loudness", "[master]") { @@ -47,3 +60,16 @@ TEST_CASE("Mastering lands near target loudness", "[master]") { REQUIRE(report.integratedLufs == Catch::Approx(plan.targetLufs).margin(1.2)); } + +TEST_CASE("Mastering dither stage remains peak safe", "[master]") { + automix::automaster::HeuristicAutoMasterStrategy strategy; + const auto input = makeBusySignal(); + + auto plan = strategy.buildPlan(automix::domain::MasterPreset::DefaultStreaming, input); + plan.ditherBitDepth = 16; + plan.truePeakDbtp = -1.0; + plan.limiterCeilingDb = -1.0; + + const auto output = strategy.applyPlan(input, plan, nullptr); + REQUIRE(peakLinear(output) <= Catch::Approx(std::pow(10.0, -1.0 / 20.0)).epsilon(0.1)); +} diff --git a/tests/unit/BatchProcessingTests.cpp b/tests/unit/BatchProcessingTests.cpp new file mode 100644 index 0000000..54975a7 --- /dev/null +++ b/tests/unit/BatchProcessingTests.cpp @@ -0,0 +1,124 @@ +#include +#include + +#include + +#include "domain/BatchTypes.h" +#include "engine/BatchQueueRunner.h" +#include "util/WavWriter.h" + +namespace { + +automix::engine::AudioBuffer makeTone(const double sampleRate, const int samples, const double frequency) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const float sample = + static_cast(0.2 * std::sin(2.0 * 3.14159265358979323846 * frequency * static_cast(i) / sampleRate)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +} // namespace + +TEST_CASE("Batch grouping detects stem sets by filename suffix", "[batch]") { + const std::filesystem::path inputDir = std::filesystem::temp_directory_path() / "automix_batch_grouping_input"; + const std::filesystem::path outputDir = std::filesystem::temp_directory_path() / "automix_batch_grouping_output"; + std::filesystem::remove_all(inputDir); + std::filesystem::remove_all(outputDir); + std::filesystem::create_directories(inputDir); + std::filesystem::create_directories(outputDir); + + automix::util::WavWriter writer; + writer.write(inputDir / "songa_vocals.wav", makeTone(44100.0, 4096, 220.0), 24); + writer.write(inputDir / "songa_bass.wav", makeTone(44100.0, 4096, 110.0), 24); + writer.write(inputDir / "songb_vocals.wav", makeTone(44100.0, 4096, 330.0), 24); + writer.write(inputDir / "songb_drums.wav", makeTone(44100.0, 4096, 440.0), 24); + + automix::engine::BatchQueueRunner runner; + const auto items = runner.buildItemsFromFolder(inputDir, outputDir); + REQUIRE(items.size() == 2); + + std::filesystem::remove_all(inputDir); + std::filesystem::remove_all(outputDir); +} + +TEST_CASE("Batch runner processes synthetic batch job with BuiltIn renderer", "[batch]") { + const std::filesystem::path inputDir = std::filesystem::temp_directory_path() / "automix_batch_process_input"; + const std::filesystem::path outputDir = std::filesystem::temp_directory_path() / "automix_batch_process_output"; + std::filesystem::remove_all(inputDir); + std::filesystem::remove_all(outputDir); + std::filesystem::create_directories(inputDir); + std::filesystem::create_directories(outputDir); + + automix::util::WavWriter writer; + writer.write(inputDir / "songc_vocals.wav", makeTone(44100.0, 8192, 260.0), 24); + writer.write(inputDir / "songc_bass.wav", makeTone(44100.0, 8192, 90.0), 24); + + automix::engine::BatchQueueRunner runner; + auto items = runner.buildItemsFromFolder(inputDir, outputDir); + REQUIRE(items.empty() == false); + + automix::domain::BatchJob job; + job.items = std::move(items); + job.settings.outputFolder = outputDir; + job.settings.analysisThreads = 1; + job.settings.parallelAnalysis = false; + job.settings.renderSettings.outputSampleRate = 44100; + job.settings.renderSettings.blockSize = 512; + job.settings.renderSettings.outputBitDepth = 24; + job.settings.renderSettings.rendererName = "BuiltIn"; + + const auto result = runner.process(job, {}, nullptr); + REQUIRE(result.completed >= 1); + + bool foundOutput = false; + for (const auto& item : job.items) { + if (std::filesystem::exists(item.outputPath)) { + foundOutput = true; + break; + } + } + REQUIRE(foundOutput); + + std::filesystem::remove_all(inputDir); + std::filesystem::remove_all(outputDir); +} + +TEST_CASE("Batch runner applies requested lossy output extension", "[batch]") { + const std::filesystem::path inputDir = std::filesystem::temp_directory_path() / "automix_batch_lossy_input"; + const std::filesystem::path outputDir = std::filesystem::temp_directory_path() / "automix_batch_lossy_output"; + std::filesystem::remove_all(inputDir); + std::filesystem::remove_all(outputDir); + std::filesystem::create_directories(inputDir); + std::filesystem::create_directories(outputDir); + + automix::util::WavWriter writer; + writer.write(inputDir / "songd_vocals.wav", makeTone(44100.0, 4096, 260.0), 24); + writer.write(inputDir / "songd_bass.wav", makeTone(44100.0, 4096, 90.0), 24); + + automix::engine::BatchQueueRunner runner; + auto items = runner.buildItemsFromFolder(inputDir, outputDir); + REQUIRE(items.empty() == false); + + automix::domain::BatchJob job; + job.items = std::move(items); + job.settings.outputFolder = outputDir; + job.settings.analysisThreads = 1; + job.settings.parallelAnalysis = false; + job.settings.renderSettings.outputSampleRate = 44100; + job.settings.renderSettings.blockSize = 512; + job.settings.renderSettings.outputBitDepth = 24; + job.settings.renderSettings.rendererName = "BuiltIn"; + job.settings.renderSettings.outputFormat = "mp3"; + job.settings.renderSettings.lossyBitrateKbps = 192; + job.settings.renderSettings.lossyQuality = 7; + + const auto result = runner.process(job, {}, nullptr); + REQUIRE(result.failed + result.completed + result.cancelled == static_cast(job.items.size())); + REQUIRE(job.items.front().outputPath.extension().string() == ".mp3"); + + std::filesystem::remove_all(inputDir); + std::filesystem::remove_all(outputDir); +} diff --git a/tests/unit/ControllerTests.cpp b/tests/unit/ControllerTests.cpp new file mode 100644 index 0000000..92e9177 --- /dev/null +++ b/tests/unit/ControllerTests.cpp @@ -0,0 +1,440 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "app/controllers/ExportController.h" +#include "app/controllers/ImportController.h" +#include "app/controllers/ModelController.h" +#include "app/controllers/OriginalMixController.h" +#include "app/controllers/ProcessingController.h" +#include "app/controllers/ProfileController.h" +#include "app/controllers/PreviewController.h" +#include "app/controllers/SessionController.h" +#include "domain/ProjectProfile.h" +#include "engine/AudioBuffer.h" +#include "util/WavWriter.h" + +namespace { + +automix::engine::AudioBuffer makeTone(const double sampleRate, const int samples, const double frequency) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const double t = static_cast(i) / sampleRate; + const float sample = static_cast(0.2 * std::sin(2.0 * std::numbers::pi * frequency * t)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +std::filesystem::path uniqueTempPath(const std::string& stem) { + const auto nonce = std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + return std::filesystem::temp_directory_path() / (stem + "_" + nonce); +} + +bool waitFor(const std::function& predicate, const int timeoutMs = 6000) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeoutMs); + while (std::chrono::steady_clock::now() < deadline) { + if (predicate()) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return predicate(); +} + +automix::domain::Session makeBasicSession(const std::filesystem::path& stemPath = {}) { + automix::domain::Session session; + session.sessionName = "controller-test-session"; + session.projectProfileId = "default"; + session.safetyPolicyId = "balanced"; + session.renderSettings.rendererName = "BuiltIn"; + session.renderSettings.outputFormat = "wav"; + session.renderSettings.outputSampleRate = 44100; + session.renderSettings.blockSize = 1024; + session.renderSettings.outputBitDepth = 24; + session.renderSettings.exportSpeedMode = "quick"; + + if (!stemPath.empty()) { + automix::domain::Stem stem; + stem.id = "stem_1"; + stem.name = "tone"; + stem.filePath = stemPath.string(); + stem.enabled = true; + session.stems.push_back(stem); + } + return session; +} + +} // namespace + +TEST_CASE("ImportController imports selected files", "[controllers][import]") { + const auto testDir = uniqueTempPath("automix_import_ok"); + std::filesystem::create_directories(testDir); + const auto wavPath = testDir / "tone.wav"; + automix::util::WavWriter().write(wavPath, makeTone(44100.0, 2048, 220.0), 24); + + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {false}; + std::optional result; + + automix::app::ImportController::Callbacks callbacks; + callbacks.onImportComplete = [&](automix::app::ImportResult value) { + result = std::move(value); + }; + automix::app::ImportController controller(pool, std::move(callbacks)); + + controller.importFiles({juce::File(wavPath.string())}, false, 4, cancelFlag); + + REQUIRE(waitFor([&]() { return result.has_value(); })); + REQUIRE_FALSE(result->cancelled); + REQUIRE(result->stems.size() == 1); + REQUIRE(result->stems.front().filePath == wavPath.string()); + + std::filesystem::remove_all(testDir); +} + +TEST_CASE("ImportController ignores empty file list", "[controllers][import]") { + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {false}; + bool invoked = false; + + automix::app::ImportController::Callbacks callbacks; + callbacks.onImportComplete = [&](automix::app::ImportResult) { + invoked = true; + }; + automix::app::ImportController controller(pool, std::move(callbacks)); + + controller.importFiles({}, false, 4, cancelFlag); + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + REQUIRE_FALSE(invoked); +} + +TEST_CASE("ImportController respects cancellation flag", "[controllers][import][cancel]") { + const auto testDir = uniqueTempPath("automix_import_cancel"); + std::filesystem::create_directories(testDir); + const auto wavPath = testDir / "tone.wav"; + automix::util::WavWriter().write(wavPath, makeTone(44100.0, 4096, 110.0), 24); + + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {true}; + std::optional result; + + automix::app::ImportController::Callbacks callbacks; + callbacks.onImportComplete = [&](automix::app::ImportResult value) { + result = std::move(value); + }; + automix::app::ImportController controller(pool, std::move(callbacks)); + + controller.importFiles({juce::File(wavPath.string())}, false, 4, cancelFlag); + + REQUIRE(waitFor([&]() { return result.has_value(); })); + REQUIRE(result->cancelled); + REQUIRE(result->stems.empty()); + + std::filesystem::remove_all(testDir); +} + +TEST_CASE("ExportController preflight blocks unpinned renderer under strict policy", "[controllers][export]") { + juce::ThreadPool pool(1); + automix::app::ExportController controller(pool, {}); + + automix::domain::ProjectProfile profile; + profile.id = "strict-profile"; + profile.pinnedRendererIds = {"PinnedRenderer"}; + + automix::app::ExportPreflightRequest request; + request.selectedRendererId = "BuiltIn"; + request.safetyPolicyId = "strict"; + request.projectProfileId = profile.id; + request.projectProfiles = {profile}; + + const auto result = controller.preflight(request); + REQUIRE_FALSE(result.allowed); +} + +TEST_CASE("ExportController returns cancelled result when cancel flag is pre-set", "[controllers][export][cancel]") { + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {true}; + std::optional result; + + automix::app::ExportController::Callbacks callbacks; + callbacks.onExportComplete = [&](automix::app::ExportResult value) { + result = std::move(value); + }; + automix::app::ExportController controller(pool, std::move(callbacks)); + + auto session = makeBasicSession(); + auto settings = session.renderSettings; + settings.exportSpeedMode = "quick"; + + controller.runExport(session, settings, {}, cancelFlag); + + REQUIRE(waitFor([&]() { return result.has_value(); })); + REQUIRE(result->cancelled); +} + +TEST_CASE("ExportController builds quick-mode render settings", "[controllers][export]") { + juce::ThreadPool pool(1); + automix::app::ExportController controller(pool, {}); + + automix::app::BuildRenderSettingsRequest request; + request.exportSpeedMode = "quick"; + request.outputFormat = "wav"; + request.lossyBitrateKbps = 256; + request.mp3UseVbr = false; + request.mp3VbrQuality = 6; + request.gpuProviderSelectionId = 3; + request.selectedRendererId = "BuiltIn"; + + const auto settings = controller.buildRenderSettings(request); + REQUIRE(settings.exportSpeedMode == "quick"); + REQUIRE(settings.blockSize == 4096); + REQUIRE(settings.outputBitDepth == 16); + REQUIRE(settings.gpuExecutionProvider == "directml"); +} + +TEST_CASE("ProcessingController auto mix cancellation terminates early", "[controllers][processing][cancel]") { + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {true}; + std::optional result; + + automix::app::ProcessingController::Callbacks callbacks; + callbacks.onAutoMixComplete = [&](automix::app::AutoMixResult value) { + result = std::move(value); + }; + automix::app::ProcessingController controller(pool, std::move(callbacks)); + + controller.runAutoMix(makeBasicSession(), std::nullopt, cancelFlag); + + REQUIRE(waitFor([&]() { return result.has_value(); })); + REQUIRE(result->cancelled); +} + +TEST_CASE("ProcessingController batch callback fires for empty folder", "[controllers][processing][batch]") { + const auto inputDir = uniqueTempPath("automix_batch_empty"); + std::filesystem::create_directories(inputDir); + + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {false}; + std::optional result; + + automix::app::ProcessingController::Callbacks callbacks; + callbacks.onBatchComplete = [&](automix::app::BatchResult value) { + result = std::move(value); + }; + automix::app::ProcessingController controller(pool, std::move(callbacks)); + + automix::domain::RenderSettings settings; + settings.rendererName = "BuiltIn"; + settings.outputFormat = "wav"; + + controller.runBatch(inputDir, settings, cancelFlag); + + REQUIRE(waitFor([&]() { return result.has_value(); })); + REQUIRE_FALSE(result->errorText.isEmpty()); + + std::filesystem::remove_all(inputDir); +} + +TEST_CASE("ModelController fetch catalog cancellation emits completion", "[controllers][model][cancel]") { + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {true}; + bool asyncCompleted = false; + std::string lastStatus; + + automix::ai::ModelManager manager; + automix::app::ModelController::Callbacks callbacks; + callbacks.onStatus = [&](const std::string& text) { + lastStatus = text; + }; + callbacks.onAsyncTaskComplete = [&]() { + asyncCompleted = true; + }; + automix::app::ModelController controller(manager, pool, std::move(callbacks)); + + controller.fetchCatalog(cancelFlag); + + REQUIRE(waitFor([&]() { return asyncCompleted; })); + REQUIRE(lastStatus.find("cancelled") != std::string::npos); +} + +TEST_CASE("ModelController install cancellation emits completion", "[controllers][model][cancel]") { + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {true}; + bool asyncCompleted = false; + std::string lastStatus; + + automix::ai::ModelManager manager; + automix::app::ModelController::Callbacks callbacks; + callbacks.onStatus = [&](const std::string& text) { + lastStatus = text; + }; + callbacks.onAsyncTaskComplete = [&]() { + asyncCompleted = true; + }; + automix::app::ModelController controller(manager, pool, std::move(callbacks)); + + controller.installModel("org/demo-model", cancelFlag); + + REQUIRE(waitFor([&]() { return asyncCompleted; })); + REQUIRE(lastStatus.find("cancelled") != std::string::npos); +} + +TEST_CASE("SessionController save and load roundtrip", "[controllers][session]") { + const auto sessionPath = uniqueTempPath("automix_session_roundtrip").replace_extension(".json"); + + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {false}; + std::optional saveResult; + std::optional loadResult; + + automix::app::SessionController::Callbacks callbacks; + callbacks.onSaveComplete = [&](automix::app::SessionSaveResult value) { + saveResult = std::move(value); + }; + callbacks.onLoadComplete = [&](automix::app::SessionLoadResult value) { + loadResult = std::move(value); + }; + automix::app::SessionController controller(pool, std::move(callbacks)); + + auto session = makeBasicSession(); + session.sessionName = "roundtrip"; + + controller.saveSession(sessionPath.string(), session, cancelFlag); + REQUIRE(waitFor([&]() { return saveResult.has_value(); })); + REQUIRE(saveResult->success); + REQUIRE_FALSE(saveResult->cancelled); + + controller.loadSession(sessionPath.string(), cancelFlag); + REQUIRE(waitFor([&]() { return loadResult.has_value(); })); + REQUIRE_FALSE(loadResult->cancelled); + REQUIRE(loadResult->session.has_value()); + REQUIRE(loadResult->session->sessionName == "roundtrip"); + + std::filesystem::remove(sessionPath); +} + +TEST_CASE("SessionController reports corrupt session load error", "[controllers][session]") { + const auto sessionPath = uniqueTempPath("automix_session_corrupt").replace_extension(".json"); + { + std::ofstream out(sessionPath); + out << "{ not valid json"; + } + + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {false}; + std::optional loadResult; + + automix::app::SessionController::Callbacks callbacks; + callbacks.onLoadComplete = [&](automix::app::SessionLoadResult value) { + loadResult = std::move(value); + }; + automix::app::SessionController controller(pool, std::move(callbacks)); + + controller.loadSession(sessionPath.string(), cancelFlag); + + REQUIRE(waitFor([&]() { return loadResult.has_value(); })); + REQUIRE_FALSE(loadResult->cancelled); + REQUIRE_FALSE(loadResult->session.has_value()); + REQUIRE_FALSE(loadResult->errorText.isEmpty()); + + std::filesystem::remove(sessionPath); +} + +TEST_CASE("SessionController save respects cancellation flag", "[controllers][session][cancel]") { + const auto sessionPath = uniqueTempPath("automix_session_cancel").replace_extension(".json"); + + juce::ThreadPool pool(1); + std::atomic_bool cancelFlag {true}; + std::optional saveResult; + + automix::app::SessionController::Callbacks callbacks; + callbacks.onSaveComplete = [&](automix::app::SessionSaveResult value) { + saveResult = std::move(value); + }; + automix::app::SessionController controller(pool, std::move(callbacks)); + + controller.saveSession(sessionPath.string(), makeBasicSession(), cancelFlag); + + REQUIRE(waitFor([&]() { return saveResult.has_value(); })); + REQUIRE(saveResult->cancelled); + REQUIRE_FALSE(saveResult->success); +} + +TEST_CASE("ProfileController applies project profile to session", "[controllers][profile]") { + automix::app::ProfileController controller; + automix::domain::Session session; + automix::domain::ProjectProfile profile; + profile.id = "podcast"; + profile.safetyPolicyId = "strict"; + profile.preferredStemCount = 6; + profile.gpuProvider = "cpu"; + profile.outputFormat = "mp3"; + profile.lossyBitrateKbps = 192; + profile.mp3UseVbr = true; + profile.mp3VbrQuality = 2; + profile.metadataPolicy = "copy_common"; + profile.rendererName = "BuiltIn"; + profile.roleModelPackId = "role-pack"; + profile.mixModelPackId = "mix-pack"; + profile.masterModelPackId = "master-pack"; + profile.platformPreset = "spotify"; + + const auto applied = controller.applyProfile(session, profile); + + REQUIRE(session.projectProfileId == "podcast"); + REQUIRE(session.safetyPolicyId == "strict"); + REQUIRE(session.preferredStemCount == 6); + REQUIRE(session.renderSettings.outputFormat == "mp3"); + REQUIRE(session.renderSettings.lossyBitrateKbps == 192); + REQUIRE(session.renderSettings.mp3UseVbr); + REQUIRE(applied.roleModelPackId == "role-pack"); + REQUIRE(applied.mixModelPackId == "mix-pack"); + REQUIRE(applied.masterModelPackId == "master-pack"); +} + +TEST_CASE("PreviewController transport application updates timeline and loop", "[controllers][preview]") { + automix::engine::TransportController transport; + std::atomic cursor {123}; + automix::domain::TimelineState timeline; + timeline.loopEnabled = true; + timeline.loopInSeconds = 0.2; + timeline.loopOutSeconds = 0.8; + + automix::engine::AudioBuffer buffer(2, 48000, 48000.0); + automix::app::PreviewController::applyTransportBuffer(buffer, timeline, transport, cursor); + + REQUIRE(cursor.load() == 0); + REQUIRE(transport.totalSamples() == 48000); + REQUIRE(transport.loopEnabled()); + REQUIRE(transport.loopInSeconds() == 0.2); + REQUIRE(transport.loopOutSeconds() == 0.8); +} + +TEST_CASE("OriginalMixController applySelectedPath fills payload", "[controllers][originalmix]") { + automix::app::OriginalMixController controller; + const auto result = controller.applySelectedPath("C:/music/original.wav", "original.wav"); + + REQUIRE(result.applied); + REQUIRE(result.path == "C:/music/original.wav"); + REQUIRE(result.statusText == "Original mix loaded"); +} + +TEST_CASE("OriginalMixController clear reports cleared state", "[controllers][originalmix]") { + automix::app::OriginalMixController controller; + const auto result = controller.clear(std::optional("C:/music/original.wav")); + + REQUIRE(result.cleared); + REQUIRE(result.statusText == "Original mix cleared"); +} diff --git a/tests/unit/ExternalLimiterRendererTests.cpp b/tests/unit/ExternalLimiterRendererTests.cpp new file mode 100644 index 0000000..1f89b07 --- /dev/null +++ b/tests/unit/ExternalLimiterRendererTests.cpp @@ -0,0 +1,53 @@ +#include +#include + +#include + +#include "domain/Session.h" +#include "renderers/ExternalLimiterRenderer.h" +#include "util/WavWriter.h" + +namespace { + +automix::engine::AudioBuffer makeTone(const double sampleRate, const int samples, const double frequency) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const float sample = + static_cast(0.25 * std::sin(2.0 * 3.14159265358979323846 * frequency * static_cast(i) / sampleRate)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +} // namespace + +TEST_CASE("External limiter renderer falls back to BuiltIn when binary is missing", "[renderer][external]") { + const std::filesystem::path tempDir = std::filesystem::temp_directory_path() / "automix_external_limiter_test"; + std::filesystem::remove_all(tempDir); + std::filesystem::create_directories(tempDir); + + automix::util::WavWriter writer; + const auto stemPath = tempDir / "stem.wav"; + writer.write(stemPath, makeTone(44100.0, 22050, 330.0), 24); + + automix::domain::Session session; + automix::domain::Stem stem; + stem.id = "s1"; + stem.name = "Stem"; + stem.filePath = stemPath.string(); + session.stems.push_back(stem); + + automix::domain::RenderSettings settings; + settings.outputPath = (tempDir / "out.wav").string(); + settings.externalRendererPath = (tempDir / "missing_external_tool.exe").string(); + + automix::renderers::ExternalLimiterRenderer renderer; + const auto result = renderer.render(session, settings, {}, nullptr); + + REQUIRE(result.success); + REQUIRE(result.rendererName.find("fallback") != std::string::npos); + REQUIRE(std::filesystem::exists(settings.outputPath)); + + std::filesystem::remove_all(tempDir); +} diff --git a/tests/unit/LookaheadLimiterTests.cpp b/tests/unit/LookaheadLimiterTests.cpp new file mode 100644 index 0000000..4617cac --- /dev/null +++ b/tests/unit/LookaheadLimiterTests.cpp @@ -0,0 +1,113 @@ +#include +#include +#include + +#include +#include + +#include "dsp/LookaheadLimiter.h" +#include "dsp/TruePeakDetector.h" +#include "engine/AudioBuffer.h" + +namespace { + +automix::engine::AudioBuffer makeHotSignal() { + constexpr double sampleRate = 48000.0; + constexpr int samples = 48000 * 2; + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const double t = static_cast(i) / sampleRate; + const float sample = static_cast(1.35 * std::sin(2.0 * 3.14159265358979323846 * 220.0 * t) + + 0.35 * std::sin(2.0 * 3.14159265358979323846 * 5100.0 * t)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +double peakLinear(const automix::engine::AudioBuffer& buffer) { + double peak = 0.0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + peak = std::max(peak, static_cast(std::abs(buffer.getSample(ch, i)))); + } + } + return peak; +} + +bool hasFiniteValues(const automix::engine::AudioBuffer& buffer) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const float sample = buffer.getSample(ch, i); + if (!std::isfinite(sample)) { + return false; + } + } + } + return true; +} + +} // namespace + +TEST_CASE("Lookahead limiter enforces ceiling and true-peak bounds", "[limiter]") { + auto signal = makeHotSignal(); + + automix::dsp::LookaheadLimiterSettings settings; + settings.ceilingDb = -1.0; + settings.lookaheadMs = 8.0; + settings.attackMs = 1.0; + settings.releaseMs = 80.0; + settings.truePeakEnabled = true; + settings.truePeakOversampleFactor = 4; + settings.softClipEnabled = true; + settings.softClipDrive = 1.2; + + automix::dsp::LookaheadLimiter limiter; + limiter.prepare(signal.getSampleRate(), signal.getNumChannels(), settings); + limiter.process(signal); + + REQUIRE(hasFiniteValues(signal)); + REQUIRE(peakLinear(signal) <= Catch::Approx(std::pow(10.0, settings.ceilingDb / 20.0)).epsilon(0.08)); + + automix::dsp::TruePeakDetector truePeak(4); + REQUIRE(truePeak.computeTruePeakDbtp(signal) <= -0.8); +} + +TEST_CASE("Lookahead limiter is deterministic for identical input", "[limiter]") { + auto signalA = makeHotSignal(); + auto signalB = signalA; + + automix::dsp::LookaheadLimiterSettings settings; + settings.ceilingDb = -1.0; + settings.truePeakEnabled = true; + settings.truePeakOversampleFactor = 4; + + automix::dsp::LookaheadLimiter limiterA; + limiterA.prepare(signalA.getSampleRate(), signalA.getNumChannels(), settings); + limiterA.process(signalA); + + automix::dsp::LookaheadLimiter limiterB; + limiterB.prepare(signalB.getSampleRate(), signalB.getNumChannels(), settings); + limiterB.process(signalB); + + for (int ch = 0; ch < signalA.getNumChannels(); ++ch) { + for (int i = 0; i < signalA.getNumSamples(); ++i) { + REQUIRE(signalA.getSample(ch, i) == Catch::Approx(signalB.getSample(ch, i)).margin(1.0e-6)); + } + } +} + +TEST_CASE("Lookahead limiter keeps silence stable", "[limiter]") { + automix::engine::AudioBuffer silence(2, 4096, 48000.0); + + automix::dsp::LookaheadLimiterSettings settings; + settings.ceilingDb = -1.0; + settings.truePeakEnabled = true; + + automix::dsp::LookaheadLimiter limiter; + limiter.prepare(silence.getSampleRate(), silence.getNumChannels(), settings); + limiter.process(silence); + + REQUIRE(hasFiniteValues(silence)); + REQUIRE(peakLinear(silence) == Catch::Approx(0.0).margin(1.0e-8)); +} diff --git a/tests/unit/LoudnessMeterTests.cpp b/tests/unit/LoudnessMeterTests.cpp new file mode 100644 index 0000000..6a25d9b --- /dev/null +++ b/tests/unit/LoudnessMeterTests.cpp @@ -0,0 +1,62 @@ +#include +#include + +#include +#include + +#include "engine/AudioBuffer.h" +#include "engine/LoudnessMeter.h" + +namespace { + +automix::engine::AudioBuffer makeSine(const double sampleRate, const int samples, const double frequency, const double amplitude) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const float sample = + static_cast(amplitude * std::sin(2.0 * 3.14159265358979323846 * frequency * static_cast(i) / sampleRate)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +automix::engine::AudioBuffer makeNoise(const double sampleRate, const int samples, const double amplitude) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + std::mt19937 rng(12345u); + std::uniform_real_distribution dist(-1.0f, 1.0f); + for (int i = 0; i < samples; ++i) { + const float sample = dist(rng) * static_cast(amplitude); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +} // namespace + +TEST_CASE("Loudness meter sanity for silence tone and noise", "[loudness]") { + automix::engine::LoudnessMeter meter; + automix::engine::AudioBuffer silence(2, 48000, 48000.0); + const auto tone = makeSine(48000.0, 48000 * 2, 1000.0, 0.2); + const auto noise = makeNoise(48000.0, 48000 * 2, 0.2); + + const auto silenceMetrics = meter.analyze(silence); + const auto toneMetrics = meter.analyze(tone); + const auto noiseMetrics = meter.analyze(noise); + + REQUIRE(silenceMetrics.integratedLufs <= -70.0); + REQUIRE(toneMetrics.integratedLufs > silenceMetrics.integratedLufs); + REQUIRE(noiseMetrics.integratedLufs > silenceMetrics.integratedLufs); +} + +TEST_CASE("Loudness meter integrated LUFS remains stable across chunk sizes", "[loudness]") { + automix::engine::LoudnessMeter meter; + const auto tone = makeSine(48000.0, 48000 * 3, 330.0, 0.25); + + const double lufs256 = meter.computeIntegratedLufs(tone, 256); + const double lufs1024 = meter.computeIntegratedLufs(tone, 1024); + const double lufs4096 = meter.computeIntegratedLufs(tone, 4096); + + REQUIRE(lufs256 == Catch::Approx(lufs1024).margin(0.25)); + REQUIRE(lufs1024 == Catch::Approx(lufs4096).margin(0.25)); +} diff --git a/tests/unit/MasteringModulesTests.cpp b/tests/unit/MasteringModulesTests.cpp new file mode 100644 index 0000000..6600890 --- /dev/null +++ b/tests/unit/MasteringModulesTests.cpp @@ -0,0 +1,70 @@ +#include + +#include +#include + +#include "automaster/HeuristicAutoMasterStrategy.h" +#include "dsp/DeEsser.h" +#include "dsp/DynamicDeHarshEq.h" +#include "dsp/MidSideProcessor.h" +#include "dsp/SoftClipper.h" +#include "domain/MasterPlan.h" +#include "engine/AudioBuffer.h" + +namespace { + +automix::engine::AudioBuffer makeSibilantLikeSignal() { + constexpr double sampleRate = 48000.0; + constexpr int samples = 48000; + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const double t = static_cast(i) / sampleRate; + const float sample = static_cast(0.25 * std::sin(2.0 * 3.14159265358979323846 * 200.0 * t) + + 0.12 * std::sin(2.0 * 3.14159265358979323846 * 7000.0 * t)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +bool allFinite(const automix::engine::AudioBuffer& buffer) { + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + if (!std::isfinite(buffer.getSample(ch, i))) { + return false; + } + } + } + return true; +} + +} // namespace + +TEST_CASE("Deterministic mastering modules keep buffers finite", "[master][modules]") { + auto signal = makeSibilantLikeSignal(); + + automix::dsp::DeEsser deEsser; + automix::dsp::DynamicDeHarshEq deHarsh; + automix::dsp::MidSideProcessor midSide; + automix::dsp::SoftClipper softClipper; + + deEsser.process(signal, 0.6); + deHarsh.process(signal, 0.5); + midSide.process(signal, 120.0, 0.9); + softClipper.process(signal, 1.2); + + REQUIRE(allFinite(signal)); +} + +TEST_CASE("Udio optimized preset enables deterministic safety modules", "[master][modules]") { + automix::automaster::HeuristicAutoMasterStrategy strategy; + const auto signal = makeSibilantLikeSignal(); + + const auto plan = strategy.buildPlan(automix::domain::MasterPreset::UdioOptimized, signal); + REQUIRE(plan.enableDeEsser); + REQUIRE(plan.enableDeHarshEq); + REQUIRE(plan.enableLowMono); + REQUIRE(plan.enableSoftClipper); + REQUIRE(plan.targetLufs == Catch::Approx(-14.0)); + REQUIRE(plan.truePeakDbtp == Catch::Approx(-1.0)); +} diff --git a/tests/unit/MetadataPolicyTests.cpp b/tests/unit/MetadataPolicyTests.cpp new file mode 100644 index 0000000..0a2662e --- /dev/null +++ b/tests/unit/MetadataPolicyTests.cpp @@ -0,0 +1,45 @@ +#include +#include + +#include + +#include "util/MetadataPolicy.h" + +TEST_CASE("Metadata policy strip removes all tags", "[metadata][policy]") { + const std::map source = { + {"title", "Song"}, + {"custom_tag", "value"}, + }; + + const auto filtered = automix::util::applyMetadataPolicy(source, "strip"); + REQUIRE(filtered.empty()); +} + +TEST_CASE("Metadata policy copy_common keeps only common distribution tags", "[metadata][policy]") { + const std::map source = { + {"title", "Song"}, + {"artist", "Artist"}, + {"custom_tag", "value"}, + }; + + const auto filtered = automix::util::applyMetadataPolicy(source, "copy_common"); + REQUIRE(filtered.count("title") == 1); + REQUIRE(filtered.count("artist") == 1); + REQUIRE(filtered.count("custom_tag") == 0); +} + +TEST_CASE("Metadata policy override_template merges template values", "[metadata][policy]") { + const std::map source = { + {"title", "Old Title"}, + {"artist", "Artist"}, + }; + const std::map templ = { + {"title", "New Title"}, + {"album", "Compilation"}, + }; + + const auto filtered = automix::util::applyMetadataPolicy(source, "override_template", templ); + REQUIRE(filtered.at("title") == "New Title"); + REQUIRE(filtered.at("album") == "Compilation"); + REQUIRE(filtered.at("artist") == "Artist"); +} diff --git a/tests/unit/OfflineRenderPipelineTests.cpp b/tests/unit/OfflineRenderPipelineTests.cpp new file mode 100644 index 0000000..f8fe391 --- /dev/null +++ b/tests/unit/OfflineRenderPipelineTests.cpp @@ -0,0 +1,155 @@ +#include +#include +#include + +#include +#include + +#include "domain/Bus.h" +#include "domain/MixPlan.h" +#include "domain/Session.h" +#include "engine/OfflineRenderPipeline.h" +#include "util/WavWriter.h" + +namespace { + +automix::engine::AudioBuffer makeTone(const double sampleRate, const int samples, const double freq, const float amplitude) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const float sample = + static_cast(amplitude * std::sin(2.0 * 3.14159265358979323846 * freq * static_cast(i) / sampleRate)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +double peakLinear(const automix::engine::AudioBuffer& buffer) { + double peak = 0.0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + peak = std::max(peak, static_cast(std::abs(buffer.getSample(ch, i)))); + } + } + return peak; +} + +double rmsLinear(const automix::engine::AudioBuffer& buffer) { + double sum = 0.0; + int count = 0; + for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { + for (int i = 0; i < buffer.getNumSamples(); ++i) { + const double sample = buffer.getSample(ch, i); + sum += sample * sample; + ++count; + } + } + return std::sqrt(sum / std::max(1, count)); +} + +automix::domain::Session makeSessionForStem(const std::filesystem::path& stemPath) { + automix::domain::Session session; + session.sessionName = "pipeline_test"; + + automix::domain::Stem stem; + stem.id = "s1"; + stem.name = "Stem"; + stem.filePath = stemPath.string(); + session.stems.push_back(stem); + return session; +} + +} // namespace + +TEST_CASE("Offline render pipeline applies role bus gain routing", "[render][pipeline]") { + const std::filesystem::path stemPath = std::filesystem::temp_directory_path() / "automix_pipeline_bus_stem.wav"; + automix::util::WavWriter writer; + writer.write(stemPath, makeTone(44100.0, 44100, 440.0, 0.45f), 24); + + automix::domain::RenderSettings settings; + settings.outputSampleRate = 44100; + settings.blockSize = 512; + + automix::engine::OfflineRenderPipeline pipeline; + auto baselineSession = makeSessionForStem(stemPath); + const auto baseline = pipeline.renderRawMix(baselineSession, settings, {}, nullptr); + + auto routedSession = makeSessionForStem(stemPath); + routedSession.stems.front().busId = "bus_music"; + routedSession.buses.push_back(automix::domain::Bus{ + .id = "bus_music", + .name = "Music", + .type = automix::domain::BusType::StemGroup, + .gainDb = -12.0, + }); + const auto routed = pipeline.renderRawMix(routedSession, settings, {}, nullptr); + + REQUIRE(routed.cancelled == false); + REQUIRE(peakLinear(routed.mixBuffer) < peakLinear(baseline.mixBuffer) * 0.4); + + std::filesystem::remove(stemPath); +} + +TEST_CASE("Offline render pipeline applies mud cut from mix plan", "[render][pipeline]") { + const std::filesystem::path stemPath = std::filesystem::temp_directory_path() / "automix_pipeline_mudcut_stem.wav"; + automix::util::WavWriter writer; + writer.write(stemPath, makeTone(44100.0, 44100, 320.0, 0.50f), 24); + + automix::domain::RenderSettings settings; + settings.outputSampleRate = 44100; + settings.blockSize = 512; + + automix::engine::OfflineRenderPipeline pipeline; + auto baselineSession = makeSessionForStem(stemPath); + const auto baseline = pipeline.renderRawMix(baselineSession, settings, {}, nullptr); + + auto processedSession = makeSessionForStem(stemPath); + automix::domain::MixPlan plan; + automix::domain::StemMixDecision decision; + decision.stemId = "s1"; + decision.mudCutDb = -6.0; + decision.highPassHz = 0.0; + plan.stemDecisions.push_back(decision); + processedSession.mixPlan = plan; + + const auto processed = pipeline.renderRawMix(processedSession, settings, {}, nullptr); + REQUIRE(processed.cancelled == false); + REQUIRE(rmsLinear(processed.mixBuffer) < rmsLinear(baseline.mixBuffer) * 0.9); + + std::filesystem::remove(stemPath); +} + +TEST_CASE("Offline render pipeline is deterministic for identical input", "[render][pipeline]") { + const std::filesystem::path stemPath = std::filesystem::temp_directory_path() / "automix_pipeline_determinism_stem.wav"; + automix::util::WavWriter writer; + writer.write(stemPath, makeTone(44100.0, 44100, 220.0, 0.40f), 24); + + automix::domain::RenderSettings settings; + settings.outputSampleRate = 44100; + settings.blockSize = 1024; + + auto session = makeSessionForStem(stemPath); + session.stems.front().busId = "bus_music"; + session.buses.push_back(automix::domain::Bus{ + .id = "bus_music", + .name = "Music", + .type = automix::domain::BusType::StemGroup, + .gainDb = -3.0, + }); + + automix::engine::OfflineRenderPipeline pipeline; + const auto runA = pipeline.renderRawMix(session, settings, {}, nullptr); + const auto runB = pipeline.renderRawMix(session, settings, {}, nullptr); + + REQUIRE(runA.cancelled == false); + REQUIRE(runB.cancelled == false); + REQUIRE(runA.mixBuffer.getNumSamples() == runB.mixBuffer.getNumSamples()); + REQUIRE(runA.mixBuffer.getNumChannels() == runB.mixBuffer.getNumChannels()); + for (int ch = 0; ch < runA.mixBuffer.getNumChannels(); ++ch) { + for (int i = 0; i < runA.mixBuffer.getNumSamples(); ++i) { + REQUIRE(runA.mixBuffer.getSample(ch, i) == Catch::Approx(runB.mixBuffer.getSample(ch, i)).margin(1.0e-7)); + } + } + + std::filesystem::remove(stemPath); +} diff --git a/tests/unit/OnnxModelInferenceTests.cpp b/tests/unit/OnnxModelInferenceTests.cpp new file mode 100644 index 0000000..5acedb6 --- /dev/null +++ b/tests/unit/OnnxModelInferenceTests.cpp @@ -0,0 +1,61 @@ +#include +#include +#include +#include + +#include + +#ifdef ENABLE_ONNX +#include "ai/FeatureSchema.h" +#include "ai/OnnxModelInference.h" +#else +#include "ai/IModelInference.h" +#endif + +TEST_CASE("ONNX inference backend validates model load and schema", "[ai][onnx]") { +#ifndef ENABLE_ONNX + SUCCEED("ENABLE_ONNX is off; ONNX backend tests skipped."); +#else + automix::ai::OnnxModelInference inference; + REQUIRE_FALSE(inference.loadModel("missing_model.onnx")); + + const auto tempDir = std::filesystem::temp_directory_path() / "automix_onnx_inference_test"; + std::filesystem::remove_all(tempDir); + std::filesystem::create_directories(tempDir); + const auto modelPath = tempDir / "model.onnx"; + + { + std::ofstream model(modelPath, std::ios::binary); + model << "dummy_onnx"; + } + { + std::ofstream meta(modelPath.string() + ".meta.json"); + meta << "{\"input_feature_count\":" << automix::ai::FeatureSchemaV1::featureCount() + << ",\"allowed_tasks\":[\"master_parameters\",\"mix_parameters\"]}"; + } + + REQUIRE(inference.loadModel(modelPath)); + REQUIRE(inference.isAvailable()); + + automix::ai::InferenceRequest mismatch{ + .task = "master_parameters", + .features = {1.0, 2.0}, + }; + const auto mismatchResult = inference.run(mismatch); + REQUIRE_FALSE(mismatchResult.usedModel); + REQUIRE(mismatchResult.logMessage.find("mismatch") != std::string::npos); + + automix::ai::InferenceRequest valid{ + .task = "master_parameters", + .features = std::vector(automix::ai::FeatureSchemaV1::featureCount(), 0.25), + }; + const auto resultA = inference.run(valid); + const auto resultB = inference.run(valid); + REQUIRE(resultA.usedModel); + REQUIRE(resultB.usedModel); + REQUIRE(resultA.outputs == resultB.outputs); + REQUIRE(inference.backendDiagnostics().find("calls=2") != std::string::npos); + + std::filesystem::remove_all(tempDir); +#endif +} diff --git a/tests/unit/ProjectProfileTests.cpp b/tests/unit/ProjectProfileTests.cpp new file mode 100644 index 0000000..1f6acae --- /dev/null +++ b/tests/unit/ProjectProfileTests.cpp @@ -0,0 +1,70 @@ +#include +#include +#include + +#include +#include + +#include "domain/ProjectProfile.h" + +TEST_CASE("Project profile defaults are available", "[profile]") { + const auto defaults = automix::domain::defaultProjectProfiles(); + REQUIRE_FALSE(defaults.empty()); + + const auto foundDefault = automix::domain::findProjectProfile(defaults, "default"); + REQUIRE(foundDefault.has_value()); + REQUIRE(foundDefault->rendererName == "BuiltIn"); +} + +TEST_CASE("Project profile loader merges asset profiles with defaults", "[profile]") { + const auto nonce = std::to_string( + std::chrono::duration_cast( + std::chrono::high_resolution_clock::now().time_since_epoch()) + .count()); + const auto root = std::filesystem::temp_directory_path() / ("automix_profile_loader_" + nonce); + const auto profileDir = root / "assets" / "profiles"; + std::filesystem::create_directories(profileDir); + + nlohmann::json fileProfiles = nlohmann::json::array(); + fileProfiles.push_back({ + {"id", "aggressive_test"}, + {"name", "Aggressive Test"}, + {"platformPreset", "youtube"}, + {"rendererName", "PhaseLimiter"}, + {"outputFormat", "mp3"}, + {"lossyBitrateKbps", 999}, + {"mp3UseVbr", true}, + {"mp3VbrQuality", 99}, + {"gpuProvider", "cuda"}, + {"roleModelPackId", "demo-role-v1"}, + {"mixModelPackId", "demo-mix-v1"}, + {"masterModelPackId", "demo-master-v1"}, + {"safetyPolicyId", "strict"}, + {"preferredStemCount", 12}, + {"metadataPolicy", "override_template"}, + {"metadataTemplate", nlohmann::json{{"album", "AutoMix Album"}}}, + {"pinnedRendererIds", nlohmann::json::array({"BuiltIn", "PhaseLimiter"})}, + }); + + { + std::ofstream out(profileDir / "project_profiles.json"); + out << fileProfiles.dump(2); + } + + const auto profiles = automix::domain::loadProjectProfiles(root); + const auto custom = automix::domain::findProjectProfile(profiles, "aggressive_test"); + REQUIRE(custom.has_value()); + REQUIRE(custom->rendererName == "PhaseLimiter"); + REQUIRE(custom->lossyBitrateKbps == 320); + REQUIRE(custom->mp3UseVbr == true); + REQUIRE(custom->mp3VbrQuality == 9); + REQUIRE(custom->preferredStemCount == 6); + REQUIRE(custom->metadataPolicy == "override_template"); + REQUIRE(custom->metadataTemplate.at("album") == "AutoMix Album"); + REQUIRE(custom->pinnedRendererIds.size() == 2); + + const auto builtIn = automix::domain::findProjectProfile(profiles, "default"); + REQUIRE(builtIn.has_value()); + + std::filesystem::remove_all(root); +} diff --git a/tests/unit/RendererRegistryTests.cpp b/tests/unit/RendererRegistryTests.cpp new file mode 100644 index 0000000..45eab3e --- /dev/null +++ b/tests/unit/RendererRegistryTests.cpp @@ -0,0 +1,69 @@ +#include +#include + +#include + +#include "renderers/RendererRegistry.h" + +TEST_CASE("Renderer registry always includes built-in renderer", "[renderer][registry]") { + automix::renderers::RendererRegistry registry; + const auto infos = registry.list(); + + bool foundBuiltIn = false; + for (const auto& info : infos) { + if (info.id == "BuiltIn") { + foundBuiltIn = true; + REQUIRE(info.available); + REQUIRE(info.linkMode == automix::renderers::RendererLinkMode::InProcess); + break; + } + } + REQUIRE(foundBuiltIn); +} + +TEST_CASE("Renderer registry includes configured user external renderer metadata", "[renderer][registry]") { + const std::filesystem::path tempBinary = std::filesystem::temp_directory_path() / "automix_external_renderer_dummy.bin"; + { + std::ofstream out(tempBinary); + out << "dummy"; + } + + automix::renderers::ExternalRendererConfig config; + config.id = "ExternalUser1"; + config.name = "Dummy Tool"; + config.licenseId = "GPL-3.0-only"; + config.binaryPath = tempBinary; + + automix::renderers::RendererRegistry registry; + const auto infos = registry.list({config}); + + bool foundExternal = false; + for (const auto& info : infos) { + if (info.id == "ExternalUser1") { + foundExternal = true; + REQUIRE(info.linkMode == automix::renderers::RendererLinkMode::External); + REQUIRE_FALSE(info.available); + REQUIRE(info.binaryPath == tempBinary); + REQUIRE(info.discovery.find("Validation failed") != std::string::npos); + break; + } + } + + REQUIRE(foundExternal); + std::filesystem::remove(tempBinary); +} + +TEST_CASE("Renderer registry scans asset descriptors", "[renderer][registry]") { + automix::renderers::RendererRegistry registry; + const auto infos = registry.list(); + + bool foundTemplate = false; + for (const auto& info : infos) { + if (info.id == "ExternalTemplate") { + foundTemplate = true; + REQUIRE(info.linkMode == automix::renderers::RendererLinkMode::External); + break; + } + } + REQUIRE(foundTemplate); +} diff --git a/tests/unit/RendererRegistryTrustPolicyTests.cpp b/tests/unit/RendererRegistryTrustPolicyTests.cpp new file mode 100644 index 0000000..b427a40 --- /dev/null +++ b/tests/unit/RendererRegistryTrustPolicyTests.cpp @@ -0,0 +1,123 @@ +#include +#include +#include +#include + +#include +#include + +#include "renderers/RendererRegistry.h" + +namespace { + +uint64_t fnv1a64(const std::string& input) { + uint64_t hash = 14695981039346656037ull; + constexpr uint64_t prime = 1099511628211ull; + for (const auto c : input) { + hash ^= static_cast(c); + hash *= prime; + } + return hash; +} + +std::string toHex(const uint64_t value) { + std::ostringstream out; + out << std::hex << value; + return out.str(); +} + +class ScopedCurrentPath { + public: + explicit ScopedCurrentPath(const std::filesystem::path& next) + : original_(std::filesystem::current_path()) { + std::filesystem::current_path(next); + } + + ~ScopedCurrentPath() { std::filesystem::current_path(original_); } + + private: + std::filesystem::path original_; +}; + +} // namespace + +TEST_CASE("Renderer registry enforces signed descriptors when policy requires it", "[renderer][registry][trust]") { + const auto nonce = std::to_string( + std::chrono::duration_cast( + std::chrono::high_resolution_clock::now().time_since_epoch()) + .count()); + const auto root = std::filesystem::temp_directory_path() / ("automix_renderer_trust_" + nonce); + const auto renderersRoot = root / "assets" / "renderers"; + const auto unsignedDir = renderersRoot / "unsigned_vendor"; + const auto signedDir = renderersRoot / "signed_vendor"; + std::filesystem::create_directories(unsignedDir); + std::filesystem::create_directories(signedDir); + + { + nlohmann::json trustPolicy = { + {"enforceSignedDescriptors", true}, + {"trustedSigners", nlohmann::json::array({"automix_official"})}, + }; + std::ofstream out(renderersRoot / "trust_policy.json"); + out << trustPolicy.dump(2); + } + + { + std::ofstream binary(unsignedDir / "vendor.bin"); + binary << "unsigned"; + nlohmann::json descriptor = { + {"id", "UnsignedVendor"}, + {"name", "Unsigned Vendor"}, + {"version", "1.0"}, + {"licenseId", "MIT"}, + {"binaryPath", "vendor.bin"}, + }; + std::ofstream out(unsignedDir / "renderer.json"); + out << descriptor.dump(2); + } + + { + std::ofstream binary(signedDir / "vendor.bin"); + binary << "signed"; + + nlohmann::json descriptor = { + {"id", "SignedVendor"}, + {"name", "Signed Vendor"}, + {"version", "1.0"}, + {"licenseId", "MIT"}, + {"binaryPath", "vendor.bin"}, + }; + const auto signature = toHex(fnv1a64(descriptor.dump())); + descriptor["signature"] = { + {"signer", "automix_official"}, + {"algorithm", "fnv1a64"}, + {"value", signature}, + }; + + std::ofstream out(signedDir / "renderer.json"); + out << descriptor.dump(2); + } + + { + ScopedCurrentPath guard(root); + automix::renderers::RendererRegistry registry; + const auto infos = registry.list(); + + bool foundUnsigned = false; + bool foundSigned = false; + for (const auto& info : infos) { + if (info.id == "UnsignedVendor") { + foundUnsigned = true; + } + if (info.id == "SignedVendor") { + foundSigned = true; + REQUIRE(info.trustPolicyStatus == "signature_valid"); + } + } + + REQUIRE_FALSE(foundUnsigned); + REQUIRE(foundSigned); + } + + std::filesystem::remove_all(root); +} diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 6a57d83..04a6891 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -12,6 +12,24 @@ TEST_CASE("Session serialization round trip preserves required fields", "[sessio session.sessionName = "round_trip"; session.originalMixPath = "C:/audio/original_mix.wav"; session.residualBlend = 7.5; + session.renderSettings.outputFormat = "mp3"; + session.renderSettings.exportSpeedMode = "quick"; + session.renderSettings.lossyBitrateKbps = 256; + session.renderSettings.lossyQuality = 8; + session.renderSettings.mp3UseVbr = true; + session.renderSettings.mp3VbrQuality = 2; + session.renderSettings.processingThreads = 4; + session.renderSettings.preferHardwareAcceleration = true; + session.renderSettings.metadataPolicy = "override_template"; + session.renderSettings.metadataTemplate = {{"artist", "AutoMixMaster"}, {"comment", "test"}}; + session.timeline.loopEnabled = true; + session.timeline.loopInSeconds = 12.5; + session.timeline.loopOutSeconds = 28.0; + session.timeline.zoom = 4.0; + session.timeline.fineScrub = true; + session.projectProfileId = "streaming_spotify"; + session.safetyPolicyId = "strict"; + session.preferredStemCount = 6; automix::domain::Stem stem; stem.id = "s1"; @@ -38,6 +56,23 @@ TEST_CASE("Session serialization round trip preserves required fields", "[sessio REQUIRE(decoded.stems.front().origin == automix::domain::StemOrigin::Separated); REQUIRE(decoded.mixPlan.has_value()); REQUIRE(decoded.mixPlan->dryWet == Catch::Approx(0.8)); + REQUIRE(decoded.renderSettings.outputFormat == "mp3"); + REQUIRE(decoded.renderSettings.exportSpeedMode == "quick"); + REQUIRE(decoded.renderSettings.lossyBitrateKbps == 256); + REQUIRE(decoded.renderSettings.lossyQuality == 8); + REQUIRE(decoded.renderSettings.mp3UseVbr == true); + REQUIRE(decoded.renderSettings.mp3VbrQuality == 2); + REQUIRE(decoded.renderSettings.processingThreads == 4); + REQUIRE(decoded.renderSettings.metadataPolicy == "override_template"); + REQUIRE(decoded.renderSettings.metadataTemplate.at("artist") == "AutoMixMaster"); + REQUIRE(decoded.timeline.loopEnabled == true); + REQUIRE(decoded.timeline.loopInSeconds == Catch::Approx(12.5)); + REQUIRE(decoded.timeline.loopOutSeconds == Catch::Approx(28.0)); + REQUIRE(decoded.timeline.zoom == Catch::Approx(4.0)); + REQUIRE(decoded.timeline.fineScrub == true); + REQUIRE(decoded.projectProfileId == "streaming_spotify"); + REQUIRE(decoded.safetyPolicyId == "strict"); + REQUIRE(decoded.preferredStemCount == 6); } TEST_CASE("Session deserialization handles missing optional fields", "[session]") { @@ -54,6 +89,20 @@ TEST_CASE("Session deserialization handles missing optional fields", "[session]" REQUIRE(decoded.originalMixPath.has_value() == false); REQUIRE(decoded.residualBlend == Catch::Approx(0.0)); REQUIRE(decoded.renderSettings.blockSize == 1024); + REQUIRE(decoded.renderSettings.outputFormat == "auto"); + REQUIRE(decoded.renderSettings.exportSpeedMode == "final"); + REQUIRE(decoded.renderSettings.lossyBitrateKbps == 320); + REQUIRE(decoded.renderSettings.lossyQuality == 7); + REQUIRE(decoded.renderSettings.mp3UseVbr == false); + REQUIRE(decoded.renderSettings.mp3VbrQuality == 4); + REQUIRE(decoded.renderSettings.metadataPolicy == "copy_all"); + REQUIRE(decoded.renderSettings.metadataTemplate.empty()); + REQUIRE(decoded.renderSettings.preferHardwareAcceleration == true); + REQUIRE(decoded.timeline.loopEnabled == false); + REQUIRE(decoded.timeline.zoom == Catch::Approx(1.0)); + REQUIRE(decoded.projectProfileId == "default"); + REQUIRE(decoded.safetyPolicyId == "balanced"); + REQUIRE(decoded.preferredStemCount == 4); REQUIRE(decoded.stems.front().enabled == true); REQUIRE(decoded.stems.front().origin == automix::domain::StemOrigin::Recorded); } diff --git a/tests/unit/StemSeparatorTests.cpp b/tests/unit/StemSeparatorTests.cpp new file mode 100644 index 0000000..7adc9b8 --- /dev/null +++ b/tests/unit/StemSeparatorTests.cpp @@ -0,0 +1,106 @@ +#include +#include +#include + +#include +#include + +#include "ai/StemSeparator.h" +#include "engine/AudioBuffer.h" +#include "util/WavWriter.h" + +namespace { + +automix::engine::AudioBuffer makeTone(const double sampleRate, const int samples, const double frequency) { + automix::engine::AudioBuffer buffer(2, samples, sampleRate); + for (int i = 0; i < samples; ++i) { + const float sample = static_cast(0.35 * std::sin(2.0 * 3.14159265358979323846 * frequency * i / sampleRate)); + buffer.setSample(0, i, sample); + buffer.setSample(1, i, sample); + } + return buffer; +} + +} // namespace + +TEST_CASE("Stem separator uses model-backed overlap-add when model metadata is present", "[ai][separator]") { + const std::filesystem::path tempRoot = std::filesystem::temp_directory_path() / "automix_separator_model_test"; + const auto modelDir = tempRoot / "model"; + const auto outputDir = tempRoot / "output"; + const auto mixPath = tempRoot / "mix.wav"; + std::filesystem::remove_all(tempRoot); + std::filesystem::create_directories(modelDir); + std::filesystem::create_directories(outputDir); + + automix::util::WavWriter writer; + writer.write(mixPath, makeTone(44100.0, 44100, 330.0), 24, "wav"); + + { + std::ofstream model(modelDir / "separator.onnx", std::ios::binary); + model << "dummy_separator_model"; + } + + { + nlohmann::json meta = { + {"input_feature_count", 27}, + {"allowed_tasks", {"stem_separation"}}, + {"execution_providers", {"cpu"}}, + }; + std::ofstream metaOut((modelDir / "separator.onnx.meta.json").string()); + metaOut << meta.dump(2); + } + + automix::ai::StemSeparator separator(modelDir); + REQUIRE(separator.isModelAvailable()); + + const auto result = separator.separate(mixPath, outputDir); + REQUIRE(result.success); + REQUIRE(result.usedModel); + REQUIRE(result.stems.size() == 4); + REQUIRE(result.generatedFiles.size() == 4); + REQUIRE(result.stemVariantCount == 4); + REQUIRE_FALSE(result.qaReportPath.empty()); + REQUIRE(std::filesystem::exists(result.qaReportPath)); + REQUIRE(result.qaMetrics.energyLeakage >= 0.0); + REQUIRE(result.qaMetrics.residualDistortion >= 0.0); + REQUIRE(result.qaMetrics.transientRetention >= 0.0); + + for (const auto& stem : result.stems) { + REQUIRE(std::filesystem::exists(stem.filePath)); + REQUIRE(stem.separationConfidence.has_value()); + REQUIRE(stem.separationArtifactRisk.has_value()); + REQUIRE(stem.separationConfidence.value() >= 0.0); + REQUIRE(stem.separationConfidence.value() <= 1.0); + REQUIRE(stem.separationArtifactRisk.value() >= 0.0); + REQUIRE(stem.separationArtifactRisk.value() <= 1.0); + } + + std::filesystem::remove_all(tempRoot); +} + +TEST_CASE("Stem separator supports deterministic 6-stem variant selection", "[ai][separator]") { + const std::filesystem::path tempRoot = std::filesystem::temp_directory_path() / "automix_separator_6stem_test"; + const auto modelDir = tempRoot / "missing_model"; + const auto outputDir = tempRoot / "output"; + const auto mixPath = tempRoot / "mix.wav"; + std::filesystem::remove_all(tempRoot); + std::filesystem::create_directories(modelDir); + std::filesystem::create_directories(outputDir); + + automix::util::WavWriter writer; + writer.write(mixPath, makeTone(44100.0, 44100, 220.0), 24, "wav"); + + automix::ai::StemSeparator separator(modelDir); + automix::ai::StemSeparator::SeparationOptions options; + options.targetStemCount = 6; + + const auto result = separator.separate(mixPath, outputDir, options); + REQUIRE(result.success); + REQUIRE_FALSE(result.usedModel); + REQUIRE(result.stems.size() == 6); + REQUIRE(result.generatedFiles.size() == 6); + REQUIRE(result.stemVariantCount == 6); + REQUIRE(std::filesystem::exists(result.qaReportPath)); + + std::filesystem::remove_all(tempRoot); +} diff --git a/tests/unit/TransportControllerTests.cpp b/tests/unit/TransportControllerTests.cpp new file mode 100644 index 0000000..eba72f8 --- /dev/null +++ b/tests/unit/TransportControllerTests.cpp @@ -0,0 +1,52 @@ +#include +#include + +#include "engine/TransportController.h" + +TEST_CASE("TransportController tracks play/seek/advance state", "[transport]") { + automix::engine::TransportController transport; + transport.setTimeline(48000, 48000.0); + + REQUIRE(transport.totalSamples() == 48000); + REQUIRE(transport.progress() == Catch::Approx(0.0)); + + transport.play(); + REQUIRE(transport.isPlaying()); + + transport.advance(24000); + REQUIRE(transport.positionSamples() == 24000); + REQUIRE(transport.progress() == Catch::Approx(0.5).margin(1.0e-6)); + + transport.seekToFraction(0.25); + REQUIRE(transport.positionSamples() == 12000); + + transport.pause(); + REQUIRE_FALSE(transport.isPlaying()); + + transport.seekToSample(60000); + REQUIRE(transport.positionSamples() == 48000); + + transport.stop(); + REQUIRE(transport.positionSamples() == 0); + REQUIRE(transport.progress() == Catch::Approx(0.0)); +} + +TEST_CASE("TransportController enforces loop markers during playback", "[transport]") { + automix::engine::TransportController transport; + transport.setTimeline(48000, 48000.0); + transport.setLoopRangeSeconds(0.25, 0.50, true); + + REQUIRE(transport.loopEnabled()); + REQUIRE(transport.loopInSeconds() == Catch::Approx(0.25).margin(1.0e-6)); + REQUIRE(transport.loopOutSeconds() == Catch::Approx(0.50).margin(1.0e-6)); + + transport.seekToSample(23500); + transport.play(); + transport.advance(2000); + + REQUIRE(transport.positionSamples() >= 12000); + REQUIRE(transport.positionSamples() <= 24000); + + transport.clearLoopRange(); + REQUIRE_FALSE(transport.loopEnabled()); +} diff --git a/tools/batch_studio_api.py b/tools/batch_studio_api.py new file mode 100644 index 0000000..15457d4 --- /dev/null +++ b/tools/batch_studio_api.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +"""Batch Studio API + +Minimal remote API wrapper for headless catalog processing and report ingestion. + +Endpoints: + GET /health + POST /v1/catalog/process + POST /v1/reports/ingest +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import sys +import time +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from typing import Any, Dict + + +def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +def _load_json_body(handler: BaseHTTPRequestHandler) -> Dict[str, Any]: + content_length = int(handler.headers.get("Content-Length", "0") or 0) + raw = handler.rfile.read(content_length) if content_length > 0 else b"{}" + try: + payload = json.loads(raw.decode("utf-8")) + except Exception: + payload = {} + if not isinstance(payload, dict): + return {} + return payload + + +def _append_jsonl(path: pathlib.Path, payload: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fp: + fp.write(json.dumps(payload, ensure_ascii=True) + "\n") + + +def _is_path_contained(user_path: str, allowed_root: pathlib.Path) -> bool: + """Return True if *user_path* resolves inside *allowed_root*. + + Prevents directory-traversal attacks when the API is network-exposed. + """ + try: + resolved = pathlib.Path(user_path).resolve() + root_resolved = allowed_root.resolve() + return resolved == root_resolved or resolved.is_relative_to(root_resolved) + except (ValueError, OSError): + return False + + +def make_handler( + *, + automix_bin: str, + output_root: pathlib.Path, + api_key: str, +) -> type[BaseHTTPRequestHandler]: + class BatchStudioHandler(BaseHTTPRequestHandler): + server_version = "AutoMixBatchStudio/1.0" + + def _authorized(self) -> bool: + if not api_key: + return True + provided = self.headers.get("x-api-key", "") + return bool(provided) and provided == api_key + + def do_GET(self) -> None: # noqa: N802 + if self.path == "/health": + _json_response(self, HTTPStatus.OK, {"ok": True, "service": "batch-studio-api"}) + return + _json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) + + def do_POST(self) -> None: # noqa: N802 + if not self._authorized(): + _json_response(self, HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) + return + + if self.path == "/v1/catalog/process": + self._handle_catalog_process() + return + + if self.path == "/v1/reports/ingest": + self._handle_report_ingest() + return + + _json_response(self, HTTPStatus.NOT_FOUND, {"error": "not_found"}) + + def _handle_catalog_process(self) -> None: + payload = _load_json_body(self) + input_dir = payload.get("input") + output_dir = payload.get("output") + if not input_dir or not output_dir: + _json_response( + self, + HTTPStatus.BAD_REQUEST, + {"error": "input and output are required"}, + ) + return + + if not _is_path_contained(str(input_dir), output_root): + _json_response( + self, + HTTPStatus.FORBIDDEN, + {"error": "input directory is outside the allowed root"}, + ) + return + if not _is_path_contained(str(output_dir), output_root): + _json_response( + self, + HTTPStatus.FORBIDDEN, + {"error": "output directory is outside the allowed root"}, + ) + return + + run_id = f"run_{int(time.time())}" + run_dir = output_root / run_id + run_dir.mkdir(parents=True, exist_ok=True) + + csv_path = run_dir / "catalog_deliverables.csv" + json_path = run_dir / "catalog_deliverables.json" + checkpoint_path = run_dir / "catalog_checkpoint.json" + + cmd = [ + automix_bin, + "catalog-process", + "--input", + str(input_dir), + "--output", + str(output_dir), + "--checkpoint", + str(checkpoint_path), + "--csv", + str(csv_path), + "--json", + str(json_path), + ] + + optional_map = { + "renderer": "--renderer", + "format": "--format", + "analysis_threads": "--analysis-threads", + "render_parallelism": "--render-parallelism", + } + for key, flag in optional_map.items(): + value = payload.get(key) + if value is not None and value != "": + cmd.extend([flag, str(value)]) + + if payload.get("resume"): + cmd.append("--resume") + + proc = subprocess.run(cmd, capture_output=True, text=True) + + response_payload: Dict[str, Any] = { + "ok": proc.returncode == 0, + "run_id": run_id, + "exit_code": proc.returncode, + "command": cmd, + "stdout": proc.stdout[-8000:], + "stderr": proc.stderr[-8000:], + "json_report": str(json_path), + "csv_report": str(csv_path), + "checkpoint": str(checkpoint_path), + } + if json_path.exists(): + try: + response_payload["deliverables"] = json.loads(json_path.read_text(encoding="utf-8")) + except Exception: + response_payload["deliverables"] = {"error": "failed_to_parse_deliverables"} + + _append_jsonl( + output_root / "runs" / "catalog_process_runs.jsonl", + { + "timestamp": int(time.time()), + "run_id": run_id, + "ok": response_payload["ok"], + "exit_code": response_payload["exit_code"], + "json_report": response_payload["json_report"], + "csv_report": response_payload["csv_report"], + }, + ) + + status = HTTPStatus.OK if proc.returncode == 0 else HTTPStatus.BAD_GATEWAY + _json_response(self, status, response_payload) + + def _handle_report_ingest(self) -> None: + payload = _load_json_body(self) + report_obj = payload.get("report") + report_path = payload.get("report_path") + + if report_obj is None and not report_path: + _json_response(self, HTTPStatus.BAD_REQUEST, {"error": "report or report_path is required"}) + return + + record: Dict[str, Any] = { + "ingested_at_epoch": int(time.time()), + "source": payload.get("source", "manual"), + } + + if report_obj is not None: + record["report"] = report_obj + + if report_path: + if not _is_path_contained(str(report_path), output_root): + _json_response( + self, + HTTPStatus.FORBIDDEN, + {"error": "report_path is outside the allowed root"}, + ) + return + path = pathlib.Path(str(report_path)) + record["report_path"] = str(path) + if path.exists(): + try: + record["report"] = json.loads(path.read_text(encoding="utf-8")) + except Exception: + record["report_parse_error"] = True + else: + record["report_missing"] = True + + ingest_log = output_root / "ingested" / "reports.jsonl" + _append_jsonl(ingest_log, record) + _json_response( + self, + HTTPStatus.OK, + { + "ok": True, + "ingested_log": str(ingest_log), + "ingested_at_epoch": record["ingested_at_epoch"], + }, + ) + + def log_message(self, fmt: str, *args: Any) -> None: + # Keep logs concise for headless usage. + sys.stderr.write("[batch-studio-api] " + (fmt % args) + "\n") + + return BatchStudioHandler + + +def main() -> int: + parser = argparse.ArgumentParser(description="AutoMixMaster Batch Studio API") + parser.add_argument("--host", default="127.0.0.1", help="Bind host") + parser.add_argument("--port", type=int, default=8089, help="Bind port") + parser.add_argument( + "--automix-bin", + default=os.environ.get("AUTOMIX_DEV_TOOLS_BIN", "automix_dev_tools"), + help="Path to automix_dev_tools binary", + ) + parser.add_argument( + "--output-root", + default=os.environ.get("AUTOMIX_BATCH_API_ROOT", "artifacts/batch_studio_api"), + help="Directory for API run artifacts", + ) + parser.add_argument( + "--api-key", + default=os.environ.get("AUTOMIX_BATCH_API_KEY", ""), + help="Optional API key; if provided, clients must pass x-api-key", + ) + args = parser.parse_args() + + output_root = pathlib.Path(args.output_root) + output_root.mkdir(parents=True, exist_ok=True) + + handler = make_handler( + automix_bin=args.automix_bin, + output_root=output_root, + api_key=args.api_key, + ) + server = ThreadingHTTPServer((args.host, args.port), handler) + print(f"Batch Studio API listening on http://{args.host}:{args.port}") + print(f"Using automix_dev_tools binary: {args.automix_bin}") + print(f"Artifacts root: {output_root}") + + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/catalog/modelpacks/demo-master-v1/model.json b/tools/catalog/modelpacks/demo-master-v1/model.json new file mode 100644 index 0000000..2f625cd --- /dev/null +++ b/tools/catalog/modelpacks/demo-master-v1/model.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "id": "demo-master-v1", + "name": "Demo Master Parameter V1", + "type": "master_parameters", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "CC-BY-4.0", + "source": "AutoMixMaster demo catalog", + "feature_schema_version": "1.0.0", + "output_schema": { + "confidence": "float", + "target_lufs": "float", + "pre_gain_db": "float", + "limiter_ceiling_db": "float", + "glue_ratio": "float" + } +} diff --git a/tools/catalog/modelpacks/demo-master-v1/model.onnx b/tools/catalog/modelpacks/demo-master-v1/model.onnx new file mode 100644 index 0000000..6bc2706 --- /dev/null +++ b/tools/catalog/modelpacks/demo-master-v1/model.onnx @@ -0,0 +1 @@ +AUTOMIX_DEMO_ONNX_MASTER_V1 diff --git a/tools/catalog/modelpacks/demo-mix-v1/model.json b/tools/catalog/modelpacks/demo-mix-v1/model.json new file mode 100644 index 0000000..62add46 --- /dev/null +++ b/tools/catalog/modelpacks/demo-mix-v1/model.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "id": "demo-mix-v1", + "name": "Demo Mix Parameter V1", + "type": "mix_parameters", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "CC-BY-4.0", + "source": "AutoMixMaster demo catalog", + "feature_schema_version": "1.0.0", + "output_schema": { + "confidence": "float", + "global_gain_db": "float", + "global_pan_bias": "float" + } +} diff --git a/tools/catalog/modelpacks/demo-mix-v1/model.onnx b/tools/catalog/modelpacks/demo-mix-v1/model.onnx new file mode 100644 index 0000000..aea7be6 --- /dev/null +++ b/tools/catalog/modelpacks/demo-mix-v1/model.onnx @@ -0,0 +1 @@ +AUTOMIX_DEMO_ONNX_MIX_V1 diff --git a/tools/catalog/modelpacks/demo-role-v1/model.json b/tools/catalog/modelpacks/demo-role-v1/model.json new file mode 100644 index 0000000..565cd5f --- /dev/null +++ b/tools/catalog/modelpacks/demo-role-v1/model.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, + "id": "demo-role-v1", + "name": "Demo Role Classifier V1", + "type": "role_classifier", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "CC-BY-4.0", + "source": "AutoMixMaster demo catalog", + "feature_schema_version": "1.0.0", + "output_schema": { + "prob_vocals": "float", + "prob_bass": "float", + "prob_drums": "float", + "prob_fx": "float" + } +} diff --git a/tools/catalog/modelpacks/demo-role-v1/model.onnx b/tools/catalog/modelpacks/demo-role-v1/model.onnx new file mode 100644 index 0000000..b2eddf9 --- /dev/null +++ b/tools/catalog/modelpacks/demo-role-v1/model.onnx @@ -0,0 +1 @@ +AUTOMIX_DEMO_ONNX_ROLE_V1 diff --git a/tools/commands/BatchCommands.cpp b/tools/commands/BatchCommands.cpp new file mode 100644 index 0000000..ee0b873 --- /dev/null +++ b/tools/commands/BatchCommands.cpp @@ -0,0 +1,47 @@ +#include "commands/CommandRegistry.h" +#include "commands/DevToolsUtils.h" + +#include +#include + +namespace { + +using namespace automix::devtools; + +int commandBatchStudioApi(const CommandArgs& args) { + const auto scriptPath = findRepoPath("tools/batch_studio_api.py"); + if (!scriptPath.has_value()) { + std::cerr << "batch-studio-api script not found under tools/batch_studio_api.py\n"; + return 1; + } + + const auto python = argValue(args, "--python") + .value_or(readEnvironment("PYTHON").value_or("python3")); + std::ostringstream command; + command << python << " \"" << scriptPath->string() << "\""; + + if (const auto host = argValue(args, "--host"); host.has_value()) { + command << " --host " << *host; + } + if (const auto port = argValue(args, "--port"); port.has_value()) { + command << " --port " << *port; + } + if (const auto bin = argValue(args, "--automix-bin"); bin.has_value()) { + command << " --automix-bin \"" << *bin << "\""; + } + if (const auto outputRoot = argValue(args, "--output-root"); outputRoot.has_value()) { + command << " --output-root \"" << *outputRoot << "\""; + } + if (const auto apiKey = argValue(args, "--api-key"); apiKey.has_value()) { + command << " --api-key \"" << *apiKey << "\""; + } + + std::cout << "Launching Batch Studio API: " << command.str() << "\n"; + return std::system(command.str().c_str()); +} + +} // namespace + +void registerBatchCommands(automix::devtools::CommandRegistry& registry) { + registry.add("batch-studio-api", commandBatchStudioApi); +} diff --git a/tools/commands/CommandRegistry.h b/tools/commands/CommandRegistry.h new file mode 100644 index 0000000..7e4d453 --- /dev/null +++ b/tools/commands/CommandRegistry.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace automix::devtools { + +using CommandArgs = std::vector; +using CommandFn = std::function; + +class CommandRegistry { + public: + void add(const std::string& name, CommandFn fn) { + commands_[name] = std::move(fn); + } + + int dispatch(const std::string& name, const CommandArgs& args) const { + const auto it = commands_.find(name); + if (it == commands_.end()) { + return -1; + } + return it->second(args); + } + + std::vector commandNames() const { + std::vector names; + names.reserve(commands_.size()); + for (const auto& [name, _] : commands_) { + names.push_back(name); + } + return names; + } + + private: + std::map commands_; +}; + +} // namespace automix::devtools diff --git a/tools/commands/Commands.h b/tools/commands/Commands.h new file mode 100644 index 0000000..4751a30 --- /dev/null +++ b/tools/commands/Commands.h @@ -0,0 +1,11 @@ +#pragma once + +namespace automix::devtools { +class CommandRegistry; +} + +void registerModelCommands(automix::devtools::CommandRegistry& registry); +void registerSessionCommands(automix::devtools::CommandRegistry& registry); +void registerRenderCommands(automix::devtools::CommandRegistry& registry); +void registerEvalCommands(automix::devtools::CommandRegistry& registry); +void registerBatchCommands(automix::devtools::CommandRegistry& registry); diff --git a/tools/commands/DevToolsUtils.cpp b/tools/commands/DevToolsUtils.cpp new file mode 100644 index 0000000..86a03c7 --- /dev/null +++ b/tools/commands/DevToolsUtils.cpp @@ -0,0 +1,718 @@ +#include "commands/DevToolsUtils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ai/OnnxModelInference.h" + +namespace automix::devtools { + +// --- Argument parsing --- + +std::optional argValue(const std::vector& args, const std::string& key) { + for (size_t i = 0; i + 1 < args.size(); ++i) { + if (args[i] == key) { + return args[i + 1]; + } + } + return std::nullopt; +} + +bool hasFlag(const std::vector& args, const std::string& key) { + return std::find(args.begin(), args.end(), key) != args.end(); +} + +std::optional parseIntArg(const std::vector& args, const std::string& key) { + const auto value = argValue(args, key); + if (!value.has_value()) { + return std::nullopt; + } + try { + return std::stoi(*value); + } catch (...) { + return std::nullopt; + } +} + +std::optional parseDoubleArg(const std::vector& args, const std::string& key) { + const auto value = argValue(args, key); + if (!value.has_value()) { + return std::nullopt; + } + try { + return std::stod(*value); + } catch (...) { + return std::nullopt; + } +} + +// --- String helpers --- + +std::string toLower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +std::string sanitizeFileName(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) { + if (std::isalnum(c) || c == '_' || c == '-') { + return static_cast(c); + } + return '_'; + }); + if (value.empty()) { + return "segment"; + } + return value; +} + +std::vector splitCommaSeparated(const std::string& value) { + std::vector items; + std::stringstream stream(value); + std::string token; + while (std::getline(stream, token, ',')) { + if (!token.empty()) { + items.push_back(token); + } + } + return items; +} + +std::string csvEscape(const std::string& value) { + if (value.find_first_of(",\"\n\r") == std::string::npos) { + return value; + } + std::string escaped = "\""; + for (const auto c : value) { + if (c == '"') { + escaped += "\"\""; + } else { + escaped += c; + } + } + escaped += "\""; + return escaped; +} + +// --- Hash / timestamp --- + +uint64_t fnv1a64(const std::string& input) { + uint64_t hash = 14695981039346656037ull; + constexpr uint64_t prime = 1099511628211ull; + for (const auto c : input) { + hash ^= static_cast(c); + hash *= prime; + } + return hash; +} + +std::string toHex(const uint64_t value) { + std::ostringstream out; + out << std::hex << value; + return out.str(); +} + +std::string iso8601NowUtc() { + const auto now = std::chrono::system_clock::now(); + const std::time_t time = std::chrono::system_clock::to_time_t(now); + std::tm utc {}; +#if defined(_WIN32) + gmtime_s(&utc, &time); +#else + gmtime_r(&time, &utc); +#endif + std::ostringstream out; + out << std::put_time(&utc, "%Y-%m-%dT%H:%M:%SZ"); + return out.str(); +} + +// --- Environment / path helpers --- + +std::optional readEnvironment(const std::string& key) { + const char* value = std::getenv(key.c_str()); + if (value == nullptr || *value == '\0') { + return std::nullopt; + } + return std::string(value); +} + +std::filesystem::path profileCatalogPath() { + return std::filesystem::path("assets") / "profiles" / "project_profiles.json"; +} + +std::string extensionForFormat(const std::string& format) { + const auto normalized = toLower(format); + if (normalized == "wav") { + return ".wav"; + } + if (normalized == "aif" || normalized == "aiff") { + return ".aiff"; + } + if (normalized == "flac") { + return ".flac"; + } + if (normalized == "mp3") { + return ".mp3"; + } + if (normalized == "ogg" || normalized == "vorbis") { + return ".ogg"; + } + return ".wav"; +} + +std::optional findRepoPath(const std::filesystem::path& relativePath) { + std::error_code error; + auto current = std::filesystem::absolute(std::filesystem::current_path(error), error); + if (error) { + return std::nullopt; + } + for (int depth = 0; depth < 6; ++depth) { + const auto candidate = current / relativePath; + if (std::filesystem::exists(candidate, error) && !error) { + return candidate; + } + if (!current.has_parent_path()) { + break; + } + current = current.parent_path(); + } + return std::nullopt; +} + +void copyDirectory(const std::filesystem::path& source, const std::filesystem::path& destination) { + std::error_code error; + std::filesystem::create_directories(destination, error); + if (error) { + throw std::runtime_error("Failed to create destination directory: " + destination.string()); + } + std::filesystem::copy(source, + destination, + std::filesystem::copy_options::recursive | std::filesystem::copy_options::overwrite_existing, + error); + if (error) { + throw std::runtime_error("Failed to copy directory: " + source.string() + " -> " + destination.string()); + } +} + +// --- File I/O --- + +std::optional loadJsonFile(const std::filesystem::path& path) { + std::ifstream in(path); + if (!in.is_open()) { + return std::nullopt; + } + try { + nlohmann::json json; + in >> json; + return json; + } catch (...) { + return std::nullopt; + } +} + +std::optional readTextFile(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) { + return std::nullopt; + } + std::ostringstream content; + content << in.rdbuf(); + return content.str(); +} + +void writeJsonFile(const std::filesystem::path& path, const nlohmann::json& payload) { + std::error_code error; + if (path.has_parent_path()) { + std::filesystem::create_directories(path.parent_path(), error); + } + std::ofstream out(path); + if (!out.is_open()) { + throw std::runtime_error("Failed to open output file: " + path.string()); + } + out << payload.dump(2); +} + +// --- Audio helpers --- + +automix::engine::AudioBuffer sliceBuffer(const automix::engine::AudioBuffer& input, + const int maxSamples) { + const int outputSamples = std::max(0, std::min(input.getNumSamples(), maxSamples)); + automix::engine::AudioBuffer output(input.getNumChannels(), outputSamples, input.getSampleRate()); + + for (int ch = 0; ch < input.getNumChannels(); ++ch) { + for (int i = 0; i < outputSamples; ++i) { + output.setSample(ch, i, input.getSample(ch, i)); + } + } + + return output; +} + +// --- Model helpers --- + +std::vector deterministicFeatures(const size_t count) { + std::vector features; + features.reserve(count); + for (size_t i = 0; i < count; ++i) { + features.push_back(static_cast(i + 1) / static_cast(count + 1)); + } + return features; +} + +std::string taskFromModelType(const std::string& type) { + if (type == "role_classifier") { + return "role_classifier"; + } + if (type == "master_parameters") { + return "master_parameters"; + } + return "mix_parameters"; +} + +// --- Supported packs / limiters --- + +const std::vector& supportedModelPacks() { + static const std::vector packs = { + {"demo-role-v1", "role_classifier", "Deterministic demo role-classifier pack."}, + {"demo-mix-v1", "mix_parameters", "Deterministic demo mix-parameter pack."}, + {"demo-master-v1", "master_parameters", "Deterministic demo mastering pack."}, + }; + return packs; +} + +const std::vector& supportedLimiters() { + static const std::vector limiters = { + {"phaselimiter", "PhaseLimiter", "Phase limiter external renderer package.", "See assets/phaselimiter/licenses"}, + {"external-template", "ExternalLimiterTemplate", "Template external limiter descriptor for custom tools.", "User-supplied"}, + }; + return limiters; +} + +// --- Report metrics --- + +ReportMetrics readReportMetrics(const std::filesystem::path& reportPath) { + ReportMetrics metrics; + const auto report = loadJsonFile(reportPath); + if (!report.has_value() || !report->is_object()) { + return metrics; + } + + metrics.loaded = true; + metrics.renderer = report->value("renderer", ""); + metrics.integratedLufs = report->value("integratedLufs", metrics.integratedLufs); + metrics.truePeakDbtp = report->value("truePeakDbtp", metrics.truePeakDbtp); + metrics.targetLufs = report->value("targetLufs", metrics.targetLufs); + metrics.targetTruePeakDbtp = report->value("targetTruePeakDbtp", metrics.targetTruePeakDbtp); + metrics.monoCorrelation = report->value("monoCorrelation", metrics.monoCorrelation); + metrics.stereoCorrelation = report->value("stereoCorrelation", metrics.stereoCorrelation); + + if (report->contains("artifactRisk")) { + metrics.artifactRisk = std::clamp(report->value("artifactRisk", 0.0), 0.0, 1.0); + } else { + const double high = report->value("spectrumHigh", 0.0); + const double mid = report->value("spectrumMid", 0.0); + metrics.artifactRisk = std::clamp((high - mid) * 0.8 + (1.0 - metrics.monoCorrelation) * 0.3, 0.0, 1.0); + } + + return metrics; +} + +double computeComparatorScore(const ReportMetrics& metrics) { + const double loudnessError = std::abs(metrics.integratedLufs - metrics.targetLufs); + const double truePeakOverflow = std::max(0.0, metrics.truePeakDbtp - metrics.targetTruePeakDbtp); + const double monoPenalty = std::max(0.0, 0.95 - metrics.monoCorrelation) * 40.0; + const double stereoPenalty = std::max(0.0, 0.80 - metrics.stereoCorrelation) * 20.0; + const double artifactPenalty = metrics.artifactRisk * 15.0; + const double score = 100.0 - loudnessError * 18.0 - truePeakOverflow * 30.0 - monoPenalty - stereoPenalty - artifactPenalty; + return std::clamp(score, 0.0, 100.0); +} + +// --- Batch status --- + +automix::domain::BatchItemStatus batchStatusFromString(const std::string& value) { + const auto normalized = toLower(value); + if (normalized == "analyzing") { + return automix::domain::BatchItemStatus::Analyzing; + } + if (normalized == "rendering") { + return automix::domain::BatchItemStatus::Rendering; + } + if (normalized == "completed") { + return automix::domain::BatchItemStatus::Completed; + } + if (normalized == "failed") { + return automix::domain::BatchItemStatus::Failed; + } + if (normalized == "cancelled") { + return automix::domain::BatchItemStatus::Cancelled; + } + return automix::domain::BatchItemStatus::Pending; +} + +// --- Deterministic inference --- + +bool DeterministicPlanDiffInference::isAvailable() const { return true; } + +bool DeterministicPlanDiffInference::loadModel(const std::filesystem::path&) { + loaded_ = true; + return true; +} + +automix::ai::InferenceResult DeterministicPlanDiffInference::run( + const automix::ai::InferenceRequest& request) const { + automix::ai::InferenceResult result; + result.usedModel = loaded_; + if (!loaded_) { + result.logMessage = "deterministic inference not loaded"; + return result; + } + + if (request.task == "mix_parameters") { + result.outputs = { + {"confidence", 0.72}, + {"global_gain_db", -0.8}, + {"global_pan_bias", 0.04}, + {"stem0_gain_db", -1.4}, + {"stem0_pan", 0.05}, + }; + result.logMessage = "deterministic mix diff inference"; + return result; + } + + if (request.task == "master_parameters") { + result.outputs = { + {"confidence", 0.68}, + {"target_lufs", -13.2}, + {"pre_gain_db", 0.6}, + {"limiter_ceiling_db", -1.2}, + {"glue_ratio", 2.8}, + {"glue_threshold_db", -19.0}, + }; + result.logMessage = "deterministic master diff inference"; + return result; + } + + result.outputs = { + {"confidence", 0.60}, + }; + result.logMessage = "deterministic generic inference"; + return result; +} + +std::unique_ptr buildPlanDiffInference( + const std::optional& modelPathArg, + const std::string& taskLabel, + std::vector* notes) { + if (modelPathArg.has_value() && !modelPathArg->empty()) { + auto onnx = std::make_unique(); + onnx->setExecutionProviderPreference("cpu"); + onnx->setWarmupEnabled(false); + if (onnx->loadModel(*modelPathArg)) { + notes->push_back("Loaded " + taskLabel + " model: " + *modelPathArg); + return onnx; + } + notes->push_back("Failed to load " + taskLabel + " model (" + *modelPathArg + + "). Falling back to deterministic model-diff adapter."); + } else { + notes->push_back("No " + taskLabel + " model path provided; using deterministic model-diff adapter."); + } + + return std::make_unique(); +} + +// --- JSON merge internals --- + +namespace { + +void recordMergeConflict(JsonMergeTelemetry* telemetry, const std::string& path) { + if (telemetry == nullptr) { + return; + } + ++telemetry->conflictCount; + if (telemetry->conflictPaths.size() < 200) { + telemetry->conflictPaths.push_back(path.empty() ? "/" : path); + } +} + +bool isDecisionLogPath(const std::string& path) { + return path == "/mixPlan/decisionLog" || path == "/masterPlan/decisionLog"; +} + +bool keyedMergePath(const std::string& path, std::string* keyField) { + if (path == "/stems") { + *keyField = "id"; + return true; + } + if (path == "/buses") { + *keyField = "id"; + return true; + } + if (path == "/mixPlan/stemDecisions") { + *keyField = "stemId"; + return true; + } + return false; +} + +std::optional mergeStringArrayUnion(const std::optional& left, + const std::optional& right) { + if (!left.has_value() && !right.has_value()) { + return std::nullopt; + } + + nlohmann::json merged = nlohmann::json::array(); + std::set seen; + + const auto append = [&](const std::optional& arrayJson) { + if (!arrayJson.has_value() || !arrayJson->is_array()) { + return; + } + for (const auto& item : *arrayJson) { + if (!item.is_string()) { + continue; + } + const auto value = item.get(); + if (seen.insert(value).second) { + merged.push_back(value); + } + } + }; + + append(left); + append(right); + return merged; +} + +bool mapArrayByKey(const std::optional& value, + const std::string& keyField, + std::map* out) { + if (!value.has_value()) { + return true; + } + if (!value->is_array()) { + return false; + } + for (const auto& item : *value) { + if (!item.is_object() || !item.contains(keyField)) { + return false; + } + std::string key; + if (item.at(keyField).is_string()) { + key = item.at(keyField).get(); + } else { + key = item.at(keyField).dump(); + } + (*out)[key] = item; + } + return true; +} + +std::optional mergeKeyedArray(const std::optional& base, + const std::optional& left, + const std::optional& right, + const std::string& path, + const std::string& keyField, + JsonMergeTelemetry* telemetry) { + std::map baseMap; + std::map leftMap; + std::map rightMap; + if (!mapArrayByKey(base, keyField, &baseMap) || + !mapArrayByKey(left, keyField, &leftMap) || + !mapArrayByKey(right, keyField, &rightMap)) { + return std::nullopt; + } + + std::set keys; + for (const auto& [key, _] : baseMap) { + keys.insert(key); + } + for (const auto& [key, _] : leftMap) { + keys.insert(key); + } + for (const auto& [key, _] : rightMap) { + keys.insert(key); + } + + nlohmann::json merged = nlohmann::json::array(); + for (const auto& key : keys) { + const auto baseIt = baseMap.find(key); + const auto leftIt = leftMap.find(key); + const auto rightIt = rightMap.find(key); + + const std::optional baseItem = + baseIt == baseMap.end() ? std::nullopt : std::optional(baseIt->second); + const std::optional leftItem = + leftIt == leftMap.end() ? std::nullopt : std::optional(leftIt->second); + const std::optional rightItem = + rightIt == rightMap.end() ? std::nullopt : std::optional(rightIt->second); + + const auto mergedItem = automix::devtools::mergeJsonNode(baseItem, + leftItem, + rightItem, + path + "/" + keyField + "=" + key, + telemetry); + if (mergedItem.has_value()) { + merged.push_back(mergedItem.value()); + } + } + + return merged; +} + +} // namespace + +std::optional mergeJsonNode(const std::optional& base, + const std::optional& left, + const std::optional& right, + const std::string& path, + JsonMergeTelemetry* telemetry) { + if (left == right) { + return left; + } + + if (left == base) { + return right; + } + + if (right == base) { + return left; + } + + if (!left.has_value() && !right.has_value()) { + return std::nullopt; + } + + if (left.has_value() && right.has_value() && + left->is_object() && right->is_object()) { + nlohmann::json merged = nlohmann::json::object(); + std::set keys; + if (base.has_value() && base->is_object()) { + for (const auto& [key, _] : base->items()) { + keys.insert(key); + } + } + for (const auto& [key, _] : left->items()) { + keys.insert(key); + } + for (const auto& [key, _] : right->items()) { + keys.insert(key); + } + + for (const auto& key : keys) { + const auto nextPath = path.empty() ? ("/" + key) : (path + "/" + key); + + const std::optional baseChild = + (base.has_value() && base->is_object() && base->contains(key)) + ? std::optional(base->at(key)) + : std::nullopt; + const std::optional leftChild = + left->contains(key) ? std::optional(left->at(key)) : std::nullopt; + const std::optional rightChild = + right->contains(key) ? std::optional(right->at(key)) : std::nullopt; + + const auto mergedChild = mergeJsonNode(baseChild, leftChild, rightChild, nextPath, telemetry); + if (mergedChild.has_value()) { + merged[key] = mergedChild.value(); + } + } + + return merged; + } + + if (left.has_value() && right.has_value() && + left->is_array() && right->is_array()) { + if (isDecisionLogPath(path)) { + return mergeStringArrayUnion(left, right); + } + + std::string keyField; + if (keyedMergePath(path, &keyField)) { + if (const auto merged = mergeKeyedArray(base, left, right, path, keyField, telemetry); merged.has_value()) { + return merged; + } + } + + recordMergeConflict(telemetry, path); + return telemetry->preferRight ? right : left; + } + + recordMergeConflict(telemetry, path); + return telemetry->preferRight ? right : left; +} + +// --- Project profile serialization --- + +nlohmann::json projectProfileToJson(const automix::domain::ProjectProfile& profile) { + return { + {"id", profile.id}, + {"name", profile.name}, + {"platformPreset", profile.platformPreset}, + {"rendererName", profile.rendererName}, + {"outputFormat", profile.outputFormat}, + {"lossyBitrateKbps", profile.lossyBitrateKbps}, + {"mp3UseVbr", profile.mp3UseVbr}, + {"mp3VbrQuality", profile.mp3VbrQuality}, + {"gpuProvider", profile.gpuProvider}, + {"roleModelPackId", profile.roleModelPackId}, + {"mixModelPackId", profile.mixModelPackId}, + {"masterModelPackId", profile.masterModelPackId}, + {"safetyPolicyId", profile.safetyPolicyId}, + {"preferredStemCount", profile.preferredStemCount}, + {"metadataPolicy", profile.metadataPolicy}, + {"metadataTemplate", profile.metadataTemplate}, + {"pinnedRendererIds", profile.pinnedRendererIds}, + }; +} + +std::optional projectProfileFromJson(const nlohmann::json& json) { + if (!json.is_object()) { + return std::nullopt; + } + + automix::domain::ProjectProfile profile; + profile.id = json.value("id", ""); + profile.name = json.value("name", profile.id); + profile.platformPreset = json.value("platformPreset", "spotify"); + profile.rendererName = json.value("rendererName", "BuiltIn"); + profile.outputFormat = json.value("outputFormat", "wav"); + profile.lossyBitrateKbps = std::clamp(json.value("lossyBitrateKbps", 320), 64, 320); + profile.mp3UseVbr = json.value("mp3UseVbr", false); + profile.mp3VbrQuality = std::clamp(json.value("mp3VbrQuality", 4), 0, 9); + profile.gpuProvider = json.value("gpuProvider", "auto"); + profile.roleModelPackId = json.value("roleModelPackId", "none"); + profile.mixModelPackId = json.value("mixModelPackId", "none"); + profile.masterModelPackId = json.value("masterModelPackId", "none"); + profile.safetyPolicyId = json.value("safetyPolicyId", "balanced"); + profile.preferredStemCount = std::clamp(json.value("preferredStemCount", 4), kMinPreferredStemCount, kMaxPreferredStemCount); + profile.metadataPolicy = json.value("metadataPolicy", "copy_common"); + if (profile.metadataPolicy != "copy_all" && + profile.metadataPolicy != "copy_common" && + profile.metadataPolicy != "copy_common_only" && + profile.metadataPolicy != "strip" && + profile.metadataPolicy != "override_template") { + profile.metadataPolicy = "copy_common"; + } + if (json.contains("metadataTemplate") && json.at("metadataTemplate").is_object()) { + profile.metadataTemplate = json.at("metadataTemplate").get>(); + } + if (json.contains("pinnedRendererIds") && json.at("pinnedRendererIds").is_array()) { + profile.pinnedRendererIds = json.at("pinnedRendererIds").get>(); + } + + if (profile.id.empty() || profile.name.empty()) { + return std::nullopt; + } + return profile; +} + +} // namespace automix::devtools diff --git a/tools/commands/DevToolsUtils.h b/tools/commands/DevToolsUtils.h new file mode 100644 index 0000000..fdfef96 --- /dev/null +++ b/tools/commands/DevToolsUtils.h @@ -0,0 +1,138 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ai/IModelInference.h" +#include "domain/BatchTypes.h" +#include "domain/ProjectProfile.h" +#include "engine/AudioBuffer.h" + +namespace automix::devtools { + +// --- Argument parsing --- + +std::optional argValue(const std::vector& args, const std::string& key); +bool hasFlag(const std::vector& args, const std::string& key); +std::optional parseIntArg(const std::vector& args, const std::string& key); +std::optional parseDoubleArg(const std::vector& args, const std::string& key); + +// --- String helpers --- + +std::string toLower(std::string value); +std::string sanitizeFileName(std::string value); +std::vector splitCommaSeparated(const std::string& value); +std::string csvEscape(const std::string& value); + +// --- Hash / timestamp --- + +uint64_t fnv1a64(const std::string& input); +std::string toHex(uint64_t value); +std::string iso8601NowUtc(); + +// --- Environment / path helpers --- + +std::optional readEnvironment(const std::string& key); +std::filesystem::path profileCatalogPath(); +std::string extensionForFormat(const std::string& format); +std::optional findRepoPath(const std::filesystem::path& relativePath); +void copyDirectory(const std::filesystem::path& source, const std::filesystem::path& destination); + +// --- File I/O --- + +std::optional loadJsonFile(const std::filesystem::path& path); +std::optional readTextFile(const std::filesystem::path& path); +void writeJsonFile(const std::filesystem::path& path, const nlohmann::json& payload); + +// --- Audio helpers --- + +automix::engine::AudioBuffer sliceBuffer(const automix::engine::AudioBuffer& input, int maxSamples); + +// --- Model helpers --- + +std::vector deterministicFeatures(size_t count); +std::string taskFromModelType(const std::string& type); + +// --- Supported packs / limiters --- + +struct SupportedModelPack { + std::string id; + std::string type; + std::string description; +}; + +struct SupportedLimiter { + std::string id; + std::string name; + std::string description; + std::string licenseId; +}; + +const std::vector& supportedModelPacks(); +const std::vector& supportedLimiters(); + +// --- Report metrics --- + +struct ReportMetrics { + bool loaded = false; + std::string renderer; + double integratedLufs = -120.0; + double truePeakDbtp = 0.0; + double targetLufs = -14.0; + double targetTruePeakDbtp = -1.0; + double monoCorrelation = 1.0; + double stereoCorrelation = 1.0; + double artifactRisk = 0.0; +}; + +ReportMetrics readReportMetrics(const std::filesystem::path& reportPath); +double computeComparatorScore(const ReportMetrics& metrics); + +// --- Batch status --- + +automix::domain::BatchItemStatus batchStatusFromString(const std::string& value); + +// --- Deterministic inference --- + +class DeterministicPlanDiffInference final : public automix::ai::IModelInference { + public: + bool isAvailable() const override; + bool loadModel(const std::filesystem::path& path) override; + automix::ai::InferenceResult run(const automix::ai::InferenceRequest& request) const override; + + private: + bool loaded_ = true; +}; + +std::unique_ptr buildPlanDiffInference( + const std::optional& modelPathArg, + const std::string& taskLabel, + std::vector* notes); + +// --- JSON merge --- + +struct JsonMergeTelemetry { + bool preferRight = true; + size_t conflictCount = 0; + std::vector conflictPaths; +}; + +std::optional mergeJsonNode(const std::optional& base, + const std::optional& left, + const std::optional& right, + const std::string& path, + JsonMergeTelemetry* telemetry); + +// --- Project profile serialization --- + +nlohmann::json projectProfileToJson(const automix::domain::ProjectProfile& profile); +std::optional projectProfileFromJson(const nlohmann::json& json); + +} // namespace automix::devtools diff --git a/tools/commands/EvalCommands.cpp b/tools/commands/EvalCommands.cpp new file mode 100644 index 0000000..53872a0 --- /dev/null +++ b/tools/commands/EvalCommands.cpp @@ -0,0 +1,287 @@ +#include "commands/CommandRegistry.h" +#include "commands/DevToolsUtils.h" + +#include +#include +#include +#include + +#include "ai/AutoMasterStrategyAI.h" +#include "ai/AutoMixStrategyAI.h" +#include "analysis/StemAnalyzer.h" +#include "automaster/HeuristicAutoMasterStrategy.h" +#include "automix/HeuristicAutoMixStrategy.h" +#include "domain/JsonSerialization.h" +#include "domain/MasterPlan.h" +#include "domain/Session.h" +#include "engine/OfflineRenderPipeline.h" +#include "engine/SessionRepository.h" +#include "RegressionHarness.h" + +namespace { + +using namespace automix::devtools; + +int commandGoldenEval(const CommandArgs& args) { + const auto baselineArg = argValue(args, "--baseline"); + const auto baselinePath = baselineArg.has_value() + ? std::filesystem::path(*baselineArg) + : findRepoPath("tests/regression/baselines.json") + .value_or(std::filesystem::path("tests/regression/baselines.json")); + const auto workDir = std::filesystem::path(argValue(args, "--work-dir") + .value_or((std::filesystem::temp_directory_path() / "automix_golden_eval").string())); + + const auto result = automix::regression::runRegressionSuite(baselinePath, workDir); + nlohmann::json rendered = nlohmann::json::array(); + for (const auto& metrics : result.rendered) { + rendered.push_back({ + {"fixtureName", metrics.fixtureName}, + {"pipelineName", metrics.pipelineName}, + {"integratedLufs", metrics.metrics.integratedLufs}, + {"truePeakDbtp", metrics.metrics.truePeakDbtp}, + {"monoCorrelation", metrics.metrics.monoCorrelation}, + {"spectrumLow", metrics.metrics.spectrumLow}, + {"spectrumMid", metrics.metrics.spectrumMid}, + {"spectrumHigh", metrics.metrics.spectrumHigh}, + {"stereoCorrelation", metrics.metrics.stereoCorrelation}, + }); + } + + nlohmann::json failures = nlohmann::json::array(); + for (const auto& failure : result.failures) { + failures.push_back({ + {"fixtureName", failure.fixtureName}, + {"pipelineName", failure.pipelineName}, + {"metricName", failure.metricName}, + {"expected", failure.expected}, + {"actual", failure.actual}, + {"tolerance", failure.tolerance}, + }); + } + + const nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"baselinePath", baselinePath.string()}, + {"workDir", workDir.string()}, + {"success", result.success}, + {"rendered", rendered}, + {"failures", failures}, + }; + + const auto outPath = + std::filesystem::path(argValue(args, "--out").value_or((workDir / "golden_eval_report.json").string())); + writeJsonFile(outPath, payload); + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Golden corpus evaluation\n"; + std::cout << " Baseline: " << baselinePath.string() << "\n"; + std::cout << " Rendered: " << result.rendered.size() << "\n"; + std::cout << " Failures: " << result.failures.size() << "\n"; + std::cout << " Report: " << outPath.string() << "\n"; + } + + return result.success ? 0 : 1; +} + +int commandPlanDiff(const CommandArgs& args) { + const auto sessionArg = argValue(args, "--session"); + if (!sessionArg.has_value()) { + std::cerr << "plan-diff requires --session \n"; + return 2; + } + + const auto mixModelArg = argValue(args, "--mix-model"); + const auto masterModelArg = argValue(args, "--master-model"); + const auto outPath = + std::filesystem::path(argValue(args, "--out") + .value_or((std::filesystem::path(*sessionArg).replace_extension(".plan_diff.json")).string())); + + automix::engine::SessionRepository repository; + auto session = repository.load(*sessionArg); + + automix::analysis::StemAnalyzer analyzer; + const auto analysisEntries = analyzer.analyzeSession(session); + + const double dryWet = session.mixPlan.has_value() ? session.mixPlan->dryWet : 1.0; + automix::automix::HeuristicAutoMixStrategy heuristicMixStrategy; + const auto heuristicMixPlan = heuristicMixStrategy.buildPlan(session, analysisEntries, dryWet); + + std::vector inferenceNotes; + auto mixInference = buildPlanDiffInference(mixModelArg, "mix", &inferenceNotes); + automix::ai::AutoMixStrategyAI mixStrategyAi; + const auto aiMixPlan = mixStrategyAi.buildPlan(session, analysisEntries, heuristicMixPlan, mixInference.get()); + + auto renderSettings = session.renderSettings; + renderSettings.outputSampleRate = renderSettings.outputSampleRate > 0 ? renderSettings.outputSampleRate : 44100; + renderSettings.blockSize = renderSettings.blockSize > 0 ? renderSettings.blockSize : 1024; + renderSettings.outputBitDepth = renderSettings.outputBitDepth > 0 ? renderSettings.outputBitDepth : 24; + + automix::engine::OfflineRenderPipeline pipeline; + auto heuristicSession = session; + heuristicSession.mixPlan = heuristicMixPlan; + const auto heuristicRaw = pipeline.renderRawMix(heuristicSession, renderSettings, {}, nullptr); + if (heuristicRaw.cancelled) { + std::cerr << "plan-diff aborted: heuristic render cancelled.\n"; + return 1; + } + + auto aiSession = session; + aiSession.mixPlan = aiMixPlan; + const auto aiRaw = pipeline.renderRawMix(aiSession, renderSettings, {}, nullptr); + if (aiRaw.cancelled) { + std::cerr << "plan-diff aborted: model render cancelled.\n"; + return 1; + } + + automix::automaster::HeuristicAutoMasterStrategy heuristicMasterStrategy; + const auto masterPreset = session.masterPlan.has_value() ? session.masterPlan->preset + : automix::domain::MasterPreset::DefaultStreaming; + const auto heuristicMasterPlan = heuristicMasterStrategy.buildPlan(masterPreset, heuristicRaw.mixBuffer); + const auto heuristicMixMetrics = analyzer.analyzeBuffer(heuristicRaw.mixBuffer); + + auto masterInference = buildPlanDiffInference(masterModelArg, "master", &inferenceNotes); + automix::ai::AutoMasterStrategyAI masterStrategyAi; + const auto aiMasterPlan = masterStrategyAi.buildPlan(heuristicMixMetrics, heuristicMasterPlan, masterInference.get()); + + nlohmann::json stemDeltas = nlohmann::json::array(); + std::map heuristicByStem; + std::map aiByStem; + for (const auto& decision : heuristicMixPlan.stemDecisions) { + heuristicByStem[decision.stemId] = decision; + } + for (const auto& decision : aiMixPlan.stemDecisions) { + aiByStem[decision.stemId] = decision; + } + + std::set stemIds; + for (const auto& [id, _] : heuristicByStem) { + stemIds.insert(id); + } + for (const auto& [id, _] : aiByStem) { + stemIds.insert(id); + } + for (const auto& stemId : stemIds) { + const auto heurIt = heuristicByStem.find(stemId); + const auto aiIt = aiByStem.find(stemId); + const double heurGain = heurIt == heuristicByStem.end() ? 0.0 : heurIt->second.gainDb; + const double aiGain = aiIt == aiByStem.end() ? 0.0 : aiIt->second.gainDb; + const double heurPan = heurIt == heuristicByStem.end() ? 0.0 : heurIt->second.pan; + const double aiPan = aiIt == aiByStem.end() ? 0.0 : aiIt->second.pan; + const double heurHighPass = heurIt == heuristicByStem.end() ? 0.0 : heurIt->second.highPassHz; + const double aiHighPass = aiIt == aiByStem.end() ? 0.0 : aiIt->second.highPassHz; + + stemDeltas.push_back({ + {"stemId", stemId}, + {"heuristicGainDb", heurGain}, + {"modelGainDb", aiGain}, + {"deltaGainDb", aiGain - heurGain}, + {"heuristicPan", heurPan}, + {"modelPan", aiPan}, + {"deltaPan", aiPan - heurPan}, + {"heuristicHighPassHz", heurHighPass}, + {"modelHighPassHz", aiHighPass}, + {"deltaHighPassHz", aiHighPass - heurHighPass}, + }); + } + + const double heuristicIntegrated = heuristicMasterStrategy.measureIntegratedLufs(heuristicRaw.mixBuffer); + const double aiIntegrated = heuristicMasterStrategy.measureIntegratedLufs(aiRaw.mixBuffer); + + const nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"sessionPath", *sessionArg}, + {"inferenceNotes", inferenceNotes}, + {"mixPlan", + { + {"heuristic", automix::domain::Json(heuristicMixPlan)}, + {"model", automix::domain::Json(aiMixPlan)}, + {"patch", nlohmann::json::diff(automix::domain::Json(heuristicMixPlan), automix::domain::Json(aiMixPlan))}, + {"stemDeltas", stemDeltas}, + }}, + {"masterPlan", + { + {"heuristic", automix::domain::Json(heuristicMasterPlan)}, + {"model", automix::domain::Json(aiMasterPlan)}, + {"patch", nlohmann::json::diff(automix::domain::Json(heuristicMasterPlan), automix::domain::Json(aiMasterPlan))}, + {"deltaTargetLufs", aiMasterPlan.targetLufs - heuristicMasterPlan.targetLufs}, + {"deltaPreGainDb", aiMasterPlan.preGainDb - heuristicMasterPlan.preGainDb}, + {"deltaLimiterCeilingDb", aiMasterPlan.limiterCeilingDb - heuristicMasterPlan.limiterCeilingDb}, + {"deltaGlueRatio", aiMasterPlan.glueRatio - heuristicMasterPlan.glueRatio}, + }}, + {"mixBusComparison", + { + {"heuristicIntegratedLufs", heuristicIntegrated}, + {"modelIntegratedLufs", aiIntegrated}, + {"deltaIntegratedLufs", aiIntegrated - heuristicIntegrated}, + }}, + }; + + writeJsonFile(outPath, payload); + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Generated heuristic-vs-model plan diff: " << outPath.string() << "\n"; + std::cout << "Mix patch ops: " + << nlohmann::json::diff(automix::domain::Json(heuristicMixPlan), automix::domain::Json(aiMixPlan)).size() + << ", master patch ops: " + << nlohmann::json::diff(automix::domain::Json(heuristicMasterPlan), automix::domain::Json(aiMasterPlan)).size() + << "\n"; + } + + return 0; +} + +int commandEvalTrend(const CommandArgs& args) { + const auto baselinePath = std::filesystem::path(argValue(args, "--baseline").value_or( + findRepoPath("tests/regression/baselines.json").value_or(std::filesystem::path("tests/regression/baselines.json")).string())); + const auto workDir = std::filesystem::path(argValue(args, "--work-dir") + .value_or((std::filesystem::temp_directory_path() / "automix_golden_eval").string())); + const auto trendPath = std::filesystem::path(argValue(args, "--trend").value_or("artifacts/eval/golden_trend.json")); + + const auto result = automix::regression::runRegressionSuite(baselinePath, workDir); + const nlohmann::json current = { + {"timestampUtc", iso8601NowUtc()}, + {"baselinePath", baselinePath.string()}, + {"workDir", workDir.string()}, + {"success", result.success}, + {"renderedCount", result.rendered.size()}, + {"failureCount", result.failures.size()}, + }; + + auto trend = loadJsonFile(trendPath).value_or(nlohmann::json::array()); + if (!trend.is_array()) { + trend = nlohmann::json::array(); + } + trend.push_back(current); + writeJsonFile(trendPath, trend); + + nlohmann::json payload = { + {"current", current}, + {"trendPath", trendPath.string()}, + {"historySize", trend.size()}, + }; + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + } + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Eval trend updated: " << trendPath.string() + << " entries=" << trend.size() + << " failures=" << result.failures.size() << "\n"; + } + + return result.success ? 0 : 1; +} + +} // namespace + +void registerEvalCommands(automix::devtools::CommandRegistry& registry) { + registry.add("golden-eval", commandGoldenEval); + registry.add("plan-diff", commandPlanDiff); + registry.add("eval-trend", commandEvalTrend); +} diff --git a/tools/commands/ModelCommands.cpp b/tools/commands/ModelCommands.cpp new file mode 100644 index 0000000..4472be8 --- /dev/null +++ b/tools/commands/ModelCommands.cpp @@ -0,0 +1,511 @@ +#include "commands/CommandRegistry.h" +#include "commands/DevToolsUtils.h" + +#include +#include +#include +#include + +#include "ai/FeatureSchema.h" +#include "ai/HuggingFaceModelHub.h" +#include "ai/ModelPackLoader.h" +#include "ai/OnnxModelInference.h" +#include "ai/RtNeuralInference.h" +#include "renderers/ExternalLimiterRenderer.h" +#include "util/LameDownloader.h" + +namespace { + +using namespace automix::devtools; + +int commandListSupportedModels(const CommandArgs&) { + std::cout << "Supported model packs:\n"; + for (const auto& pack : supportedModelPacks()) { + std::cout << " - " << pack.id << " [" << pack.type << "] " << pack.description << "\n"; + } + return 0; +} + +int commandInstallSupportedModel(const CommandArgs& args) { + const auto idArg = argValue(args, "--id"); + if (!idArg.has_value()) { + std::cerr << "install-supported-model requires --id \n"; + return 2; + } + const std::filesystem::path destinationRoot = argValue(args, "--dest").value_or("assets/models"); + + const auto it = std::find_if(supportedModelPacks().begin(), supportedModelPacks().end(), + [&](const SupportedModelPack& pack) { return pack.id == *idArg; }); + if (it == supportedModelPacks().end()) { + std::cerr << "Unknown supported model id: " << *idArg << "\n"; + return 2; + } + + const auto source = findRepoPath(std::filesystem::path("tools/catalog/modelpacks") / it->id); + if (!source.has_value()) { + std::cerr << "Catalog source not found for model: " << it->id << "\n"; + return 1; + } + + const auto destination = destinationRoot / it->id; + copyDirectory(source.value(), destination); + std::cout << "Installed model pack '" << it->id << "' to " << destination.string() << "\n"; + return 0; +} + +int commandListSupportedLimiters(const CommandArgs&) { + std::cout << "Supported limiters:\n"; + for (const auto& limiter : supportedLimiters()) { + std::cout << " - " << limiter.id << " [" << limiter.name << "] " << limiter.description << "\n"; + } + return 0; +} + +int commandInstallSupportedLimiter(const CommandArgs& args) { + const auto idArg = argValue(args, "--id"); + if (!idArg.has_value()) { + std::cerr << "install-supported-limiter requires --id \n"; + return 2; + } + const std::filesystem::path destinationRoot = argValue(args, "--dest").value_or("assets/limiters"); + std::filesystem::create_directories(destinationRoot); + + if (*idArg == "phaselimiter") { + const auto source = findRepoPath("assets/phaselimiter"); + if (!source.has_value()) { + std::cerr << "PhaseLimiter source package not found under assets/phaselimiter\n"; + return 1; + } + const auto destination = destinationRoot / "phaselimiter"; + std::filesystem::create_directories(destination); + copyDirectory(source.value(), destination / "runtime"); + + nlohmann::json descriptor = { + {"id", "PhaseLimiterPack"}, + {"name", "PhaseLimiter (pack)"}, + {"version", "external"}, + {"licenseId", "See assets/phaselimiter/licenses"}, + {"binaryPath", "runtime/phase_limiter"}, + {"bundledByDefault", false}, + }; + std::ofstream out(destination / "renderer.json"); + out << descriptor.dump(2); + std::cout << "Installed limiter '" << *idArg << "' to " << destination.string() << "\n"; + return 0; + } + + if (*idArg == "external-template") { + const auto destination = destinationRoot / "external-template"; + std::filesystem::create_directories(destination); + nlohmann::json descriptor = { + {"id", "ExternalTemplate"}, + {"name", "External Limiter Template"}, + {"version", "1.0"}, + {"licenseId", "User-supplied"}, + {"binaryPath", "your_limiter_binary_here"}, + {"bundledByDefault", false}, + }; + std::ofstream out(destination / "renderer.json"); + out << descriptor.dump(2); + std::cout << "Installed limiter template to " << destination.string() << "\n"; + return 0; + } + + std::cerr << "Unknown supported limiter id: " << *idArg << "\n"; + return 2; +} + +int commandInstallLameFallback(const CommandArgs& args) { + const bool force = hasFlag(args, "--force"); + const bool jsonOutput = hasFlag(args, "--json"); + + const auto result = automix::util::LameDownloader::ensureAvailable(force); + const auto cachePath = automix::util::LameDownloader::cacheBinaryPath(); + + if (jsonOutput) { + nlohmann::json payload = { + {"success", result.success}, + {"attempted", result.attempted}, + {"path", result.success ? result.executablePath.string() : cachePath.string()}, + {"detail", result.detail}, + }; + std::cout << payload.dump(2) << "\n"; + return result.success ? 0 : 1; + } + + std::cout << "LAME fallback installation:\n"; + std::cout << " Success: " << (result.success ? "yes" : "no") << "\n"; + std::cout << " Attempted download: " << (result.attempted ? "yes" : "no") << "\n"; + std::cout << " Binary path: " << (result.success ? result.executablePath.string() : cachePath.string()) << "\n"; + if (!result.detail.empty()) { + std::cout << " Detail: " << result.detail << "\n"; + } + + return result.success ? 0 : 1; +} + +int commandValidateModelPack(const CommandArgs& args) { + const auto packArg = argValue(args, "--pack"); + if (!packArg.has_value()) { + std::cerr << "validate-modelpack requires --pack \n"; + return 2; + } + + const std::filesystem::path packDir(*packArg); + automix::ai::ModelPackLoader loader; + const auto maybePack = loader.load(packDir); + if (!maybePack.has_value()) { + std::cerr << "Model pack validation failed: could not load model.json or model file.\n"; + return 1; + } + const auto& pack = maybePack.value(); + + std::cout << "Model pack loaded: " << pack.id << " engine=" << pack.engine + << " type=" << pack.type << " version=" << pack.version << "\n"; + + std::unique_ptr inference = std::make_unique(); + if (pack.engine == "onnxruntime") { + inference = std::make_unique(); + } else if (pack.engine == "rtneural") { + inference = std::make_unique(); + if (!inference->isAvailable()) { + std::cout << "Warning: RTNeural backend not enabled in this build. Schema-only validation performed.\n"; + } + } + + const auto modelPath = pack.rootPath / pack.modelFile; + if (!inference->loadModel(modelPath)) { + if (pack.engine == "unknown") { + std::cout << "Warning: unknown model engine; skipped runtime inference validation.\n"; + return 0; + } + if (!inference->isAvailable()) { + return 0; + } + std::cerr << "Model pack validation failed: backend refused to load model file.\n"; + return 1; + } + + const size_t featureCount = pack.inputFeatureCount.value_or(automix::ai::FeatureSchemaV1::featureCount()); + const automix::ai::InferenceRequest request{ + .task = taskFromModelType(pack.type), + .features = deterministicFeatures(featureCount), + }; + const auto result = inference->run(request); + if (!result.usedModel) { + std::cerr << "Model pack validation failed: sample inference did not use model (" << result.logMessage << ")\n"; + return 1; + } + + for (const auto& key : pack.expectedOutputKeys) { + if (!result.outputs.contains(key)) { + std::cerr << "Model pack validation failed: missing expected output key '" << key << "'\n"; + return 1; + } + } + + std::cout << "Model pack validation passed.\n"; + return 0; +} + +int commandValidateExternalLimiter(const CommandArgs& args) { + const auto binaryArg = argValue(args, "--binary"); + if (!binaryArg.has_value()) { + std::cerr << "validate-external-limiter requires --binary \n"; + return 2; + } + + const bool jsonOutput = hasFlag(args, "--json"); + const std::filesystem::path binaryPath(*binaryArg); + const auto validation = automix::renderers::ExternalLimiterRenderer::validateBinary(binaryPath); + + if (jsonOutput) { + nlohmann::json payload = { + {"binary", binaryPath.string()}, + {"valid", validation.valid}, + {"version", validation.version}, + {"errorCode", validation.errorCode}, + {"diagnostics", validation.diagnostics}, + {"supportedFeatures", validation.supportedFeatures}, + }; + std::cout << payload.dump(2) << "\n"; + return validation.valid ? 0 : 1; + } + + std::cout << "External limiter validation summary:\n"; + std::cout << " Binary: " << binaryPath.string() << "\n"; + std::cout << " Valid: " << (validation.valid ? "yes" : "no") << "\n"; + std::cout << " Version: " << (validation.version.empty() ? "(none)" : validation.version) << "\n"; + std::cout << " Error code: " << (validation.errorCode.empty() ? "(none)" : validation.errorCode) << "\n"; + std::cout << " Diagnostics: " << validation.diagnostics << "\n"; + + if (!validation.supportedFeatures.empty()) { + std::cout << " Supported features:\n"; + for (const auto& feature : validation.supportedFeatures) { + std::cout << " - " << feature << "\n"; + } + } else { + std::cout << " Supported features: (none reported)\n"; + } + + return validation.valid ? 0 : 1; +} + +int commandExternalLimiterCompat(const CommandArgs& args) { + const auto binaryArg = argValue(args, "--binary"); + if (!binaryArg.has_value()) { + std::cerr << "external-limiter-compat requires --binary \n"; + return 2; + } + + const auto timeoutMs = parseIntArg(args, "--timeout-ms").value_or(5000); + const auto requiredFeatures = + argValue(args, "--required-features").has_value() + ? splitCommaSeparated(argValue(args, "--required-features").value()) + : std::vector{}; + + const std::filesystem::path binaryPath(*binaryArg); + const auto validation = automix::renderers::ExternalLimiterRenderer::validateBinary(binaryPath, timeoutMs); + + std::unordered_set supported; + for (const auto& feature : validation.supportedFeatures) { + supported.insert(toLower(feature)); + } + std::vector missingRequired; + for (const auto& required : requiredFeatures) { + if (supported.find(toLower(required)) == supported.end()) { + missingRequired.push_back(required); + } + } + + const bool strictFeatureCheck = !requiredFeatures.empty(); + const bool featureCompatible = missingRequired.empty(); + const bool compatible = validation.valid && (!strictFeatureCheck || featureCompatible); + const std::string tier = !validation.valid ? "incompatible" + : (featureCompatible ? "compatible" : "partial"); + + nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"binary", binaryPath.string()}, + {"timeoutMs", timeoutMs}, + {"valid", validation.valid}, + {"tier", tier}, + {"version", validation.version}, + {"errorCode", validation.errorCode}, + {"diagnostics", validation.diagnostics}, + {"supportedFeatures", validation.supportedFeatures}, + {"requiredFeatures", requiredFeatures}, + {"missingRequiredFeatures", missingRequired}, + }; + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + std::cout << "Compatibility report: " << *outArg << "\n"; + } + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "External limiter compatibility\n"; + std::cout << " Binary: " << binaryPath.string() << "\n"; + std::cout << " Tier: " << tier << "\n"; + std::cout << " Valid: " << (validation.valid ? "yes" : "no") << "\n"; + std::cout << " Version: " << (validation.version.empty() ? "(none)" : validation.version) << "\n"; + if (!missingRequired.empty()) { + std::cout << " Missing required features:\n"; + for (const auto& feature : missingRequired) { + std::cout << " - " << feature << "\n"; + } + } + std::cout << " Diagnostics: " << validation.diagnostics << "\n"; + } + + return compatible ? 0 : 1; +} + +int commandModelBrowse(const CommandArgs& args) { + automix::ai::HuggingFaceModelHub hub; + automix::ai::HubModelQueryOptions options; + options.maxResultsPerQuery = static_cast(std::clamp(parseIntArg(args, "--limit").value_or(6), 1, 20)); + + if (const auto tokenEnvArg = argValue(args, "--token-env"); tokenEnvArg.has_value()) { + options.token = readEnvironment(*tokenEnvArg).value_or(""); + } + + const auto models = hub.discoverRecommended(options); + nlohmann::json payload = nlohmann::json::array(); + for (const auto& model : models) { + payload.push_back({ + {"repoId", model.repoId}, + {"useCase", model.useCase}, + {"license", model.license}, + {"downloads", model.downloads}, + {"likes", model.likes}, + {"revision", model.revision}, + {"primaryFile", model.primaryFile}, + {"recommended", model.recommended}, + {"gated", model.gated}, + {"sourceUrl", model.sourceUrl}, + }); + } + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + std::cout << "Wrote model catalog to " << *outArg << "\n"; + } + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Recommended model catalog entries: " << payload.size() << "\n"; + for (const auto& model : payload) { + std::cout << " - " << model.value("repoId", "") + << " useCase=" << model.value("useCase", "") + << " downloads=" << model.value("downloads", 0) + << " license=" << model.value("license", "") + << (model.value("recommended", false) ? " [recommended]" : "") + << "\n"; + } + } + + return payload.empty() ? 1 : 0; +} + +int commandModelInstall(const CommandArgs& args) { + const auto repoArg = argValue(args, "--repo"); + if (!repoArg.has_value()) { + std::cerr << "model-install requires --repo \n"; + return 2; + } + + automix::ai::HubInstallOptions options; + options.destinationRoot = argValue(args, "--dest").value_or("assets/modelhub"); + options.overwrite = hasFlag(args, "--force"); + options.downloadReadme = !hasFlag(args, "--no-readme"); + if (const auto tokenEnvArg = argValue(args, "--token-env"); tokenEnvArg.has_value()) { + options.token = readEnvironment(*tokenEnvArg).value_or(""); + } + + automix::ai::HuggingFaceModelHub hub; + const auto installed = hub.installModel(*repoArg, options); + + nlohmann::json payload = { + {"success", installed.success}, + {"repoId", installed.repoId}, + {"revision", installed.revision}, + {"installPath", installed.installPath.string()}, + {"primaryFilePath", installed.primaryFilePath.string()}, + {"metadataPath", installed.metadataPath.string()}, + {"message", installed.message}, + {"downloadedFiles", installed.downloadedFiles}, + }; + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + std::cout << "Model install report: " << *outArg << "\n"; + } + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Model install result: " << (installed.success ? "success" : "failed") << "\n"; + std::cout << " Repo: " << installed.repoId << "\n"; + std::cout << " Revision: " << installed.revision << "\n"; + std::cout << " Path: " << installed.installPath.string() << "\n"; + std::cout << " Detail: " << installed.message << "\n"; + } + + return installed.success ? 0 : 1; +} + +int commandModelHealth(const CommandArgs& args) { + const std::filesystem::path root = argValue(args, "--root").value_or("assets/modelhub"); + const auto registryPath = root / "install_registry.json"; + const auto registry = loadJsonFile(registryPath); + if (!registry.has_value() || !registry->is_array()) { + std::cerr << "Model registry not found: " << registryPath.string() << "\n"; + return 1; + } + + nlohmann::json checks = nlohmann::json::array(); + int ok = 0; + int failed = 0; + + for (const auto& item : *registry) { + if (!item.is_object()) { + continue; + } + const auto repoId = item.value("repoId", ""); + const std::filesystem::path installPath(item.value("installPath", "")); + const std::filesystem::path primaryPath = installPath / item.value("primaryFile", ""); + std::error_code error; + const bool downloaded = std::filesystem::is_regular_file(primaryPath, error) && !error; + bool loadable = false; + std::string detail; + + if (downloaded) { + const auto extension = toLower(primaryPath.extension().string()); + if (extension == ".onnx") { + automix::ai::OnnxModelInference inference; + loadable = inference.loadModel(primaryPath); + detail = loadable ? "ONNX load ok" : "ONNX load failed"; + } else { + loadable = true; + detail = "Non-ONNX model file present (schema/runtime check skipped)"; + } + } else { + detail = "Primary model file missing"; + } + + if (downloaded && loadable) { + ++ok; + } else { + ++failed; + } + + checks.push_back({ + {"repoId", repoId}, + {"downloaded", downloaded}, + {"loadable", loadable}, + {"expectedIoSchema", "modelhub.json metadata"}, + {"detail", detail}, + }); + } + + nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"root", root.string()}, + {"ok", ok}, + {"failed", failed}, + {"checks", checks}, + }; + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + std::cout << "Model health report: " << *outArg << "\n"; + } + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Model health: ok=" << ok << " failed=" << failed << "\n"; + } + + return failed == 0 ? 0 : 1; +} + +} // namespace + +void registerModelCommands(automix::devtools::CommandRegistry& registry) { + registry.add("list-supported-models", commandListSupportedModels); + registry.add("install-supported-model", commandInstallSupportedModel); + registry.add("list-supported-limiters", commandListSupportedLimiters); + registry.add("install-supported-limiter", commandInstallSupportedLimiter); + registry.add("install-lame-fallback", commandInstallLameFallback); + registry.add("validate-modelpack", commandValidateModelPack); + registry.add("validate-external-limiter", commandValidateExternalLimiter); + registry.add("external-limiter-compat", commandExternalLimiterCompat); + registry.add("model-browse", commandModelBrowse); + registry.add("model-install", commandModelInstall); + registry.add("model-health", commandModelHealth); +} diff --git a/tools/commands/RenderCommands.cpp b/tools/commands/RenderCommands.cpp new file mode 100644 index 0000000..94756bf --- /dev/null +++ b/tools/commands/RenderCommands.cpp @@ -0,0 +1,562 @@ +#include "commands/CommandRegistry.h" +#include "commands/DevToolsUtils.h" + +#include +#include +#include +#include +#include +#include + +#include "analysis/StemAnalyzer.h" +#include "domain/Session.h" +#include "domain/Stem.h" +#include "domain/StemOrigin.h" +#include "domain/StemRole.h" +#include "engine/AudioFileIO.h" +#include "engine/BatchQueueRunner.h" +#include "engine/OfflineRenderPipeline.h" +#include "engine/SessionRepository.h" +#include "renderers/RendererFactory.h" +#include "ai/FeatureSchema.h" +#include "util/WavWriter.h" + +namespace { + +using namespace automix::devtools; + +int commandCompareRenders(const CommandArgs& args) { + const auto sessionPathArg = argValue(args, "--session"); + if (!sessionPathArg.has_value()) { + std::cerr << "compare-renders requires --session \n"; + return 2; + } + + const auto outDirArg = argValue(args, "--out-dir").value_or("comparison_out"); + const auto renderersArg = argValue(args, "--renderers").value_or("BuiltIn,PhaseLimiter"); + auto rendererIds = splitCommaSeparated(renderersArg); + if (rendererIds.empty()) { + rendererIds = {"BuiltIn"}; + } + + const auto formatOverride = argValue(args, "--format"); + const auto externalBinaryArg = argValue(args, "--external-binary"); + const bool jsonOutput = hasFlag(args, "--json"); + + struct ComparatorRow { + std::string rendererId; + bool success = false; + bool cancelled = false; + std::string outputPath; + std::string reportPath; + std::string message; + ReportMetrics metrics; + double score = 0.0; + }; + + automix::engine::SessionRepository repository; + const auto session = repository.load(*sessionPathArg); + + const std::filesystem::path outDir(outDirArg); + std::filesystem::create_directories(outDir); + const auto stem = sanitizeFileName(session.sessionName.empty() ? "session" : session.sessionName); + + std::vector rows; + rows.reserve(rendererIds.size()); + + for (const auto& rendererId : rendererIds) { + auto settings = session.renderSettings; + settings.rendererName = rendererId; + if (formatOverride.has_value()) { + settings.outputFormat = *formatOverride; + } + if (settings.outputFormat.empty() || settings.outputFormat == "auto") { + settings.outputFormat = "wav"; + } + if (externalBinaryArg.has_value()) { + settings.externalRendererPath = *externalBinaryArg; + } + + const auto outputName = stem + "_" + sanitizeFileName(rendererId) + extensionForFormat(settings.outputFormat); + settings.outputPath = (outDir / outputName).string(); + + ComparatorRow row; + row.rendererId = rendererId; + try { + auto renderer = automix::renderers::createRenderer(rendererId); + const auto result = renderer->render(session, settings, {}, nullptr); + row.success = result.success; + row.cancelled = result.cancelled; + row.outputPath = result.outputAudioPath; + row.reportPath = result.reportPath; + row.message = result.logs.empty() ? "" : result.logs.back(); + + std::filesystem::path reportCandidate(result.reportPath); + if (reportCandidate.empty()) { + reportCandidate = std::filesystem::path(settings.outputPath + ".report.json"); + } + row.metrics = readReportMetrics(reportCandidate); + row.score = row.success && row.metrics.loaded ? computeComparatorScore(row.metrics) + : (row.success ? 50.0 : 0.0); + } catch (const std::exception& error) { + row.success = false; + row.message = error.what(); + } + + rows.push_back(row); + } + + std::sort(rows.begin(), rows.end(), [](const ComparatorRow& a, const ComparatorRow& b) { + if (a.success != b.success) { + return a.success > b.success; + } + if (a.score != b.score) { + return a.score > b.score; + } + return a.rendererId < b.rendererId; + }); + + nlohmann::json ranking = nlohmann::json::array(); + for (size_t i = 0; i < rows.size(); ++i) { + const auto& row = rows[i]; + ranking.push_back({ + {"rank", i + 1}, + {"rendererId", row.rendererId}, + {"success", row.success}, + {"cancelled", row.cancelled}, + {"score", row.score}, + {"outputPath", row.outputPath}, + {"reportPath", row.reportPath}, + {"message", row.message}, + {"metrics", + { + {"integratedLufs", row.metrics.integratedLufs}, + {"targetLufs", row.metrics.targetLufs}, + {"truePeakDbtp", row.metrics.truePeakDbtp}, + {"targetTruePeakDbtp", row.metrics.targetTruePeakDbtp}, + {"monoCorrelation", row.metrics.monoCorrelation}, + {"stereoCorrelation", row.metrics.stereoCorrelation}, + {"artifactRisk", row.metrics.artifactRisk}, + }}, + }); + } + + nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"sessionPath", *sessionPathArg}, + {"outDir", outDir.string()}, + {"renderers", rendererIds}, + {"ranking", ranking}, + }; + const auto jsonReportPath = outDir / "comparison_report.json"; + writeJsonFile(jsonReportPath, payload); + + const auto csvPath = outDir / "comparison_report.csv"; + std::ofstream csv(csvPath); + if (csv.is_open()) { + csv << "rank,renderer,success,cancelled,score,integrated_lufs,target_lufs,true_peak_dbtp,target_true_peak_dbtp,mono_corr,stereo_corr,artifact_risk,output_path,report_path,message\n"; + for (size_t i = 0; i < rows.size(); ++i) { + const auto& row = rows[i]; + csv << (i + 1) << "," + << csvEscape(row.rendererId) << "," + << (row.success ? "true" : "false") << "," + << (row.cancelled ? "true" : "false") << "," + << row.score << "," + << row.metrics.integratedLufs << "," + << row.metrics.targetLufs << "," + << row.metrics.truePeakDbtp << "," + << row.metrics.targetTruePeakDbtp << "," + << row.metrics.monoCorrelation << "," + << row.metrics.stereoCorrelation << "," + << row.metrics.artifactRisk << "," + << csvEscape(row.outputPath) << "," + << csvEscape(row.reportPath) << "," + << csvEscape(row.message) << "\n"; + } + } + + if (jsonOutput) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Multi-render comparison complete. Ranking:\n"; + for (size_t i = 0; i < rows.size(); ++i) { + const auto& row = rows[i]; + std::cout << " " << (i + 1) << ". " << row.rendererId + << " success=" << (row.success ? "yes" : "no") + << " score=" << std::fixed << std::setprecision(2) << row.score + << " LUFS=" << row.metrics.integratedLufs + << " TP=" << row.metrics.truePeakDbtp << "\n"; + } + std::cout << "JSON report: " << jsonReportPath.string() << "\n"; + std::cout << "CSV report: " << csvPath.string() << "\n"; + } + + const auto successes = std::count_if(rows.begin(), rows.end(), [](const ComparatorRow& row) { return row.success; }); + return successes > 0 ? 0 : 1; +} + +int commandCatalogProcess(const CommandArgs& args) { + const auto inputArg = argValue(args, "--input"); + const auto outputArg = argValue(args, "--output"); + if (!inputArg.has_value() || !outputArg.has_value()) { + std::cerr << "catalog-process requires --input --output \n"; + return 2; + } + + const std::filesystem::path inputDir(*inputArg); + const std::filesystem::path outputDir(*outputArg); + std::filesystem::create_directories(outputDir); + + const auto checkpointPath = + std::filesystem::path(argValue(args, "--checkpoint").value_or((outputDir / "catalog_checkpoint.json").string())); + const bool resume = hasFlag(args, "--resume"); + const auto csvPath = + std::filesystem::path(argValue(args, "--csv").value_or((outputDir / "catalog_deliverables.csv").string())); + const auto jsonPath = + std::filesystem::path(argValue(args, "--json").value_or((outputDir / "catalog_deliverables.json").string())); + + automix::engine::BatchQueueRunner runner; + auto discoveredItems = runner.buildItemsFromFolder(inputDir, outputDir); + if (discoveredItems.empty()) { + std::cout << "No audio items found in " << inputDir.string() << "\n"; + nlohmann::json emptyPayload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"inputDir", inputDir.string()}, + {"outputDir", outputDir.string()}, + {"summary", {{"total", 0}, {"completed", 0}, {"failed", 0}, {"cancelled", 0}}}, + {"items", nlohmann::json::array()}, + }; + writeJsonFile(jsonPath, emptyPayload); + return 0; + } + + std::unordered_map checkpointBySession; + if (resume) { + if (const auto checkpoint = loadJsonFile(checkpointPath); checkpoint.has_value() && checkpoint->contains("items")) { + for (const auto& item : checkpoint->at("items")) { + if (!item.is_object()) { + continue; + } + const auto sessionName = item.value("sessionName", ""); + if (!sessionName.empty()) { + checkpointBySession[sessionName] = item; + } + } + } + } + + std::vector completedFromCheckpoint; + std::vector pendingItems; + completedFromCheckpoint.reserve(discoveredItems.size()); + pendingItems.reserve(discoveredItems.size()); + + for (auto& item : discoveredItems) { + const auto it = checkpointBySession.find(item.session.sessionName); + if (it != checkpointBySession.end()) { + const auto& checkpointItem = it->second; + item.status = batchStatusFromString(checkpointItem.value("status", "pending")); + item.error = checkpointItem.value("error", ""); + item.reportPath = checkpointItem.value("reportPath", ""); + if (checkpointItem.contains("outputPath")) { + item.outputPath = checkpointItem.value("outputPath", item.outputPath.string()); + } + } + + const bool checkpointCompleted = + item.status == automix::domain::BatchItemStatus::Completed && + !item.reportPath.empty() && + std::filesystem::exists(item.reportPath); + + if (checkpointCompleted) { + completedFromCheckpoint.push_back(item); + continue; + } + + item.status = automix::domain::BatchItemStatus::Pending; + pendingItems.push_back(item); + } + + automix::domain::BatchJob job; + job.items = std::move(pendingItems); + job.settings.outputFolder = outputDir; + job.settings.renderSettings.rendererName = argValue(args, "--renderer").value_or("BuiltIn"); + job.settings.renderSettings.outputFormat = argValue(args, "--format").value_or("wav"); + job.settings.analysisThreads = parseIntArg(args, "--analysis-threads").value_or(1); + job.settings.renderParallelism = parseIntArg(args, "--render-parallelism").value_or(1); + job.settings.parallelAnalysis = !hasFlag(args, "--serial-analysis"); + + std::atomic_bool cancelFlag {false}; + const auto processResult = runner.process(job, {}, &cancelFlag); + + std::vector allItems = completedFromCheckpoint; + allItems.insert(allItems.end(), job.items.begin(), job.items.end()); + std::sort(allItems.begin(), allItems.end(), [](const automix::domain::BatchItem& a, const automix::domain::BatchItem& b) { + return a.session.sessionName < b.session.sessionName; + }); + + int completed = 0; + int failed = 0; + int cancelled = 0; + nlohmann::json itemPayload = nlohmann::json::array(); + for (const auto& item : allItems) { + completed += item.status == automix::domain::BatchItemStatus::Completed ? 1 : 0; + failed += item.status == automix::domain::BatchItemStatus::Failed ? 1 : 0; + cancelled += item.status == automix::domain::BatchItemStatus::Cancelled ? 1 : 0; + + const auto metrics = item.reportPath.empty() ? ReportMetrics{} : readReportMetrics(item.reportPath); + itemPayload.push_back({ + {"sessionName", item.session.sessionName}, + {"status", automix::domain::toString(item.status)}, + {"outputPath", item.outputPath.string()}, + {"reportPath", item.reportPath}, + {"error", item.error}, + {"integratedLufs", metrics.integratedLufs}, + {"truePeakDbtp", metrics.truePeakDbtp}, + {"artifactRisk", metrics.artifactRisk}, + }); + } + + const nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"inputDir", inputDir.string()}, + {"outputDir", outputDir.string()}, + {"checkpoint", checkpointPath.string()}, + {"resumeEnabled", resume}, + {"processedInThisRun", + { + {"completed", processResult.completed}, + {"failed", processResult.failed}, + {"cancelled", processResult.cancelled}, + }}, + {"summary", + { + {"total", static_cast(allItems.size())}, + {"completed", completed}, + {"failed", failed}, + {"cancelled", cancelled}, + }}, + {"items", itemPayload}, + }; + + writeJsonFile(jsonPath, payload); + writeJsonFile(checkpointPath, payload); + + std::ofstream csv(csvPath); + if (csv.is_open()) { + csv << "session_name,status,output_path,report_path,integrated_lufs,true_peak_dbtp,artifact_risk,error\n"; + for (const auto& item : allItems) { + const auto metrics = item.reportPath.empty() ? ReportMetrics{} : readReportMetrics(item.reportPath); + csv << csvEscape(item.session.sessionName) << "," + << csvEscape(automix::domain::toString(item.status)) << "," + << csvEscape(item.outputPath.string()) << "," + << csvEscape(item.reportPath) << "," + << metrics.integratedLufs << "," + << metrics.truePeakDbtp << "," + << metrics.artifactRisk << "," + << csvEscape(item.error) << "\n"; + } + } + + std::cout << "Catalog processing complete. total=" << allItems.size() + << " completed=" << completed + << " failed=" << failed + << " cancelled=" << cancelled << "\n"; + std::cout << "Deliverables JSON: " << jsonPath.string() << "\n"; + std::cout << "Deliverables CSV: " << csvPath.string() << "\n"; + std::cout << "Checkpoint: " << checkpointPath.string() << "\n"; + + return failed == 0 && cancelled == 0 ? 0 : 1; +} + +int commandExportSegments(const CommandArgs& args) { + const auto sessionPathArg = argValue(args, "--session"); + const auto outDirArg = argValue(args, "--out-dir"); + if (!sessionPathArg.has_value() || !outDirArg.has_value()) { + std::cerr << "export-segments requires --session and --out-dir \n"; + return 2; + } + + double segmentSeconds = 5.0; + if (const auto secondsArg = argValue(args, "--segment-seconds"); secondsArg.has_value()) { + segmentSeconds = std::max(0.5, std::stod(*secondsArg)); + } + + automix::engine::SessionRepository repository; + const auto session = repository.load(*sessionPathArg); + + const std::filesystem::path outDir(*outDirArg); + const std::filesystem::path stemDir = outDir / "stems"; + std::filesystem::create_directories(stemDir); + + automix::engine::AudioFileIO fileIO; + automix::util::WavWriter writer; + for (const auto& stem : session.stems) { + if (!stem.enabled) { + continue; + } + + const auto buffer = fileIO.readAudioFile(stem.filePath); + const int maxSamples = static_cast(segmentSeconds * buffer.getSampleRate()); + const auto segment = sliceBuffer(buffer, maxSamples); + const auto outPath = stemDir / (sanitizeFileName(stem.id + "_" + stem.name) + ".wav"); + writer.write(outPath, segment, 24); + } + + automix::domain::RenderSettings settings = session.renderSettings; + settings.outputSampleRate = settings.outputSampleRate > 0 ? settings.outputSampleRate : 44100; + settings.blockSize = settings.blockSize > 0 ? settings.blockSize : 1024; + settings.outputBitDepth = 24; + settings.outputPath = (outDir / "mix_full.wav").string(); + + automix::engine::OfflineRenderPipeline pipeline; + const auto rawMix = pipeline.renderRawMix(session, settings, {}, nullptr); + if (rawMix.cancelled) { + std::cerr << "Raw mix render cancelled while exporting segments.\n"; + return 1; + } + + const int maxMixSamples = static_cast(segmentSeconds * rawMix.mixBuffer.getSampleRate()); + const auto mixSegment = sliceBuffer(rawMix.mixBuffer, maxMixSamples); + writer.write(outDir / "mix_segment.wav", mixSegment, 24); + + nlohmann::json manifest = { + {"sessionName", session.sessionName}, + {"segmentSeconds", segmentSeconds}, + {"stemCount", session.stems.size()}, + {"mixSegmentPath", (outDir / "mix_segment.wav").string()}, + }; + std::ofstream manifestOut(outDir / "manifest.json"); + manifestOut << manifest.dump(2); + + std::cout << "Exported stem and mix segments to " << outDir.string() << "\n"; + return 0; +} + +int commandExportFeatures(const CommandArgs& args) { + const auto sessionPathArg = argValue(args, "--session"); + const auto outPathArg = argValue(args, "--out"); + if (!sessionPathArg.has_value() || !outPathArg.has_value()) { + std::cerr << "export-features requires --session and --out \n"; + return 2; + } + const auto manifestPathArg = argValue(args, "--manifest"); + const auto datasetIdArg = argValue(args, "--dataset-id"); + const auto sourceTagArg = argValue(args, "--source-tag"); + const auto lineageParentsArg = argValue(args, "--lineage-parents"); + + automix::engine::SessionRepository repository; + const auto session = repository.load(*sessionPathArg); + + std::unordered_map stemsById; + for (const auto& stem : session.stems) { + stemsById.emplace(stem.id, stem); + } + + automix::analysis::StemAnalyzer analyzer; + const auto entries = analyzer.analyzeSession(session); + + std::ofstream out(*outPathArg); + if (!out.is_open()) { + std::cerr << "Failed to open output file: " << *outPathArg << "\n"; + return 1; + } + + for (const auto& entry : entries) { + nlohmann::json line; + line["sessionName"] = session.sessionName; + line["stemId"] = entry.stemId; + line["stemName"] = entry.stemName; + line["metrics"] = { + {"peakDb", entry.metrics.peakDb}, + {"rmsDb", entry.metrics.rmsDb}, + {"crestDb", entry.metrics.crestDb}, + {"crestFactor", entry.metrics.crestFactor}, + {"lowEnergy", entry.metrics.lowEnergy}, + {"midEnergy", entry.metrics.midEnergy}, + {"highEnergy", entry.metrics.highEnergy}, + {"silenceRatio", entry.metrics.silenceRatio}, + {"stereoCorrelation", entry.metrics.stereoCorrelation}, + {"stereoWidth", entry.metrics.stereoWidth}, + {"dcOffset", entry.metrics.dcOffset}, + {"subEnergy", entry.metrics.subEnergy}, + {"bassEnergy", entry.metrics.bassEnergy}, + {"lowMidEnergy", entry.metrics.lowMidEnergy}, + {"highMidEnergy", entry.metrics.highMidEnergy}, + {"presenceEnergy", entry.metrics.presenceEnergy}, + {"airEnergy", entry.metrics.airEnergy}, + {"spectralCentroidHz", entry.metrics.spectralCentroidHz}, + {"spectralSpreadHz", entry.metrics.spectralSpreadHz}, + {"spectralFlatness", entry.metrics.spectralFlatness}, + {"spectralFlux", entry.metrics.spectralFlux}, + {"onsetStrength", entry.metrics.onsetStrength}, + {"mfccCoefficients", entry.metrics.mfccCoefficients}, + {"constantQBins", entry.metrics.constantQBins}, + {"channelBalanceDb", entry.metrics.channelBalanceDb}, + {"artifactRisk", entry.metrics.artifactRisk}, + {"artifactSwirlRisk", entry.metrics.artifactProfile.swirlRisk}, + {"artifactSmearRisk", entry.metrics.artifactProfile.smearRisk}, + {"artifactNoiseDominance", entry.metrics.artifactProfile.noiseDominance}, + {"artifactHarmonicity", entry.metrics.artifactProfile.harmonicity}, + {"artifactPhaseInstability", entry.metrics.artifactProfile.phaseInstability}, + }; + + const auto it = stemsById.find(entry.stemId); + if (it != stemsById.end()) { + line["origin"] = automix::domain::toString(it->second.origin); + line["role"] = automix::domain::toString(it->second.role); + } + + out << line.dump() << "\n"; + } + + std::cout << "Exported " << entries.size() << " feature rows to " << *outPathArg << "\n"; + + if (manifestPathArg.has_value()) { + const auto sessionText = readTextFile(*sessionPathArg).value_or(""); + const auto featureText = readTextFile(*outPathArg).value_or(""); + const auto lineageParents = + lineageParentsArg.has_value() ? splitCommaSeparated(*lineageParentsArg) : std::vector{}; + const auto datasetId = datasetIdArg.has_value() && !datasetIdArg->empty() + ? *datasetIdArg + : (sanitizeFileName(session.sessionName) + "_" + toHex(fnv1a64(iso8601NowUtc()))); + + nlohmann::json manifest = { + {"schemaVersion", 1}, + {"generatedAtUtc", iso8601NowUtc()}, + {"datasetId", datasetId}, + {"sourceTag", sourceTagArg.value_or("manual")}, + {"sourceSessionPath", *sessionPathArg}, + {"sessionName", session.sessionName}, + {"rowCount", entries.size()}, + {"featureSchemaVersion", "v1"}, + {"featureCountPerStem", automix::ai::FeatureSchemaV1::featureCount()}, + {"featureFilePath", *outPathArg}, + {"lineageParents", lineageParents}, + {"sessionHashFnv1a64", toHex(fnv1a64(sessionText))}, + {"featureFileHashFnv1a64", toHex(fnv1a64(featureText))}, + {"columns", nlohmann::json::array({ + "peakDb", "rmsDb", "crestDb", "crestFactor", "lowEnergy", "midEnergy", + "highEnergy", "silenceRatio", "stereoCorrelation", "stereoWidth", "dcOffset", + "subEnergy", "bassEnergy", "lowMidEnergy", "highMidEnergy", "presenceEnergy", + "airEnergy", "spectralCentroidHz", "spectralSpreadHz", "spectralFlatness", + "spectralFlux", "onsetStrength", "mfccCoefficients", "constantQBins", + "channelBalanceDb", "artifactRisk", "artifactSwirlRisk", "artifactSmearRisk", + "artifactNoiseDominance", "artifactHarmonicity", "artifactPhaseInstability", + })}, + }; + + writeJsonFile(*manifestPathArg, manifest); + std::cout << "Wrote feature lineage manifest to " << *manifestPathArg << "\n"; + } + + return 0; +} + +} // namespace + +void registerRenderCommands(automix::devtools::CommandRegistry& registry) { + registry.add("compare-renders", commandCompareRenders); + registry.add("catalog-process", commandCatalogProcess); + registry.add("export-segments", commandExportSegments); + registry.add("export-features", commandExportFeatures); +} diff --git a/tools/commands/SessionCommands.cpp b/tools/commands/SessionCommands.cpp new file mode 100644 index 0000000..413d207 --- /dev/null +++ b/tools/commands/SessionCommands.cpp @@ -0,0 +1,448 @@ +#include "commands/CommandRegistry.h" +#include "commands/DevToolsUtils.h" + +#include +#include +#include + +#include "analysis/StemHealthAssistant.h" +#include "analysis/StemAnalyzer.h" +#include "domain/JsonSerialization.h" +#include "domain/Session.h" +#include "engine/SessionRepository.h" + +namespace { + +using namespace automix::devtools; + +int commandSessionDiff(const CommandArgs& args) { + const auto baseArg = argValue(args, "--base"); + const auto headArg = argValue(args, "--head"); + if (!baseArg.has_value() || !headArg.has_value()) { + std::cerr << "session-diff requires --base --head \n"; + return 2; + } + + const auto baseJson = loadJsonFile(*baseArg); + const auto headJson = loadJsonFile(*headArg); + if (!baseJson.has_value() || !headJson.has_value()) { + std::cerr << "Failed to load session JSON for diff.\n"; + return 1; + } + + const auto patch = nlohmann::json::diff(*baseJson, *headJson); + std::map opCounts; + for (const auto& op : patch) { + if (op.is_object()) { + opCounts[op.value("op", "unknown")] += 1; + } + } + + if (const auto outPathArg = argValue(args, "--out"); outPathArg.has_value()) { + writeJsonFile(*outPathArg, patch); + std::cout << "Wrote JSON patch to " << *outPathArg << "\n"; + } else { + std::cout << patch.dump(2) << "\n"; + } + + if (hasFlag(args, "--summary")) { + std::cout << "Patch operations: total=" << patch.size(); + for (const auto& [op, count] : opCounts) { + std::cout << " " << op << "=" << count; + } + std::cout << "\n"; + } + + return 0; +} + +int commandSessionMerge(const CommandArgs& args) { + const auto baseArg = argValue(args, "--base"); + const auto leftArg = argValue(args, "--left"); + const auto rightArg = argValue(args, "--right"); + const auto outArg = argValue(args, "--out"); + if (!baseArg.has_value() || !leftArg.has_value() || !rightArg.has_value() || !outArg.has_value()) { + std::cerr << "session-merge requires --base --left --right --out \n"; + return 2; + } + + const auto baseJson = loadJsonFile(*baseArg); + const auto leftJson = loadJsonFile(*leftArg); + const auto rightJson = loadJsonFile(*rightArg); + if (!baseJson.has_value() || !leftJson.has_value() || !rightJson.has_value()) { + std::cerr << "Failed to load session JSON for merge.\n"; + return 1; + } + + JsonMergeTelemetry telemetry; + telemetry.preferRight = toLower(argValue(args, "--prefer").value_or("right")) != "left"; + + const auto merged = mergeJsonNode(baseJson, leftJson, rightJson, "", &telemetry); + if (!merged.has_value()) { + std::cerr << "Merged session resolved to null unexpectedly.\n"; + return 1; + } + + automix::domain::Session mergedSession; + try { + mergedSession = merged->get(); + } catch (const std::exception& error) { + std::cerr << "Merged JSON is not a valid Session schema: " << error.what() << "\n"; + return 1; + } + + automix::engine::SessionRepository repository; + repository.save(*outArg, mergedSession); + + nlohmann::json report = { + {"generatedAtUtc", iso8601NowUtc()}, + {"base", *baseArg}, + {"left", *leftArg}, + {"right", *rightArg}, + {"out", *outArg}, + {"preferredSide", telemetry.preferRight ? "right" : "left"}, + {"conflictCount", telemetry.conflictCount}, + {"conflictPaths", telemetry.conflictPaths}, + }; + + if (const auto reportArg = argValue(args, "--report"); reportArg.has_value()) { + writeJsonFile(*reportArg, report); + std::cout << "Merge report: " << *reportArg << "\n"; + } + + if (hasFlag(args, "--json")) { + std::cout << report.dump(2) << "\n"; + } else { + std::cout << "Merged session written to " << *outArg + << " (conflicts resolved=" << telemetry.conflictCount + << ", preferred=" << (telemetry.preferRight ? "right" : "left") << ")\n"; + } + + return 0; +} + +int commandSessionReview(const CommandArgs& args) { + const auto baseArg = argValue(args, "--base"); + const auto headArg = argValue(args, "--head"); + if (!baseArg.has_value() || !headArg.has_value()) { + std::cerr << "session-review requires --base --head \n"; + return 2; + } + + const auto baseJson = loadJsonFile(*baseArg); + const auto headJson = loadJsonFile(*headArg); + if (!baseJson.has_value() || !headJson.has_value()) { + std::cerr << "Failed to load session files for review.\n"; + return 1; + } + + const auto patch = nlohmann::json::diff(*baseJson, *headJson); + std::map topLevelCounts; + std::vector highlights; + + for (const auto& op : patch) { + if (!op.is_object()) { + continue; + } + const auto path = op.value("path", ""); + if (path.empty()) { + continue; + } + const auto slash = path.find('/', 1); + const auto top = slash == std::string::npos ? path : path.substr(0, slash); + topLevelCounts[top] += 1; + + if (path.find("/renderSettings") == 0 || + path.find("/timeline") == 0 || + path.find("/mixPlan") == 0 || + path.find("/masterPlan") == 0) { + highlights.push_back(op.value("op", "op") + " " + path); + } + } + + nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"base", *baseArg}, + {"head", *headArg}, + {"patchOperations", patch.size()}, + {"topLevelChangeCounts", topLevelCounts}, + {"highlights", highlights}, + {"patch", patch}, + }; + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + std::cout << "Session review written to " << *outArg << "\n"; + } + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Session review summary:\n"; + for (const auto& [top, count] : topLevelCounts) { + std::cout << " " << top << ": " << count << "\n"; + } + if (!highlights.empty()) { + std::cout << "Highlights:\n"; + for (const auto& line : highlights) { + std::cout << " - " << line << "\n"; + } + } + } + + return 0; +} + +int commandProfileExport(const CommandArgs& args) { + const auto outArg = argValue(args, "--out"); + if (!outArg.has_value()) { + std::cerr << "profile-export requires --out \n"; + return 2; + } + + const auto idArg = argValue(args, "--id"); + const auto profiles = automix::domain::loadProjectProfiles(std::filesystem::current_path()); + nlohmann::json payload = nlohmann::json::array(); + + if (idArg.has_value()) { + const auto profile = automix::domain::findProjectProfile(profiles, *idArg); + if (!profile.has_value()) { + std::cerr << "Profile not found: " << *idArg << "\n"; + return 1; + } + payload.push_back(projectProfileToJson(profile.value())); + } else { + for (const auto& profile : profiles) { + payload.push_back(projectProfileToJson(profile)); + } + } + + writeJsonFile(*outArg, payload); + std::cout << "Exported " << payload.size() << " profile(s) to " << *outArg << "\n"; + return 0; +} + +int commandProfileImport(const CommandArgs& args) { + const auto inArg = argValue(args, "--in"); + if (!inArg.has_value()) { + std::cerr << "profile-import requires --in \n"; + return 2; + } + + const auto source = loadJsonFile(*inArg); + if (!source.has_value()) { + std::cerr << "Failed to read profile file: " << *inArg << "\n"; + return 1; + } + + std::vector imported; + if (source->is_array()) { + for (const auto& item : *source) { + if (const auto parsed = projectProfileFromJson(item); parsed.has_value()) { + imported.push_back(parsed.value()); + } + } + } else if (source->is_object()) { + if (const auto parsed = projectProfileFromJson(*source); parsed.has_value()) { + imported.push_back(parsed.value()); + } + } + + if (imported.empty()) { + std::cerr << "No valid profile records found in " << *inArg << "\n"; + return 1; + } + + const auto outPath = argValue(args, "--out").value_or(profileCatalogPath().string()); + nlohmann::json existing = loadJsonFile(outPath).value_or(nlohmann::json::array()); + if (!existing.is_array()) { + existing = nlohmann::json::array(); + } + + std::map byId; + for (const auto& item : existing) { + if (!item.is_object()) { + continue; + } + const auto id = item.value("id", ""); + if (!id.empty()) { + byId[id] = item; + } + } + for (const auto& profile : imported) { + byId[profile.id] = projectProfileToJson(profile); + } + + nlohmann::json merged = nlohmann::json::array(); + for (const auto& [id, item] : byId) { + (void)id; + merged.push_back(item); + } + + writeJsonFile(outPath, merged); + std::cout << "Imported " << imported.size() << " profile(s) into " << outPath << "\n"; + return 0; +} + +int commandStemHealth(const CommandArgs& args) { + const auto sessionPathArg = argValue(args, "--session"); + if (!sessionPathArg.has_value()) { + std::cerr << "stem-health requires --session \n"; + return 2; + } + + const auto outPathArg = argValue(args, "--out"); + const bool jsonOutput = hasFlag(args, "--json"); + + automix::engine::SessionRepository repository; + const auto session = repository.load(*sessionPathArg); + + automix::analysis::StemAnalyzer analyzer; + const auto analysisEntries = analyzer.analyzeSession(session); + automix::analysis::StemHealthAssistant assistant; + const auto report = assistant.analyze(session, analysisEntries); + const auto reportJson = assistant.toJson(report); + const auto reportText = assistant.toText(report); + + if (outPathArg.has_value()) { + std::filesystem::path outPath(*outPathArg); + std::filesystem::path jsonPath = outPath; + std::filesystem::path textPath = outPath; + + if (outPath.extension() == ".json") { + textPath.replace_extension(".txt"); + } else if (outPath.extension() == ".txt") { + jsonPath.replace_extension(".json"); + } else { + jsonPath += ".json"; + textPath += ".txt"; + } + + writeJsonFile(jsonPath, reportJson); + std::ofstream textOut(textPath); + if (textOut.is_open()) { + textOut << reportText << "\n"; + } + std::cout << "Wrote stem health report JSON: " << jsonPath.string() << "\n"; + std::cout << "Wrote stem health report text: " << textPath.string() << "\n"; + } + + if (jsonOutput) { + std::cout << reportJson.dump(2) << "\n"; + } else { + std::cout << reportText << "\n"; + } + + return 0; +} + +int commandAdaptiveAssistant(const CommandArgs& args) { + const auto sessionArg = argValue(args, "--session"); + if (!sessionArg.has_value()) { + std::cerr << "adaptive-assistant requires --session \n"; + return 2; + } + + automix::engine::SessionRepository repository; + const auto session = repository.load(*sessionArg); + automix::analysis::StemAnalyzer analyzer; + const auto analysisEntries = analyzer.analyzeSession(session); + automix::analysis::StemHealthAssistant assistant; + const auto health = assistant.analyze(session, analysisEntries); + + nlohmann::json fixes = nlohmann::json::array(); + for (const auto& issue : health.issues) { + if (issue.code == "harshness_risk") { + fixes.push_back({ + {"priority", issue.severity == automix::analysis::StemHealthSeverity::Critical ? "high" : "medium"}, + {"stemId", issue.stemId}, + {"action", "reduce_high_band_harshness"}, + {"details", "Apply de-harsh EQ in 3k-8k region and lower clipping-prone gains."}, + }); + } else if (issue.code == "pumping_risk") { + fixes.push_back({ + {"priority", "medium"}, + {"stemId", issue.stemId}, + {"action", "relax_compression"}, + {"details", "Increase compressor release and lower ratio/threshold aggressiveness."}, + }); + } else if (issue.code == "masking_conflict" || issue.code == "spectral_masking") { + fixes.push_back({ + {"priority", "medium"}, + {"stemId", issue.stemId}, + {"action", "rebalance_masking"}, + {"details", "Cut overlapping bands and spread conflicting stems with mild pan/EQ separation."}, + }); + } else if (issue.code == "mono_risk") { + fixes.push_back({ + {"priority", "medium"}, + {"stemId", issue.stemId}, + {"action", "improve_mono_compatibility"}, + {"details", "Narrow extreme stereo content and verify mono fold-down phase correlation."}, + }); + } + } + + const auto compareArg = argValue(args, "--compare-report"); + if (compareArg.has_value()) { + if (const auto compare = loadJsonFile(*compareArg); compare.has_value() && + compare->contains("ranking") && + compare->at("ranking").is_array() && + !compare->at("ranking").empty()) { + const auto top = compare->at("ranking").front(); + const auto bestRenderer = top.value("rendererId", ""); + const auto score = top.value("score", 0.0); + if (!bestRenderer.empty()) { + fixes.push_back({ + {"priority", "medium"}, + {"stemId", ""}, + {"action", "renderer_recommendation"}, + {"details", "Comparator top renderer is '" + bestRenderer + "' (score=" + std::to_string(score) + + "). Consider profile pinning to this renderer."}, + }); + } + } + } + + if (fixes.empty()) { + fixes.push_back({ + {"priority", "low"}, + {"stemId", ""}, + {"action", "no_critical_changes"}, + {"details", "No major corrective chain detected; keep current profile and run final compliance check."}, + }); + } + + const nlohmann::json payload = { + {"generatedAtUtc", iso8601NowUtc()}, + {"sessionPath", *sessionArg}, + {"overallRisk", health.overallRisk}, + {"hasCriticalIssues", health.hasCriticalIssues}, + {"issueCount", health.issues.size()}, + {"fixChain", fixes}, + }; + + if (const auto outArg = argValue(args, "--out"); outArg.has_value()) { + writeJsonFile(*outArg, payload); + std::cout << "Adaptive assistant report: " << *outArg << "\n"; + } + + if (hasFlag(args, "--json")) { + std::cout << payload.dump(2) << "\n"; + } else { + std::cout << "Adaptive assistant generated " << fixes.size() << " fix-chain step(s).\n"; + } + return 0; +} + +} // namespace + +void registerSessionCommands(automix::devtools::CommandRegistry& registry) { + registry.add("session-diff", commandSessionDiff); + registry.add("session-merge", commandSessionMerge); + registry.add("session-review", commandSessionReview); + registry.add("profile-export", commandProfileExport); + registry.add("profile-import", commandProfileImport); + registry.add("stem-health", commandStemHealth); + registry.add("adaptive-assistant", commandAdaptiveAssistant); +} diff --git a/tools/dev_tools.cpp b/tools/dev_tools.cpp index 77ee7bd..073ba1d 100644 --- a/tools/dev_tools.cpp +++ b/tools/dev_tools.cpp @@ -1,288 +1,34 @@ #include -#include -#include -#include -#include #include -#include #include -#include -#include #include -#include - -#include "ai/IModelInference.h" -#include "ai/ModelPackLoader.h" -#ifdef ENABLE_ONNX -#include "ai/OnnxModelInference.h" -#endif -#include "ai/RtNeuralInference.h" -#include "analysis/StemAnalyzer.h" -#include "domain/Stem.h" -#include "domain/StemOrigin.h" -#include "domain/StemRole.h" -#include "engine/AudioFileIO.h" -#include "engine/OfflineRenderPipeline.h" -#include "engine/SessionRepository.h" -#include "util/WavWriter.h" +#include "commands/CommandRegistry.h" +#include "commands/Commands.h" namespace { -std::string sanitizeFileName(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char c) { - if (std::isalnum(c) || c == '_' || c == '-') { - return static_cast(c); - } - return '_'; - }); - if (value.empty()) { - return "segment"; - } - return value; -} - -std::optional argValue(const std::vector& args, const std::string& key) { - for (size_t i = 0; i + 1 < args.size(); ++i) { - if (args[i] == key) { - return args[i + 1]; - } - } - return std::nullopt; -} - -automix::engine::AudioBuffer sliceBuffer(const automix::engine::AudioBuffer& input, - const int maxSamples) { - const int outputSamples = std::max(0, std::min(input.getNumSamples(), maxSamples)); - automix::engine::AudioBuffer output(input.getNumChannels(), outputSamples, input.getSampleRate()); - - for (int ch = 0; ch < input.getNumChannels(); ++ch) { - for (int i = 0; i < outputSamples; ++i) { - output.setSample(ch, i, input.getSample(ch, i)); - } - } - - return output; -} - -std::vector deterministicFeatures(const size_t count) { - std::vector features; - features.reserve(count); - for (size_t i = 0; i < count; ++i) { - features.push_back(static_cast(i + 1) / static_cast(count + 1)); - } - return features; -} - -std::string taskFromModelType(const std::string& type) { - if (type == "role_classifier") { - return "role_classifier"; - } - if (type == "master_parameters") { - return "master_parameters"; - } - return "mix_parameters"; -} - -int commandExportFeatures(const std::vector& args) { - const auto sessionPathArg = argValue(args, "--session"); - const auto outPathArg = argValue(args, "--out"); - if (!sessionPathArg.has_value() || !outPathArg.has_value()) { - std::cerr << "export-features requires --session and --out \n"; - return 2; - } - - automix::engine::SessionRepository repository; - const auto session = repository.load(*sessionPathArg); - - std::unordered_map stemsById; - for (const auto& stem : session.stems) { - stemsById.emplace(stem.id, stem); - } - - automix::analysis::StemAnalyzer analyzer; - const auto entries = analyzer.analyzeSession(session); - - std::ofstream out(*outPathArg); - if (!out.is_open()) { - std::cerr << "Failed to open output file: " << *outPathArg << "\n"; - return 1; - } - - for (const auto& entry : entries) { - nlohmann::json line; - line["sessionName"] = session.sessionName; - line["stemId"] = entry.stemId; - line["stemName"] = entry.stemName; - line["metrics"] = { - {"peakDb", entry.metrics.peakDb}, - {"rmsDb", entry.metrics.rmsDb}, - {"crestDb", entry.metrics.crestDb}, - {"lowEnergy", entry.metrics.lowEnergy}, - {"midEnergy", entry.metrics.midEnergy}, - {"highEnergy", entry.metrics.highEnergy}, - {"silenceRatio", entry.metrics.silenceRatio}, - {"stereoCorrelation", entry.metrics.stereoCorrelation}, - {"stereoWidth", entry.metrics.stereoWidth}, - {"artifactRisk", entry.metrics.artifactRisk}, - }; - - const auto it = stemsById.find(entry.stemId); - if (it != stemsById.end()) { - line["origin"] = automix::domain::toString(it->second.origin); - line["role"] = automix::domain::toString(it->second.role); - } - - out << line.dump() << "\n"; - } - - std::cout << "Exported " << entries.size() << " feature rows to " << *outPathArg << "\n"; - return 0; -} - -int commandExportSegments(const std::vector& args) { - const auto sessionPathArg = argValue(args, "--session"); - const auto outDirArg = argValue(args, "--out-dir"); - if (!sessionPathArg.has_value() || !outDirArg.has_value()) { - std::cerr << "export-segments requires --session and --out-dir \n"; - return 2; - } - - double segmentSeconds = 5.0; - if (const auto secondsArg = argValue(args, "--segment-seconds"); secondsArg.has_value()) { - segmentSeconds = std::max(0.5, std::stod(*secondsArg)); - } - - automix::engine::SessionRepository repository; - const auto session = repository.load(*sessionPathArg); - - const std::filesystem::path outDir(*outDirArg); - const std::filesystem::path stemDir = outDir / "stems"; - std::filesystem::create_directories(stemDir); - - automix::engine::AudioFileIO fileIO; - automix::util::WavWriter writer; - for (const auto& stem : session.stems) { - if (!stem.enabled) { - continue; - } - - const auto buffer = fileIO.readAudioFile(stem.filePath); - const int maxSamples = static_cast(segmentSeconds * buffer.getSampleRate()); - const auto segment = sliceBuffer(buffer, maxSamples); - const auto outPath = stemDir / (sanitizeFileName(stem.id + "_" + stem.name) + ".wav"); - writer.write(outPath, segment, 24); +void printUsage(const automix::devtools::CommandRegistry& registry) { + std::cout << "Usage: automix_dev_tools [options]\n\n"; + std::cout << "Available commands:\n"; + auto names = registry.commandNames(); + for (const auto& name : names) { + std::cout << " " << name << "\n"; } - - automix::domain::RenderSettings settings = session.renderSettings; - settings.outputSampleRate = settings.outputSampleRate > 0 ? settings.outputSampleRate : 44100; - settings.blockSize = settings.blockSize > 0 ? settings.blockSize : 1024; - settings.outputBitDepth = 24; - settings.outputPath = (outDir / "mix_full.wav").string(); - - automix::engine::OfflineRenderPipeline pipeline; - const auto rawMix = pipeline.renderRawMix(session, settings, {}, nullptr); - if (rawMix.cancelled) { - std::cerr << "Raw mix render cancelled while exporting segments.\n"; - return 1; - } - - const int maxMixSamples = static_cast(segmentSeconds * rawMix.mixBuffer.getSampleRate()); - const auto mixSegment = sliceBuffer(rawMix.mixBuffer, maxMixSamples); - writer.write(outDir / "mix_segment.wav", mixSegment, 24); - - nlohmann::json manifest = { - {"sessionName", session.sessionName}, - {"segmentSeconds", segmentSeconds}, - {"stemCount", session.stems.size()}, - {"mixSegmentPath", (outDir / "mix_segment.wav").string()}, - }; - std::ofstream manifestOut(outDir / "manifest.json"); - manifestOut << manifest.dump(2); - - std::cout << "Exported stem and mix segments to " << outDir.string() << "\n"; - return 0; -} - -int commandValidateModelPack(const std::vector& args) { - const auto packArg = argValue(args, "--pack"); - if (!packArg.has_value()) { - std::cerr << "validate-modelpack requires --pack \n"; - return 2; - } - - const std::filesystem::path packDir(*packArg); - automix::ai::ModelPackLoader loader; - const auto maybePack = loader.load(packDir); - if (!maybePack.has_value()) { - std::cerr << "Model pack validation failed: could not load model.json or model file.\n"; - return 1; - } - const auto& pack = maybePack.value(); - - std::cout << "Model pack loaded: " << pack.id << " engine=" << pack.engine - << " type=" << pack.type << " version=" << pack.version << "\n"; - - std::unique_ptr inference = std::make_unique(); - if (pack.engine == "onnxruntime") { -#ifdef ENABLE_ONNX - inference = std::make_unique(); -#else - std::cout << "Warning: ONNX backend not enabled in this build. Schema-only validation performed.\n"; -#endif - } else if (pack.engine == "rtneural") { - inference = std::make_unique(); - if (!inference->isAvailable()) { - std::cout << "Warning: RTNeural backend not enabled in this build. Schema-only validation performed.\n"; - } - } - - const auto modelPath = pack.rootPath / pack.modelFile; - if (!inference->loadModel(modelPath)) { - if (pack.engine == "unknown") { - std::cout << "Warning: unknown model engine; skipped runtime inference validation.\n"; - return 0; - } - if (!inference->isAvailable()) { - return 0; - } - std::cerr << "Model pack validation failed: backend refused to load model file.\n"; - return 1; - } - - const size_t featureCount = pack.inputFeatureCount.value_or(5u); - const automix::ai::InferenceRequest request{ - .task = taskFromModelType(pack.type), - .features = deterministicFeatures(featureCount), - }; - const auto result = inference->run(request); - if (!result.usedModel) { - std::cerr << "Model pack validation failed: sample inference did not use model (" << result.logMessage << ")\n"; - return 1; - } - - for (const auto& key : pack.expectedOutputKeys) { - if (!result.outputs.contains(key)) { - std::cerr << "Model pack validation failed: missing expected output key '" << key << "'\n"; - return 1; - } - } - - std::cout << "Model pack validation passed.\n"; - return 0; -} - -void printUsage() { - std::cout << "Usage:\n"; - std::cout << " automix_dev_tools export-features --session --out \n"; - std::cout << " automix_dev_tools export-segments --session --out-dir [--segment-seconds ]\n"; - std::cout << " automix_dev_tools validate-modelpack --pack \n"; + std::cout << "\nRun a command without arguments for command-specific help.\n"; } } // namespace int main(int argc, char** argv) { try { + automix::devtools::CommandRegistry registry; + registerModelCommands(registry); + registerSessionCommands(registry); + registerRenderCommands(registry); + registerEvalCommands(registry); + registerBatchCommands(registry); + std::vector args; args.reserve(static_cast(argc)); for (int i = 1; i < argc; ++i) { @@ -290,23 +36,18 @@ int main(int argc, char** argv) { } if (args.empty()) { - printUsage(); + printUsage(registry); return 2; } const std::string command = args.front(); - if (command == "export-features") { - return commandExportFeatures(args); - } - if (command == "export-segments") { - return commandExportSegments(args); - } - if (command == "validate-modelpack") { - return commandValidateModelPack(args); + const int result = registry.dispatch(command, args); + if (result == -1) { + std::cerr << "Unknown command: " << command << "\n\n"; + printUsage(registry); + return 2; } - - printUsage(); - return 2; + return result; } catch (const std::exception& error) { std::cerr << "Developer tool error: " << error.what() << "\n"; return 1; diff --git a/tools/training/README.md b/tools/training/README.md new file mode 100644 index 0000000..0f47072 --- /dev/null +++ b/tools/training/README.md @@ -0,0 +1,12 @@ +# Training Workspace + +Minimal local tooling for feature export and model-pack evaluation. + +## Scripts + +- `export_feature_vectors.py`: exports deterministic feature vectors from JSON analysis dumps. +- `evaluate_model_pack.py`: validates manifest metadata and summarizes pack compatibility. + +## Schema + +`feature_schema_v1.json` is the canonical schema mirrored by `src/ai/FeatureSchema.h`. diff --git a/tools/training/evaluate_model_pack.py b/tools/training/evaluate_model_pack.py new file mode 100644 index 0000000..59d042b --- /dev/null +++ b/tools/training/evaluate_model_pack.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +import argparse +import json +from pathlib import Path + +REQUIRED = [ + "id", + "type", + "engine", + "model_file", + "license", + "source", + "feature_schema_version", +] + + +def evaluate(pack_dir: Path) -> dict: + model_json = pack_dir / "model.json" + if not model_json.exists(): + return {"valid": False, "error": "model.json missing"} + + with model_json.open("r", encoding="utf-8") as f: + metadata = json.load(f) + + missing = [key for key in REQUIRED if not metadata.get(key)] + model_path = pack_dir / metadata.get("model_file", "") + + valid = not missing and model_path.exists() + return { + "valid": valid, + "missing_fields": missing, + "model_exists": model_path.exists(), + "pack_id": metadata.get("id", ""), + "schema_version": metadata.get("feature_schema_version", ""), + "license": metadata.get("license", ""), + "source": metadata.get("source", ""), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="Validate and summarize an AutoMixMaster model pack.") + parser.add_argument("--pack", required=True, help="Path to model pack folder") + parser.add_argument("--output", required=False, help="Optional output JSON path") + args = parser.parse_args() + + report = evaluate(Path(args.pack)) + text = json.dumps(report, indent=2) + print(text) + + if args.output: + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(text, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/training/export_feature_vectors.py b/tools/training/export_feature_vectors.py new file mode 100644 index 0000000..76a4bc0 --- /dev/null +++ b/tools/training/export_feature_vectors.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import argparse +import json +from pathlib import Path + +FEATURES = [ + "rms_db", + "low_energy_ratio", + "mid_energy_ratio", + "high_energy_ratio", + "artifact_risk", +] + + +def stem_to_vector(stem: dict) -> list[float]: + return [ + float(stem.get("rmsDb", -120.0)), + float(stem.get("lowEnergy", 0.0)), + float(stem.get("midEnergy", 0.0)), + float(stem.get("highEnergy", 0.0)), + float(stem.get("artifactRisk", 0.0)), + ] + + +def export_vectors(input_path: Path, output_path: Path) -> None: + with input_path.open("r", encoding="utf-8") as f: + data = json.load(f) + + stems = data.get("stems", []) + vectors = [] + for stem in stems: + vectors.append( + { + "stem_id": stem.get("stemId", ""), + "stem_name": stem.get("stemName", ""), + "features": stem_to_vector(stem), + "schema_version": "1.0.0", + } + ) + + out = {"feature_names": FEATURES, "items": vectors} + with output_path.open("w", encoding="utf-8") as f: + json.dump(out, f, indent=2) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Export AutoMixMaster feature vectors from analysis JSON.") + parser.add_argument("--input", required=True, help="Input analysis JSON path") + parser.add_argument("--output", required=True, help="Output feature JSON path") + args = parser.parse_args() + + export_vectors(Path(args.input), Path(args.output)) + + +if __name__ == "__main__": + main() diff --git a/tools/training/feature_schema_v1.json b/tools/training/feature_schema_v1.json new file mode 100644 index 0000000..08c6c2b --- /dev/null +++ b/tools/training/feature_schema_v1.json @@ -0,0 +1,77 @@ +{ + "version": "1.0.0", + "description": "Feature schema consumed by model packs compatible with ai::FeatureSchemaV1.", + "features": [ + "rms_db", + "peak_db", + "crest_db", + "dc_offset", + "low_energy_ratio", + "mid_energy_ratio", + "high_energy_ratio", + "sub_energy_ratio", + "bass_energy_ratio", + "low_mid_energy_ratio", + "high_mid_energy_ratio", + "presence_energy_ratio", + "air_energy_ratio", + "spectral_centroid_hz", + "spectral_spread_hz", + "spectral_flatness", + "spectral_flux", + "silence_ratio", + "stereo_correlation", + "stereo_width", + "channel_balance_db", + "artifact_risk", + "artifact_swirl_risk", + "artifact_smear_risk", + "artifact_noise_dominance", + "artifact_harmonicity", + "artifact_phase_instability", + "crest_factor", + "onset_strength", + "mfcc_0", + "mfcc_1", + "mfcc_2", + "mfcc_3", + "mfcc_4", + "mfcc_5", + "mfcc_6", + "mfcc_7", + "mfcc_8", + "mfcc_9", + "mfcc_10", + "mfcc_11", + "mfcc_12", + "cqt_0", + "cqt_1", + "cqt_2", + "cqt_3", + "cqt_4", + "cqt_5", + "cqt_6", + "cqt_7", + "cqt_8", + "cqt_9", + "cqt_10", + "cqt_11", + "cqt_12", + "cqt_13", + "cqt_14", + "cqt_15", + "cqt_16", + "cqt_17", + "cqt_18", + "cqt_19", + "cqt_20", + "cqt_21", + "cqt_22", + "cqt_23" + ], + "output_types": [ + "role_classifier", + "mix_parameters", + "master_parameters" + ] +} diff --git a/tools/training/model_pack_template/model.json b/tools/training/model_pack_template/model.json new file mode 100644 index 0000000..c068c0a --- /dev/null +++ b/tools/training/model_pack_template/model.json @@ -0,0 +1,19 @@ +{ + "schema_version": 1, + "id": "example-mix-pack-v1", + "name": "Example Mix Pack", + "type": "mix_parameters", + "engine": "onnxruntime", + "version": "1.0.0", + "model_file": "model.onnx", + "license": "MIT", + "source": "https://example.com/model-card", + "intended_use": "Local mix parameter suggestion", + "feature_schema_version": "1.0.0", + "input_feature_count": 5, + "output_schema": { + "global_gain_db": "float", + "global_pan_bias": "float", + "confidence": "float" + } +}