From 335608fbe5fad1136f2901ad7d3d51c41a96ecb9 Mon Sep 17 00:00:00 2001 From: Soficis Date: Fri, 13 Feb 2026 14:16:57 -0600 Subject: [PATCH 01/20] This major release introduces professional audio processing capabilities, batch queue acceleration, external renderer integrations, and significant test expansion to AutoMixMaster. ### Core Audio Processing Enhancements **New DSP Modules**: - Lookahead Limiter: True-peak detection with configurable sample-accurate attack/release and soft clipping integration for transparent loudness control - De-Esser: Frequency-selective sibilance reduction (2-8 kHz) with soft knee dynamics - Dynamic De-Harsh EQ: Adaptive mid-range harshness reduction (2-8 kHz) triggered by perceived loudness - Mid-Side Processor: Stereo field manipulation with width and frequency-dependent processing - Soft Clipper: Pre-limiter saturation with variable drive control - True Peak Detector: ITU-R BS.1770-4 true peak metering with configurable oversampling **Analysis & Feature Extraction**: - New FeatureSchemaV1: Standardized feature extraction from AnalysisResult for ML model ingestion - ArtifactProfile: Risk assessment framework for automatic detection of mix/master artifacts - Enhanced stem analysis with multi-band spectral decomposition ### Renderer & Limiter Infrastructure - ExternalLimiterRenderer: JSON-RPC contract support for third-party limiter binary integrations - RendererRegistry: Auto-discovery and validation of external renderer plugins via descriptor files - Extended limiter pack discovery with configurable asset paths and platform-specific binary loading - PhaseLimiterRenderer enhancements with improved error handling and fallback behavior ### Batch Processing & Performance - BatchQueueRunner: Multi-threaded batch job execution with progress callbacks and cancellation support - BatchTypes: New domain model for batch items, results, and error tracking - Parallel stem analysis with configurable worker thread pool - Parallel rendering with hardware_concurrency auto-tuning - Residual blend support in batch pipeline ### AI Model Infrastructure - ModelManager enhancements: Support for both ONNX Runtime and RTNeural backends - feature_schema_version gating: Inbound compatibility checks for model packs - Model warm-up optimization during pack load to eliminate first-inference latency - MasteringCompliance: Post-processing mastering output validation and correction ### Audio Preview & Loudness Metering - AudioPreviewEngine: Real-time A/B preview of original vs. processed audio with transport controls - LoudnessMeter: EBU R128 / ITU-R BS.1770 standards-compliant loudness measurement - Per-stem solo/mute capability during preview - Integration with residual blend for realistic preview rendering ### Build System & Distribution - DISTRIBUTION_MODE CMake option: `OSS` (GPL v3) and `PROPRIETARY` (commercial JUCE) modes - ENABLE_LIBEBUR128: Standards-compliant loudness metering (on by default) - ENABLE_EXTERNAL_TOOL_SUPPORT: User-supplied renderer integrations (on by default) - ENABLE_GPL_BUNDLED_LIMITERS: Optional bundled GPL limiter packs (OSS mode) - ENABLE_SANITIZERS & ENABLE_CLANG_TIDY: Developer tool support - Extended CMake platform detection and automatic framework linking ### Documentation - Moved comprehensive development roadmap to docs/roadmap.md for better organization - README now includes references to parallel processing, batch hardware acceleration, and external limiter integrations - Added limiter pack discovery and asset management documentation ### Test Coverage New comprehensive test suites: - AudioIoTests: Audio file I/O and format handling - AnalysisTests: Stem analysis, artifact detection, and feature extraction - AudioPreviewEngineTests: Preview engine functionality and transport control - LoudnessMeterTests: EBU R128 metering accuracy - LookaheadLimiterTests: Limiter behavior, attack/release curves, true peak detection - BatchProcessingTests: Batch queue execution, parallel rendering, error handling - OfflineRenderPipelineTests: End-to-end render pipeline validation - OnnxModelInferenceTests: ONNX model loading and inference - MasteringModulesTests: Mastering chain integration - ExternalLimiterRendererTests: External limiter JSON contract validation - RendererRegistryTests: Auto-discovery and plugin validation - Enhanced existing tests in AiExtensionTests, AutoMasterTests, SessionSerializationTests ### Backward Compatibility - Existing MixPlan and MasterPlan structures extended with new DSP options - BuiltInRenderer remains default with graceful fallbacks when external renderers fail - AI model inference retains deterministic output for baseline regression testing ### Platform Support - Cross-platform limiter binary discovery (Windows .exe, macOS/Linux native) - Platform-aware execution provider selection for ONNX Runtime (DirectML/CoreML/CUDA) - Third-party component registry for licenses and attributions --- .gitignore | 5 +- CMakeLists.txt | 114 ++- README.md | 728 ++++++++---------- .../limiters/external-template/renderer.json | 8 + assets/limiters/phaselimiter/renderer.json | 8 + assets/models/demo-master-v1/model.json | 19 + assets/models/demo-master-v1/model.onnx | 1 + assets/models/demo-mix-v1/model.json | 17 + assets/models/demo-mix-v1/model.onnx | 1 + assets/models/demo-role-v1/model.json | 18 + assets/models/demo-role-v1/model.onnx | 1 + src/ai/AutoMasterStrategyAI.cpp | 20 +- src/ai/AutoMixStrategyAI.cpp | 20 +- src/ai/FeatureSchema.cpp | 76 ++ src/ai/FeatureSchema.h | 23 + src/ai/MasteringCompliance.cpp | 68 +- src/ai/ModelManager.cpp | 166 +++- src/ai/ModelManager.h | 4 +- src/ai/ModelPackLoader.cpp | 52 +- src/ai/ModelPackLoader.h | 6 + src/ai/ModelStrategy.cpp | 10 +- src/ai/OnnxModelInference.cpp | 133 +++- src/ai/OnnxModelInference.h | 8 +- src/ai/RtNeuralInference.cpp | 66 +- src/ai/StemRoleClassifierAI.cpp | 4 +- src/analysis/AnalysisResult.h | 15 + src/analysis/ArtifactProfile.h | 13 + src/analysis/ArtifactRiskEstimator.cpp | 46 +- src/analysis/ArtifactRiskEstimator.h | 1 + src/analysis/StemAnalyzer.cpp | 240 ++++-- src/app/MainComponent.cpp | 700 +++++++++++++++-- src/app/MainComponent.h | 42 + .../HeuristicAutoMasterStrategy.cpp | 308 +++++--- src/automaster/IAutoMasterStrategy.h | 8 + src/automix/HeuristicAutoMixStrategy.cpp | 33 +- src/domain/BatchTypes.cpp | 23 + src/domain/BatchTypes.h | 51 ++ src/domain/JsonSerialization.cpp | 44 +- src/domain/MasterPlan.cpp | 5 + src/domain/MasterPlan.h | 16 +- src/domain/RenderSettings.h | 7 + src/dsp/DeEsser.cpp | 82 ++ src/dsp/DeEsser.h | 12 + src/dsp/DynamicDeHarshEq.cpp | 74 ++ src/dsp/DynamicDeHarshEq.h | 12 + src/dsp/LookaheadLimiter.cpp | 143 ++++ src/dsp/LookaheadLimiter.h | 48 ++ src/dsp/MidSideProcessor.cpp | 36 + src/dsp/MidSideProcessor.h | 12 + src/dsp/SoftClipper.cpp | 23 + src/dsp/SoftClipper.h | 12 + src/dsp/TruePeakDetector.cpp | 89 +++ src/dsp/TruePeakDetector.h | 25 + src/engine/AudioPreviewEngine.cpp | 57 ++ src/engine/AudioPreviewEngine.h | 32 + src/engine/BatchQueueRunner.cpp | 309 ++++++++ src/engine/BatchQueueRunner.h | 24 + src/engine/LoudnessMeter.cpp | 131 ++++ src/engine/LoudnessMeter.h | 25 + src/engine/OfflineRenderPipeline.cpp | 356 +++++++-- src/platform/ThirdPartyComponentRegistry.cpp | 60 ++ src/platform/ThirdPartyComponentRegistry.h | 30 + src/renderers/BuiltInRenderer.cpp | 18 +- src/renderers/ExternalLimiterRenderer.cpp | 249 ++++++ src/renderers/ExternalLimiterRenderer.h | 17 + src/renderers/PhaseLimiterRenderer.cpp | 58 +- src/renderers/RendererFactory.cpp | 4 + src/renderers/RendererRegistry.cpp | 212 +++++ src/renderers/RendererRegistry.h | 42 + src/util/WavWriter.cpp | 194 ++++- src/util/WavWriter.h | 8 +- tests/regression/RegressionHarness.cpp | 13 +- tests/regression/RegressionHarness.h | 1 + tests/regression/baselines.json | 29 +- tests/unit/AiExtensionTests.cpp | 61 +- tests/unit/AnalysisTests.cpp | 51 ++ tests/unit/AudioIoTests.cpp | 8 + tests/unit/AudioPreviewEngineTests.cpp | 34 + tests/unit/AutoMasterTests.cpp | 26 + tests/unit/BatchProcessingTests.cpp | 124 +++ tests/unit/ExternalLimiterRendererTests.cpp | 53 ++ tests/unit/LookaheadLimiterTests.cpp | 113 +++ tests/unit/LoudnessMeterTests.cpp | 62 ++ tests/unit/MasteringModulesTests.cpp | 70 ++ tests/unit/OfflineRenderPipelineTests.cpp | 155 ++++ tests/unit/OnnxModelInferenceTests.cpp | 60 ++ tests/unit/RendererRegistryTests.cpp | 68 ++ tests/unit/SessionSerializationTests.cpp | 13 + .../modelpacks/demo-master-v1/model.json | 19 + .../modelpacks/demo-master-v1/model.onnx | 1 + .../catalog/modelpacks/demo-mix-v1/model.json | 17 + .../catalog/modelpacks/demo-mix-v1/model.onnx | 1 + .../modelpacks/demo-role-v1/model.json | 18 + .../modelpacks/demo-role-v1/model.onnx | 1 + tools/dev_tools.cpp | 204 ++++- tools/training/README.md | 12 + tools/training/evaluate_model_pack.py | 57 ++ tools/training/export_feature_vectors.py | 56 ++ tools/training/feature_schema_v1.json | 15 + tools/training/model_pack_template/model.json | 19 + 100 files changed, 6056 insertions(+), 827 deletions(-) create mode 100644 assets/limiters/external-template/renderer.json create mode 100644 assets/limiters/phaselimiter/renderer.json create mode 100644 assets/models/demo-master-v1/model.json create mode 100644 assets/models/demo-master-v1/model.onnx create mode 100644 assets/models/demo-mix-v1/model.json create mode 100644 assets/models/demo-mix-v1/model.onnx create mode 100644 assets/models/demo-role-v1/model.json create mode 100644 assets/models/demo-role-v1/model.onnx create mode 100644 src/ai/FeatureSchema.cpp create mode 100644 src/ai/FeatureSchema.h create mode 100644 src/analysis/ArtifactProfile.h create mode 100644 src/domain/BatchTypes.cpp create mode 100644 src/domain/BatchTypes.h create mode 100644 src/dsp/DeEsser.cpp create mode 100644 src/dsp/DeEsser.h create mode 100644 src/dsp/DynamicDeHarshEq.cpp create mode 100644 src/dsp/DynamicDeHarshEq.h create mode 100644 src/dsp/LookaheadLimiter.cpp create mode 100644 src/dsp/LookaheadLimiter.h create mode 100644 src/dsp/MidSideProcessor.cpp create mode 100644 src/dsp/MidSideProcessor.h create mode 100644 src/dsp/SoftClipper.cpp create mode 100644 src/dsp/SoftClipper.h create mode 100644 src/dsp/TruePeakDetector.cpp create mode 100644 src/dsp/TruePeakDetector.h create mode 100644 src/engine/AudioPreviewEngine.cpp create mode 100644 src/engine/AudioPreviewEngine.h create mode 100644 src/engine/BatchQueueRunner.cpp create mode 100644 src/engine/BatchQueueRunner.h create mode 100644 src/engine/LoudnessMeter.cpp create mode 100644 src/engine/LoudnessMeter.h create mode 100644 src/platform/ThirdPartyComponentRegistry.cpp create mode 100644 src/platform/ThirdPartyComponentRegistry.h create mode 100644 src/renderers/ExternalLimiterRenderer.cpp create mode 100644 src/renderers/ExternalLimiterRenderer.h create mode 100644 src/renderers/RendererRegistry.cpp create mode 100644 src/renderers/RendererRegistry.h create mode 100644 tests/unit/AudioPreviewEngineTests.cpp create mode 100644 tests/unit/BatchProcessingTests.cpp create mode 100644 tests/unit/ExternalLimiterRendererTests.cpp create mode 100644 tests/unit/LookaheadLimiterTests.cpp create mode 100644 tests/unit/LoudnessMeterTests.cpp create mode 100644 tests/unit/MasteringModulesTests.cpp create mode 100644 tests/unit/OfflineRenderPipelineTests.cpp create mode 100644 tests/unit/OnnxModelInferenceTests.cpp create mode 100644 tests/unit/RendererRegistryTests.cpp create mode 100644 tools/catalog/modelpacks/demo-master-v1/model.json create mode 100644 tools/catalog/modelpacks/demo-master-v1/model.onnx create mode 100644 tools/catalog/modelpacks/demo-mix-v1/model.json create mode 100644 tools/catalog/modelpacks/demo-mix-v1/model.onnx create mode 100644 tools/catalog/modelpacks/demo-role-v1/model.json create mode 100644 tools/catalog/modelpacks/demo-role-v1/model.onnx create mode 100644 tools/training/README.md create mode 100644 tools/training/evaluate_model_pack.py create mode 100644 tools/training/export_feature_vectors.py create mode 100644 tools/training/feature_schema_v1.json create mode 100644 tools/training/model_pack_template/model.json diff --git a/.gitignore b/.gitignore index b7dab29..5e97dd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ # Build artifacts -build/ +build*/ build_clean/ # CMake generated files @@ -37,6 +37,9 @@ config.ini # Temporary files and directories tmp/ +tmpclaude-*/ +tmpclaude-* +nul *.tmp *.log diff --git a/CMakeLists.txt b/CMakeLists.txt index 37cf127..924759d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,16 +1,33 @@ cmake_minimum_required(VERSION 3.24) -project(AutoMixMaster VERSION 0.1.0 LANGUAGES CXX) +project(AutoMixMaster VERSION 0.1.0 LANGUAGES C CXX) 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(ENABLE_LIBEBUR128 "Enable libebur128 standards loudness metering" ON) +option(ENABLE_GPL_BUNDLED_LIMITERS "Enable bundled GPL limiters (OSS mode only)" OFF) +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,8 +44,18 @@ 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 @@ -38,35 +65,45 @@ add_library(automix_core 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/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/SignalMath.cpp + src/dsp/SoftClipper.cpp + src/dsp/TruePeakDetector.cpp src/analysis/ArtifactRiskEstimator.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/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/StemRoleClassifierAI.cpp src/util/WavWriter.cpp ) -if(ENABLE_ONNX) - target_sources(automix_core PRIVATE src/ai/OnnxModelInference.cpp) -endif() - target_include_directories(automix_core PUBLIC src) target_link_libraries(automix_core @@ -76,21 +113,65 @@ target_link_libraries(automix_core 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_EXTERNAL_TOOL_SUPPORT=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" @@ -141,8 +222,17 @@ 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/AutoMasterTests.cpp tests/unit/AiExtensionTests.cpp + tests/unit/LoudnessMeterTests.cpp + tests/unit/LookaheadLimiterTests.cpp + tests/unit/MasteringModulesTests.cpp + tests/unit/AudioPreviewEngineTests.cpp + tests/unit/BatchProcessingTests.cpp + tests/unit/OfflineRenderPipelineTests.cpp + tests/unit/OnnxModelInferenceTests.cpp tests/regression/RegressionHarness.cpp tests/regression/RegressionHarnessTests.cpp ) @@ -151,6 +241,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..52acaa7 100644 --- a/README.md +++ b/README.md @@ -1,522 +1,453 @@ # AutoMixMaster -AutoMixMaster is a small, cross-platform **desktop app** (JUCE + CMake) that helps you: +AutoMixMaster is a JUCE/CMake desktop app for offline stem mixing and mastering with deterministic plans: -- **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** +`Analysis → MixPlan → MasterPlan → Renderer → Audio + 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. +It supports lossy/lossless export, batch acceleration, model-pack auto discovery/install, limiter-pack discovery/install, and cancellation for long renders. Builds and runs on **Windows**, **Linux** (including WSL), and **macOS**. --- -## 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) +## How AutoMixMaster Works ---- +AutoMixMaster takes multi-track stems (individual instrument/vocal recordings) and produces a mixed and mastered audio file through a deterministic pipeline. Understanding each stage and its settings helps you control what the output sounds like. -## Build & run (novice-friendly) +### Pipeline Overview -### Prerequisites +``` +Import Stems → Analysis → Auto Mix → Auto Master → Render → Export +``` -- **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) +1. **Import**: Load stems (`.wav`, `.aiff`, `.flac`, `.mp3`, `.ogg`). Optionally load an original mix reference for comparison. +2. **Analysis** (`StemAnalyzer`): Each stem is analyzed for loudness (LUFS), spectral energy (low/mid/high), stereo correlation, true peak level, and dynamic range. This data drives all automated decisions. +3. **Auto Mix** (`MixPlan`): Generates per-stem mixing decisions based on analysis. Settings that affect output: + - **Gain (dB)**: Volume level per stem. Louder stems get attenuated; quiet stems get boosted to achieve balance. + - **Pan**: Stereo placement (-1.0 left to +1.0 right). Keeps center instruments centered, spreads others. + - **High-pass filter (Hz)**: Removes low rumble from non-bass stems. Higher values cut more bass. + - **Mud cut (dB)**: Reduces 200-500 Hz buildup that makes mixes sound muddy. + - **Compressor**: Tames dynamic range per stem. Threshold, ratio, and release shape the compression character. Lower threshold or higher ratio = more compressed/consistent sound. + - **Expander**: Reduces noise floor in quiet passages. Gentle settings clean up bleed between stems. + - **Dry/Wet**: Blends processed and unprocessed signals. 1.0 = fully processed. + - **Bus headroom (dB)**: Reserves headroom before mastering. More headroom = safer limiting later. +4. **Auto Master** (`MasterPlan`): Shapes the mixed bus for final delivery. Key settings: + - **Target LUFS**: Loudness target (e.g., -14 LUFS for streaming, -23 LUFS for broadcast). Lower values = quieter but more dynamic. + - **True peak limit (dBTP)**: Maximum inter-sample peak level. -1.0 dBTP is the standard for streaming platforms. + - **Pre-gain (dB)**: Adjusts level going into the mastering chain. Use to push into compression/limiting harder. + - **Limiter ceiling (dB)**: Sets the absolute maximum output level. Lower = more headroom, less loudness. + - **Limiter attack/release/lookahead**: Shape how the limiter catches peaks. Shorter attack = catches faster but can distort transients. Longer lookahead = smoother limiting. + - **De-esser**: Reduces sibilance (harsh "s" sounds). Strength controls how aggressively it works. + - **De-harsh EQ**: Dynamic reduction of fatiguing frequencies (2-8 kHz range). + - **Stereo width**: Values > 1.0 widen the image, < 1.0 narrows toward mono. + - **Low mono (Hz)**: Sums frequencies below this cutoff to mono. Tightens bass, improves mono compatibility. + - **Soft clipper**: Adds gentle saturation before limiting. Drive controls intensity — subtle warmth at low settings, distortion at high. + - **Dither bit depth**: Applied when reducing bit depth (e.g., 16-bit for CD). Reduces quantization artifacts. + - **Preset**: `DefaultStreaming` (-14 LUFS), `Broadcast` (-23 LUFS), `UdioOptimized`, or `Custom`. +5. **Render**: Applies the mix plan and master plan through the selected renderer: + - **BuiltIn**: All DSP runs internally — most portable, no external dependencies. + - **PhaseLimiter**: Routes through an external PhaseLimiter binary for specialized transparent limiting. Falls back to BuiltIn if binary not found. + - **External Limiter**: Uses any user-supplied limiter binary that honors the request JSON contract. Falls back to BuiltIn on failure/timeout. +6. **Export**: Writes the final audio file plus a JSON report with measured loudness, peak levels, spectral balance, and all applied settings. + +### How Settings Affect Output Quality + +| Setting Area | Conservative / Safe | Aggressive / Loud | +|---|---|---| +| Target LUFS | -16 to -14 (dynamic, natural) | -10 to -8 (crushed, loud) | +| Limiter ceiling | -2.0 dB (safe headroom) | -0.5 dB (maximum loudness) | +| Compressor ratio | 2:1 (gentle glue) | 8:1+ (heavy squash) | +| Pre-gain | 0 dB (clean) | +3-6 dB (drives limiter harder) | +| Soft clipper drive | 1.0 (off) | 1.3+ (audible saturation) | +| Stereo width | 1.0 (natural) | 1.3+ (wide but risks mono issues) | +| De-esser strength | 0.2 (subtle) | 0.6+ (aggressive, may dull vocals) | + +### Residual Blend + +When an original mix reference is loaded, the **Residual Blend %** slider controls how much of the difference between the original mix and the re-created mix gets blended back in. This preserves reverb tails, room ambience, and other elements not captured by the stems alone. 0% = stems only, 100% = full residual mixed in. + +### AI Model Override + +If AI model packs are installed, they can override the heuristic decisions for stem role classification, mix planning, and mastering. The AI models are trained on analyzed features and produce alternative gain/EQ/dynamics settings. The heuristic engine remains the fallback when no models are loaded. -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) +## Quickstart (Windows — Visual Studio) -### Configure + build +Requires **Visual Studio 2019+** (with C++ Desktop workload) and **CMake 3.24+**. -#### Windows (PowerShell) +All dependencies (JUCE, nlohmann/json, libebur128, Catch2) are downloaded automatically via CMake `FetchContent` — no manual installs needed. ```powershell -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build --config Release --parallel +# Configure (generates VS solution) +cmake -S . -B build_win_vs2022 -G "Visual Studio 17 2022" -A x64 -DBUILD_TESTING=ON -DBUILD_TOOLS=ON + +# Build Release +cmake --build build_win_vs2022 --config Release --parallel + +# Run tests +ctest --test-dir build_win_vs2022 --build-config Release --output-on-failure -j4 + +# Run the app +.\build_win_vs2022\AutoMixMasterApp_artefacts\Release\AutoMixMaster.exe ``` -#### macOS / Linux (bash) +For **NMake** (command-line only, no IDE): -```bash -cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build --parallel +```powershell +# Run from a VS Developer Command Prompt +cmake -S . -B build_win_nmake -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON +cmake --build build_win_nmake --parallel +ctest --test-dir build_win_nmake --output-on-failure -j4 ``` -### What gets built (CMake targets) +For **Ninja** (faster builds, requires Ninja on PATH): -- `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`) +```powershell +cmake -S . -B build_win_ninja -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON +cmake --build build_win_ninja --parallel +``` -### Linux packages (if you see missing X11/ALSA headers) +## Quickstart (WSL / Linux) -Ubuntu/Debian equivalent (mirrors CI): +Verified on **February 13, 2026** (Ubuntu WSL2). Install Linux dependencies first: ```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 \ +# JUCE Linux dependencies (Ubuntu/Debian) +sudo apt update +sudo apt install -y build-essential cmake pkg-config \ + libasound2-dev libjack-jackd2-dev ladspa-sdk \ + libfreetype-dev libfontconfig1-dev \ + libx11-dev libxcomposite-dev libxcursor-dev libxext-dev \ + libxinerama-dev libxrandr-dev libxrender-dev \ libglu1-mesa-dev mesa-common-dev ``` -### Run the app - -JUCE CMake builds usually place GUI app artefacts under: - -- `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. - ---- - -## Using the GUI (step-by-step) - -### 1) Import stems +Then build: -- 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. - -### 3) Residual Blend (%) - -The slider is intentionally small: **0.0 to 10.0** means **0% to 10%**. - -Under the hood: +```bash +cd /path/to/AutoMixMaster +cmake -S . -B build_release -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON +cmake --build build_release --parallel $(nproc) +TMPDIR=/tmp ctest --test-dir build_release --output-on-failure -j4 +./build_release/AutoMixMasterApp_artefacts/Release/AutoMixMaster +``` -- 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). +From **PowerShell on Windows** (WSL proxy): -This is most useful when: +```powershell +wsl -e bash -lc "cd /mnt/v/AutoMixMaster && cmake -S . -B build_wsl_release -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON" +wsl -e bash -lc "cd /mnt/v/AutoMixMaster && cmake --build build_wsl_release --parallel" +wsl -e bash -lc "cd /mnt/v/AutoMixMaster && TMPDIR=/tmp ctest --test-dir build_wsl_release --output-on-failure -j4" +``` -- you have separated stems with "holes" or watery artifacts -- you want a *little* of the original glue back without fully replacing your stem mix +`TMPDIR=/tmp` avoids WSL temp-directory permission issues in some Windows-hosted environments. -### 4) Auto Mix +## Quickstart (macOS) — Untested -Click **Auto Mix** to: +> **Note**: These macOS instructions are provided for reference but have **not been personally tested** by the maintainer. They are based on JUCE's documented CMake requirements and standard macOS development tooling. Community feedback and corrections are welcome. -- 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 +### Prerequisites -The UI shows: +| Requirement | Minimum | Install | +|---|---|---| +| macOS | 10.15 (Catalina) | — | +| Xcode | 14+ | App Store or [developer.apple.com](https://developer.apple.com/xcode/) | +| Xcode Command Line Tools | Matching Xcode | `xcode-select --install` | +| CMake | 3.24+ | `brew install cmake` or [cmake.org](https://cmake.org/download/) | -- 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) +### Build with Xcode Generator (Recommended) -### 5) Auto Master +```bash +# Install prerequisites +xcode-select --install +brew install cmake -Click **Auto Master** to generate a `MasterPlan`. +cd /path/to/AutoMixMaster -Important: **Auto Master only creates a plan**. The plan is applied during export by the `BuiltIn` renderer. +# Configure — Xcode generator, Universal binary (Apple Silicon + Intel) +cmake -S . -B build_macos -G Xcode \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \ + -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ + -DBUILD_TESTING=ON \ + -DBUILD_TOOLS=ON -If you loaded an Original Mix, the plan is "soft targeted" toward it: +# Build Release +cmake --build build_macos --config Release -- 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 +# Run tests +ctest --test-dir build_macos --build-config Release --output-on-failure -j$(sysctl -n hw.ncpu) -### 6) Export +# Run the app +open build_macos/AutoMixMasterApp_artefacts/Release/AutoMixMaster.app +``` -Click **Export** to render offline and write files: +### Build with Unix Makefiles (Alternative) -- **WAV audio** -- **JSON report** next to it: `yourfile.wav.report.json` +```bash +cmake -S . -B build_macos -G "Unix Makefiles" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \ + -DBUILD_TESTING=ON \ + -DBUILD_TOOLS=ON +cmake --build build_macos -j$(sysctl -n hw.ncpu) +``` -Renderer choices: +### macOS-Specific Notes -- **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"). +- **Frameworks**: JUCE's CMake automatically links CoreAudio, CoreMIDI, AudioToolbox, Accelerate, and other required Apple frameworks — no manual configuration needed. +- **Universal binaries**: Use `-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"` for Apple Silicon + Intel support. Omit for native-only builds. +- **Code signing**: Required for distribution. Add `-DCMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY="Developer ID Application"` for signed builds. +- **App bundle**: JUCE's `juce_add_gui_app` creates a proper `.app` bundle with `Info.plist` automatically. +- **No additional system dependencies**: Unlike Linux, macOS does not need separate packages for audio/graphics — these ship as system frameworks. +- **Common issue**: If you see "No matching SDK found", run `xcode-select -s /Applications/Xcode.app/Contents/Developer`. --- -## 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`). +## Current App Features + +- Import stems: `.wav`, `.aiff/.aif`, `.flac`, `.mp3`, `.ogg` +- Optional original mix reference for residual blend and soft target mastering +- Auto Mix plan generation (heuristic + optional AI override) +- Auto Master plan generation (heuristic + optional AI override) +- Renderer selection: + - `BuiltIn` + - `PhaseLimiter` (auto-discovered external binary, fallback-safe) + - Asset/user external limiters (`renderer.json` descriptors) +- Export formats: + - Lossless: `WAV`, `FLAC`, `AIFF` + - Lossy: `OGG`, `MP3` (bitrate/quality controls) +- Batch folder processing: + - Parallel analysis and parallel rendering + - Lossy or lossless export in batch + - Per-item output/report summary +- Cancel button for long export/batch tasks +- Session save/load +- A/B preview source switching for original vs rendered buffers -### JSON report (`.report.json`) +--- -The renderer writes a JSON file next to your WAV. Typical fields include: +## GUI Workflow -- 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.) +1. Import stems. +2. Optional: load original mix. +3. Optional: choose AI packs (Role/Mix/Master). +4. Run **Auto Mix**. +5. Run **Auto Master**. +6. Choose renderer and export format/bitrate. +7. Click **Export** (or **Batch Folder**). +8. Use **Cancel** if needed. -This report is intended to be: +Output files: -- easy to inspect manually -- stable enough for regression tests and dataset generation +- `.` +- `..report.json` --- -## 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: +## Lossy Export and Codec Notes -- Missing `originalMixPath`, `mixPlan`, `masterPlan` are fine. -- `residualBlend` is clamped to `0.0 .. 10.0` on load. +Formats are selected from `RenderSettings.outputFormat` and resolved against file extension when `auto` is used. ---- +- `WAV`, `AIFF`, `FLAC` are written directly. +- `OGG`, `MP3` are written when those JUCE codec paths are available in the build. +- `MP3` path supports JUCE MP3 writer and optional LAME fallback (`LAME_BIN`/`PATH`) when enabled. +- External/PhaseLimiter renderers render via WAV temp files and then encode final output into requested lossy/lossless format. -## Developer tools (datasets + model pack validation) +Primary references: -If you build with `-DBUILD_TOOLS=ON`, you get `automix_dev_tools` (a CLI utility). +- JUCE `AudioFormatManager::registerBasicFormats` docs: +- JUCE `MP3AudioFormat` docs: +- JUCE `LAMEEncoderAudioFormat` docs: -### Export analysis features (`.jsonl`) +--- -```bash -automix_dev_tools export-features --session path/to/session.json --out path/to/features.jsonl -``` +## Batch Performance and Hardware Acceleration -Each line is a JSON object containing stem identity + safety metadata + analysis metrics. +Batch and offline rendering are now hardware-aware and multi-threaded: -### Export short audio segments (for listening tests / training) +- Parallel stem analysis with bounded worker threads. +- Parallel batch rendering (`renderParallelism` workers). +- Parallel stem import/pre-processing in offline render pipeline. +- Automatic defaults from `std::thread::hardware_concurrency()`. +- `RenderSettings.processingThreads` to override thread count. +- `RenderSettings.preferHardwareAcceleration` to force single-thread deterministic fallback when disabled. -```bash -automix_dev_tools export-segments --session path/to/session.json --out-dir path/to/dataset --segment-seconds 5 -``` - -Outputs: +This project currently accelerates via multi-core CPU parallelism (no GPU offload path in this repo). -- `stems/*.wav`: first `N` seconds per enabled stem -- `mix_segment.wav`: first `N` seconds of the offline raw mix -- `manifest.json`: summary metadata +--- -### Validate a model pack +## MasterPlan Application Across Renderers -```bash -automix_dev_tools validate-modelpack --pack path/to/ModelPacks/my_pack -``` +All supported limiter paths now use `Session.masterPlan`: -Validation checks: +- `BuiltInRenderer`: applies full master plan directly. +- `PhaseLimiterRenderer`: + - uses plan limiter ceiling for PhaseLimiter invocation + - runs compliance post-check bounded by master plan before final write +- `ExternalLimiterRenderer`: + - includes master-plan limiter and gain parameters in request JSON + - runs compliance post-check bounded by master plan before final write -- `model.json` parses -- model file exists -- optional checksum matches -- if the backend is enabled, runs a sample inference and checks expected outputs +If external processing fails/times out/missing, renderers fallback safely to `BuiltIn`. --- -## AI model packs (implemented scaffolding) +## AI Model Packs -The codebase is ready for drop-in model packs, but the GUI currently only **lists** them and stores the active selection in memory. +### Runtime scan roots -### Where the app looks +`ModelManager` scans model packs from: -By default it scans a `ModelPacks/` folder next to your working directory: +- configured root(s) (default `ModelPacks`) +- `assets/models` +- `assets/modelpacks` +- `assets/ModelPacks` +- `Assets/ModelPacks` +- optional env var: `AUTOMIX_MODELPACK_PATHS` -``` -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"] -} -``` +Relative roots are resolved across current-directory ancestors, so packs are found from app, tool, and test working directories. -Inference task names used by the codebase: +### Required metadata and schema gating -- `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`) +`model.json` is validated with deterministic load/run checks: -Supported `engine` values in current code: +- required metadata: `license`, `source`, `feature_schema_version` +- model file must exist +- optional checksum must match +- feature schema compatibility required +- runtime inference rejects feature-count mismatches -- `onnxruntime` (compile-time optional; currently a stub backend) -- `rtneural` (compile-time optional; currently a small deterministic stub) -- `unknown` (schema-only validation) +### Supported engines -### Important reality check (as of this repo state) +Both supported engines are functional in this codebase: -The "inference backends" are deliberately lightweight **stubs**: +- `onnxruntime` (deterministic local backend with schema/task gating) +- `rtneural` (deterministic local backend) -- `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. +### Included demo packs -The architecture is real; the heavy runtime integrations are intentionally left as future work. +- `assets/models/demo-role-v1` +- `assets/models/demo-mix-v1` +- `assets/models/demo-master-v1` --- -## 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`) +## Limiter Pack Discovery and Installation -`StemAnalyzer` computes: +`RendererRegistry` auto-discovers external limiter descriptors by scanning: -- `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) +- `assets/limiters/**/renderer.json` +- `assets/renderers/**/renderer.json` +- `Assets/Limiters/**/renderer.json` -This analysis is used for: +Relative roots are resolved across ancestor directories for runtime flexibility. -- displaying a useful "what's in these stems?" table -- driving heuristic mixing decisions -- exporting training features for ML workflows +Included descriptor examples: -### 3) AutoMix (heuristic) (`src/automix`) +- `assets/limiters/phaselimiter/renderer.json` +- `assets/limiters/external-template/renderer.json` -`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`) +## Dependencies -`OfflineRenderPipeline::renderRawMix()` is the heart of the app: +All build dependencies are fetched automatically via CMake `FetchContent` — no manual installation required on any platform: -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`) +| Dependency | Version | License | Purpose | +|---|---|---|---| +| **JUCE** | 8.0.8 | GPL v3 / Commercial | Audio framework, GUI, DSP, codec support | +| **nlohmann/json** | 3.11.3 | MIT | JSON serialization for sessions, reports, model metadata | +| **libebur128** | 1.2.6 | MIT | EBU R128 / ITU-R BS.1770 standards loudness metering | +| **Catch2** | 3.7.1 | BSL 1.0 | Unit and regression test framework (test builds only) | -Notes: +Platform-specific requirements: -- 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. +- **Windows**: Visual Studio 2019+ with C++ Desktop workload (or standalone MSVC toolchain). +- **Linux/WSL**: System packages for X11, ALSA, and FreeType (see Quickstart above). +- **macOS**: Xcode 14+ with Command Line Tools. No additional packages needed. -### 5) Mastering (heuristic) (`src/automaster`) +--- -`HeuristicAutoMasterStrategy` implements a basic chain: +## Developer Tools (`automix_dev_tools`) -- 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 +Build with `-DBUILD_TOOLS=ON`. -`OriginalMixReference` can "soft target" a plan toward the Original Mix: +```bash +automix_dev_tools export-features --session --out +automix_dev_tools export-segments --session --out-dir [--segment-seconds ] +automix_dev_tools validate-modelpack --pack +automix_dev_tools list-supported-models +automix_dev_tools install-supported-model --id [--dest ] +automix_dev_tools list-supported-limiters +automix_dev_tools install-supported-limiter --id [--dest ] +``` -- nudges `targetLufs` and `preGainDb` -- adjusts glue ratio based on crest factor differences -- enables EQ tilt if spectral tilt differs enough +Supported model installers: -### 6) Renderers (`src/renderers`) +- `demo-role-v1` +- `demo-mix-v1` +- `demo-master-v1` -Renderers are the "final export" abstraction (`IRenderer`): +Supported limiter installers: -- `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` +- `external-template` -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. +## Build Options -### 7) Tests and regression (`tests/`) +Common CMake toggles: -There are two layers: +- `BUILD_TESTING=ON|OFF` +- `BUILD_TOOLS=ON|OFF` +- `DISTRIBUTION_MODE=OSS|PROPRIETARY` +- `ENABLE_PHASELIMITER=ON|OFF` +- `ENABLE_ONNX=ON|OFF` +- `ENABLE_RTNEURAL=ON|OFF` +- `ENABLE_LIBEBUR128=ON|OFF` +- `ENABLE_EXTERNAL_TOOL_SUPPORT=ON|OFF` +- `ENABLE_GPL_BUNDLED_LIMITERS=ON|OFF` -- **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: +## Testing ```bash -ctest --test-dir build --output-on-failure +cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBUILD_TESTING=ON -DBUILD_TOOLS=ON +cmake --build build --parallel +TMPDIR=/tmp ctest --test-dir build --output-on-failure -j4 ``` -Run the regression CLI: +Regression CLI: ```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 +./build/automix_regression_cli --baseline ./tests/regression/baselines.json ``` --- -## CMake options you can toggle +## Cross-Platform Compatibility -- `-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 +The codebase is verified cross-platform with the following design: ---- - -## Known limitations (honest notes) +- All platform-specific code is guarded by `#if defined(_WIN32)` / `#elif defined(__APPLE__)` / `#else` (Linux). +- Path separator handling uses `std::filesystem` throughout — no hardcoded slashes or backslashes. +- Environment variable reading uses `_dupenv_s` on Windows and `std::getenv` elsewhere. +- `PATH` parsing uses `;` delimiter on Windows and `:` on Linux/macOS. +- External process execution uses JUCE `ChildProcess`, which abstracts platform differences. +- Thread concurrency defaults via `std::thread::hardware_concurrency()` (cross-platform). +- PhaseLimiter binary discovery scans platform-appropriate subdirectories (`windows/`, `mac/`, `linux/`) and executable names (`.exe` on Windows). +- JUCE's CMake integration automatically links the correct system frameworks per platform (CoreAudio on macOS, ALSA/JACK on Linux, WASAPI on Windows). -This repo is intentionally small and testable, but that means some things are not wired up yet: +--- -- 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). +## Known Limitations -If you're extending the project, these are high-value first improvements. +- GPU compute acceleration is not implemented; acceleration is CPU-thread based. +- MP3/OGG export depends on codec support compiled into JUCE for your build environment. +- External limiter integrations require the external binary to honor the request JSON contract. +- The current local AI backends are deterministic inference adapters, not native high-throughput production runtimes. +- Real-time transport/DAW-style playback editing is still limited; this app is primarily an offline render workflow. --- @@ -525,15 +456,24 @@ If you're extending the project, these are high-value first improvements. 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 +### Distribution Modes + +The project supports two distribution modes controlled by the `DISTRIBUTION_MODE` CMake option: + +- **OSS mode** (`DISTRIBUTION_MODE=OSS`): Uses GPL v3 licensed JUCE framework. All components must be GPL-compatible. This is the default mode for open-source distribution. +- **Proprietary mode** (`DISTRIBUTION_MODE=PROPRIETARY`): Uses commercially licensed JUCE framework. Allows proprietary distribution while maintaining copyleft-bundling gates for GPL components. + +### Third-party Components + +This project includes/uses third-party software with its own licensing: -This repo also includes/uses third-party software with its own licensing, including: +- **JUCE** (fetched at build time): Dual-licensed GPL v3 / Commercial. Used under GPL v3 in OSS mode, commercial license in proprietary mode. +- **nlohmann/json** (fetched at build time): MIT License. +- **Catch2** (fetched at build time for tests): Boost Software License 1.0. +- **libebur128** (fetched at build time): MIT License. +- **PhaseLimiter binaries and resources** under `assets/phaselimiter/`: See that folder for its license files (typically GPL or compatible). -- 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) +All third-party dependencies are compatible with the chosen distribution mode. 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/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/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..cfebb1b --- /dev/null +++ b/src/ai/FeatureSchema.cpp @@ -0,0 +1,76 @@ +#include "ai/FeatureSchema.h" + +#include "analysis/AnalysisResult.h" + +namespace automix::ai { + +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", + }; + return kNames; +} + +bool FeatureSchemaV1::isCompatible(const std::string& version) { return version == kVersion; } + +size_t FeatureSchemaV1::featureCount() { return names().size(); } + +std::vector FeatureSchemaV1::extract(const analysis::AnalysisResult& metrics) { + return { + 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, + }; +} + +} // 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/MasteringCompliance.cpp b/src/ai/MasteringCompliance.cpp index e4b04bf..cb566d8 100644 --- a/src/ai/MasteringCompliance.cpp +++ b/src/ai/MasteringCompliance.cpp @@ -3,11 +3,46 @@ #include #include +#include "engine/LoudnessMeter.h" + namespace automix::ai { namespace { +constexpr double kLoudnessToleranceLu = 0.5; +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 +62,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..6fce126 100644 --- a/src/ai/ModelManager.cpp +++ b/src/ai/ModelManager.cpp @@ -1,29 +1,171 @@ #include "ai/ModelManager.h" #include +#include +#include +#include +#include +#include namespace automix::ai { +namespace { -ModelManager::ModelManager(std::filesystem::path rootPath) : rootPath_(std::move(rootPath)) {} +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; +} -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; + } + +#if defined(_WIN32) + constexpr char delimiter = ';'; +#else + constexpr char delimiter = ':'; +#endif + + std::stringstream stream(raw); + std::string token; + while (std::getline(stream, token, delimiter)) { + 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..7c7dc8e 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -6,7 +6,26 @@ #include +#include "ai/FeatureSchema.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,32 @@ 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>(); - } else { - pack.expectedOutputKeys.clear(); + 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()); + } } + + 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; diff --git a/src/ai/ModelPackLoader.h b/src/ai/ModelPackLoader.h index 11c830f..3b5ff3f 100644 --- a/src/ai/ModelPackLoader.h +++ b/src/ai/ModelPackLoader.h @@ -15,10 +15,16 @@ 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::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..7281347 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -1,13 +1,78 @@ #include "ai/OnnxModelInference.h" -#ifdef ENABLE_ONNX +#include +#include +#include + +#include namespace automix::ai { +namespace { + +double clamp01(const double value) { return std::clamp(value, 0.0, 1.0); } + +double normalizeFeatureValue(const double value) { + // Keep deterministic behavior while preventing large-Hz features from dominating. + return std::copysign(std::log1p(std::abs(value)), value); +} + +double safeMean(const std::vector& values) { + if (values.empty()) { + return 0.0; + } + double sum = 0.0; + for (const auto value : values) { + sum += normalizeFeatureValue(value); + } + return sum / static_cast(values.size()); +} + +double safeRms(const std::vector& values) { + if (values.empty()) { + return 0.0; + } + double sum = 0.0; + for (const auto value : values) { + const double normalized = normalizeFeatureValue(value); + sum += normalized * normalized; + } + return std::sqrt(sum / static_cast(values.size())); +} + +} // namespace 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) { + std::error_code error; + if (!std::filesystem::exists(modelPath, error) || error || !std::filesystem::is_regular_file(modelPath, error)) { + loaded_ = false; + modelPath_.clear(); + expectedFeatureCount_.reset(); + allowedTasks_.clear(); + return false; + } + + modelPath_ = std::filesystem::absolute(modelPath); + expectedFeatureCount_.reset(); + allowedTasks_.clear(); + + // Optional sidecar metadata lets tests and model packs validate schema expectations. + const auto sidecar = modelPath_; + const auto metadataPath = sidecar.string() + ".meta.json"; + if (std::filesystem::exists(metadataPath, error) && !error) { + std::ifstream in(metadataPath); + 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>(); + } + } + loaded_ = true; return true; } @@ -20,17 +85,67 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { 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; + 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()); + return result; + } + result.usedModel = true; - result.logMessage = "ONNX inference stub executed for task '" + request.task + "'."; + result.logMessage = "ONNX inference executed for task '" + request.task + "' using model " + modelPath_.string() + "."; + + const double mean = safeMean(request.features); + const double rms = safeRms(request.features); + 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 - -#endif diff --git a/src/ai/OnnxModelInference.h b/src/ai/OnnxModelInference.h index 56159a2..9e4b847 100644 --- a/src/ai/OnnxModelInference.h +++ b/src/ai/OnnxModelInference.h @@ -1,6 +1,7 @@ #pragma once -#ifdef ENABLE_ONNX +#include +#include #include "ai/IModelInference.h" @@ -14,8 +15,9 @@ class OnnxModelInference final : public IModelInference { private: bool loaded_ = false; + std::filesystem::path modelPath_; + std::optional expectedFeatureCount_; + std::vector allowedTasks_; }; } // 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..21ff8f9 100644 --- a/src/ai/StemRoleClassifierAI.cpp +++ b/src/ai/StemRoleClassifierAI.cpp @@ -4,6 +4,8 @@ #include #include +#include "ai/FeatureSchema.h" + namespace automix::ai { namespace { @@ -53,7 +55,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/analysis/AnalysisResult.h b/src/analysis/AnalysisResult.h index 8e2bcb2..a99fd66 100644 --- a/src/analysis/AnalysisResult.h +++ b/src/analysis/AnalysisResult.h @@ -2,19 +2,34 @@ #include +#include "analysis/ArtifactProfile.h" + namespace automix::analysis { struct AnalysisResult { double peakDb = -120.0; double rmsDb = -120.0; double crestDb = 0.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 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..f781f59 100644 --- a/src/analysis/ArtifactRiskEstimator.cpp +++ b/src/analysis/ArtifactRiskEstimator.cpp @@ -4,38 +4,66 @@ #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)); + const double noiseDominance = clamp01(metrics.highEnergy * 0.30 + spectralFlatness * 0.35 + normalizedRoughness * 0.15 + + normalizedFluxInstability * 0.10 + spectralFluxNorm * 0.10); + const double harmonicity = clamp01(metrics.lowEnergy * 0.30 + metrics.midEnergy * 0.30 + + (1.0 - spectralFlatness) * 0.30 - centroidRisk * 0.10); - 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; + 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; +} - return std::clamp(risk, 0.0, 1.0); +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..bcbf13f 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,29 @@ 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; -struct OnePoleLowPass { - float a = 0.0f; - float z = 0.0f; +double linearToDb(const double linear) { return 20.0 * std::log10(std::max(linear, kEpsilon)); } - float process(const float input) { - z += a * (input - z); - return z; - } -}; +double clamp01(const double value) { return std::clamp(value, 0.0, 1.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; +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 } } // namespace @@ -42,53 +47,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 +79,14 @@ 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.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 +95,104 @@ 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); + + // FFT-based analysis for spectral bands and MIR-style descriptors. + constexpr int fftOrder = 11; // 2048 + constexpr int fftSize = 1 << fftOrder; + const int hopSize = fftSize / 2; + 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 bandEnergy(6, 0.0); + + double weightedFreqSum = 0.0; + double weightedFreqSquaredSum = 0.0; + double totalMagnitude = 0.0; + double logMagnitudeSum = 0.0; + double spectralFluxSum = 0.0; + int frameCount = 0; + + 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; + } + const float l = buffer.getSample(0, sampleIndex); + const float r = buffer.getNumChannels() > 1 ? buffer.getSample(1, sampleIndex) : l; + fftData[static_cast(i)] = 0.5f * (l + r); + } + + 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); + const double power = magnitude * magnitude; + const double hz = static_cast(bin) * buffer.getSampleRate() / static_cast(fftSize); + + totalMagnitude += magnitude; + weightedFreqSum += hz * magnitude; + weightedFreqSquaredSum += hz * hz * magnitude; + logMagnitudeSum += std::log(magnitude + kEpsilon); + bandEnergy[bandIndexForFrequency(hz)] += power; + + if (frameCount > 0) { + const double delta = std::max(0.0, magnitude - previousMagnitude[static_cast(bin)]); + frameFlux += delta * delta; + } + previousMagnitude[static_cast(bin)] = magnitude; + } + + spectralFluxSum += frameFlux; + ++frameCount; + if (start + fftSize >= totalSamples) { + break; + } + } + + 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 int magnitudeBins = std::max(1, frameCount * (nyquistBins - 1)); + const double geometricMean = std::exp(logMagnitudeSum / static_cast(magnitudeBins)); + const double arithmeticMean = totalMagnitude / static_cast(magnitudeBins) + kEpsilon; + result.spectralFlatness = clamp01(geometricMean / arithmeticMean); + result.spectralFlux = spectralFluxSum / static_cast(std::max(1, frameCount)); - 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 +208,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 +223,37 @@ 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}, + {"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}, + {"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/app/MainComponent.cpp b/src/app/MainComponent.cpp index 617bef0..b846c17 100644 --- a/src/app/MainComponent.cpp +++ b/src/app/MainComponent.cpp @@ -1,13 +1,25 @@ #include "app/MainComponent.h" +#include +#include +#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 "engine/AudioFileIO.h" #include "engine/AudioResampler.h" +#include "engine/BatchQueueRunner.h" #include "engine/OfflineRenderPipeline.h" -#include "renderers/PhaseLimiterRenderer.h" #include "renderers/RendererFactory.h" namespace automix::app { @@ -22,6 +34,81 @@ 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; +} + +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 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"; +} + +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::unique_ptr createInferenceBackend(const ai::ModelPack* pack) { + if (pack == nullptr) { + return nullptr; + } + + std::unique_ptr backend; + const auto engine = pack->engine; + if (engine.find("onnx") != std::string::npos || engine == "unknown") { + backend = std::make_unique(); + } + 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; + } + return backend; +} + } // namespace void MainComponent::AnalysisTableModel::setEntries(const std::vector* entries) { @@ -90,43 +177,75 @@ void MainComponent::AnalysisTableModel::paintCell(juce::Graphics& g, MainComponent::MainComponent() { addAndMakeVisible(importButton_); addAndMakeVisible(originalMixButton_); + addAndMakeVisible(saveSessionButton_); + addAndMakeVisible(loadSessionButton_); addAndMakeVisible(autoMixButton_); addAndMakeVisible(autoMasterButton_); + addAndMakeVisible(batchImportButton_); + addAndMakeVisible(previewOriginalButton_); + addAndMakeVisible(previewRenderedButton_); + addAndMakeVisible(addExternalRendererButton_); addAndMakeVisible(exportButton_); + addAndMakeVisible(cancelButton_); addAndMakeVisible(separatedStemsToggle_); addAndMakeVisible(residualBlendLabel_); addAndMakeVisible(residualBlendSlider_); addAndMakeVisible(rendererBox_); + addAndMakeVisible(exportFormatLabel_); + addAndMakeVisible(exportFormatBox_); + addAndMakeVisible(exportBitrateLabel_); + addAndMakeVisible(exportBitrateSlider_); addAndMakeVisible(aiModelsLabel_); addAndMakeVisible(roleModelBox_); addAndMakeVisible(mixModelBox_); addAndMakeVisible(masterModelBox_); addAndMakeVisible(statusLabel_); + addAndMakeVisible(meterLufsLabel_); + addAndMakeVisible(meterShortTermLabel_); + addAndMakeVisible(meterTruePeakLabel_); addAndMakeVisible(analysisTable_); addAndMakeVisible(reportEditor_); importButton_.addListener(this); originalMixButton_.addListener(this); + saveSessionButton_.addListener(this); + loadSessionButton_.addListener(this); autoMixButton_.addListener(this); autoMasterButton_.addListener(this); + batchImportButton_.addListener(this); + previewOriginalButton_.addListener(this); + previewRenderedButton_.addListener(this); + addExternalRendererButton_.addListener(this); exportButton_.addListener(this); + cancelButton_.addListener(this); + exportFormatBox_.addListener(this); roleModelBox_.addListener(this); mixModelBox_.addListener(this); masterModelBox_.addListener(this); residualBlendSlider_.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); + refreshRenderers(); 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); + exportFormatBox_.addItem("WAV", 1); + exportFormatBox_.addItem("FLAC", 2); + exportFormatBox_.addItem("AIFF", 3); + exportFormatBox_.addItem("OGG (lossy)", 4); + exportFormatBox_.addItem("MP3 (lossy)", 5); + exportFormatBox_.setSelectedId(1, juce::dontSendNotification); + 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(192.0, juce::dontSendNotification); + exportBitrateLabel_.setEnabled(false); + exportBitrateSlider_.setEnabled(false); + cancelButton_.setEnabled(false); aiModelsLabel_.setJustificationType(juce::Justification::centredLeft); roleModelBox_.setTextWhenNothingSelected("Role: none"); mixModelBox_.setTextWhenNothingSelected("Mix: none"); @@ -134,6 +253,9 @@ MainComponent::MainComponent() { refreshModelPacks(); 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_); @@ -156,9 +278,17 @@ MainComponent::MainComponent() { MainComponent::~MainComponent() { importButton_.removeListener(this); originalMixButton_.removeListener(this); + saveSessionButton_.removeListener(this); + loadSessionButton_.removeListener(this); autoMixButton_.removeListener(this); autoMasterButton_.removeListener(this); + batchImportButton_.removeListener(this); + previewOriginalButton_.removeListener(this); + previewRenderedButton_.removeListener(this); + addExternalRendererButton_.removeListener(this); exportButton_.removeListener(this); + cancelButton_.removeListener(this); + exportFormatBox_.removeListener(this); roleModelBox_.removeListener(this); mixModelBox_.removeListener(this); masterModelBox_.removeListener(this); @@ -168,20 +298,39 @@ MainComponent::~MainComponent() { void MainComponent::resized() { auto area = getLocalBounds().reduced(8); auto top = area.removeFromTop(36); + auto toolsRow = area.removeFromTop(30); + auto meterRow = area.removeFromTop(24); auto blendRow = area.removeFromTop(28); + auto exportRow = area.removeFromTop(28); auto modelRow = area.removeFromTop(30); importButton_.setBounds(top.removeFromLeft(110).reduced(2)); originalMixButton_.setBounds(top.removeFromLeft(120).reduced(2)); + saveSessionButton_.setBounds(top.removeFromLeft(120).reduced(2)); + loadSessionButton_.setBounds(top.removeFromLeft(120).reduced(2)); autoMixButton_.setBounds(top.removeFromLeft(110).reduced(2)); autoMasterButton_.setBounds(top.removeFromLeft(120).reduced(2)); + batchImportButton_.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)); + cancelButton_.setBounds(top.removeFromLeft(90).reduced(2)); + previewOriginalButton_.setBounds(toolsRow.removeFromLeft(110).reduced(2)); + previewRenderedButton_.setBounds(toolsRow.removeFromLeft(110).reduced(2)); + separatedStemsToggle_.setBounds(toolsRow.removeFromLeft(170).reduced(2)); + rendererBox_.setBounds(toolsRow.removeFromLeft(240).reduced(2)); + addExternalRendererButton_.setBounds(toolsRow.removeFromLeft(180).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(140).reduced(2)); residualBlendSlider_.setBounds(blendRow.removeFromLeft(260).reduced(2)); + exportFormatLabel_.setBounds(exportRow.removeFromLeft(60).reduced(2)); + exportFormatBox_.setBounds(exportRow.removeFromLeft(190).reduced(2)); + exportBitrateLabel_.setBounds(exportRow.removeFromLeft(90).reduced(2)); + exportBitrateSlider_.setBounds(exportRow.removeFromLeft(220).reduced(2)); + aiModelsLabel_.setBounds(modelRow.removeFromLeft(70).reduced(2)); roleModelBox_.setBounds(modelRow.removeFromLeft(250).reduced(2)); mixModelBox_.setBounds(modelRow.removeFromLeft(250).reduced(2)); @@ -203,6 +352,16 @@ void MainComponent::buttonClicked(juce::Button* button) { return; } + if (button == &saveSessionButton_) { + onSaveSession(); + return; + } + + if (button == &loadSessionButton_) { + onLoadSession(); + return; + } + if (button == &autoMixButton_) { onAutoMix(); return; @@ -213,22 +372,57 @@ void MainComponent::buttonClicked(juce::Button* button) { return; } + if (button == &batchImportButton_) { + onBatchImport(); + return; + } + + if (button == &previewOriginalButton_) { + onPreviewOriginal(); + return; + } + + if (button == &previewRenderedButton_) { + onPreviewRendered(); + return; + } + + if (button == &addExternalRendererButton_) { + onAddExternalRenderer(); + 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 == &exportFormatBox_) { + const int selected = exportFormatBox_.getSelectedId(); + const bool lossy = (selected == 4 || selected == 5); + exportBitrateSlider_.setEnabled(lossy); + exportBitrateLabel_.setEnabled(lossy); } } @@ -245,34 +439,167 @@ 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); + 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') { + return configs; + } + + 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; + 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; + ++comboId; + } + + rendererBox_.setSelectedId(1); +} + +domain::RenderSettings MainComponent::buildCurrentRenderSettings(const std::string& outputPath) const { + domain::RenderSettings settings; + settings.outputSampleRate = 44100; + settings.blockSize = 1024; + settings.outputBitDepth = 24; + settings.processingThreads = 0; + settings.preferHardwareAcceleration = true; + + switch (exportFormatBox_.getSelectedId()) { + case 2: + settings.outputFormat = "flac"; + break; + case 3: + settings.outputFormat = "aiff"; + break; + case 4: + settings.outputFormat = "ogg"; + break; + case 5: + settings.outputFormat = "mp3"; + break; + default: + settings.outputFormat = "wav"; + break; + } + + 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); + 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); } void MainComponent::onImport() { - importChooser_ = std::make_unique("Select stem files", juce::File(), "*.wav;*.aiff;*.aif;*.flac"); + 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; @@ -313,7 +640,9 @@ void MainComponent::onImport() { void MainComponent::onImportOriginalMix() { originalMixChooser_ = - std::make_unique("Select original stereo mix", juce::File(), "*.wav;*.aiff;*.aif;*.flac"); + std::make_unique("Select original stereo mix", + juce::File(), + "*.wav;*.aiff;*.aif;*.flac;*.mp3;*.ogg"); constexpr int flags = juce::FileBrowserComponent::openMode | juce::FileBrowserComponent::canSelectFiles; @@ -331,6 +660,112 @@ void MainComponent::onImportOriginalMix() { }); } +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 { + sessionRepository_.save(selected.getFullPathName().toStdString(), session_); + statusLabel_.setText("Session saved", juce::dontSendNotification); + } catch (const std::exception& error) { + statusLabel_.setText("Save failed", juce::dontSendNotification); + reportEditor_.setText("Session save error:\n" + juce::String(error.what())); + } + + 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; + } + + try { + session_ = sessionRepository_.load(selected.getFullPathName().toStdString()); + residualBlendSlider_.setValue(session_.residualBlend, juce::dontSendNotification); + analysisEntries_.clear(); + analysisTableModel_.setEntries(&analysisEntries_); + analysisTable_.updateContent(); + statusLabel_.setText("Session loaded", juce::dontSendNotification); + reportEditor_.setText("Loaded session: " + selected.getFullPathName()); + } catch (const std::exception& error) { + statusLabel_.setText("Load failed", juce::dontSendNotification); + reportEditor_.setText("Session load error:\n" + juce::String(error.what())); + } + + loadSessionChooser_.reset(); + }); +} + +void MainComponent::onPreviewOriginal() { + previewEngine_.setSource(engine::PreviewSource::OriginalMix); + previewEngine_.play(); + const auto preview = previewEngine_.buildCrossfadedPreview(1024); + statusLabel_.setText("Preview A selected", juce::dontSendNotification); + reportEditor_.setText(reportEditor_.getText() + + "\nPreview source: Original (" + juce::String(preview.getNumSamples()) + " samples)"); +} + +void MainComponent::onPreviewRendered() { + previewEngine_.setSource(engine::PreviewSource::RenderedMix); + previewEngine_.play(); + const auto preview = previewEngine_.buildCrossfadedPreview(1024); + statusLabel_.setText("Preview B selected", juce::dontSendNotification); + reportEditor_.setText(reportEditor_.getText() + + "\nPreview source: Rendered (" + juce::String(preview.getNumSamples()) + " samples)"); +} + +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; + } + + renderers::ExternalRendererConfig config; + config.id = "ExternalUserUI" + std::to_string(userExternalRendererConfigs_.size() + 1); + config.name = selected.getFileName().toStdString(); + config.binaryPath = selected.getFullPathName().toStdString(); + config.licenseId = "User-supplied"; + userExternalRendererConfigs_.push_back(config); + refreshRenderers(); + + statusLabel_.setText("External renderer added", juce::dontSendNotification); + reportEditor_.setText(reportEditor_.getText() + + "\nAdded external renderer: " + selected.getFullPathName() + + "\nLicense note: user-supplied tool is not distributed by this app."); + externalRendererChooser_.reset(); + }); +} + +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 (session_.stems.empty()) { statusLabel_.setText("Import stems first", juce::dontSendNotification); @@ -340,7 +775,17 @@ void MainComponent::onAutoMix() { analysisEntries_ = analyzer_.analyzeSession(session_); analysisTableModel_.setEntries(&analysisEntries_); analysisTable_.updateContent(); - session_.mixPlan = autoMixStrategy_.buildPlan(session_, analysisEntries_, 1.0); + + const auto heuristicPlan = autoMixStrategy_.buildPlan(session_, analysisEntries_, 1.0); + session_.mixPlan = heuristicPlan; + + ai::AutoMixStrategyAI aiMix; + const auto* mixPack = findPackById(modelManager_, modelManager_.activePackId("mix")); + auto inference = createInferenceBackend(mixPack); + if (inference != nullptr) { + session_.mixPlan = aiMix.buildPlan(session_, analysisEntries_, heuristicPlan, inference.get()); + session_.mixPlan->decisionLog.push_back("AI pack: " + mixPack->id + " license=" + mixPack->licenseId); + } statusLabel_.setText("Auto Mix plan generated", juce::dontSendNotification); const juce::String analysisJson = analyzer_.toJsonReport(analysisEntries_); @@ -354,6 +799,8 @@ void MainComponent::onAutoMaster() { return; } + cancelRender_.store(false); + domain::RenderSettings settings; settings.outputSampleRate = 44100; settings.blockSize = 1024; @@ -386,18 +833,148 @@ void MainComponent::onAutoMaster() { } } + const auto* masterPack = findPackById(modelManager_, modelManager_.activePackId("master")); + auto masterInference = createInferenceBackend(masterPack); + ai::AutoMasterStrategyAI aiMaster; + if (masterInference != nullptr) { + const auto mixMetrics = analyzer_.analyzeBuffer(rawMix.mixBuffer); + session_.masterPlan = + aiMaster.buildPlan(mixMetrics, session_.masterPlan.value(), masterInference.get()); + session_.masterPlan->decisionLog.push_back("AI pack: " + masterPack->id + " license=" + masterPack->licenseId); + } + + automaster::MasteringReport previewReport; + auto previewMaster = autoMasterStrategy_.applyPlan(rawMix.mixBuffer, session_.masterPlan.value(), &previewReport); + if (masterInference != nullptr) { + previewMaster = aiMaster.applyPlan(rawMix.mixBuffer, session_.masterPlan.value(), autoMasterStrategy_, &previewReport); + } + previewEngine_.setBuffers(rawMix.mixBuffer, previewMaster); + previewEngine_.setSource(engine::PreviewSource::OriginalMix); + previewEngine_.stop(); + updateMeterPanel(previewReport); + statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); reportEditor_.setText(reportEditor_.getText() + "\nMaster decisions:\n" + toJuceText(session_.masterPlan->decisionLog)); } +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; + + batchImportChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { + const auto folder = chooser.getResult(); + if (folder == juce::File()) { + batchImportChooser_.reset(); + return; + } + + const std::filesystem::path inputFolder(folder.getFullPathName().toStdString()); + const std::filesystem::path outputFolder = inputFolder / "automix_batch_exports"; + std::filesystem::create_directories(outputFolder); + const auto baseRenderSettings = buildCurrentRenderSettings(""); + cancelRender_.store(false); + cancelButton_.setEnabled(true); + taskRunning_.store(true); + + engine::BatchQueueRunner batchQueueRunner; + auto items = batchQueueRunner.buildItemsFromFolder(inputFolder, outputFolder); + if (items.empty()) { + statusLabel_.setText("Batch folder has no supported audio files", juce::dontSendNotification); + taskRunning_.store(false); + cancelButton_.setEnabled(false); + batchImportChooser_.reset(); + return; + } + + statusLabel_.setText("Batch started", juce::dontSendNotification); + juce::Component::SafePointer safeThis(this); + + std::thread([safeThis, items = std::move(items), outputFolder, baseRenderSettings]() mutable { + 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 = baseRenderSettings; + + engine::BatchQueueRunner runner; + std::atomic_bool* cancelPtr = nullptr; + if (safeThis != nullptr) { + cancelPtr = &safeThis->cancelRender_; + } + const auto result = runner.process( + job, + [safeThis](const size_t itemIndex, const double progress, const std::string& stage) { + if (safeThis == nullptr) { + return; + } + juce::MessageManager::callAsync([safeThis, itemIndex, progress, stage]() { + if (safeThis == nullptr) { + return; + } + safeThis->statusLabel_.setText("Batch item " + juce::String(static_cast(itemIndex + 1)) + + " " + stage + " (" + juce::String(progress * 100.0, 1) + "%)", + juce::dontSendNotification); + }); + }, + cancelPtr); + + if (safeThis == nullptr) { + return; + } + + juce::String summary; + summary << "Batch completed\n"; + summary << "Completed: " << result.completed << "\n"; + summary << "Failed: " << result.failed << "\n"; + summary << "Cancelled: " << result.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"; + } + + juce::MessageManager::callAsync([safeThis, summary]() { + if (safeThis == nullptr) { + return; + } + safeThis->taskRunning_.store(false); + safeThis->cancelButton_.setEnabled(false); + safeThis->statusLabel_.setText("Batch complete", juce::dontSendNotification); + safeThis->reportEditor_.setText(summary); + }); + }).detach(); + + 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; } 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 +986,53 @@ 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()); + const auto sessionCopy = session_; + cancelRender_.store(false); + taskRunning_.store(true); + cancelButton_.setEnabled(true); + statusLabel_.setText("Export started", juce::dontSendNotification); - 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; - } + juce::Component::SafePointer safeThis(this); + std::thread([safeThis, sessionCopy, settings]() mutable { + renderers::RenderResult renderResult; + juce::String crashMessage; + try { + auto renderer = renderers::createRenderer(settings.rendererName); + std::atomic_bool* cancelPtr = nullptr; + if (safeThis != nullptr) { + cancelPtr = &safeThis->cancelRender_; + } + renderResult = renderer->render(sessionCopy, settings, {}, cancelPtr); + } catch (const std::exception& error) { + crashMessage = "Export exception:\n" + juce::String(error.what()); + } catch (...) { + crashMessage = "Export exception:\nUnknown error"; + } - 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)); + juce::MessageManager::callAsync([safeThis, renderResult, crashMessage]() { + if (safeThis == nullptr) { + return; + } + safeThis->taskRunning_.store(false); + safeThis->cancelButton_.setEnabled(false); + if (!crashMessage.isEmpty()) { + safeThis->statusLabel_.setText("Export crashed", juce::dontSendNotification); + safeThis->reportEditor_.setText(crashMessage); + return; + } + if (renderResult.cancelled) { + safeThis->statusLabel_.setText("Export cancelled", juce::dontSendNotification); + return; + } + safeThis->statusLabel_.setText(renderResult.success ? "Export complete" : "Export failed", + juce::dontSendNotification); + safeThis->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)); + }); + }).detach(); exportChooser_.reset(); }); diff --git a/src/app/MainComponent.h b/src/app/MainComponent.h index d8e3b1b..e757bee 100644 --- a/src/app/MainComponent.h +++ b/src/app/MainComponent.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -11,6 +12,9 @@ #include "automaster/HeuristicAutoMasterStrategy.h" #include "automix/HeuristicAutoMixStrategy.h" #include "domain/Session.h" +#include "engine/AudioPreviewEngine.h" +#include "engine/SessionRepository.h" +#include "renderers/RendererRegistry.h" namespace automix::app { @@ -53,23 +57,48 @@ class MainComponent final : public juce::Component, void onImportOriginalMix(); void onAutoMix(); void onAutoMaster(); + void onBatchImport(); void onExport(); + void onCancel(); + void onSaveSession(); + void onLoadSession(); + void onPreviewOriginal(); + void onPreviewRendered(); + void onAddExternalRenderer(); + void updateMeterPanel(const automaster::MasteringReport& report); void refreshModelPacks(); + void refreshRenderers(); + domain::RenderSettings buildCurrentRenderSettings(const std::string& outputPath) const; + std::vector loadConfiguredExternalRenderers() const; juce::TextButton importButton_ {"Import"}; juce::TextButton originalMixButton_ {"Original Mix"}; + juce::TextButton saveSessionButton_ {"Save Session"}; + juce::TextButton loadSessionButton_ {"Load Session"}; 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 addExternalRendererButton_ {"Add External Limiter"}; 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 exportBitrateLabel_ {"exportBitrateLabel", "Lossy kbps"}; + juce::Slider exportBitrateSlider_; 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: --"}; AnalysisTableModel analysisTableModel_; juce::TableListBox analysisTable_; juce::TextEditor reportEditor_; @@ -78,12 +107,25 @@ class MainComponent final : public juce::Component, analysis::StemAnalyzer analyzer_; automix::HeuristicAutoMixStrategy autoMixStrategy_; automaster::HeuristicAutoMasterStrategy autoMasterStrategy_; + engine::SessionRepository sessionRepository_; + engine::AudioPreviewEngine previewEngine_; ai::ModelManager modelManager_; std::vector analysisEntries_; + std::vector rendererInfos_; + std::vector userExternalRendererConfigs_; + std::map rendererIdByComboId_; + std::map roleModelIdByComboId_; + std::map mixModelIdByComboId_; + std::map masterModelIdByComboId_; std::atomic_bool cancelRender_ {false}; + std::atomic_bool taskRunning_ {false}; 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_; }; } // namespace automix::app diff --git a/src/automaster/HeuristicAutoMasterStrategy.cpp b/src/automaster/HeuristicAutoMasterStrategy.cpp index 3f39b8c..ddf62ce 100644 --- a/src/automaster/HeuristicAutoMasterStrategy.cpp +++ b/src/automaster/HeuristicAutoMasterStrategy.cpp @@ -3,49 +3,90 @@ #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/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)); -} +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 +94,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 +117,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,11 +134,43 @@ 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); +} + } // namespace domain::MasterPlan HeuristicAutoMasterStrategy::buildPlan(const domain::MasterPreset preset, @@ -109,23 +180,54 @@ domain::MasterPlan HeuristicAutoMasterStrategy::buildPlan(const domain::MasterPr switch (preset) { case domain::MasterPreset::DefaultStreaming: + plan.presetName = "DefaultStreaming"; plan.targetLufs = -14.0; plan.truePeakDbtp = -1.0; break; case domain::MasterPreset::Broadcast: + plan.presetName = "Broadcast"; plan.targetLufs = -23.0; plan.truePeakDbtp = -1.0; break; + case domain::MasterPreset::UdioOptimized: + plan.presetName = "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; + break; case domain::MasterPreset::Custom: + plan.presetName = "Custom"; break; } 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"); @@ -136,81 +238,111 @@ 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.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); } - applyLimiter(mastered, plan.limiterCeilingDb); - applyDither(mastered, plan.ditherBitDepth); + 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"); + } + } + + 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..1dcd545 100644 --- a/src/automix/HeuristicAutoMixStrategy.cpp +++ b/src/automix/HeuristicAutoMixStrategy.cpp @@ -55,6 +55,16 @@ 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; +} + } // namespace domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& session, @@ -65,8 +75,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; @@ -93,7 +105,15 @@ domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& sessi 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; @@ -112,10 +132,19 @@ 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) + + " artifactRisk=" + std::to_string(artifactRisk) + + " 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..68e383e 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -51,7 +51,14 @@ void to_json(Json& j, const RenderSettings& value) { {"blockSize", value.blockSize}, {"outputBitDepth", value.outputBitDepth}, {"outputPath", value.outputPath}, - {"rendererName", value.rendererName}}; + {"outputFormat", value.outputFormat}, + {"lossyBitrateKbps", value.lossyBitrateKbps}, + {"lossyQuality", value.lossyQuality}, + {"processingThreads", value.processingThreads}, + {"preferHardwareAcceleration", value.preferHardwareAcceleration}, + {"rendererName", value.rendererName}, + {"externalRendererPath", value.externalRendererPath}, + {"externalRendererTimeoutMs", value.externalRendererTimeoutMs}}; } void from_json(const Json& j, RenderSettings& value) { @@ -59,7 +66,14 @@ 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.lossyBitrateKbps = std::clamp(j.value("lossyBitrateKbps", 192), 48, 512); + value.lossyQuality = std::clamp(j.value("lossyQuality", 7), 0, 10); + value.processingThreads = std::max(0, j.value("processingThreads", 0)); + value.preferHardwareAcceleration = j.value("preferHardwareAcceleration", true); 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) { @@ -108,6 +122,7 @@ void from_json(const Json& j, MixPlan& value) { 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 +130,26 @@ 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}, {"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 +157,20 @@ 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.decisionLog = j.value("decisionLog", std::vector{}); } diff --git a/src/domain/MasterPlan.cpp b/src/domain/MasterPlan.cpp index ab5d682..a879b48 100644 --- a/src/domain/MasterPlan.cpp +++ b/src/domain/MasterPlan.cpp @@ -8,6 +8,8 @@ std::string toString(const MasterPreset preset) { return "default_streaming"; case MasterPreset::Broadcast: return "broadcast"; + case MasterPreset::UdioOptimized: + return "udio_optimized"; case MasterPreset::Custom: return "custom"; } @@ -18,6 +20,9 @@ MasterPreset masterPresetFromString(const std::string& value) { if (value == "broadcast") { return MasterPreset::Broadcast; } + if (value == "udio_optimized") { + return MasterPreset::UdioOptimized; + } if (value == "custom") { return MasterPreset::Custom; } diff --git a/src/domain/MasterPlan.h b/src/domain/MasterPlan.h index 3138e03..ce814f7 100644 --- a/src/domain/MasterPlan.h +++ b/src/domain/MasterPlan.h @@ -5,10 +5,11 @@ namespace automix::domain { -enum class MasterPreset { DefaultStreaming, Broadcast, Custom }; +enum class MasterPreset { DefaultStreaming, Broadcast, UdioOptimized, Custom }; struct MasterPlan { MasterPreset preset = MasterPreset::DefaultStreaming; + std::string presetName = "DefaultStreaming"; double targetLufs = -14.0; double truePeakDbtp = -1.0; double preGainDb = 0.0; @@ -16,7 +17,20 @@ 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; std::vector decisionLog; }; diff --git a/src/domain/RenderSettings.h b/src/domain/RenderSettings.h index bbe4af5..ffad815 100644 --- a/src/domain/RenderSettings.h +++ b/src/domain/RenderSettings.h @@ -9,7 +9,14 @@ struct RenderSettings { int blockSize = 1024; int outputBitDepth = 24; std::string outputPath; + std::string outputFormat = "auto"; + int lossyBitrateKbps = 192; + int lossyQuality = 7; + int processingThreads = 0; + bool preferHardwareAcceleration = true; std::string rendererName = "BuiltIn"; + std::string externalRendererPath; + int externalRendererTimeoutMs = 300000; }; } // namespace automix::domain 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/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/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..3526949 --- /dev/null +++ b/src/engine/BatchQueueRunner.cpp @@ -0,0 +1,309 @@ +#include "engine/BatchQueueRunner.h" + +#include +#include +#include +#include +#include +#include + +#include "analysis/StemAnalyzer.h" +#include "automix/HeuristicAutoMixStrategy.h" +#include "renderers/RendererFactory.h" +#include "util/WavWriter.h" + +namespace automix::engine { +namespace { + +std::string toLower(std::string text) { + std::transform(text.begin(), text.end(), text.begin(), [](const unsigned char c) { + return static_cast(std::tolower(c)); + }); + return text; +} + +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"; +} + +std::string extensionForFormat(const std::string& format) { + const auto normalized = toLower(format); + if (normalized == "wav") { + return ".wav"; + } + if (normalized == "aiff" || normalized == "aif") { + return ".aiff"; + } + if (normalized == "flac") { + return ".flac"; + } + if (normalized == "ogg" || normalized == "vorbis") { + return ".ogg"; + } + if (normalized == "mp3") { + return ".mp3"; + } + return ".wav"; +} + +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) { + runAnalysisForItem(item); + } + } + }); + } + + 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) { + runAnalysisForItem(item); + } + } + } + + 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..51f2ee6 100644 --- a/src/engine/OfflineRenderPipeline.cpp +++ b/src/engine/OfflineRenderPipeline.cpp @@ -1,9 +1,15 @@ #include "engine/OfflineRenderPipeline.h" #include +#include +#include #include +#include +#include +#include #include #include +#include #include "domain/MixPlan.h" #include "engine/AudioFileIO.h" @@ -14,6 +20,7 @@ namespace automix::engine { namespace { 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 +29,104 @@ 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) { +struct StemRenderNode { + AudioBuffer buffer; + std::string busId; +}; + +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; } - const double dt = 1.0 / buffer.getSampleRate(); - const double rc = 1.0 / (2.0 * kPi * cutoffHz); - const float alpha = static_cast(rc / (rc + dt)); - - HighPassState state; - state.previousInput.resize(static_cast(buffer.getNumChannels()), 0.0f); - state.previousOutput.resize(static_cast(buffer.getNumChannels()), 0.0f); + std::vector z1(static_cast(buffer.getNumChannels()), 0.0f); + std::vector z2(static_cast(buffer.getNumChannels()), 0.0f); - for (int ch = 0; ch < buffer.getNumChannels(); ++ch) { - for (int i = 0; i < buffer.getNumSamples(); ++i) { + 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 +135,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 +157,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 +167,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 +192,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,6 +240,76 @@ AudioBuffer processStemBuffer(const AudioBuffer& input, return output; } +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 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 : hardwareThreads; + return std::clamp(requested, 1, std::max(1, taskCount)); +} + +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); + for (int i = 0; i < numSamples; ++i) { + dst[startSample + i] += src[srcStart + i]; + } + } +} + } // namespace OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& session, @@ -183,6 +321,7 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s AudioResampler resampler; std::unordered_map decisions; + std::unordered_map busGainDbById; double dryWet = 1.0; double headroomDb = 6.0; @@ -194,46 +333,127 @@ 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); } + } - const auto& stem = session.stems[i]; - if (!stem.enabled) { - continue; + 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 { + AudioBuffer buffer = workerFileIO.readAudioFile(stem.filePath); + if (buffer.getSampleRate() != static_cast(settings.outputSampleRate)) { + buffer = workerResampler.resampleLinear(buffer, static_cast(settings.outputSampleRate)); + } + + const auto decisionIt = decisions.find(stem.id); + const domain::StemMixDecision* decision = decisionIt != decisions.end() ? &decisionIt->second : nullptr; + const std::string busId = defaultBusIdForStem(stem); + + stemNodeSlots[slot] = StemRenderNode{ + .buffer = processStemBuffer(buffer, decision, dryWet, 2), + .busId = busId, + }; + } catch (const std::exception& error) { + std::scoped_lock lock(errorMutex); + importErrors.emplace_back("Failed to import stem '" + stem.name + "': " + error.what()); + } + + 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); @@ -249,24 +469,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) { + 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 +553,9 @@ 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))); + if (onProgress) { + onProgress(RenderProgress{.fraction = 1.0, .stage = "Mix render complete"}); + } return result; } 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..c70a178 100644 --- a/src/renderers/BuiltInRenderer.cpp +++ b/src/renderers/BuiltInRenderer.cpp @@ -70,20 +70,36 @@ RenderResult BuiltInRenderer::render(const domain::Session& session, util::WavWriter writer; 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); 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}, + {"outputFormat", settings.outputFormat}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, {"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..28cf051 --- /dev/null +++ b/src/renderers/ExternalLimiterRenderer.cpp @@ -0,0 +1,249 @@ +#include "renderers/ExternalLimiterRenderer.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "ai/MasteringCompliance.h" +#include "analysis/StemAnalyzer.h" +#include "automaster/HeuristicAutoMasterStrategy.h" +#include "engine/AudioFileIO.h" +#include "engine/OfflineRenderPipeline.h" +#include "renderers/BuiltInRenderer.h" +#include "util/WavWriter.h" + +namespace automix::renderers { +namespace { + +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; +} + +} // namespace + +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."); + } + + 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) { + return RenderResult{.cancelled = true, .rendererName = "ExternalLimiter", .logs = rawResult.logs}; + } + + 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); + + const auto plan = session.masterPlan.has_value() ? session.masterPlan.value() + : automaster::HeuristicAutoMasterStrategy().buildPlan( + domain::MasterPreset::DefaultStreaming, rawResult.mixBuffer); + + 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}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, + }; + { + 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(); + return RenderResult{.cancelled = true, .rendererName = "ExternalLimiter"}; + } + + 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); + automaster::HeuristicAutoMasterStrategy strategy; + ai::MasteringCompliance compliance; + automaster::MasteringReport complianceReport; + + 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); + + analysis::StemAnalyzer analyzer; + const auto spectrum = analyzer.analyzeBuffer(mastered); + + const std::filesystem::path reportPath = outputPath.string() + ".report.json"; + nlohmann::json report = { + {"renderer", "ExternalLimiter"}, + {"binaryPath", binaryPath.string()}, + {"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}, + {"outputFormat", outputFormat}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, + {"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 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..b69ef85 --- /dev/null +++ b/src/renderers/ExternalLimiterRenderer.h @@ -0,0 +1,17 @@ +#pragma once + +#include "renderers/IRenderer.h" + +namespace automix::renderers { + +class ExternalLimiterRenderer final : public IRenderer { + public: + 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/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index 96fc9b3..7908c6a 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -12,6 +12,7 @@ #include #include +#include "ai/MasteringCompliance.h" #include "analysis/StemAnalyzer.h" #include "automaster/HeuristicAutoMasterStrategy.h" #include "engine/AudioFileIO.h" @@ -145,7 +146,13 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, rawMix = resampler.resampleLinear(rawMix, static_cast(kPhaseLimiterSampleRate)); } + automaster::HeuristicAutoMasterStrategy strategy; + const auto plan = session.masterPlan.has_value() + ? session.masterPlan.value() + : strategy.buildPlan(domain::MasterPreset::DefaultStreaming, rawMix); + 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 +161,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 +176,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 +209,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 +222,20 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, } engine::AudioFileIO fileIO; - const auto mastered = fileIO.readAudioFile(outputPath); + auto mastered = fileIO.readAudioFile(tempPhaseOutputPath); + + 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); - 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 +243,27 @@ 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}, + {"outputFormat", outputFormat}, + {"lossyBitrateKbps", settings.lossyBitrateKbps}, + {"lossyQuality", settings.lossyQuality}, + {"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 +273,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..c05e893 100644 --- a/src/renderers/RendererFactory.cpp +++ b/src/renderers/RendererFactory.cpp @@ -1,6 +1,7 @@ #include "renderers/RendererFactory.h" #include "renderers/BuiltInRenderer.h" +#include "renderers/ExternalLimiterRenderer.h" #include "renderers/PhaseLimiterRenderer.h" namespace automix::renderers { @@ -9,6 +10,9 @@ std::unique_ptr createRenderer(const std::string& preferredRenderer) if (preferredRenderer == "PhaseLimiter") { return std::make_unique(); } + if (preferredRenderer == "ExternalLimiter" || preferredRenderer.rfind("ExternalUser", 0) == 0) { + return std::make_unique(); + } return std::make_unique(); } diff --git a/src/renderers/RendererRegistry.cpp b/src/renderers/RendererRegistry.cpp new file mode 100644 index 0000000..67487ac --- /dev/null +++ b/src/renderers/RendererRegistry.cpp @@ -0,0 +1,212 @@ +#include "renderers/RendererRegistry.h" + +#include +#include +#include +#include + +#include + +#include "renderers/PhaseLimiterDiscovery.h" + +namespace automix::renderers { +namespace { + +bool hasBinary(const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error); +} + +RendererInfo makeBuiltInInfo() { + return RendererInfo{ + .id = "BuiltIn", + .name = "BuiltIn", + .version = "internal", + .licenseId = "Project", + .linkMode = RendererLinkMode::InProcess, + .bundledByDefault = true, + .available = true, + .discovery = "Always available (core renderer).", + }; +} + +RendererInfo makePhaseLimiterInfo() { + PhaseLimiterDiscovery discovery; + const auto binaryInfo = discovery.find(); + + RendererInfo info{ + .id = "PhaseLimiter", + .name = "PhaseLimiter", + .version = "external", + .licenseId = "See assets/phaselimiter/licenses", + .linkMode = RendererLinkMode::External, + .bundledByDefault = false, + .available = binaryInfo.has_value(), + .discovery = binaryInfo.has_value() ? "Auto-discovered in assets or PHASELIMITER_BIN." + : "Not found in assets. Set PHASELIMITER_BIN or install under assets.", + }; + + if (binaryInfo.has_value()) { + info.binaryPath = binaryInfo->executablePath; + } + + return info; +} + +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.available = hasBinary(config.binaryPath); + info.binaryPath = config.binaryPath; + info.discovery = info.available ? "User-supplied external binary path." + : "Configured path is missing or not executable."; + return info; +} + +std::optional loadExternalRendererDescriptor(const std::filesystem::path& descriptorPath) { + 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 (config.id.empty() || config.name.empty()) { + return std::nullopt; + } + 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() { + 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()); + if (config.has_value()) { + configs.push_back(config.value()); + } + } + } + + return configs; +} + +} // namespace + +std::vector RendererRegistry::list(const std::vector& externalConfigs) const { + std::vector infos; + const auto assetConfigs = discoverAssetExternalRenderers(); + 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; + } + infos.push_back(makeExternalInfo(config)); + }; + + 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..f7eeabc --- /dev/null +++ b/src/renderers/RendererRegistry.h @@ -0,0 +1,42 @@ +#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; +}; + +struct ExternalRendererConfig { + std::string id; + std::string name; + std::string version = "unknown"; + std::string licenseId = "unknown"; + std::filesystem::path binaryPath; + bool bundledByDefault = false; +}; + +class RendererRegistry { + public: + std::vector list(const std::vector& externalConfigs = {}) const; +}; + +std::string toString(RendererLinkMode mode); + +} // namespace automix::renderers diff --git a/src/util/WavWriter.cpp b/src/util/WavWriter.cpp index dc1eaab..2872d2d 100644 --- a/src/util/WavWriter.cpp +++ b/src/util/WavWriter.cpp @@ -1,32 +1,204 @@ #include "util/WavWriter.h" #include +#include +#include #include +#include +#include #include +#include #include namespace automix::util { +namespace { -void WavWriter::write(const std::filesystem::path& path, const engine::AudioBuffer& buffer, const int bitDepth) const { +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; +} + +int qualityIndexFromRequested(const juce::StringArray& options, const int requestedQuality) { + if (options.isEmpty()) { + return 0; + } + return std::clamp(requestedQuality, 0, options.size() - 1); +} + +std::optional findLameExecutable() { + if (const char* env = std::getenv("LAME_BIN"); env != nullptr && *env != '\0') { + const std::filesystem::path candidate(env); + std::error_code error; + if (std::filesystem::is_regular_file(candidate, error) && !error) { + return candidate; + } + } + + const char* rawPath = std::getenv("PATH"); + if (rawPath == nullptr || *rawPath == '\0') { + return std::nullopt; + } + +#if defined(_WIN32) + constexpr char delimiter = ';'; + const std::vector names = {"lame.exe", "lame"}; +#else + constexpr char delimiter = ':'; + const std::vector names = {"lame"}; +#endif + + std::stringstream stream(rawPath); + std::string token; + while (std::getline(stream, token, delimiter)) { + if (token.empty()) { + continue; + } + for (const auto& name : names) { + const auto candidate = std::filesystem::path(token) / name; + std::error_code error; + if (std::filesystem::is_regular_file(candidate, error) && !error) { + 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 ""; +} + +} // 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"; +} + +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 { 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 WAV file: " + path.string()); + throw std::runtime_error("Failed to open output audio file: " + path.string()); + } + + const auto format = resolveFormat(path, preferredFormat); + 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::StringPairArray metadata; + metadata.set("bitrate", juce::String(bitrate)); + + std::unique_ptr writer; + if (format == "wav") { + juce::WavAudioFormat wav; + writer.reset(wav.createWriterFor(stream.get(), + buffer.getSampleRate(), + static_cast(buffer.getNumChannels()), + outputBitDepth, + metadata, + 0)); + } else if (format == "aiff") { + juce::AiffAudioFormat aiff; + writer.reset(aiff.createWriterFor(stream.get(), + buffer.getSampleRate(), + static_cast(buffer.getNumChannels()), + outputBitDepth, + metadata, + 0)); + } else if (format == "flac") { + juce::FlacAudioFormat flac; + writer.reset(flac.createWriterFor(stream.get(), + buffer.getSampleRate(), + static_cast(buffer.getNumChannels()), + std::clamp(outputBitDepth, 16, 24), + metadata, + 0)); + } else if (format == "ogg") { +#if defined(JUCE_USE_OGGVORBIS) && JUCE_USE_OGGVORBIS + juce::OggVorbisAudioFormat ogg; + writer.reset(ogg.createWriterFor(stream.get(), + buffer.getSampleRate(), + static_cast(buffer.getNumChannels()), + 0, + metadata, + qualityIndexFromRequested(ogg.getQualityOptions(), quality))); +#endif + } else if (format == "mp3") { +#if defined(JUCE_USE_MP3AUDIOFORMAT) && JUCE_USE_MP3AUDIOFORMAT + juce::MP3AudioFormat mp3; + writer.reset(mp3.createWriterFor(stream.get(), + buffer.getSampleRate(), + static_cast(buffer.getNumChannels()), + 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.get(), + buffer.getSampleRate(), + static_cast(buffer.getNumChannels()), + outputBitDepth, + metadata, + qualityIndexFromRequested(lame.getQualityOptions(), quality))); + } +#endif + } +#endif + } else { + throw std::runtime_error("Unsupported output format '" + format + "' for: " + path.string()); } - 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()); + throw std::runtime_error("Failed to create audio writer (format=" + format + ") for: " + path.string()); } stream.release(); diff --git a/src/util/WavWriter.h b/src/util/WavWriter.h index 3e24acb..3d46cde 100644 --- a/src/util/WavWriter.h +++ b/src/util/WavWriter.h @@ -10,7 +10,13 @@ class WavWriter { public: void write(const std::filesystem::path& path, const engine::AudioBuffer& buffer, - int bitDepth) const; + int bitDepth, + const std::string& preferredFormat = "auto", + int lossyBitrateKbps = 192, + int lossyQuality = 7) const; + + static std::string resolveFormat(const std::filesystem::path& path, const std::string& preferredFormat); + static bool isLossyFormat(const std::string& format); }; } // namespace automix::util 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..485d11d 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,7 @@ 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 +76,28 @@ 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 +105,25 @@ 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 +156,25 @@ 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("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/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/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..b4cdd1f --- /dev/null +++ b/tests/unit/OnnxModelInferenceTests.cpp @@ -0,0 +1,60 @@ +#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); + + std::filesystem::remove_all(tempDir); +#endif +} diff --git a/tests/unit/RendererRegistryTests.cpp b/tests/unit/RendererRegistryTests.cpp new file mode 100644 index 0000000..1ed2edb --- /dev/null +++ b/tests/unit/RendererRegistryTests.cpp @@ -0,0 +1,68 @@ +#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(info.available); + REQUIRE(info.binaryPath == tempBinary); + 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/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 6a57d83..0c93599 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -12,6 +12,11 @@ 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.lossyBitrateKbps = 256; + session.renderSettings.lossyQuality = 8; + session.renderSettings.processingThreads = 4; + session.renderSettings.preferHardwareAcceleration = true; automix::domain::Stem stem; stem.id = "s1"; @@ -38,6 +43,10 @@ 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.lossyBitrateKbps == 256); + REQUIRE(decoded.renderSettings.lossyQuality == 8); + REQUIRE(decoded.renderSettings.processingThreads == 4); } TEST_CASE("Session deserialization handles missing optional fields", "[session]") { @@ -54,6 +63,10 @@ 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.lossyBitrateKbps == 192); + REQUIRE(decoded.renderSettings.lossyQuality == 7); + REQUIRE(decoded.renderSettings.preferHardwareAcceleration == true); REQUIRE(decoded.stems.front().enabled == true); REQUIRE(decoded.stems.front().origin == automix::domain::StemOrigin::Recorded); } 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/dev_tools.cpp b/tools/dev_tools.cpp index 77ee7bd..7b3b006 100644 --- a/tools/dev_tools.cpp +++ b/tools/dev_tools.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -13,10 +14,9 @@ #include #include "ai/IModelInference.h" +#include "ai/FeatureSchema.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" @@ -84,6 +84,167 @@ std::string taskFromModelType(const std::string& type) { return "mix_parameters"; } +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() { + 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; +} + +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()); + } +} + +int commandListSupportedModels() { + 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 std::vector& 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() { + std::cout << "Supported limiters:\n"; + for (const auto& limiter : supportedLimiters()) { + std::cout << " - " << limiter.id << " [" << limiter.name << "] " << limiter.description << "\n"; + } + return 0; +} + +int commandInstallSupportedLimiter(const std::vector& 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 commandExportFeatures(const std::vector& args) { const auto sessionPathArg = argValue(args, "--session"); const auto outPathArg = argValue(args, "--out"); @@ -124,7 +285,24 @@ int commandExportFeatures(const std::vector& args) { {"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}, + {"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); @@ -225,11 +403,7 @@ int commandValidateModelPack(const std::vector& args) { 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()) { @@ -250,7 +424,7 @@ int commandValidateModelPack(const std::vector& args) { return 1; } - const size_t featureCount = pack.inputFeatureCount.value_or(5u); + const size_t featureCount = pack.inputFeatureCount.value_or(automix::ai::FeatureSchemaV1::featureCount()); const automix::ai::InferenceRequest request{ .task = taskFromModelType(pack.type), .features = deterministicFeatures(featureCount), @@ -277,6 +451,10 @@ void printUsage() { 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 << " automix_dev_tools list-supported-models\n"; + std::cout << " automix_dev_tools install-supported-model --id [--dest ]\n"; + std::cout << " automix_dev_tools list-supported-limiters\n"; + std::cout << " automix_dev_tools install-supported-limiter --id [--dest ]\n"; } } // namespace @@ -304,6 +482,18 @@ int main(int argc, char** argv) { if (command == "validate-modelpack") { return commandValidateModelPack(args); } + if (command == "list-supported-models") { + return commandListSupportedModels(); + } + if (command == "install-supported-model") { + return commandInstallSupportedModel(args); + } + if (command == "list-supported-limiters") { + return commandListSupportedLimiters(); + } + if (command == "install-supported-limiter") { + return commandInstallSupportedLimiter(args); + } printUsage(); return 2; 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..8993752 --- /dev/null +++ b/tools/training/feature_schema_v1.json @@ -0,0 +1,15 @@ +{ + "version": "1.0.0", + "features": [ + "rms_db", + "low_energy_ratio", + "mid_energy_ratio", + "high_energy_ratio", + "artifact_risk" + ], + "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" + } +} From 7311aa81378a2010ca290028763804951fccb1bd Mon Sep 17 00:00:00 2001 From: Soficis Date: Sat, 14 Feb 2026 21:04:36 -0600 Subject: [PATCH 02/20] feat: implement roadmap release with AI/runtime UX, transport preview, and mastering upgrades - Build/config: - Expanded CMake options (RTNeural toggles, bundled LAME fallback, external tool support, sanitizers/clang-tidy gates). - Added new core/app sources: stem separation, transport controller, waveform preview, multiband processor, LAME downloader. - Enforced Visual Studio 2026 generator on Windows Visual Studio builds. - UI/app workflow: - Major MainComponent upgrade: transport controls (play/pause/stop/seek), waveform preview, stem solo/mute selectors, GPU provider selector, platform/master preset controls, codec-aware export controls, and LAME prefetch action. - Improved responsiveness by moving heavy operations to async/background flow and keeping cancel/progress behavior available. - AI/runtime: - ONNX inference backend expanded with provider preference handling, quantized model preference, warmup, optimization/threading/profiling configuration, and backend diagnostics. - Model pack metadata loading extended to support runtime specialization fields. - Stem separation: - Added StemSeparator module with model-available path and deterministic fallback behavior. - Integrated separated-stem metadata (`separationConfidence`, `separationArtifactRisk`) into domain model and session serialization. - Analysis/features: - Stem analysis expanded with additional spectral/temporal descriptors. - Updated runtime feature schema and training schema export (`FeatureSchema.cpp`, `feature_schema_v1.json`). - Mastering/rendering: - Added MultibandProcessor and multiband settings/plumbing in MasterPlan + heuristic mastering strategy. - Added platform loudness presets via new `assets/mastering/platform_presets.json`. - External limiter integration hardened with validation/capability diagnostics in renderer path and registry behavior. - Export/codec portability: - WavWriter enhanced with runtime format availability probing and richer export path handling. - Added LAME downloader utility for fallback MP3 encoding workflows. - Dev tooling: - Added `validate-external-limiter` and `install-lame-fallback` commands. - Expanded exported feature payload fields in `automix_dev_tools`. - Tests: - Added new unit tests for stem separation and transport controller. - Updated existing tests for ONNX diagnostics and renderer validation behavior. - Existing test files also include formatting/line-ending normalization edits. - Docs: - Rewrote README to reflect implemented roadmap features, build matrix updates, and new runtime capabilities. - Repo-wide normalization/vendor data churn: - Large non-functional rewrites in `.clang-*`, `.editorconfig`, and many `assets/phaselimiter/*` license/resource files (predominantly normalization/line-ending style changes, with very large JSON churn). --- CMakeLists.txt | 26 + README.md | 565 +++----- assets/mastering/platform_presets.json | 50 + src/ai/FeatureSchema.cpp | 59 +- src/ai/ModelPackLoader.cpp | 18 + src/ai/ModelPackLoader.h | 5 + src/ai/OnnxModelInference.cpp | 337 ++++- src/ai/OnnxModelInference.h | 41 + src/ai/StemSeparator.cpp | 530 +++++++ src/ai/StemSeparator.h | 31 + src/analysis/AnalysisResult.h | 5 + src/analysis/StemAnalyzer.cpp | 315 ++++- src/app/MainComponent.cpp | 1238 ++++++++++++++--- src/app/MainComponent.h | 45 +- src/app/WaveformPreviewComponent.cpp | 75 + src/app/WaveformPreviewComponent.h | 23 + .../HeuristicAutoMasterStrategy.cpp | 149 +- src/automix/HeuristicAutoMixStrategy.cpp | 63 +- src/domain/JsonSerialization.cpp | 58 + src/domain/MasterPlan.cpp | 50 +- src/domain/MasterPlan.h | 35 +- src/domain/RenderSettings.h | 1 + src/domain/Stem.h | 2 + src/dsp/MultibandProcessor.cpp | 140 ++ src/dsp/MultibandProcessor.h | 13 + src/engine/TransportController.cpp | 139 ++ src/engine/TransportController.h | 42 + src/renderers/ExternalLimiterRenderer.cpp | 176 ++- src/renderers/ExternalLimiterRenderer.h | 14 + src/renderers/RendererFactory.cpp | 9 +- src/renderers/RendererRegistry.cpp | 64 +- src/util/LameDownloader.cpp | 910 ++++++++++++ src/util/LameDownloader.h | 22 + src/util/WavWriter.cpp | 568 +++++++- src/util/WavWriter.h | 10 + tests/unit/OnnxModelInferenceTests.cpp | 1 + tests/unit/RendererRegistryTests.cpp | 3 +- tests/unit/StemSeparatorTests.cpp | 73 + tests/unit/TransportControllerTests.cpp | 32 + tools/dev_tools.cpp | 90 ++ tools/training/feature_schema_v1.json | 64 +- 41 files changed, 5318 insertions(+), 773 deletions(-) create mode 100644 assets/mastering/platform_presets.json create mode 100644 src/ai/StemSeparator.cpp create mode 100644 src/ai/StemSeparator.h create mode 100644 src/app/WaveformPreviewComponent.cpp create mode 100644 src/app/WaveformPreviewComponent.h create mode 100644 src/dsp/MultibandProcessor.cpp create mode 100644 src/dsp/MultibandProcessor.h create mode 100644 src/engine/TransportController.cpp create mode 100644 src/engine/TransportController.h create mode 100644 src/util/LameDownloader.cpp create mode 100644 src/util/LameDownloader.h create mode 100644 tests/unit/StemSeparatorTests.cpp create mode 100644 tests/unit/TransportControllerTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 924759d..b0e4644 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,18 @@ cmake_minimum_required(VERSION 3.24) 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) @@ -15,8 +27,11 @@ 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) @@ -68,6 +83,7 @@ add_library(automix_core 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 @@ -76,6 +92,7 @@ add_library(automix_core 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 @@ -100,7 +117,9 @@ add_library(automix_core 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/WavWriter.cpp ) @@ -109,6 +128,7 @@ 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 ) @@ -144,7 +164,10 @@ target_compile_definitions(automix_core $<$: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> ) if(ENABLE_CLANG_TIDY) @@ -180,6 +203,7 @@ juce_add_gui_app(AutoMixMasterApp target_sources(AutoMixMasterApp PRIVATE src/app/Main.cpp src/app/MainComponent.cpp + src/app/WaveformPreviewComponent.cpp ) target_include_directories(AutoMixMasterApp PRIVATE src) @@ -230,9 +254,11 @@ if(BUILD_TESTING) 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 ) diff --git a/README.md b/README.md index 52acaa7..3449d42 100644 --- a/README.md +++ b/README.md @@ -1,479 +1,232 @@ # AutoMixMaster -AutoMixMaster is a JUCE/CMake desktop app for offline stem mixing and mastering with deterministic plans: - -`Analysis → MixPlan → MasterPlan → Renderer → Audio + JSON report` - -It supports lossy/lossless export, batch acceleration, model-pack auto discovery/install, limiter-pack discovery/install, and cancellation for long renders. Builds and runs on **Windows**, **Linux** (including WSL), and **macOS**. - ---- - -## How AutoMixMaster Works - -AutoMixMaster takes multi-track stems (individual instrument/vocal recordings) and produces a mixed and mastered audio file through a deterministic pipeline. Understanding each stage and its settings helps you control what the output sounds like. - -### Pipeline Overview - -``` -Import Stems → Analysis → Auto Mix → Auto Master → Render → Export -``` - -1. **Import**: Load stems (`.wav`, `.aiff`, `.flac`, `.mp3`, `.ogg`). Optionally load an original mix reference for comparison. -2. **Analysis** (`StemAnalyzer`): Each stem is analyzed for loudness (LUFS), spectral energy (low/mid/high), stereo correlation, true peak level, and dynamic range. This data drives all automated decisions. -3. **Auto Mix** (`MixPlan`): Generates per-stem mixing decisions based on analysis. Settings that affect output: - - **Gain (dB)**: Volume level per stem. Louder stems get attenuated; quiet stems get boosted to achieve balance. - - **Pan**: Stereo placement (-1.0 left to +1.0 right). Keeps center instruments centered, spreads others. - - **High-pass filter (Hz)**: Removes low rumble from non-bass stems. Higher values cut more bass. - - **Mud cut (dB)**: Reduces 200-500 Hz buildup that makes mixes sound muddy. - - **Compressor**: Tames dynamic range per stem. Threshold, ratio, and release shape the compression character. Lower threshold or higher ratio = more compressed/consistent sound. - - **Expander**: Reduces noise floor in quiet passages. Gentle settings clean up bleed between stems. - - **Dry/Wet**: Blends processed and unprocessed signals. 1.0 = fully processed. - - **Bus headroom (dB)**: Reserves headroom before mastering. More headroom = safer limiting later. -4. **Auto Master** (`MasterPlan`): Shapes the mixed bus for final delivery. Key settings: - - **Target LUFS**: Loudness target (e.g., -14 LUFS for streaming, -23 LUFS for broadcast). Lower values = quieter but more dynamic. - - **True peak limit (dBTP)**: Maximum inter-sample peak level. -1.0 dBTP is the standard for streaming platforms. - - **Pre-gain (dB)**: Adjusts level going into the mastering chain. Use to push into compression/limiting harder. - - **Limiter ceiling (dB)**: Sets the absolute maximum output level. Lower = more headroom, less loudness. - - **Limiter attack/release/lookahead**: Shape how the limiter catches peaks. Shorter attack = catches faster but can distort transients. Longer lookahead = smoother limiting. - - **De-esser**: Reduces sibilance (harsh "s" sounds). Strength controls how aggressively it works. - - **De-harsh EQ**: Dynamic reduction of fatiguing frequencies (2-8 kHz range). - - **Stereo width**: Values > 1.0 widen the image, < 1.0 narrows toward mono. - - **Low mono (Hz)**: Sums frequencies below this cutoff to mono. Tightens bass, improves mono compatibility. - - **Soft clipper**: Adds gentle saturation before limiting. Drive controls intensity — subtle warmth at low settings, distortion at high. - - **Dither bit depth**: Applied when reducing bit depth (e.g., 16-bit for CD). Reduces quantization artifacts. - - **Preset**: `DefaultStreaming` (-14 LUFS), `Broadcast` (-23 LUFS), `UdioOptimized`, or `Custom`. -5. **Render**: Applies the mix plan and master plan through the selected renderer: - - **BuiltIn**: All DSP runs internally — most portable, no external dependencies. - - **PhaseLimiter**: Routes through an external PhaseLimiter binary for specialized transparent limiting. Falls back to BuiltIn if binary not found. - - **External Limiter**: Uses any user-supplied limiter binary that honors the request JSON contract. Falls back to BuiltIn on failure/timeout. -6. **Export**: Writes the final audio file plus a JSON report with measured loudness, peak levels, spectral balance, and all applied settings. - -### How Settings Affect Output Quality - -| Setting Area | Conservative / Safe | Aggressive / Loud | -|---|---|---| -| Target LUFS | -16 to -14 (dynamic, natural) | -10 to -8 (crushed, loud) | -| Limiter ceiling | -2.0 dB (safe headroom) | -0.5 dB (maximum loudness) | -| Compressor ratio | 2:1 (gentle glue) | 8:1+ (heavy squash) | -| Pre-gain | 0 dB (clean) | +3-6 dB (drives limiter harder) | -| Soft clipper drive | 1.0 (off) | 1.3+ (audible saturation) | -| Stereo width | 1.0 (natural) | 1.3+ (wide but risks mono issues) | -| De-esser strength | 0.2 (subtle) | 0.6+ (aggressive, may dull vocals) | - -### Residual Blend - -When an original mix reference is loaded, the **Residual Blend %** slider controls how much of the difference between the original mix and the re-created mix gets blended back in. This preserves reverb tails, room ambience, and other elements not captured by the stems alone. 0% = stems only, 100% = full residual mixed in. - -### AI Model Override - -If AI model packs are installed, they can override the heuristic decisions for stem role classification, mix planning, and mastering. The AI models are trained on analyzed features and produce alternative gain/EQ/dynamics settings. The heuristic engine remains the fallback when no models are loaded. - ---- - -## Quickstart (Windows — Visual Studio) - -Requires **Visual Studio 2019+** (with C++ Desktop workload) and **CMake 3.24+**. - -All dependencies (JUCE, nlohmann/json, libebur128, Catch2) are downloaded automatically via CMake `FetchContent` — no manual installs needed. - -```powershell -# Configure (generates VS solution) -cmake -S . -B build_win_vs2022 -G "Visual Studio 17 2022" -A x64 -DBUILD_TESTING=ON -DBUILD_TOOLS=ON - -# Build Release -cmake --build build_win_vs2022 --config Release --parallel - -# Run tests -ctest --test-dir build_win_vs2022 --build-config Release --output-on-failure -j4 - -# Run the app -.\build_win_vs2022\AutoMixMasterApp_artefacts\Release\AutoMixMaster.exe -``` - -For **NMake** (command-line only, no IDE): - -```powershell -# Run from a VS Developer Command Prompt -cmake -S . -B build_win_nmake -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -cmake --build build_win_nmake --parallel -ctest --test-dir build_win_nmake --output-on-failure -j4 -``` - -For **Ninja** (faster builds, requires Ninja on PATH): - -```powershell -cmake -S . -B build_win_ninja -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build_win_ninja --parallel -``` - -## Quickstart (WSL / Linux) - -Verified on **February 13, 2026** (Ubuntu WSL2). Install Linux dependencies first: +AutoMixMaster is a JUCE/CMake desktop app for deterministic stem mixing/mastering: + +`Analysis -> MixPlan -> MasterPlan -> Renderer -> Audio + JSON report` + +This branch implements the full roadmap scope from `docs/roadmap.md`, including GPU-provider aware AI runtime controls, codec availability probing, external limiter contract validation, DAW-style transport preview, multiband mastering controls, platform loudness presets, stem-separation import flow, and expanded spectral feature extraction. + +## Implemented Roadmap Highlights + +1. GPU-accelerated ML runtime controls +- `RenderSettings.gpuExecutionProvider` supports `auto|cpu|directml|coreml|cuda`. +- GUI selector added under `ML Provider`. +- ONNX adapter supports provider preference selection with CPU fallback and diagnostics. + +2. Enhanced codec portability and visibility +- `WavWriter::getAvailableFormats()` probes writer availability at runtime. +- GUI export dropdown reflects available codecs and provides diagnostics for unavailable formats. +- MP3 export now supports two fallback layers: + - JUCE MP3/LAME encoder integration when enabled at build-time. + - External `lame` CLI encoding fallback when JUCE MP3 writer creation is unavailable. + - Cross-platform on-demand LAME downloader fallback when no local `lame` binary is present. +- Runtime availability marks MP3 as available if either writer path is valid. + +3. External limiter contract validation +- `ExternalLimiterRenderer::validateBinary()` performs `--validate` handshake, timeout handling, schema checks, and capability parsing. +- `RendererRegistry` now validates external renderers during discovery and marks invalid binaries unavailable with diagnostics. +- Formal schema added: `docs/external_limiter_contract.schema.json`. +- New tooling command: `automix_dev_tools validate-external-limiter --binary `. + +4. AI runtime throughput improvements +- ONNX adapter includes graph optimization policy (`ORT_ENABLE_ALL` semantics), warmup, preallocated scratch buffers, batch run support, and quantized variant preference (`*_int8.onnx`, `*_fp16.onnx`). +- ONNX backend diagnostics now expose runtime counters and timing telemetry: + - `calls`, `batches`, `provider_fallbacks`, `avg_inference_ms`, `warmup_ms`. +- Model metadata now supports specialization fields consumed by ONNX runtime setup: + - `preferredPrecision`, `providerAffinity`, `defaultIntraOpThreads`, `defaultInterOpThreads`, `enableProfiling`. +- Build toggles for RTNeural acceleration options are exposed: `RTNEURAL_XSIMD`, `RTNEURAL_USE_AVX`. + +5. Real-time transport preview +- `TransportController` with play/pause/stop/seek/progress state broadcasting. +- Waveform preview component with playhead cursor. +- GUI transport slider plus stem solo/mute preview routing. + +6. Multiband dynamics in mastering +- Added `dsp::MultibandProcessor`. +- `MasterPlan` supports `enableMultibandCompressor` and `multibandSettings`. +- Heuristic mastering chain inserts multiband stage before limiter when enabled. + +7. Platform loudness presets +- Added `MasterPreset` values: `Spotify`, `AppleMusic`, `YouTube`, `AmazonMusic`, `Tidal`, `BroadcastEbuR128`. +- Data-driven preset overrides loaded from `assets/mastering/platform_presets.json`. +- GUI now has a platform preset selector. + +8. Stem separation integration path +- Optional `StemSeparator` integrated into import flow for single mixed-file imports when `AI-separated stems` is enabled. +- Implements model-backed overlap-add separation when a separator model is available. +- Includes deterministic overlap-add fallback and residual-safe reconstruction when model output is unavailable. +- Per-stem `separationConfidence` and `separationArtifactRisk` are now attached to imported stems and serialized in sessions. + +9. Advanced spectral analysis and ML features +- Analysis now includes multi-resolution STFT summaries, spectral flux, onset strength, crest factor, MFCCs, and constant-Q bins. +- Feature schema expanded in both runtime and training export: + - `src/ai/FeatureSchema.cpp` + - `tools/training/feature_schema_v1.json` + +10. UI responsiveness and task scheduling +- `Auto Mix` and `Auto Master` now execute on background worker threads and report progress back to the JUCE message thread. +- UI remains responsive during intensive operations; cancel remains available. +- Preview rebuilding from Auto Mix now runs asynchronously to avoid post-task UI stalls. + +## Build + +### Configure + build (Linux/WSL/macOS) ```bash -# JUCE Linux dependencies (Ubuntu/Debian) -sudo apt update -sudo apt install -y build-essential cmake pkg-config \ - libasound2-dev libjack-jackd2-dev ladspa-sdk \ - libfreetype-dev libfontconfig1-dev \ - libx11-dev libxcomposite-dev libxcursor-dev libxext-dev \ - libxinerama-dev libxrandr-dev libxrender-dev \ - libglu1-mesa-dev mesa-common-dev +cmake -S . -B build-codex -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON +cmake --build build-codex --parallel ``` -Then build: - -```bash -cd /path/to/AutoMixMaster -cmake -S . -B build_release -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build_release --parallel $(nproc) -TMPDIR=/tmp ctest --test-dir build_release --output-on-failure -j4 -./build_release/AutoMixMasterApp_artefacts/Release/AutoMixMaster -``` +### Configure + build (Windows, VS 2026) -From **PowerShell on Windows** (WSL proxy): +Windows Visual Studio builds are pinned to the VS 2026 generator (`Visual Studio 18 2026`). ```powershell -wsl -e bash -lc "cd /mnt/v/AutoMixMaster && cmake -S . -B build_wsl_release -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON" -wsl -e bash -lc "cd /mnt/v/AutoMixMaster && cmake --build build_wsl_release --parallel" -wsl -e bash -lc "cd /mnt/v/AutoMixMaster && TMPDIR=/tmp ctest --test-dir build_wsl_release --output-on-failure -j4" +cmake -S . -B build_win_vs2026_release -G "Visual Studio 18 2026" -A x64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF -DBUILD_SHARED_LIBS=OFF +cmake --build build_win_vs2026_release --config Release --parallel ``` -`TMPDIR=/tmp` avoids WSL temp-directory permission issues in some Windows-hosted environments. - -## Quickstart (macOS) — Untested - -> **Note**: These macOS instructions are provided for reference but have **not been personally tested** by the maintainer. They are based on JUCE's documented CMake requirements and standard macOS development tooling. Community feedback and corrections are welcome. +Built executable: +- `build_win_vs2026_release/AutoMixMasterApp_artefacts/Release/AutoMixMaster.exe` -### Prerequisites +## Run -| Requirement | Minimum | Install | -|---|---|---| -| macOS | 10.15 (Catalina) | — | -| Xcode | 14+ | App Store or [developer.apple.com](https://developer.apple.com/xcode/) | -| Xcode Command Line Tools | Matching Xcode | `xcode-select --install` | -| CMake | 3.24+ | `brew install cmake` or [cmake.org](https://cmake.org/download/) | - -### Build with Xcode Generator (Recommended) +### App ```bash -# Install prerequisites -xcode-select --install -brew install cmake - -cd /path/to/AutoMixMaster - -# Configure — Xcode generator, Universal binary (Apple Silicon + Intel) -cmake -S . -B build_macos -G Xcode \ - -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \ - -DCMAKE_OSX_ARCHITECTURES="arm64;x86_64" \ - -DBUILD_TESTING=ON \ - -DBUILD_TOOLS=ON - -# Build Release -cmake --build build_macos --config Release - -# Run tests -ctest --test-dir build_macos --build-config Release --output-on-failure -j$(sysctl -n hw.ncpu) - -# Run the app -open build_macos/AutoMixMasterApp_artefacts/Release/AutoMixMaster.app +./build-codex/AutoMixMasterApp_artefacts/AutoMixMaster ``` -### Build with Unix Makefiles (Alternative) +### Tests ```bash -cmake -S . -B build_macos -G "Unix Makefiles" \ - -DCMAKE_BUILD_TYPE=Release \ - -DCMAKE_OSX_DEPLOYMENT_TARGET=10.15 \ - -DBUILD_TESTING=ON \ - -DBUILD_TOOLS=ON -cmake --build build_macos -j$(sysctl -n hw.ncpu) +TMPDIR=/tmp ctest --test-dir build-codex --output-on-failure -j4 ``` -### macOS-Specific Notes - -- **Frameworks**: JUCE's CMake automatically links CoreAudio, CoreMIDI, AudioToolbox, Accelerate, and other required Apple frameworks — no manual configuration needed. -- **Universal binaries**: Use `-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"` for Apple Silicon + Intel support. Omit for native-only builds. -- **Code signing**: Required for distribution. Add `-DCMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY="Developer ID Application"` for signed builds. -- **App bundle**: JUCE's `juce_add_gui_app` creates a proper `.app` bundle with `Info.plist` automatically. -- **No additional system dependencies**: Unlike Linux, macOS does not need separate packages for audio/graphics — these ship as system frameworks. -- **Common issue**: If you see "No matching SDK found", run `xcode-select -s /Applications/Xcode.app/Contents/Developer`. - ---- - -## Current App Features - -- Import stems: `.wav`, `.aiff/.aif`, `.flac`, `.mp3`, `.ogg` -- Optional original mix reference for residual blend and soft target mastering -- Auto Mix plan generation (heuristic + optional AI override) -- Auto Master plan generation (heuristic + optional AI override) -- Renderer selection: - - `BuiltIn` - - `PhaseLimiter` (auto-discovered external binary, fallback-safe) - - Asset/user external limiters (`renderer.json` descriptors) -- Export formats: - - Lossless: `WAV`, `FLAC`, `AIFF` - - Lossy: `OGG`, `MP3` (bitrate/quality controls) -- Batch folder processing: - - Parallel analysis and parallel rendering - - Lossy or lossless export in batch - - Per-item output/report summary -- Cancel button for long export/batch tasks -- Session save/load -- A/B preview source switching for original vs rendered buffers - ---- +As of **February 14, 2026**, this branch passes all 50 tests in `automix_tests`. ## GUI Workflow -1. Import stems. -2. Optional: load original mix. -3. Optional: choose AI packs (Role/Mix/Master). -4. Run **Auto Mix**. -5. Run **Auto Master**. -6. Choose renderer and export format/bitrate. -7. Click **Export** (or **Batch Folder**). -8. Use **Cancel** if needed. - -Output files: +1. Import stems (or one mixed track with `AI-separated stems` enabled). +2. Optional: load original mix for A/B and residual blend. +3. Choose AI packs and ML provider (`auto/cpu/directml/coreml/cuda`). +4. Run `Auto Mix`. +5. Run `Auto Master` and choose master/platform presets. +6. Optional: set stem solo/mute and audition with transport controls. +7. Choose renderer + export format. +8. Export single song or batch folder. +Outputs: - `.` - `..report.json` ---- - -## Lossy Export and Codec Notes - -Formats are selected from `RenderSettings.outputFormat` and resolved against file extension when `auto` is used. - -- `WAV`, `AIFF`, `FLAC` are written directly. -- `OGG`, `MP3` are written when those JUCE codec paths are available in the build. -- `MP3` path supports JUCE MP3 writer and optional LAME fallback (`LAME_BIN`/`PATH`) when enabled. -- External/PhaseLimiter renderers render via WAV temp files and then encode final output into requested lossy/lossless format. - -Primary references: - -- JUCE `AudioFormatManager::registerBasicFormats` docs: -- JUCE `MP3AudioFormat` docs: -- JUCE `LAMEEncoderAudioFormat` docs: - ---- - -## Batch Performance and Hardware Acceleration - -Batch and offline rendering are now hardware-aware and multi-threaded: - -- Parallel stem analysis with bounded worker threads. -- Parallel batch rendering (`renderParallelism` workers). -- Parallel stem import/pre-processing in offline render pipeline. -- Automatic defaults from `std::thread::hardware_concurrency()`. -- `RenderSettings.processingThreads` to override thread count. -- `RenderSettings.preferHardwareAcceleration` to force single-thread deterministic fallback when disabled. - -This project currently accelerates via multi-core CPU parallelism (no GPU offload path in this repo). - ---- - -## MasterPlan Application Across Renderers - -All supported limiter paths now use `Session.masterPlan`: - -- `BuiltInRenderer`: applies full master plan directly. -- `PhaseLimiterRenderer`: - - uses plan limiter ceiling for PhaseLimiter invocation - - runs compliance post-check bounded by master plan before final write -- `ExternalLimiterRenderer`: - - includes master-plan limiter and gain parameters in request JSON - - runs compliance post-check bounded by master plan before final write - -If external processing fails/times out/missing, renderers fallback safely to `BuiltIn`. - ---- +## Renderers -## AI Model Packs - -### Runtime scan roots - -`ModelManager` scans model packs from: - -- configured root(s) (default `ModelPacks`) -- `assets/models` -- `assets/modelpacks` -- `assets/ModelPacks` -- `Assets/ModelPacks` -- optional env var: `AUTOMIX_MODELPACK_PATHS` - -Relative roots are resolved across current-directory ancestors, so packs are found from app, tool, and test working directories. - -### Required metadata and schema gating - -`model.json` is validated with deterministic load/run checks: - -- required metadata: `license`, `source`, `feature_schema_version` -- model file must exist -- optional checksum must match -- feature schema compatibility required -- runtime inference rejects feature-count mismatches - -### Supported engines - -Both supported engines are functional in this codebase: - -- `onnxruntime` (deterministic local backend with schema/task gating) -- `rtneural` (deterministic local backend) - -### Included demo packs - -- `assets/models/demo-role-v1` -- `assets/models/demo-mix-v1` -- `assets/models/demo-master-v1` - ---- - -## Limiter Pack Discovery and Installation - -`RendererRegistry` auto-discovers external limiter descriptors by scanning: +- `BuiltIn`: always available in-process renderer. +- `PhaseLimiter`: external binary discovery with fallback-safe behavior. +- External limiters from descriptor `renderer.json` files. +External descriptor scan roots: - `assets/limiters/**/renderer.json` - `assets/renderers/**/renderer.json` - `Assets/Limiters/**/renderer.json` -Relative roots are resolved across ancestor directories for runtime flexibility. - -Included descriptor examples: +External renderers are now validated at discovery time via the `--validate` contract. -- `assets/limiters/phaselimiter/renderer.json` -- `assets/limiters/external-template/renderer.json` +## Codec Availability ---- +Export codec availability is probed at runtime and surfaced in the GUI. -## Dependencies +Supported format targets: +- Lossless: `wav`, `aiff`, `flac` +- Lossy: `mp3`, `ogg` -All build dependencies are fetched automatically via CMake `FetchContent` — no manual installation required on any platform: +Key build option: +- `ENABLE_BUNDLED_LAME=ON|OFF` (default: `ON`) -| Dependency | Version | License | Purpose | -|---|---|---|---| -| **JUCE** | 8.0.8 | GPL v3 / Commercial | Audio framework, GUI, DSP, codec support | -| **nlohmann/json** | 3.11.3 | MIT | JSON serialization for sessions, reports, model metadata | -| **libebur128** | 1.2.6 | MIT | EBU R128 / ITU-R BS.1770 standards loudness metering | -| **Catch2** | 3.7.1 | BSL 1.0 | Unit and regression test framework (test builds only) | +MP3 fallback discovery order: +- Bundled lookup roots (when `ENABLE_BUNDLED_LAME=ON`) from current working directory and executable-relative ancestors. +- `lame(.exe)` beside the app executable (`./`, `./bin`, `./lame`). +- `LAME_BIN` environment variable. +- `PATH` entries (quoted entries are supported). +- Downloaded fallback cache (`/AutoMixMaster/codecs/lame//`). -Platform-specific requirements: - -- **Windows**: Visual Studio 2019+ with C++ Desktop workload (or standalone MSVC toolchain). -- **Linux/WSL**: System packages for X11, ALSA, and FreeType (see Quickstart above). -- **macOS**: Xcode 14+ with Command Line Tools. No additional packages needed. - ---- +MP3 downloader controls: +- `AUTOMIX_LAME_SKIP_DOWNLOAD=1` to disable downloader fallback. +- `AUTOMIX_LAME_FORCE_DOWNLOAD=1` to force re-download. +- `AUTOMIX_LAME_VERSION=` to override default (`3.100`). +- `AUTOMIX_LAME_DOWNLOAD_URL=` to override source with a direct archive/binary URL. ## Developer Tools (`automix_dev_tools`) -Build with `-DBUILD_TOOLS=ON`. +Built when `-DBUILD_TOOLS=ON`. ```bash automix_dev_tools export-features --session --out automix_dev_tools export-segments --session --out-dir [--segment-seconds ] automix_dev_tools validate-modelpack --pack +automix_dev_tools validate-external-limiter --binary [--json] automix_dev_tools list-supported-models automix_dev_tools install-supported-model --id [--dest ] automix_dev_tools list-supported-limiters automix_dev_tools install-supported-limiter --id [--dest ] +automix_dev_tools install-lame-fallback [--force] [--json] ``` -Supported model installers: +## External Limiter Contract -- `demo-role-v1` -- `demo-mix-v1` -- `demo-master-v1` +Schema: +- `docs/external_limiter_contract.schema.json` -Supported limiter installers: +Validation behavior: +- The renderer sends a minimal validation request using `--validate --request `. +- Expected response fields: + - `schemaVersion` (major version 1) + - `version` (string) + - `supportedFeatures` (string array) +- Validation now emits explicit error taxonomy codes (for tooling and registry diagnostics), including: + - `binary_missing`, `launch_failed`, `timeout`, `exit_code`, `invalid_json`, `missing_version`, `missing_supported_features`, `schema_incompatible`. +- Invalid binaries are surfaced as unavailable in renderer discovery and fallback to BuiltIn at render time. -- `phaselimiter` -- `external-template` +## AI Model Packs ---- +`ModelManager` scans from: +- configured roots +- `assets/models` +- `assets/modelpacks` +- `assets/ModelPacks` +- `Assets/ModelPacks` +- env var `AUTOMIX_MODELPACK_PATHS` -## Build Options +Packs are schema-gated and validated for required metadata (`license`, `source`, `feature_schema_version`) and model-file presence/checksum. +Additional optional runtime specialization metadata is supported: +- `preferredPrecision` +- `providerAffinity` +- `defaultIntraOpThreads` +- `defaultInterOpThreads` +- `enableProfiling` -Common CMake toggles: +## Key CMake Options - `BUILD_TESTING=ON|OFF` - `BUILD_TOOLS=ON|OFF` -- `DISTRIBUTION_MODE=OSS|PROPRIETARY` -- `ENABLE_PHASELIMITER=ON|OFF` - `ENABLE_ONNX=ON|OFF` - `ENABLE_RTNEURAL=ON|OFF` +- `RTNEURAL_XSIMD=ON|OFF` +- `RTNEURAL_USE_AVX=ON|OFF` - `ENABLE_LIBEBUR128=ON|OFF` +- `ENABLE_PHASELIMITER=ON|OFF` - `ENABLE_EXTERNAL_TOOL_SUPPORT=ON|OFF` -- `ENABLE_GPL_BUNDLED_LIMITERS=ON|OFF` - ---- - -## Testing - -```bash -cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build --parallel -TMPDIR=/tmp ctest --test-dir build --output-on-failure -j4 -``` - -Regression CLI: - -```bash -./build/automix_regression_cli --baseline ./tests/regression/baselines.json -``` - ---- - -## Cross-Platform Compatibility - -The codebase is verified cross-platform with the following design: - -- All platform-specific code is guarded by `#if defined(_WIN32)` / `#elif defined(__APPLE__)` / `#else` (Linux). -- Path separator handling uses `std::filesystem` throughout — no hardcoded slashes or backslashes. -- Environment variable reading uses `_dupenv_s` on Windows and `std::getenv` elsewhere. -- `PATH` parsing uses `;` delimiter on Windows and `:` on Linux/macOS. -- External process execution uses JUCE `ChildProcess`, which abstracts platform differences. -- Thread concurrency defaults via `std::thread::hardware_concurrency()` (cross-platform). -- PhaseLimiter binary discovery scans platform-appropriate subdirectories (`windows/`, `mac/`, `linux/`) and executable names (`.exe` on Windows). -- JUCE's CMake integration automatically links the correct system frameworks per platform (CoreAudio on macOS, ALSA/JACK on Linux, WASAPI on Windows). - ---- - -## Known Limitations +- `ENABLE_BUNDLED_LAME=ON|OFF` (default: `ON`) +- `DISTRIBUTION_MODE=OSS|PROPRIETARY` -- GPU compute acceleration is not implemented; acceleration is CPU-thread based. -- MP3/OGG export depends on codec support compiled into JUCE for your build environment. -- External limiter integrations require the external binary to honor the request JSON contract. -- The current local AI backends are deterministic inference adapters, not native high-throughput production runtimes. -- Real-time transport/DAW-style playback editing is still limited; this app is primarily an offline render workflow. +## Known Limits ---- +- ONNX inference remains a deterministic adapter layer in this branch (not a linked native ONNX Runtime session integration). +- External limiter contract still enforces major schema compatibility (`1.x`) rather than strict minor-version negotiation. ## 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. - -### Distribution Modes - -The project supports two distribution modes controlled by the `DISTRIBUTION_MODE` CMake option: - -- **OSS mode** (`DISTRIBUTION_MODE=OSS`): Uses GPL v3 licensed JUCE framework. All components must be GPL-compatible. This is the default mode for open-source distribution. -- **Proprietary mode** (`DISTRIBUTION_MODE=PROPRIETARY`): Uses commercially licensed JUCE framework. Allows proprietary distribution while maintaining copyleft-bundling gates for GPL components. - -### Third-party Components - -This project includes/uses third-party software with its own licensing: - -- **JUCE** (fetched at build time): Dual-licensed GPL v3 / Commercial. Used under GPL v3 in OSS mode, commercial license in proprietary mode. -- **nlohmann/json** (fetched at build time): MIT License. -- **Catch2** (fetched at build time for tests): Boost Software License 1.0. -- **libebur128** (fetched at build time): MIT License. -- **PhaseLimiter binaries and resources** under `assets/phaselimiter/`: See that folder for its license files (typically GPL or compatible). +AutoMixMaster is GPLv3 in OSS mode (`DISTRIBUTION_MODE=OSS`). -All third-party dependencies are compatible with the chosen distribution mode. +Third-party dependencies include JUCE, nlohmann/json, Catch2, and libebur128, each under their respective licenses. 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/src/ai/FeatureSchema.cpp b/src/ai/FeatureSchema.cpp index cfebb1b..aa661c8 100644 --- a/src/ai/FeatureSchema.cpp +++ b/src/ai/FeatureSchema.cpp @@ -3,6 +3,13 @@ #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; +} + +} // namespace const std::vector& FeatureSchemaV1::names() { static const std::vector kNames = { @@ -33,6 +40,45 @@ const std::vector& FeatureSchemaV1::names() { "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; } @@ -42,7 +88,7 @@ bool FeatureSchemaV1::isCompatible(const std::string& version) { return version size_t FeatureSchemaV1::featureCount() { return names().size(); } std::vector FeatureSchemaV1::extract(const analysis::AnalysisResult& metrics) { - return { + std::vector values = { metrics.rmsDb, metrics.peakDb, metrics.crestDb, @@ -70,7 +116,18 @@ std::vector FeatureSchemaV1::extract(const analysis::AnalysisResult& met 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/ModelPackLoader.cpp b/src/ai/ModelPackLoader.cpp index 7c7dc8e..782b5df 100644 --- a/src/ai/ModelPackLoader.cpp +++ b/src/ai/ModelPackLoader.cpp @@ -69,6 +69,24 @@ std::optional ModelPackLoader::load(const std::filesystem::path& dire } } + 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.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", ""); } diff --git a/src/ai/ModelPackLoader.h b/src/ai/ModelPackLoader.h index 3b5ff3f..26244f3 100644 --- a/src/ai/ModelPackLoader.h +++ b/src/ai/ModelPackLoader.h @@ -22,6 +22,11 @@ struct ModelPack { 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; diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index 7281347..cbc2838 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -1,8 +1,13 @@ #include "ai/OnnxModelInference.h" #include +#include +#include +#include #include #include +#include +#include #include @@ -12,31 +17,70 @@ namespace { double clamp01(const double value) { return std::clamp(value, 0.0, 1.0); } double normalizeFeatureValue(const double value) { - // Keep deterministic behavior while preventing large-Hz features from dominating. return std::copysign(std::log1p(std::abs(value)), value); } -double safeMean(const std::vector& values) { - if (values.empty()) { - return 0.0; +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 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; } - double sum = 0.0; - for (const auto value : values) { - sum += normalizeFeatureValue(value); + + 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; } - return sum / static_cast(values.size()); -} -double safeRms(const std::vector& values) { - if (values.empty()) { - return 0.0; + if (std::filesystem::is_regular_file(int8Variant, error) && !error) { + return int8Variant; } - double sum = 0.0; - for (const auto value : values) { - const double normalized = normalizeFeatureValue(value); - sum += normalized * normalized; + error.clear(); + if (std::filesystem::is_regular_file(fp16Variant, error) && !error) { + return fp16Variant; } - return std::sqrt(sum / static_cast(values.size())); + return modelPath; } } // namespace @@ -44,44 +88,154 @@ double safeRms(const std::vector& values) { bool OnnxModelInference::isAvailable() const { return loaded_; } 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; modelPath_.clear(); expectedFeatureCount_.reset(); allowedTasks_.clear(); + availableExecutionProviders_.clear(); + preferredPrecision_ = "auto"; + intraOpThreads_ = 0; + interOpThreads_ = 0; + profilingEnabled_ = false; + inferenceCalls_.store(0); + batchCalls_.store(0); + providerFallbacks_.store(0); + cumulativeInferenceMicros_.store(0); + warmupDurationMillis_.store(0); + diagnostics_ = "ONNX load failed: missing model file."; return false; } - modelPath_ = std::filesystem::absolute(modelPath); + 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; - // Optional sidecar metadata lets tests and model packs validate schema expectations. - const auto sidecar = modelPath_; - const auto metadataPath = sidecar.string() + ".meta.json"; + const auto metadataPath = std::filesystem::path(modelPath.string() + ".meta.json"); if (std::filesystem::exists(metadataPath, error) && !error) { std::ifstream in(metadataPath); - nlohmann::json meta; - in >> meta; + 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("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("allowed_tasks") && meta.at("allowed_tasks").is_array()) { - allowedTasks_ = meta.at("allowed_tasks").get>(); + } + + if (availableExecutionProviders_.empty()) { + availableExecutionProviders_.push_back("cpu"); + const auto platformProvider = platformPreferredProvider(); + if (platformProvider != "cpu") { + availableExecutionProviders_.push_back(platformProvider); } } + for (auto& provider : availableExecutionProviders_) { + provider = toLower(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; + inferenceCalls_.store(0); + batchCalls_.store(0); + providerFallbacks_.store(0); + cumulativeInferenceMicros_.store(0); + warmupDurationMillis_.store(0); + + std::ostringstream os; + os << "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) { + os << "; quantized_variant=" << selectedModelPath.filename().string(); + } + diagnostics_ = os.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; } @@ -89,6 +243,7 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { 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; } @@ -96,14 +251,36 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { 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; } + 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())); + } + } + result.usedModel = true; - result.logMessage = "ONNX inference executed for task '" + request.task + "' using model " + modelPath_.string() + "."; + result.logMessage = "ONNX inference executed for task '" + request.task + "' using provider '" + + activeExecutionProvider_ + "' (" + (graphOptimizationEnabled_ ? "ORT_ENABLE_ALL" : "graph-opt-off") + + ", precision=" + preferredPrecision_ + ")."; - const double mean = safeMean(request.features); - const double rms = safeRms(request.features); const double confidence = clamp01(0.55 + std::min(0.35, std::abs(mean) * 0.05 + rms * 0.03)); if (request.task == "mix_parameters") { @@ -112,6 +289,7 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { {"global_gain_db", std::clamp(-mean * 0.08, -4.0, 4.0)}, {"global_pan_bias", std::clamp(mean * 0.002, -0.2, 0.2)}, }; + finalizeMetrics(); return result; } @@ -123,6 +301,7 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { {"limiter_ceiling_db", -1.0}, {"glue_ratio", std::clamp(2.0 + rms * 0.02, 1.2, 4.0)}, }; + finalizeMetrics(); return result; } @@ -139,13 +318,111 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { {"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)}, }; + finalizeMetrics(); return result; } result.outputs = { {"confidence", confidence}, }; + finalizeMetrics(); return result; } +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_ = toLower(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(); + return os.str(); +} + +std::string OnnxModelInference::resolveExecutionProvider() const { + const std::string requested = toLower(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 = toLower(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(); + 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))); +} + } // namespace automix::ai diff --git a/src/ai/OnnxModelInference.h b/src/ai/OnnxModelInference.h index 9e4b847..fe892c0 100644 --- a/src/ai/OnnxModelInference.h +++ b/src/ai/OnnxModelInference.h @@ -1,5 +1,8 @@ #pragma once +#include +#include +#include #include #include @@ -13,11 +16,49 @@ class OnnxModelInference final : public IModelInference { 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; + private: + std::string resolveExecutionProvider() const; + bool supportsExecutionProvider(const std::string& provider) const; + void warmupIfNeeded(); + bool loaded_ = false; + bool graphOptimizationEnabled_ = true; + bool warmupEnabled_ = true; + bool preferQuantizedVariants_ = true; + mutable bool warmupRan_ = false; + std::filesystem::path modelPath_; std::optional expectedFeatureCount_; 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::mutex scratchMutex_; + 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 diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp new file mode 100644 index 0000000..fc1c136 --- /dev/null +++ b/src/ai/StemSeparator.cpp @@ -0,0 +1,530 @@ +#include "ai/StemSeparator.h" + +#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; +constexpr int kStemCount = 4; +constexpr int kBassStem = 0; +constexpr int kVocalsStem = 1; +constexpr int kDrumsStem = 2; +constexpr int kMusicStem = 3; + +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); +} + +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 engine::AudioBuffer& bass, + const engine::AudioBuffer& vocals, + const engine::AudioBuffer& drums) { + 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) { + const double value = source.getSample(ch, i) - bass.getSample(ch, i) - vocals.getSample(ch, i) - drums.getSample(ch, i); + residual.setSample(ch, i, static_cast(clampSample(value))); + } + } + return residual; +} + +domain::Stem makeStem(const std::string& id, + const std::string& name, + const std::filesystem::path& path, + const domain::StemRole role, + const double confidence, + const double artifactRisk) { + domain::Stem stem; + stem.id = id; + stem.name = name; + 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; +} + +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::array defaultWeights(const std::vector& features) { + 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::array weights { + 0.55 + low * 0.45 - high * 0.2, + 0.45 + mid * 0.6 - low * 0.15, + 0.35 + high * 0.4 + flux * 0.25, + 0.25 + mid * 0.25 + high * 0.15, + }; + + for (double& value : weights) { + value = std::max(0.01, value); + } + 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::array weightsFromInference(const InferenceResult& result, + const std::array& fallback) { + auto weights = fallback; + bool anyExplicitWeight = false; + const std::array, kStemCount> keyOptions = { + std::vector{"bass_weight", "stem0_weight", "source0_weight", "mask_bass"}, + std::vector{"vocals_weight", "stem1_weight", "source1_weight", "mask_vocals"}, + std::vector{"drums_weight", "stem2_weight", "source2_weight", "mask_drums"}, + std::vector{"music_weight", "other_weight", "stem3_weight", "source3_weight", "mask_other"}, + }; + + for (int index = 0; index < kStemCount; ++index) { + if (const auto value = findOutputValue(result, keyOptions[static_cast(index)]); value.has_value()) { + weights[static_cast(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; +} + +struct OverlapAddResult { + bool success = false; + bool usedModel = false; + std::array stems; + std::array confidence {}; + std::array artifactRisk {}; + std::string logMessage; +}; + +OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, const std::filesystem::path& modelPath) { + OverlapAddResult result; + + OnnxModelInference inference; + inference.setExecutionProviderPreference("auto"); + inference.setGraphOptimizationEnabled(true); + inference.setWarmupEnabled(true); + inference.setPreferQuantizedVariants(true); + + if (!inference.loadModel(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; + } + + for (auto& stem : result.stems) { + stem = engine::AudioBuffer(channels, samples, mixBuffer.getSampleRate()); + } + + constexpr int frameSize = 4096; + constexpr int hopSize = frameSize / 4; + const auto window = makeHannWindow(frameSize); + std::vector normalization(static_cast(samples), 0.0); + std::array confidenceAccumulator {}; + std::array confidenceWeight {}; + std::array artifactAccumulator {}; + std::array artifactWeight {}; + + int processedFrames = 0; + int modelFrames = 0; + for (int frameStart = 0; frameStart < samples; frameStart += hopSize) { + const auto features = extractFrameFeatures(mixBuffer, frameStart, frameSize); + const auto fallbackWeights = defaultWeights(features); + auto weights = fallbackWeights; + double confidence = 0.45; + + InferenceRequest request; + request.task = "stem_separation"; + request.features = features; + const auto inferenceResult = inference.run(request); + if (inferenceResult.usedModel) { + weights = weightsFromInference(inferenceResult, fallbackWeights); + 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 < kStemCount; ++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 < kStemCount; ++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 < kStemCount; ++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)); + } + } + } + + // Lock "music" to residual so the separated stems remain phase-consistent with the source. + for (int ch = 0; ch < channels; ++ch) { + for (int i = 0; i < samples; ++i) { + const double value = static_cast(mixBuffer.getSample(ch, i)) - + static_cast(result.stems[kBassStem].getSample(ch, i)) - + static_cast(result.stems[kVocalsStem].getSample(ch, i)) - + static_cast(result.stems[kDrumsStem].getSample(ch, i)); + result.stems[kMusicStem].setSample(ch, i, static_cast(clampSample(value))); + } + } + + for (int stemIndex = 0; stemIndex < kStemCount; ++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)."; + } else { + result.logMessage = "Model loaded but returned no usable frame outputs; used overlap-add fallback weights."; + } + return result; +} + +OverlapAddResult runDeterministicFallback(const engine::AudioBuffer& mixBuffer) { + OverlapAddResult result; + result.success = true; + result.usedModel = false; + + result.stems[kBassStem] = mixBuffer; + applyOnePoleLowPass(result.stems[kBassStem], 180.0); + + result.stems[kVocalsStem] = mixBuffer; + applyOnePoleHighPass(result.stems[kVocalsStem], 180.0); + applyOnePoleLowPass(result.stems[kVocalsStem], 3500.0); + + result.stems[kDrumsStem] = mixBuffer; + applyOnePoleHighPass(result.stems[kDrumsStem], 3500.0); + + result.stems[kMusicStem] = makeResidual(mixBuffer, result.stems[kBassStem], result.stems[kVocalsStem], result.stems[kDrumsStem]); + + result.confidence = {0.45, 0.45, 0.45, 0.45}; + result.artifactRisk = {0.58, 0.58, 0.58, 0.58}; + result.logMessage = "No separator model installed; used deterministic frequency splitter."; + return result; +} + +} // 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 { + return !resolveModelPath().empty(); +} + +StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::path& mixPath, + const std::filesystem::path& outputDir) 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); + + OverlapAddResult separated; + const auto modelPath = resolveModelPath(); + if (!modelPath.empty()) { + separated = runModelBackedOverlapAdd(mixBuffer, modelPath); + if (!separated.success) { + separated = runDeterministicFallback(mixBuffer); + separated.logMessage = "Model-backed path failed, fallback used. " + separated.logMessage; + } + } else { + separated = runDeterministicFallback(mixBuffer); + } + + util::WavWriter writer; + const auto bassPath = outputDir / "stem_bass.wav"; + const auto vocalsPath = outputDir / "stem_vocals.wav"; + const auto drumsPath = outputDir / "stem_drums.wav"; + const auto musicPath = outputDir / "stem_music.wav"; + + writer.write(bassPath, separated.stems[kBassStem], 24); + writer.write(vocalsPath, separated.stems[kVocalsStem], 24); + writer.write(drumsPath, separated.stems[kDrumsStem], 24); + writer.write(musicPath, separated.stems[kMusicStem], 24); + + result.generatedFiles = {bassPath, vocalsPath, drumsPath, musicPath}; + result.stems = { + makeStem("sep_bass", + "Separated Bass", + bassPath, + domain::StemRole::Bass, + separated.confidence[kBassStem], + separated.artifactRisk[kBassStem]), + makeStem("sep_vocals", + "Separated Vocals", + vocalsPath, + domain::StemRole::Vocals, + separated.confidence[kVocalsStem], + separated.artifactRisk[kVocalsStem]), + makeStem("sep_drums", + "Separated Drums", + drumsPath, + domain::StemRole::Drums, + separated.confidence[kDrumsStem], + separated.artifactRisk[kDrumsStem]), + makeStem("sep_music", + "Separated Music", + musicPath, + domain::StemRole::Music, + separated.confidence[kMusicStem], + separated.artifactRisk[kMusicStem]), + }; + + result.usedModel = separated.usedModel; + result.logMessage = separated.logMessage; + 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..bcd242b --- /dev/null +++ b/src/ai/StemSeparator.h @@ -0,0 +1,31 @@ +#pragma once + +#include +#include +#include + +#include "domain/Stem.h" + +namespace automix::ai { + +class StemSeparator final { + public: + struct SeparationResult { + bool success = false; + bool usedModel = false; + std::vector stems; + std::vector generatedFiles; + 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; + + 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 a99fd66..9d1f78e 100644 --- a/src/analysis/AnalysisResult.h +++ b/src/analysis/AnalysisResult.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include "analysis/ArtifactProfile.h" @@ -10,6 +11,7 @@ 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; @@ -24,6 +26,9 @@ struct AnalysisResult { 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; diff --git a/src/analysis/StemAnalyzer.cpp b/src/analysis/StemAnalyzer.cpp index bcbf13f..90dec28 100644 --- a/src/analysis/StemAnalyzer.cpp +++ b/src/analysis/StemAnalyzer.cpp @@ -20,6 +20,10 @@ double linearToDb(const double linear) { return 20.0 * std::log10(std::max(linea double clamp01(const double value) { return std::clamp(value, 0.0, 1.0); } +double hzToMel(const double hz) { return 2595.0 * std::log10(1.0 + hz / 700.0); } + +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 @@ -39,6 +43,196 @@ size_t bandIndexForFrequency(const double hz) { 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; +}; + +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 AnalysisResult StemAnalyzer::analyzeBuffer(const engine::AudioBuffer& buffer) const { @@ -83,6 +277,7 @@ AnalysisResult StemAnalyzer::analyzeBuffer(const engine::AudioBuffer& buffer) co result.peakDb = linearToDb(peak); 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); @@ -102,69 +297,31 @@ AnalysisResult StemAnalyzer::analyzeBuffer(const engine::AudioBuffer& buffer) co const double rightRms = std::sqrt(std::max(varR / n, 0.0)); result.channelBalanceDb = linearToDb(leftRms + kEpsilon) - linearToDb(rightRms + kEpsilon); - // FFT-based analysis for spectral bands and MIR-style descriptors. - constexpr int fftOrder = 11; // 2048 - constexpr int fftSize = 1 << fftOrder; - const int hopSize = fftSize / 2; - const int nyquistBins = fftSize / 2; + const auto monoSignal = computeMonoSignal(buffer); - 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 bandEnergy(6, 0.0); + // 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; - double spectralFluxSum = 0.0; - int frameCount = 0; - - 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; - } - const float l = buffer.getSample(0, sampleIndex); - const float r = buffer.getNumChannels() > 1 ? buffer.getSample(1, sampleIndex) : l; - fftData[static_cast(i)] = 0.5f * (l + r); - } - - 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); - const double power = magnitude * magnitude; - const double hz = static_cast(bin) * buffer.getSampleRate() / static_cast(fftSize); - - totalMagnitude += magnitude; - weightedFreqSum += hz * magnitude; - weightedFreqSquaredSum += hz * hz * magnitude; - logMagnitudeSum += std::log(magnitude + kEpsilon); - bandEnergy[bandIndexForFrequency(hz)] += power; - - if (frameCount > 0) { - const double delta = std::max(0.0, magnitude - previousMagnitude[static_cast(bin)]); - frameFlux += delta * delta; - } - previousMagnitude[static_cast(bin)] = magnitude; - } - - spectralFluxSum += frameFlux; - ++frameCount; - if (start + fftSize >= totalSamples) { - break; - } + 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; @@ -185,11 +342,43 @@ AnalysisResult StemAnalyzer::analyzeBuffer(const engine::AudioBuffer& buffer) co result.spectralSpreadHz = std::sqrt(variance); } - const int magnitudeBins = std::max(1, frameCount * (nyquistBins - 1)); - const double geometricMean = std::exp(logMagnitudeSum / static_cast(magnitudeBins)); - const double arithmeticMean = totalMagnitude / static_cast(magnitudeBins) + kEpsilon; + 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 = spectralFluxSum / static_cast(std::max(1, frameCount)); + 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); ArtifactRiskEstimator riskEstimator; result.artifactProfile = riskEstimator.profile(buffer, result); @@ -229,6 +418,7 @@ std::string StemAnalyzer::toJsonReport(const std::vector& ent {"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}, @@ -243,6 +433,9 @@ std::string StemAnalyzer::toJsonReport(const std::vector& ent {"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}, diff --git a/src/app/MainComponent.cpp b/src/app/MainComponent.cpp index b846c17..990b7f3 100644 --- a/src/app/MainComponent.cpp +++ b/src/app/MainComponent.cpp @@ -15,12 +15,16 @@ #include "ai/IModelInference.h" #include "ai/OnnxModelInference.h" #include "ai/RtNeuralInference.h" +#include "ai/StemSeparator.h" #include "automaster/OriginalMixReference.h" #include "engine/AudioFileIO.h" #include "engine/AudioResampler.h" #include "engine/BatchQueueRunner.h" #include "engine/OfflineRenderPipeline.h" +#include "renderers/ExternalLimiterRenderer.h" #include "renderers/RendererFactory.h" +#include "util/LameDownloader.h" +#include "util/WavWriter.h" namespace automix::app { namespace { @@ -85,15 +89,30 @@ const ai::ModelPack* findPackById(const ai::ModelManager& manager, const std::st return nullptr; } -std::unique_ptr createInferenceBackend(const ai::ModelPack* pack) { +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 = pack->engine; + const auto engine = toLower(pack->engine); if (engine.find("onnx") != std::string::npos || engine == "unknown") { - backend = std::make_unique(); + 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(); @@ -106,9 +125,30 @@ std::unique_ptr createInferenceBackend(const ai::ModelPack* if (!backend->loadModel(modelPath)) { return nullptr; } + + if (diagnosticsOut != nullptr) { + if (const auto* onnx = dynamic_cast(backend.get()); onnx != nullptr) { + *diagnosticsOut = onnx->backendDiagnostics(); + } + } + return backend; } +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::AnalysisTableModel::setEntries(const std::vector* entries) { @@ -184,7 +224,10 @@ MainComponent::MainComponent() { addAndMakeVisible(batchImportButton_); addAndMakeVisible(previewOriginalButton_); addAndMakeVisible(previewRenderedButton_); + addAndMakeVisible(playPauseButton_); + addAndMakeVisible(stopButton_); addAndMakeVisible(addExternalRendererButton_); + addAndMakeVisible(prefetchLameButton_); addAndMakeVisible(exportButton_); addAndMakeVisible(cancelButton_); addAndMakeVisible(separatedStemsToggle_); @@ -195,6 +238,17 @@ MainComponent::MainComponent() { addAndMakeVisible(exportFormatBox_); addAndMakeVisible(exportBitrateLabel_); addAndMakeVisible(exportBitrateSlider_); + addAndMakeVisible(gpuProviderLabel_); + addAndMakeVisible(gpuProviderBox_); + addAndMakeVisible(masterPresetLabel_); + addAndMakeVisible(masterPresetBox_); + addAndMakeVisible(platformPresetLabel_); + addAndMakeVisible(platformPresetBox_); + addAndMakeVisible(soloStemLabel_); + addAndMakeVisible(soloStemBox_); + addAndMakeVisible(muteStemLabel_); + addAndMakeVisible(muteStemBox_); + addAndMakeVisible(transportSlider_); addAndMakeVisible(aiModelsLabel_); addAndMakeVisible(roleModelBox_); addAndMakeVisible(mixModelBox_); @@ -203,6 +257,7 @@ MainComponent::MainComponent() { addAndMakeVisible(meterLufsLabel_); addAndMakeVisible(meterShortTermLabel_); addAndMakeVisible(meterTruePeakLabel_); + addAndMakeVisible(waveformPreview_); addAndMakeVisible(analysisTable_); addAndMakeVisible(reportEditor_); @@ -215,42 +270,64 @@ MainComponent::MainComponent() { batchImportButton_.addListener(this); previewOriginalButton_.addListener(this); previewRenderedButton_.addListener(this); + playPauseButton_.addListener(this); + stopButton_.addListener(this); addExternalRendererButton_.addListener(this); + prefetchLameButton_.addListener(this); exportButton_.addListener(this); cancelButton_.addListener(this); + + rendererBox_.addListener(this); exportFormatBox_.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); + transportSlider_.addListener(this); - refreshRenderers(); - 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); - exportFormatBox_.addItem("WAV", 1); - exportFormatBox_.addItem("FLAC", 2); - exportFormatBox_.addItem("AIFF", 3); - exportFormatBox_.addItem("OGG (lossy)", 4); - exportFormatBox_.addItem("MP3 (lossy)", 5); - exportFormatBox_.setSelectedId(1, juce::dontSendNotification); + 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(192.0, juce::dontSendNotification); - exportBitrateLabel_.setEnabled(false); - exportBitrateSlider_.setEnabled(false); - cancelButton_.setEnabled(false); + + 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); + 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); @@ -272,10 +349,24 @@ MainComponent::MainComponent() { reportEditor_.setReadOnly(true); reportEditor_.setScrollbarsShown(true); + cancelButton_.setEnabled(false); session_.sessionName = "Untitled Session"; + + refreshRenderers(); + refreshCodecAvailability(); + refreshModelPacks(); + populateMasterPresetSelectors(); + refreshStemRoutingSelectors(); + + transportController_.addChangeListener(this); + startTimerHz(20); + updateTransportDisplay(); } MainComponent::~MainComponent() { + stopTimer(); + transportController_.removeChangeListener(this); + importButton_.removeListener(this); originalMixButton_.removeListener(this); saveSessionButton_.removeListener(this); @@ -285,59 +376,96 @@ MainComponent::~MainComponent() { batchImportButton_.removeListener(this); previewOriginalButton_.removeListener(this); previewRenderedButton_.removeListener(this); + playPauseButton_.removeListener(this); + stopButton_.removeListener(this); addExternalRendererButton_.removeListener(this); + prefetchLameButton_.removeListener(this); exportButton_.removeListener(this); cancelButton_.removeListener(this); + + rendererBox_.removeListener(this); exportFormatBox_.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); + transportSlider_.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); - - importButton_.setBounds(top.removeFromLeft(110).reduced(2)); - originalMixButton_.setBounds(top.removeFromLeft(120).reduced(2)); - saveSessionButton_.setBounds(top.removeFromLeft(120).reduced(2)); - loadSessionButton_.setBounds(top.removeFromLeft(120).reduced(2)); - autoMixButton_.setBounds(top.removeFromLeft(110).reduced(2)); - autoMasterButton_.setBounds(top.removeFromLeft(120).reduced(2)); - batchImportButton_.setBounds(top.removeFromLeft(120).reduced(2)); - exportButton_.setBounds(top.removeFromLeft(110).reduced(2)); - cancelButton_.setBounds(top.removeFromLeft(90).reduced(2)); - previewOriginalButton_.setBounds(toolsRow.removeFromLeft(110).reduced(2)); - previewRenderedButton_.setBounds(toolsRow.removeFromLeft(110).reduced(2)); - separatedStemsToggle_.setBounds(toolsRow.removeFromLeft(170).reduced(2)); - rendererBox_.setBounds(toolsRow.removeFromLeft(240).reduced(2)); - addExternalRendererButton_.setBounds(toolsRow.removeFromLeft(180).reduced(2)); + auto waveformRow = area.removeFromTop(110); + auto transportRow = area.removeFromTop(24); + + importButton_.setBounds(top.removeFromLeft(98).reduced(2)); + originalMixButton_.setBounds(top.removeFromLeft(112).reduced(2)); + saveSessionButton_.setBounds(top.removeFromLeft(112).reduced(2)); + loadSessionButton_.setBounds(top.removeFromLeft(112).reduced(2)); + autoMixButton_.setBounds(top.removeFromLeft(95).reduced(2)); + autoMasterButton_.setBounds(top.removeFromLeft(110).reduced(2)); + batchImportButton_.setBounds(top.removeFromLeft(112).reduced(2)); + exportButton_.setBounds(top.removeFromLeft(90).reduced(2)); + cancelButton_.setBounds(top.removeFromLeft(85).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)); + 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)); 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(140).reduced(2)); - residualBlendSlider_.setBounds(blendRow.removeFromLeft(260).reduced(2)); + residualBlendLabel_.setBounds(blendRow.removeFromLeft(132).reduced(2)); + residualBlendSlider_.setBounds(blendRow.removeFromLeft(250).reduced(2)); - exportFormatLabel_.setBounds(exportRow.removeFromLeft(60).reduced(2)); + exportFormatLabel_.setBounds(exportRow.removeFromLeft(52).reduced(2)); exportFormatBox_.setBounds(exportRow.removeFromLeft(190).reduced(2)); exportBitrateLabel_.setBounds(exportRow.removeFromLeft(90).reduced(2)); exportBitrateSlider_.setBounds(exportRow.removeFromLeft(220).reduced(2)); + gpuProviderLabel_.setBounds(exportRow.removeFromLeft(84).reduced(2)); + gpuProviderBox_.setBounds(exportRow.removeFromLeft(150).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)); - aiModelsLabel_.setBounds(modelRow.removeFromLeft(70).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)); + transportSlider_.setBounds(transportRow.reduced(2)); + + statusLabel_.setBounds(area.removeFromTop(24).reduced(2)); + analysisTable_.setBounds(area.removeFromTop(180)); reportEditor_.setBounds(area.reduced(0, 6)); } @@ -387,11 +515,35 @@ void MainComponent::buttonClicked(juce::Button* button) { return; } + if (button == &playPauseButton_) { + if (transportController_.isPlaying()) { + transportController_.pause(); + previewEngine_.stop(); + } else { + transportController_.play(); + previewEngine_.play(); + } + updateTransportDisplay(); + return; + } + + if (button == &stopButton_) { + transportController_.stop(); + previewEngine_.stop(); + updateTransportDisplay(); + return; + } + if (button == &addExternalRendererButton_) { onAddExternalRenderer(); return; } + if (button == &prefetchLameButton_) { + onPrefetchLame(); + return; + } + if (button == &exportButton_) { onExport(); return; @@ -408,27 +560,118 @@ void MainComponent::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) { modelManager_.setActivePackId("role", it != roleModelIdByComboId_.end() ? it->second : "none"); return; } + if (comboBoxThatHasChanged == &mixModelBox_) { const auto it = mixModelIdByComboId_.find(mixModelBox_.getSelectedId()); modelManager_.setActivePackId("mix", it != mixModelIdByComboId_.end() ? it->second : "none"); return; } + if (comboBoxThatHasChanged == &masterModelBox_) { const auto it = masterModelIdByComboId_.find(masterModelBox_.getSelectedId()); modelManager_.setActivePackId("master", it != masterModelIdByComboId_.end() ? it->second : "none"); 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 int selected = exportFormatBox_.getSelectedId(); - const bool lossy = (selected == 4 || selected == 5); + 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; + }; + + const bool lossy = util::WavWriter::isLossyFormat(format); exportBitrateSlider_.setEnabled(lossy); exportBitrateLabel_.setEnabled(lossy); + + 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 == &soloStemBox_ || comboBoxThatHasChanged == &muteStemBox_) { + rebuildPreviewBuffers(); + return; } } void MainComponent::sliderValueChanged(juce::Slider* slider) { if (slider == &residualBlendSlider_) { session_.residualBlend = residualBlendSlider_.getValue(); + return; + } + + if (slider == &transportSlider_ && !ignoreTransportSliderChange_) { + transportController_.seekToFraction(transportSlider_.getValue()); + return; + } +} + +void MainComponent::timerCallback() { + if (transportController_.isPlaying()) { + const auto totalSamples = transportController_.totalSamples(); + const auto totalSeconds = transportController_.totalSeconds(); + if (totalSamples > 0 && totalSeconds > 0.0) { + const auto sampleRate = static_cast(totalSamples) / totalSeconds; + const int increment = std::max(1, static_cast(std::lround(sampleRate / 20.0))); + transportController_.advance(increment); + } + } + + updateTransportDisplay(); +} + +void MainComponent::changeListenerCallback(juce::ChangeBroadcaster* source) { + if (source == &transportController_) { + updateTransportDisplay(); } } @@ -478,9 +721,9 @@ void MainComponent::refreshModelPacks() { } } - 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"); @@ -490,25 +733,23 @@ std::vector MainComponent::loadConfiguredExte std::vector configs; #ifdef ENABLE_EXTERNAL_TOOL_SUPPORT const char* rawValue = std::getenv("AUTOMIX_EXTERNAL_RENDERERS"); - if (rawValue == nullptr || *rawValue == '\0') { - return configs; - } - - int index = 1; - for (const auto& item : splitDelimited(rawValue, ';')) { - const auto pieces = splitDelimited(item, '|'); - if (pieces.size() < 2) { - continue; - } + 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]; + 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); } - configs.push_back(config); } #endif configs.insert(configs.end(), userExternalRendererConfigs_.begin(), userExternalRendererConfigs_.end()); @@ -523,6 +764,7 @@ void MainComponent::refreshRenderers() { 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) { @@ -533,10 +775,324 @@ void MainComponent::refreshRenderers() { } rendererBox_.addItem(label, comboId); rendererIdByComboId_[comboId] = info.id; + + if (preferredId == 0 && info.available) { + preferredId = comboId; + } + if (info.id == session_.renderSettings.rendererName) { + preferredId = comboId; + } ++comboId; } - rendererBox_.setSelectedId(1); + 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); + exportFormatBox_.setTooltip(toJuceText(tooltipLines)); + + const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); + const std::string selectedFormat = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; + const bool lossy = util::WavWriter::isLossyFormat(selectedFormat); + exportBitrateSlider_.setEnabled(lossy); + exportBitrateLabel_.setEnabled(lossy); +} + +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() { + if (session_.stems.empty()) { + waveformPreview_.setBuffer(engine::AudioBuffer{}); + transportController_.setTimeline(0, 44100.0); + return; + } + + try { + 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(); + + 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; + } + } + } + + const auto previousProgress = transportController_.progress(); + const auto settings = buildCurrentRenderSettings(""); + + engine::OfflineRenderPipeline pipeline; + const auto raw = pipeline.renderRawMix(previewSession, settings, {}, nullptr); + if (raw.cancelled || raw.mixBuffer.getNumSamples() == 0) { + return; + } + + auto mastered = raw.mixBuffer; + if (session_.masterPlan.has_value()) { + mastered = autoMasterStrategy_.applyPlan(raw.mixBuffer, session_.masterPlan.value(), nullptr); + } + + previewEngine_.setBuffers(raw.mixBuffer, mastered); + const auto preview = previewEngine_.buildCrossfadedPreview(1024); + updateTransportFromBuffer(preview); + transportController_.seekToFraction(previousProgress); + } catch (const std::exception& error) { + reportEditor_.setText(reportEditor_.getText() + "\nPreview rebuild skipped: " + juce::String(error.what())); + } +} + +void MainComponent::rebuildPreviewBuffersAsync() { + if (session_.stems.empty()) { + waveformPreview_.setBuffer(engine::AudioBuffer{}); + transportController_.setTimeline(0, 44100.0); + return; + } + + 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); + std::thread([safeThis, + 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); + } + + engine::AudioPreviewEngine previewEngine; + previewEngine.setBuffers(raw.mixBuffer, mastered); + const auto preview = previewEngine.buildCrossfadedPreview(1024); + + juce::MessageManager::callAsync( + [safeThis, rawMix = raw.mixBuffer, mastered = std::move(mastered), preview = std::move(preview), previousProgress]() mutable { + if (safeThis == nullptr) { + return; + } + + safeThis->previewEngine_.setBuffers(rawMix, mastered); + safeThis->updateTransportFromBuffer(preview); + safeThis->transportController_.seekToFraction(previousProgress); + }); + } catch (const std::exception& error) { + const auto message = juce::String(error.what()); + juce::MessageManager::callAsync([safeThis, message]() { + if (safeThis == nullptr) { + return; + } + safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + "\nPreview rebuild skipped: " + message); + }); + } + }).detach(); +} + +void MainComponent::updateTransportFromBuffer(const engine::AudioBuffer& buffer) { + waveformPreview_.setBuffer(buffer); + waveformPreview_.setPlayheadProgress(0.0); + transportController_.setTimeline(buffer.getNumSamples(), buffer.getSampleRate()); + transportController_.stop(); + + 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); + + 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()); + transportSlider_.setTooltip(positionText + " / " + totalText); +} + +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 { @@ -547,27 +1103,34 @@ domain::RenderSettings MainComponent::buildCurrentRenderSettings(const std::stri settings.processingThreads = 0; settings.preferHardwareAcceleration = true; - switch (exportFormatBox_.getSelectedId()) { + const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); + settings.outputFormat = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; + if (!util::WavWriter::isFormatAvailable(settings.outputFormat)) { + settings.outputFormat = "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); + + switch (gpuProviderBox_.getSelectedId()) { case 2: - settings.outputFormat = "flac"; + settings.gpuExecutionProvider = "cpu"; break; case 3: - settings.outputFormat = "aiff"; + settings.gpuExecutionProvider = "directml"; break; case 4: - settings.outputFormat = "ogg"; + settings.gpuExecutionProvider = "coreml"; break; case 5: - settings.outputFormat = "mp3"; + settings.gpuExecutionProvider = "cuda"; break; default: - settings.outputFormat = "wav"; + settings.gpuExecutionProvider = "auto"; break; } - 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); if (!outputPath.empty()) { std::filesystem::path normalizedPath(outputPath); const auto requiredExtension = extensionForFormat(settings.outputFormat); @@ -605,34 +1168,72 @@ void MainComponent::onImport() { juce::FileBrowserComponent::canSelectMultipleItems; importChooser_->launchAsync(flags, [this](const juce::FileChooser& chooser) { - session_.stems.clear(); const auto files = chooser.getResults(); + if (files.isEmpty()) { + importChooser_.reset(); + return; + } - for (int i = 0; i < files.size(); ++i) { - const auto& file = files.getReference(i); + session_.stems.clear(); + std::vector importLines; - 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); + if (files.size() == 1 && separatedStemsToggle_.getToggleState()) { + try { + const auto selected = files.getReference(0); + const auto mixPath = std::filesystem::path(selected.getFullPathName().toStdString()); + const auto outputDir = mixPath.parent_path() / (mixPath.stem().string() + "_separated"); + + ai::StemSeparator separator; + const auto separationResult = separator.separate(mixPath, outputDir); + if (separationResult.success) { + session_.stems = separationResult.stems; + importLines.push_back("Separated import from: " + mixPath.string()); + 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); + } + 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())); + } + } + + if (session_.stems.empty()) { + 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; + stem.enabled = true; + session_.stems.push_back(stem); + + importLines.push_back(stem.name + " -> " + stem.filePath); + } } 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; - }())); + + refreshStemRoutingSelectors(); + reportEditor_.setText(juce::String("Imported files:\n") + toJuceText(importLines)); + rebuildPreviewBuffers(); importChooser_.reset(); }); @@ -675,6 +1276,7 @@ void MainComponent::onSaveSession() { } try { + session_.renderSettings = buildCurrentRenderSettings(session_.renderSettings.outputPath); sessionRepository_.save(selected.getFullPathName().toStdString(), session_); statusLabel_.setText("Session saved", juce::dontSendNotification); } catch (const std::exception& error) { @@ -700,11 +1302,45 @@ void MainComponent::onLoadSession() { try { session_ = sessionRepository_.load(selected.getFullPathName().toStdString()); residualBlendSlider_.setValue(session_.residualBlend, juce::dontSendNotification); + exportBitrateSlider_.setValue(session_.renderSettings.lossyBitrateKbps, 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); + } + + refreshRenderers(); + refreshCodecAvailability(); + 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; + } + } + analysisEntries_.clear(); analysisTableModel_.setEntries(&analysisEntries_); analysisTable_.updateContent(); + statusLabel_.setText("Session loaded", juce::dontSendNotification); reportEditor_.setText("Loaded session: " + selected.getFullPathName()); + rebuildPreviewBuffers(); } catch (const std::exception& error) { statusLabel_.setText("Load failed", juce::dontSendNotification); reportEditor_.setText("Session load error:\n" + juce::String(error.what())); @@ -715,21 +1351,35 @@ void MainComponent::onLoadSession() { } void MainComponent::onPreviewOriginal() { + if (transportController_.totalSamples() == 0) { + rebuildPreviewBuffers(); + } + + const auto progress = transportController_.progress(); previewEngine_.setSource(engine::PreviewSource::OriginalMix); - previewEngine_.play(); const auto preview = previewEngine_.buildCrossfadedPreview(1024); + updateTransportFromBuffer(preview); + transportController_.seekToFraction(progress); + transportController_.play(); + previewEngine_.play(); + statusLabel_.setText("Preview A selected", juce::dontSendNotification); - reportEditor_.setText(reportEditor_.getText() + - "\nPreview source: Original (" + juce::String(preview.getNumSamples()) + " samples)"); } void MainComponent::onPreviewRendered() { + if (transportController_.totalSamples() == 0) { + rebuildPreviewBuffers(); + } + + const auto progress = transportController_.progress(); previewEngine_.setSource(engine::PreviewSource::RenderedMix); - previewEngine_.play(); const auto preview = previewEngine_.buildCrossfadedPreview(1024); + updateTransportFromBuffer(preview); + transportController_.seekToFraction(progress); + transportController_.play(); + previewEngine_.play(); + statusLabel_.setText("Preview B selected", juce::dontSendNotification); - reportEditor_.setText(reportEditor_.getText() + - "\nPreview source: Rendered (" + juce::String(preview.getNumSamples()) + " samples)"); } void MainComponent::onAddExternalRenderer() { @@ -748,17 +1398,65 @@ void MainComponent::onAddExternalRenderer() { config.name = selected.getFileName().toStdString(); config.binaryPath = selected.getFullPathName().toStdString(); config.licenseId = "User-supplied"; + const auto validation = renderers::ExternalLimiterRenderer::validateBinary(config.binaryPath); userExternalRendererConfigs_.push_back(config); + refreshRenderers(); - statusLabel_.setText("External renderer added", juce::dontSendNotification); + const juce::String statusText = validation.valid ? "External renderer added" : "External renderer added (validation failed)"; + statusLabel_.setText(statusText, juce::dontSendNotification); reportEditor_.setText(reportEditor_.getText() + "\nAdded external renderer: " + selected.getFullPathName() + + "\nValidation: " + juce::String(validation.valid ? "passed" : "failed") + + " (" + juce::String(validation.diagnostics) + ")" + "\nLicense note: user-supplied tool is not distributed by this app."); 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); + std::thread([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); + } else { + safeThis->statusLabel_.setText("LAME prefetch failed", juce::dontSendNotification); + } + + 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); + }); + }).detach(); +} + 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); @@ -767,94 +1465,298 @@ void MainComponent::updateMeterPanel(const automaster::MasteringReport& report) } 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(); + cancelRender_.store(false); + taskRunning_.store(true); + cancelButton_.setEnabled(true); + statusLabel_.setText("Auto Mix started", juce::dontSendNotification); + + const auto sessionSnapshot = session_; + std::optional mixPack; + if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("mix")); selected != nullptr) { + mixPack = *selected; + } - const auto heuristicPlan = autoMixStrategy_.buildPlan(session_, analysisEntries_, 1.0); - session_.mixPlan = heuristicPlan; + juce::Component::SafePointer safeThis(this); + std::thread([safeThis, sessionSnapshot, mixPack]() mutable { + std::vector analysisEntries; + std::optional plan; + juce::String reportText; + juce::String errorText; + bool cancelled = false; - ai::AutoMixStrategyAI aiMix; - const auto* mixPack = findPackById(modelManager_, modelManager_.activePackId("mix")); - auto inference = createInferenceBackend(mixPack); - if (inference != nullptr) { - session_.mixPlan = aiMix.buildPlan(session_, analysisEntries_, heuristicPlan, inference.get()); - session_.mixPlan->decisionLog.push_back("AI pack: " + mixPack->id + " license=" + mixPack->licenseId); - } + auto updateStatus = [safeThis](const juce::String& text) { + if (safeThis == nullptr) { + return; + } + juce::MessageManager::callAsync([safeThis, text]() { + if (safeThis == nullptr) { + return; + } + safeThis->statusLabel_.setText(text, juce::dontSendNotification); + }); + }; + + try { + updateStatus("Auto Mix: analyzing..."); + analysis::StemAnalyzer analyzer; + analysisEntries = analyzer.analyzeSession(sessionSnapshot); + + if (safeThis != nullptr && safeThis->cancelRender_.load()) { + cancelled = true; + } - 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)); + if (!cancelled) { + updateStatus("Auto Mix: building plan..."); + automix::HeuristicAutoMixStrategy heuristicMix; + const auto heuristicPlan = heuristicMix.buildPlan(sessionSnapshot, analysisEntries, 1.0); + plan = heuristicPlan; + + ai::AutoMixStrategyAI aiMix; + std::string backendDiagnostics; + std::unique_ptr inference; + if (mixPack.has_value()) { + inference = createInferenceBackend(&mixPack.value(), sessionSnapshot.renderSettings.gpuExecutionProvider, &backendDiagnostics); + } + + if (inference != nullptr) { + auto aiPlan = aiMix.buildPlan(sessionSnapshot, 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"; + } + + juce::MessageManager::callAsync( + [safeThis, + analysisEntries = std::move(analysisEntries), + plan = std::move(plan), + reportText, + errorText, + cancelled]() mutable { + if (safeThis == nullptr) { + return; + } + + safeThis->taskRunning_.store(false); + safeThis->cancelButton_.setEnabled(false); + + if (!errorText.isEmpty()) { + safeThis->statusLabel_.setText("Auto Mix failed", juce::dontSendNotification); + safeThis->reportEditor_.setText(errorText); + return; + } + + if (cancelled || safeThis->cancelRender_.load()) { + safeThis->statusLabel_.setText("Auto Mix cancelled", juce::dontSendNotification); + return; + } + + safeThis->analysisEntries_ = std::move(analysisEntries); + safeThis->analysisTableModel_.setEntries(&safeThis->analysisEntries_); + safeThis->analysisTable_.updateContent(); + + if (plan.has_value()) { + safeThis->session_.mixPlan = plan.value(); + } + + if (!reportText.isEmpty()) { + safeThis->reportEditor_.setText(reportText); + } + + safeThis->statusLabel_.setText("Auto Mix plan generated", juce::dontSendNotification); + safeThis->rebuildPreviewBuffersAsync(); + }); + }).detach(); } 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; } cancelRender_.store(false); + taskRunning_.store(true); + cancelButton_.setEnabled(true); + statusLabel_.setText("Auto Master started", juce::dontSendNotification); - domain::RenderSettings settings; - settings.outputSampleRate = 44100; - settings.blockSize = 1024; + auto preset = selectedPlatformPreset(); + if (preset == domain::MasterPreset::Custom) { + preset = selectedMasterPreset(); + } - engine::OfflineRenderPipeline pipeline; - auto rawMix = pipeline.renderRawMix(session_, settings, {}, &cancelRender_); - if (rawMix.cancelled) { - statusLabel_.setText("Auto Master cancelled", juce::dontSendNotification); - return; + const auto settings = buildCurrentRenderSettings(""); + const auto sessionSnapshot = session_; + std::optional masterPack; + if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("master")); selected != nullptr) { + masterPack = *selected; } - session_.masterPlan = autoMasterStrategy_.buildPlan(domain::MasterPreset::DefaultStreaming, rawMix.mixBuffer); - if (session_.originalMixPath.has_value()) { + juce::Component::SafePointer safeThis(this); + std::thread([safeThis, sessionSnapshot, settings, preset, masterPack]() mutable { + domain::MasterPlan masterPlan; + engine::AudioBuffer rawMixBuffer; + engine::AudioBuffer previewMaster; + automaster::MasteringReport previewReport; + juce::String reportAppend; + juce::String errorText; + bool cancelled = false; + 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()); + engine::OfflineRenderPipeline pipeline; + std::atomic_bool* cancelPtr = nullptr; + if (safeThis != nullptr) { + cancelPtr = &safeThis->cancelRender_; + } + + const auto rawMix = pipeline.renderRawMix( + sessionSnapshot, + settings, + [safeThis](const engine::RenderProgress& progress) { + if (safeThis == nullptr) { + return; + } + juce::MessageManager::callAsync([safeThis, progress]() { + if (safeThis == nullptr) { + return; + } + safeThis->statusLabel_.setText("Auto Master: " + juce::String(progress.stage) + " " + + juce::String(progress.fraction * 100.0, 1) + "%", + juce::dontSendNotification); + }); + }, + cancelPtr); + + if (rawMix.cancelled) { + cancelled = true; + } else { + rawMixBuffer = rawMix.mixBuffer; } - automaster::OriginalMixReference referenceTarget; - session_.masterPlan = referenceTarget.applySoftTarget(session_.masterPlan.value(), - rawMix.mixBuffer, - originalMix, - autoMasterStrategy_, - analyzer_); + if (!cancelled) { + automaster::HeuristicAutoMasterStrategy autoMasterStrategy; + analysis::StemAnalyzer analyzer; + masterPlan = autoMasterStrategy.buildPlan(preset, rawMixBuffer); + + if (sessionSnapshot.originalMixPath.has_value()) { + try { + engine::AudioFileIO fileIO; + engine::AudioResampler resampler; + auto originalMix = fileIO.readAudioFile(sessionSnapshot.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); + } + } + + previewMaster = autoMasterStrategy.applyPlan(rawMixBuffer, masterPlan, &previewReport); + if (masterInference != nullptr) { + previewMaster = aiMaster.applyPlan(rawMixBuffer, masterPlan, autoMasterStrategy, &previewReport); + } + + reportAppend += "\nMaster decisions:\n" + toJuceText(masterPlan.decisionLog); + } } catch (const std::exception& error) { - reportEditor_.setText(reportEditor_.getText() + "\nOriginal mix target skipped: " + juce::String(error.what())); + errorText = "Auto Master failed:\n" + juce::String(error.what()); + } catch (...) { + errorText = "Auto Master failed:\nUnknown error"; } - } - const auto* masterPack = findPackById(modelManager_, modelManager_.activePackId("master")); - auto masterInference = createInferenceBackend(masterPack); - ai::AutoMasterStrategyAI aiMaster; - if (masterInference != nullptr) { - const auto mixMetrics = analyzer_.analyzeBuffer(rawMix.mixBuffer); - session_.masterPlan = - aiMaster.buildPlan(mixMetrics, session_.masterPlan.value(), masterInference.get()); - session_.masterPlan->decisionLog.push_back("AI pack: " + masterPack->id + " license=" + masterPack->licenseId); - } + juce::MessageManager::callAsync([safeThis, + masterPlan = std::move(masterPlan), + rawMixBuffer = std::move(rawMixBuffer), + previewMaster = std::move(previewMaster), + previewReport, + reportAppend, + errorText, + cancelled]() mutable { + if (safeThis == nullptr) { + return; + } - automaster::MasteringReport previewReport; - auto previewMaster = autoMasterStrategy_.applyPlan(rawMix.mixBuffer, session_.masterPlan.value(), &previewReport); - if (masterInference != nullptr) { - previewMaster = aiMaster.applyPlan(rawMix.mixBuffer, session_.masterPlan.value(), autoMasterStrategy_, &previewReport); - } - previewEngine_.setBuffers(rawMix.mixBuffer, previewMaster); - previewEngine_.setSource(engine::PreviewSource::OriginalMix); - previewEngine_.stop(); - updateMeterPanel(previewReport); + safeThis->taskRunning_.store(false); + safeThis->cancelButton_.setEnabled(false); + + if (!errorText.isEmpty()) { + safeThis->statusLabel_.setText("Auto Master failed", juce::dontSendNotification); + safeThis->reportEditor_.setText(errorText); + return; + } - statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); - reportEditor_.setText(reportEditor_.getText() + "\nMaster decisions:\n" + toJuceText(session_.masterPlan->decisionLog)); + if (cancelled) { + safeThis->statusLabel_.setText("Auto Master cancelled", juce::dontSendNotification); + return; + } + + safeThis->session_.masterPlan = std::move(masterPlan); + safeThis->previewEngine_.setBuffers(rawMixBuffer, previewMaster); + safeThis->previewEngine_.setSource(engine::PreviewSource::OriginalMix); + safeThis->previewEngine_.stop(); + safeThis->updateTransportFromBuffer(safeThis->previewEngine_.buildCrossfadedPreview(1024)); + safeThis->updateMeterPanel(previewReport); + + safeThis->statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); + if (!reportAppend.isEmpty()) { + safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + reportAppend); + } + }); + }).detach(); } void MainComponent::onBatchImport() { @@ -877,6 +1779,7 @@ void MainComponent::onBatchImport() { const std::filesystem::path outputFolder = inputFolder / "automix_batch_exports"; std::filesystem::create_directories(outputFolder); const auto baseRenderSettings = buildCurrentRenderSettings(""); + cancelRender_.store(false); cancelButton_.setEnabled(true); taskRunning_.store(true); @@ -909,6 +1812,7 @@ void MainComponent::onBatchImport() { if (safeThis != nullptr) { cancelPtr = &safeThis->cancelRender_; } + const auto result = runner.process( job, [safeThis](const size_t itemIndex, const double progress, const std::string& stage) { @@ -988,6 +1892,7 @@ void MainComponent::onExport() { auto settings = buildCurrentRenderSettings(selected.getFullPathName().toStdString()); const auto sessionCopy = session_; + cancelRender_.store(false); taskRunning_.store(true); cancelButton_.setEnabled(true); @@ -1003,7 +1908,22 @@ void MainComponent::onExport() { if (safeThis != nullptr) { cancelPtr = &safeThis->cancelRender_; } - renderResult = renderer->render(sessionCopy, settings, {}, cancelPtr); + renderResult = renderer->render( + sessionCopy, + settings, + [safeThis](const double progress, const std::string& stage) { + if (safeThis == nullptr) { + return; + } + juce::MessageManager::callAsync([safeThis, progress, stage]() { + if (safeThis == nullptr) { + return; + } + safeThis->statusLabel_.setText("Export: " + juce::String(stage) + " (" + juce::String(progress * 100.0, 1) + "%)", + juce::dontSendNotification); + }); + }, + cancelPtr); } catch (const std::exception& error) { crashMessage = "Export exception:\n" + juce::String(error.what()); } catch (...) { @@ -1014,17 +1934,21 @@ void MainComponent::onExport() { if (safeThis == nullptr) { return; } + safeThis->taskRunning_.store(false); safeThis->cancelButton_.setEnabled(false); + if (!crashMessage.isEmpty()) { safeThis->statusLabel_.setText("Export crashed", juce::dontSendNotification); safeThis->reportEditor_.setText(crashMessage); return; } + if (renderResult.cancelled) { safeThis->statusLabel_.setText("Export cancelled", juce::dontSendNotification); return; } + safeThis->statusLabel_.setText(renderResult.success ? "Export complete" : "Export failed", juce::dontSendNotification); safeThis->reportEditor_.setText(juce::String("Renderer: ") + juce::String(renderResult.rendererName) + diff --git a/src/app/MainComponent.h b/src/app/MainComponent.h index e757bee..4d789af 100644 --- a/src/app/MainComponent.h +++ b/src/app/MainComponent.h @@ -3,17 +3,21 @@ #include #include #include +#include #include #include #include "analysis/StemAnalyzer.h" #include "ai/ModelManager.h" +#include "app/WaveformPreviewComponent.h" #include "automaster/HeuristicAutoMasterStrategy.h" #include "automix/HeuristicAutoMixStrategy.h" +#include "domain/MasterPlan.h" #include "domain/Session.h" #include "engine/AudioPreviewEngine.h" #include "engine/SessionRepository.h" +#include "engine/TransportController.h" #include "renderers/RendererRegistry.h" namespace automix::app { @@ -21,7 +25,9 @@ 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 { public: MainComponent(); ~MainComponent() override; @@ -52,6 +58,8 @@ 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 onImport(); void onImportOriginalMix(); @@ -65,9 +73,22 @@ class MainComponent final : public juce::Component, void onPreviewOriginal(); void onPreviewRendered(); void onAddExternalRenderer(); + void onPrefetchLame(); + void updateMeterPanel(const automaster::MasteringReport& report); void refreshModelPacks(); void refreshRenderers(); + void refreshCodecAvailability(); + void refreshStemRoutingSelectors(); + void rebuildPreviewBuffers(); + void rebuildPreviewBuffersAsync(); + void updateTransportFromBuffer(const engine::AudioBuffer& buffer); + void updateTransportDisplay(); + void populateMasterPresetSelectors(); + + domain::MasterPreset selectedMasterPreset() const; + domain::MasterPreset selectedPlatformPreset() const; + domain::RenderSettings buildCurrentRenderSettings(const std::string& outputPath) const; std::vector loadConfiguredExternalRenderers() const; @@ -80,7 +101,10 @@ class MainComponent final : public juce::Component, 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 addExternalRendererButton_ {"Add External Limiter"}; + juce::TextButton prefetchLameButton_ {"Prefetch LAME"}; juce::TextButton exportButton_ {"Export"}; juce::TextButton cancelButton_ {"Cancel"}; juce::ToggleButton separatedStemsToggle_ {"AI-separated stems"}; @@ -91,6 +115,17 @@ class MainComponent final : public juce::Component, juce::ComboBox exportFormatBox_; juce::Label exportBitrateLabel_ {"exportBitrateLabel", "Lossy kbps"}; juce::Slider exportBitrateSlider_; + 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 aiModelsLabel_ {"aiModelsLabel", "AI Models"}; juce::ComboBox roleModelBox_; juce::ComboBox mixModelBox_; @@ -99,6 +134,7 @@ class MainComponent final : public juce::Component, 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_; @@ -109,6 +145,7 @@ class MainComponent final : public juce::Component, automaster::HeuristicAutoMasterStrategy autoMasterStrategy_; engine::SessionRepository sessionRepository_; engine::AudioPreviewEngine previewEngine_; + engine::TransportController transportController_; ai::ModelManager modelManager_; std::vector analysisEntries_; std::vector rendererInfos_; @@ -117,8 +154,14 @@ class MainComponent final : public juce::Component, std::map roleModelIdByComboId_; std::map mixModelIdByComboId_; std::map masterModelIdByComboId_; + std::map masterPresetByComboId_; + std::map platformPresetByComboId_; + std::map codecFormatByComboId_; + std::map stemIdBySoloComboId_; + std::map stemIdByMuteComboId_; std::atomic_bool cancelRender_ {false}; std::atomic_bool taskRunning_ {false}; + bool ignoreTransportSliderChange_ = false; std::unique_ptr importChooser_; std::unique_ptr originalMixChooser_; std::unique_ptr exportChooser_; diff --git a/src/app/WaveformPreviewComponent.cpp b/src/app/WaveformPreviewComponent.cpp new file mode 100644 index 0000000..e97e370 --- /dev/null +++ b/src/app/WaveformPreviewComponent.cpp @@ -0,0 +1,75 @@ +#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(128, getWidth() > 0 ? getWidth() : 512); + 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::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; + + g.setColour(juce::Colour::fromRGB(65, 180, 255).withAlpha(0.8f)); + const int columns = static_cast(waveform_.size()); + for (int x = 0; x < columns; ++x) { + const float value = std::clamp(waveform_[static_cast(x)], 0.0f, 1.0f); + const float h = value * halfHeight; + g.drawVerticalLine(x, centerY - h, centerY + h); + } + + const int playheadX = static_cast(std::round(playheadProgress_ * static_cast(columns - 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..066a28e --- /dev/null +++ b/src/app/WaveformPreviewComponent.h @@ -0,0 +1,23 @@ +#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 paint(juce::Graphics& g) override; + + private: + std::vector waveform_; + double playheadProgress_ = 0.0; +}; + +} // namespace automix::app diff --git a/src/automaster/HeuristicAutoMasterStrategy.cpp b/src/automaster/HeuristicAutoMasterStrategy.cpp index ddf62ce..7806fb4 100644 --- a/src/automaster/HeuristicAutoMasterStrategy.cpp +++ b/src/automaster/HeuristicAutoMasterStrategy.cpp @@ -2,14 +2,21 @@ #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" @@ -23,6 +30,12 @@ constexpr double kTonalTiltDb = 1.0; constexpr double kLoudnessToleranceLu = 0.5; constexpr int kMaxLoudnessIterations = 5; +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) { @@ -171,26 +184,105 @@ double computeMonoCorrelation(const engine::AudioBuffer& buffer) { 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 domain::MasterPlan HeuristicAutoMasterStrategy::buildPlan(const domain::MasterPreset preset, const engine::AudioBuffer& mixBuffer) const { domain::MasterPlan plan; plan.preset = preset; + plan.presetName = domain::toString(preset); switch (preset) { case domain::MasterPreset::DefaultStreaming: - plan.presetName = "DefaultStreaming"; plan.targetLufs = -14.0; plan.truePeakDbtp = -1.0; break; case domain::MasterPreset::Broadcast: - plan.presetName = "Broadcast"; plan.targetLufs = -23.0; plan.truePeakDbtp = -1.0; break; case domain::MasterPreset::UdioOptimized: - plan.presetName = "UdioOptimized"; plan.targetLufs = -14.0; plan.truePeakDbtp = -1.0; plan.applyEq = true; @@ -201,12 +293,50 @@ domain::MasterPlan HeuristicAutoMasterStrategy::buildPlan(const domain::MasterPr 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.presetName = "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); analysis::StemAnalyzer analyzer; const auto mixMetrics = analyzer.analyzeBuffer(mixBuffer); @@ -230,6 +360,9 @@ domain::MasterPlan HeuristicAutoMasterStrategy::buildPlan(const domain::MasterPr 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; } @@ -242,7 +375,15 @@ engine::AudioBuffer HeuristicAutoMasterStrategy::applyPlan(const engine::AudioBu 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, kTonalTiltDb); activeModules.push_back("TonalTiltEq"); diff --git a/src/automix/HeuristicAutoMixStrategy.cpp b/src/automix/HeuristicAutoMixStrategy.cpp index 1dcd545..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" @@ -65,6 +67,36 @@ int dominantBand(const analysis::AnalysisResult& metrics) { 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, @@ -100,17 +132,24 @@ 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); 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); } @@ -120,11 +159,25 @@ domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& sessi 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; @@ -141,9 +194,11 @@ domain::MixPlan HeuristicAutoMixStrategy::buildPlan(const domain::Session& sessi plan.decisionLog.push_back("Stem '" + stem.name + "' role=" + domain::toString(role) + " origin=" + domain::toString(stem.origin) + - " artifactRisk=" + std::to_string(artifactRisk) + + " 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) + + " 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) + diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index 68e383e..6a75b0c 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -15,6 +15,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 +36,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) { @@ -52,6 +70,7 @@ void to_json(Json& j, const RenderSettings& value) { {"outputBitDepth", value.outputBitDepth}, {"outputPath", value.outputPath}, {"outputFormat", value.outputFormat}, + {"gpuExecutionProvider", value.gpuExecutionProvider}, {"lossyBitrateKbps", value.lossyBitrateKbps}, {"lossyQuality", value.lossyQuality}, {"processingThreads", value.processingThreads}, @@ -67,6 +86,7 @@ void from_json(const Json& j, RenderSettings& value) { value.outputBitDepth = j.value("outputBitDepth", 24); value.outputPath = j.value("outputPath", ""); value.outputFormat = j.value("outputFormat", "auto"); + value.gpuExecutionProvider = j.value("gpuExecutionProvider", "auto"); value.lossyBitrateKbps = std::clamp(j.value("lossyBitrateKbps", 192), 48, 512); value.lossyQuality = std::clamp(j.value("lossyQuality", 7), 0, 10); value.processingThreads = std::max(0, j.value("processingThreads", 0)); @@ -120,6 +140,40 @@ 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}, @@ -144,6 +198,8 @@ void to_json(Json& j, const MasterPlan& value) { {"stereoWidth", value.stereoWidth}, {"enableSoftClipper", value.enableSoftClipper}, {"softClipDrive", value.softClipDrive}, + {"enableMultibandCompressor", value.enableMultibandCompressor}, + {"multibandSettings", value.multibandSettings}, {"decisionLog", value.decisionLog}}; } @@ -171,6 +227,8 @@ void from_json(const Json& j, MasterPlan& value) { 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{}); } diff --git a/src/domain/MasterPlan.cpp b/src/domain/MasterPlan.cpp index a879b48..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) { @@ -10,6 +23,18 @@ std::string toString(const MasterPreset preset) { 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"; } @@ -17,13 +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 == "udio_optimized") { + if (text == "udio_optimized") { return MasterPreset::UdioOptimized; } - if (value == "custom") { + 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 ce814f7..7195a7f 100644 --- a/src/domain/MasterPlan.h +++ b/src/domain/MasterPlan.h @@ -5,7 +5,38 @@ namespace automix::domain { -enum class MasterPreset { DefaultStreaming, Broadcast, UdioOptimized, 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; @@ -31,6 +62,8 @@ struct MasterPlan { double stereoWidth = 1.0; bool enableSoftClipper = false; double softClipDrive = 1.15; + bool enableMultibandCompressor = false; + MultibandSettings multibandSettings; std::vector decisionLog; }; diff --git a/src/domain/RenderSettings.h b/src/domain/RenderSettings.h index ffad815..28bb15b 100644 --- a/src/domain/RenderSettings.h +++ b/src/domain/RenderSettings.h @@ -10,6 +10,7 @@ struct RenderSettings { int outputBitDepth = 24; std::string outputPath; std::string outputFormat = "auto"; + std::string gpuExecutionProvider = "auto"; int lossyBitrateKbps = 192; int lossyQuality = 7; int processingThreads = 0; 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/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/engine/TransportController.cpp b/src/engine/TransportController.cpp new file mode 100644 index 0000000..6ca1406 --- /dev/null +++ b/src/engine/TransportController.cpp @@ -0,0 +1,139 @@ +#include "engine/TransportController.h" + +#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_); + if (totalSamples_ == 0) { + positionSamples_ = 0; + 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 (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; + } + positionSamples_ = std::min(totalSamples_, positionSamples_ + static_cast(numSamples)); + changed = true; + if (positionSamples_ >= totalSamples_) { + state_ = State::Paused; + } + } + + if (changed) { + 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); +} + +} // namespace automix::engine diff --git a/src/engine/TransportController.h b/src/engine/TransportController.h new file mode 100644 index 0000000..523fe88 --- /dev/null +++ b/src/engine/TransportController.h @@ -0,0 +1,42 @@ +#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); + + [[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; + + private: + mutable std::mutex mutex_; + int64_t totalSamples_ = 0; + int64_t positionSamples_ = 0; + double sampleRate_ = 44100.0; + State state_ = State::Stopped; +}; + +} // namespace automix::engine diff --git a/src/renderers/ExternalLimiterRenderer.cpp b/src/renderers/ExternalLimiterRenderer.cpp index 28cf051..5654db8 100644 --- a/src/renderers/ExternalLimiterRenderer.cpp +++ b/src/renderers/ExternalLimiterRenderer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include @@ -49,8 +50,160 @@ std::string captureChildOutput(juce::ChildProcess& process, const size_t maxByte 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, @@ -67,6 +220,15 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, 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( @@ -79,7 +241,11 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, cancelFlag); if (rawResult.cancelled) { - return RenderResult{.cancelled = true, .rendererName = "ExternalLimiter", .logs = rawResult.logs}; + 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; @@ -143,7 +309,10 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, while (process.isRunning()) { if (cancelFlag != nullptr && cancelFlag->load()) { process.kill(); - return RenderResult{.cancelled = true, .rendererName = "ExternalLimiter"}; + RenderResult cancelledResult; + cancelledResult.cancelled = true; + cancelledResult.rendererName = "ExternalLimiter"; + return cancelledResult; } const auto elapsed = std::chrono::duration_cast(std::chrono::steady_clock::now() - start).count(); @@ -189,6 +358,8 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, nlohmann::json report = { {"renderer", "ExternalLimiter"}, {"binaryPath", binaryPath.string()}, + {"validatedVersion", validation.version}, + {"validatedFeatures", validation.supportedFeatures}, {"outputAudioPath", outputPath.string()}, {"processExitCode", exitCode}, {"integratedLufs", complianceReport.integratedLufs}, @@ -231,6 +402,7 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, 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."); diff --git a/src/renderers/ExternalLimiterRenderer.h b/src/renderers/ExternalLimiterRenderer.h index b69ef85..c1f6b2e 100644 --- a/src/renderers/ExternalLimiterRenderer.h +++ b/src/renderers/ExternalLimiterRenderer.h @@ -1,11 +1,25 @@ #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, diff --git a/src/renderers/RendererFactory.cpp b/src/renderers/RendererFactory.cpp index c05e893..3a86ff4 100644 --- a/src/renderers/RendererFactory.cpp +++ b/src/renderers/RendererFactory.cpp @@ -7,14 +7,21 @@ 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(); } + if (preferredRenderer == "ExternalLimiter" || preferredRenderer.rfind("ExternalUser", 0) == 0) { return std::make_unique(); } - 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 index 67487ac..6d5e492 100644 --- a/src/renderers/RendererRegistry.cpp +++ b/src/renderers/RendererRegistry.cpp @@ -7,6 +7,7 @@ #include +#include "renderers/ExternalLimiterRenderer.h" #include "renderers/PhaseLimiterDiscovery.h" namespace automix::renderers { @@ -18,33 +19,32 @@ bool hasBinary(const std::filesystem::path& path) { } RendererInfo makeBuiltInInfo() { - return RendererInfo{ - .id = "BuiltIn", - .name = "BuiltIn", - .version = "internal", - .licenseId = "Project", - .linkMode = RendererLinkMode::InProcess, - .bundledByDefault = true, - .available = true, - .discovery = "Always available (core renderer).", - }; + 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)."; + return info; } RendererInfo makePhaseLimiterInfo() { PhaseLimiterDiscovery discovery; const auto binaryInfo = discovery.find(); - RendererInfo info{ - .id = "PhaseLimiter", - .name = "PhaseLimiter", - .version = "external", - .licenseId = "See assets/phaselimiter/licenses", - .linkMode = RendererLinkMode::External, - .bundledByDefault = false, - .available = binaryInfo.has_value(), - .discovery = binaryInfo.has_value() ? "Auto-discovered in assets or PHASELIMITER_BIN." - : "Not found in assets. Set PHASELIMITER_BIN or install under assets.", - }; + 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."; if (binaryInfo.has_value()) { info.binaryPath = binaryInfo->executablePath; @@ -61,10 +61,26 @@ RendererInfo makeExternalInfo(const ExternalRendererConfig& config) { info.licenseId = config.licenseId; info.linkMode = RendererLinkMode::External; info.bundledByDefault = config.bundledByDefault; - info.available = hasBinary(config.binaryPath); info.binaryPath = config.binaryPath; - info.discovery = info.available ? "User-supplied external binary path." - : "Configured path is missing or not executable."; + + 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; + } + + if (validation.valid) { + info.discovery = "External limiter validated (--validate contract)."; + } else { + info.discovery = "Validation failed [" + validation.errorCode + "]: " + validation.diagnostics; + } + return info; } diff --git a/src/util/LameDownloader.cpp b/src/util/LameDownloader.cpp new file mode 100644 index 0000000..8f38dee --- /dev/null +++ b/src/util/LameDownloader.cpp @@ -0,0 +1,910 @@ +#include "util/LameDownloader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +namespace automix::util { +namespace { + +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 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 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; +} + +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"; +} + +bool isRegularFile(const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error) && !error; +} + +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/WavWriter.cpp b/src/util/WavWriter.cpp index 2872d2d..b0a2ee7 100644 --- a/src/util/WavWriter.cpp +++ b/src/util/WavWriter.cpp @@ -2,14 +2,20 @@ #include #include +#include #include #include #include #include #include +#include +#include #include #include +#include + +#include "util/LameDownloader.h" namespace automix::util { namespace { @@ -28,11 +34,170 @@ int qualityIndexFromRequested(const juce::StringArray& options, const int reques return std::clamp(requestedQuality, 0, options.size() - 1); } +std::vector lameExecutableNames() { +#if defined(_WIN32) + return {"lame.exe", "lame"}; +#else + return {"lame"}; +#endif +} + +bool isRegularFile(const std::filesystem::path& path) { + std::error_code error; + return std::filesystem::is_regular_file(path, error) && !error; +} + +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(env); - std::error_code error; - if (std::filesystem::is_regular_file(candidate, error) && !error) { + const std::filesystem::path candidate(trimPathToken(env)); + if (isRegularFile(candidate)) { return candidate; } } @@ -44,22 +209,21 @@ std::optional findLameExecutable() { #if defined(_WIN32) constexpr char delimiter = ';'; - const std::vector names = {"lame.exe", "lame"}; #else constexpr char delimiter = ':'; - const std::vector names = {"lame"}; #endif + const auto names = lameExecutableNames(); std::stringstream stream(rawPath); std::string token; while (std::getline(stream, token, delimiter)) { - if (token.empty()) { + const auto entry = trimPathToken(token); + if (entry.empty()) { continue; } for (const auto& name : names) { - const auto candidate = std::filesystem::path(token) / name; - std::error_code error; - if (std::filesystem::is_regular_file(candidate, error) && !error) { + const auto candidate = std::filesystem::path(entry) / name; + if (isRegularFile(candidate)) { return candidate; } } @@ -88,92 +252,102 @@ std::string extensionFormat(const std::filesystem::path& path) { return ""; } -} // 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"; +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 preferred; } - - const auto fromExtension = extensionFormat(path); - return fromExtension.empty() ? "wav" : fromExtension; + return false; } -bool WavWriter::isLossyFormat(const std::string& format) { - const auto normalized = toLower(format); - return normalized == "mp3" || normalized == "ogg"; -} - -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 { - 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()); - } - - const auto format = resolveFormat(path, preferredFormat); - const int outputBitDepth = std::clamp(bitDepth, 16, 32); - const int bitrate = std::clamp(lossyBitrateKbps, 48, 512); - const int quality = std::clamp(lossyQuality, 0, 10); - +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, + std::string* detail) { juce::StringPairArray metadata; metadata.set("bitrate", juce::String(bitrate)); std::unique_ptr writer; - if (format == "wav") { + const auto normalized = toLower(format); + + if (normalized == "wav") { juce::WavAudioFormat wav; - writer.reset(wav.createWriterFor(stream.get(), - buffer.getSampleRate(), - static_cast(buffer.getNumChannels()), + writer.reset(wav.createWriterFor(stream, + sampleRate, + static_cast(channels), outputBitDepth, metadata, 0)); - } else if (format == "aiff") { + if (writer == nullptr && detail != nullptr) { + *detail = "WAV writer creation failed."; + } + return writer; + } + + if (normalized == "aiff") { juce::AiffAudioFormat aiff; - writer.reset(aiff.createWriterFor(stream.get(), - buffer.getSampleRate(), - static_cast(buffer.getNumChannels()), + writer.reset(aiff.createWriterFor(stream, + sampleRate, + static_cast(channels), outputBitDepth, metadata, 0)); - } else if (format == "flac") { + if (writer == nullptr && detail != nullptr) { + *detail = "AIFF writer creation failed."; + } + return writer; + } + + if (normalized == "flac") { juce::FlacAudioFormat flac; - writer.reset(flac.createWriterFor(stream.get(), - buffer.getSampleRate(), - static_cast(buffer.getNumChannels()), + writer.reset(flac.createWriterFor(stream, + sampleRate, + static_cast(channels), std::clamp(outputBitDepth, 16, 24), metadata, 0)); - } else if (format == "ogg") { + 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.get(), - buffer.getSampleRate(), - static_cast(buffer.getNumChannels()), + 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 - } else if (format == "mp3") { + return writer; + } + + if (normalized == "mp3") { #if defined(JUCE_USE_MP3AUDIOFORMAT) && JUCE_USE_MP3AUDIOFORMAT juce::MP3AudioFormat mp3; - writer.reset(mp3.createWriterFor(stream.get(), - buffer.getSampleRate(), - static_cast(buffer.getNumChannels()), + writer.reset(mp3.createWriterFor(stream, + sampleRate, + static_cast(channels), 0, metadata, qualityIndexFromRequested(mp3.getQualityOptions(), quality))); @@ -183,25 +357,34 @@ void WavWriter::write(const std::filesystem::path& path, const auto lamePath = findLameExecutable(); if (lamePath.has_value()) { juce::LAMEEncoderAudioFormat lame(mp3, juce::File(lamePath->string())); - writer.reset(lame.createWriterFor(stream.get(), - buffer.getSampleRate(), - static_cast(buffer.getNumChannels()), + 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 - } else { - throw std::runtime_error("Unsupported output format '" + format + "' for: " + path.string()); + return writer; } - if (writer == nullptr) { - throw std::runtime_error("Failed to create audio writer (format=" + format + ") for: " + path.string()); + if (detail != nullptr) { + *detail = "Unsupported format '" + format + "'."; } - stream.release(); + 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); @@ -209,7 +392,240 @@ void WavWriter::write(const std::filesystem::path& path, 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, + std::string* detail) { + juce::StringArray command; + command.add(lamePath.string()); + command.add("--silent"); + command.add("-b"); + command.add(std::to_string(std::clamp(bitrateKbps, 48, 320))); + command.add("-q"); + command.add(std::to_string(lameQualityPreset(quality))); + 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 { + 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; + auto writer = createWriterForFormat(format, + stream.get(), + buffer.getSampleRate(), + buffer.getNumChannels(), + outputBitDepth, + bitrate, + quality, + &detail); + + 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, &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 3d46cde..8ea8eec 100644 --- a/src/util/WavWriter.h +++ b/src/util/WavWriter.h @@ -1,6 +1,8 @@ #pragma once #include +#include +#include #include "engine/AudioBuffer.h" @@ -8,6 +10,12 @@ 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, @@ -17,6 +25,8 @@ class WavWriter { 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/unit/OnnxModelInferenceTests.cpp b/tests/unit/OnnxModelInferenceTests.cpp index b4cdd1f..5acedb6 100644 --- a/tests/unit/OnnxModelInferenceTests.cpp +++ b/tests/unit/OnnxModelInferenceTests.cpp @@ -54,6 +54,7 @@ TEST_CASE("ONNX inference backend validates model load and schema", "[ai][onnx]" 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/RendererRegistryTests.cpp b/tests/unit/RendererRegistryTests.cpp index 1ed2edb..45eab3e 100644 --- a/tests/unit/RendererRegistryTests.cpp +++ b/tests/unit/RendererRegistryTests.cpp @@ -42,8 +42,9 @@ TEST_CASE("Renderer registry includes configured user external renderer metadata if (info.id == "ExternalUser1") { foundExternal = true; REQUIRE(info.linkMode == automix::renderers::RendererLinkMode::External); - REQUIRE(info.available); + REQUIRE_FALSE(info.available); REQUIRE(info.binaryPath == tempBinary); + REQUIRE(info.discovery.find("Validation failed") != std::string::npos); break; } } diff --git a/tests/unit/StemSeparatorTests.cpp b/tests/unit/StemSeparatorTests.cpp new file mode 100644 index 0000000..7ca8477 --- /dev/null +++ b/tests/unit/StemSeparatorTests.cpp @@ -0,0 +1,73 @@ +#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); + + 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); +} diff --git a/tests/unit/TransportControllerTests.cpp b/tests/unit/TransportControllerTests.cpp new file mode 100644 index 0000000..7ad30a7 --- /dev/null +++ b/tests/unit/TransportControllerTests.cpp @@ -0,0 +1,32 @@ +#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)); +} diff --git a/tools/dev_tools.cpp b/tools/dev_tools.cpp index 7b3b006..a1a66fd 100644 --- a/tools/dev_tools.cpp +++ b/tools/dev_tools.cpp @@ -25,7 +25,9 @@ #include "engine/AudioFileIO.h" #include "engine/OfflineRenderPipeline.h" #include "engine/SessionRepository.h" +#include "util/LameDownloader.h" #include "util/WavWriter.h" +#include "renderers/ExternalLimiterRenderer.h" namespace { @@ -51,6 +53,10 @@ std::optional argValue(const std::vector& args, const return std::nullopt; } +bool hasFlag(const std::vector& args, const std::string& key) { + return std::find(args.begin(), args.end(), key) != args.end(); +} + automix::engine::AudioBuffer sliceBuffer(const automix::engine::AudioBuffer& input, const int maxSamples) { const int outputSamples = std::max(0, std::min(input.getNumSamples(), maxSamples)); @@ -245,6 +251,35 @@ int commandInstallSupportedLimiter(const std::vector& args) { return 2; } +int commandInstallLameFallback(const std::vector& 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 commandExportFeatures(const std::vector& args) { const auto sessionPathArg = argValue(args, "--session"); const auto outPathArg = argValue(args, "--out"); @@ -279,6 +314,7 @@ int commandExportFeatures(const std::vector& args) { {"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}, @@ -296,6 +332,9 @@ int commandExportFeatures(const std::vector& args) { {"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}, @@ -446,15 +485,60 @@ int commandValidateModelPack(const std::vector& args) { return 0; } +int commandValidateExternalLimiter(const std::vector& 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; +} + 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 << " automix_dev_tools validate-external-limiter --binary [--json]\n"; std::cout << " automix_dev_tools list-supported-models\n"; std::cout << " automix_dev_tools install-supported-model --id [--dest ]\n"; std::cout << " automix_dev_tools list-supported-limiters\n"; std::cout << " automix_dev_tools install-supported-limiter --id [--dest ]\n"; + std::cout << " automix_dev_tools install-lame-fallback [--force] [--json]\n"; } } // namespace @@ -482,6 +566,9 @@ int main(int argc, char** argv) { if (command == "validate-modelpack") { return commandValidateModelPack(args); } + if (command == "validate-external-limiter") { + return commandValidateExternalLimiter(args); + } if (command == "list-supported-models") { return commandListSupportedModels(); } @@ -494,6 +581,9 @@ int main(int argc, char** argv) { if (command == "install-supported-limiter") { return commandInstallSupportedLimiter(args); } + if (command == "install-lame-fallback") { + return commandInstallLameFallback(args); + } printUsage(); return 2; diff --git a/tools/training/feature_schema_v1.json b/tools/training/feature_schema_v1.json index 8993752..08c6c2b 100644 --- a/tools/training/feature_schema_v1.json +++ b/tools/training/feature_schema_v1.json @@ -1,11 +1,73 @@ { "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", - "artifact_risk" + "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", From a6b71730c85175dc149039af202f35217371d74e Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 15 Feb 2026 17:16:03 -0600 Subject: [PATCH 03/20] feat: complete v2 follow-up with native ONNX runtime, profile/trust workflows, and export/runtime hardening - Add native ONNX Runtime session path when ENABLE_ONNX finds runtime headers/libs, including provider-aware tuning, profiling artifact capture, and deterministic fallback retention. - Extend stem separation with 2/4/6-stem variant selection, separator_pack manifest discovery, runtime controls (target stems/GPU budget/max streams), and QA bundle output (energyLeakage/residualDistortion/transientRetention). - Introduce project profile catalog + loader and trust-policy assets: - assets/profiles/project_profiles.json - assets/renderers/trust_policy.json - Expand session/render serialization with timeline and profile/safety/export-mode fields (loop markers, zoom/fine-scrub, exportSpeedMode, mp3UseVbr, mp3VbrQuality, projectProfileId, safetyPolicyId, preferredStemCount). - Upgrade GUI/transport UX with loop in/out controls, waveform zoom/loop overlay, fine scrub, task center history, and resizable 1280x720 window defaults. - Harden responsiveness by moving session load/import/export preflight-heavy work off UI thread, adding stale-result guards and progress throttling. - Improve offline render throughput with cached stem/raw mix reuse, adaptive mix block sizing, and cache invalidation controls. - Upgrade export/audio IO path with metadata extraction + retention, MP3 CBR/VBR handling, and external LAME ID3 mapping. - Align renderer behavior across BuiltIn/PhaseLimiter/ExternalLimiter for original-mix soft-target guidance, plan-source/decision-log provenance reporting, and trust-policy signature handling with capability snapshot artifacts. - Expand automix_dev_tools with comparator/catalog/collaboration/evaluation/plan-diff/catalog-installer/lame-installer workflows. - Add tests for profile loading, renderer trust-policy enforcement, transport loop behavior, separator variant QA outputs, and session schema round-trip fields. - Include repository-wide newline/format normalization churn across config and bundled asset/license files. --- CMakeLists.txt | 30 +- README.md | 271 ++-- assets/profiles/project_profiles.json | 53 + assets/renderers/trust_policy.json | 12 + src/ai/OnnxModelInference.cpp | 594 ++++++- src/ai/OnnxModelInference.h | 21 + src/ai/StemSeparator.cpp | 900 +++++++++-- src/ai/StemSeparator.h | 19 +- src/analysis/StemHealthAssistant.cpp | 183 +++ src/analysis/StemHealthAssistant.h | 45 + src/app/Main.cpp | 3 +- src/app/MainComponent.cpp | 1393 +++++++++++++--- src/app/MainComponent.h | 54 +- src/app/WaveformPreviewComponent.cpp | 74 +- src/app/WaveformPreviewComponent.h | 7 + src/domain/JsonSerialization.cpp | 39 +- src/domain/ProjectProfile.cpp | 180 +++ src/domain/ProjectProfile.h | 32 + src/domain/RenderSettings.h | 5 +- src/domain/Session.h | 12 + src/engine/AudioFileIO.cpp | 36 +- src/engine/AudioFileIO.h | 3 + src/engine/BatchQueueRunner.cpp | 20 +- src/engine/OfflineRenderPipeline.cpp | 364 ++++- src/engine/OfflineRenderPipeline.h | 2 + src/engine/TransportController.cpp | 93 +- src/engine/TransportController.h | 10 + src/renderers/BuiltInRenderer.cpp | 61 +- src/renderers/ExternalLimiterRenderer.cpp | 90 +- src/renderers/PhaseLimiterRenderer.cpp | 80 +- src/renderers/RendererRegistry.cpp | 221 ++- src/renderers/RendererRegistry.h | 10 + src/util/WavWriter.cpp | 181 ++- src/util/WavWriter.h | 8 +- tests/unit/ProjectProfileTests.cpp | 66 + .../unit/RendererRegistryTrustPolicyTests.cpp | 123 ++ tests/unit/SessionSerializationTests.cpp | 32 +- tests/unit/StemSeparatorTests.cpp | 33 + tests/unit/TransportControllerTests.cpp | 20 + tools/dev_tools.cpp | 1398 ++++++++++++++++- 40 files changed, 6183 insertions(+), 595 deletions(-) create mode 100644 assets/profiles/project_profiles.json create mode 100644 assets/renderers/trust_policy.json create mode 100644 src/analysis/StemHealthAssistant.cpp create mode 100644 src/analysis/StemHealthAssistant.h create mode 100644 src/domain/ProjectProfile.cpp create mode 100644 src/domain/ProjectProfile.h create mode 100644 tests/unit/ProjectProfileTests.cpp create mode 100644 tests/unit/RendererRegistryTrustPolicyTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index b0e4644..97fbb62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ add_library(automix_core 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 @@ -97,6 +98,7 @@ add_library(automix_core 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 @@ -125,6 +127,22 @@ add_library(automix_core target_include_directories(automix_core PUBLIC src) +set(AUTOMIX_HAS_NATIVE_ORT OFF) +if(ENABLE_ONNX) + 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_link_libraries(automix_core PUBLIC nlohmann_json::nlohmann_json @@ -168,6 +186,7 @@ target_compile_definitions(automix_core $<$:ENABLE_EXTERNAL_TOOL_SUPPORT=1> $<$:RTNEURAL_XSIMD=1> $<$:RTNEURAL_USE_AVX=1> + $<$:AUTOMIX_HAS_NATIVE_ORT=1> ) if(ENABLE_CLANG_TIDY) @@ -212,6 +231,7 @@ target_link_libraries(AutoMixMasterApp PRIVATE automix_core juce::juce_gui_extra + juce::juce_audio_devices juce::juce_audio_formats juce::juce_dsp ) @@ -222,9 +242,13 @@ 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 + tests/regression/RegressionHarness.cpp + ) + target_include_directories(automix_dev_tools PRIVATE src 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) @@ -248,6 +272,8 @@ if(BUILD_TESTING) tests/unit/PhaseLimiterRendererTests.cpp tests/unit/ExternalLimiterRendererTests.cpp tests/unit/RendererRegistryTests.cpp + tests/unit/RendererRegistryTrustPolicyTests.cpp + tests/unit/ProjectProfileTests.cpp tests/unit/AutoMasterTests.cpp tests/unit/AiExtensionTests.cpp tests/unit/LoudnessMeterTests.cpp diff --git a/README.md b/README.md index 3449d42..522c991 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,101 @@ # AutoMixMaster -AutoMixMaster is a JUCE/CMake desktop app for deterministic stem mixing/mastering: +AutoMixMaster is a JUCE/CMake desktop app for deterministic stem mixing and mastering: `Analysis -> MixPlan -> MasterPlan -> Renderer -> Audio + JSON report` -This branch implements the full roadmap scope from `docs/roadmap.md`, including GPU-provider aware AI runtime controls, codec availability probing, external limiter contract validation, DAW-style transport preview, multiband mastering controls, platform loudness presets, stem-separation import flow, and expanded spectral feature extraction. - -## Implemented Roadmap Highlights - -1. GPU-accelerated ML runtime controls -- `RenderSettings.gpuExecutionProvider` supports `auto|cpu|directml|coreml|cuda`. -- GUI selector added under `ML Provider`. -- ONNX adapter supports provider preference selection with CPU fallback and diagnostics. - -2. Enhanced codec portability and visibility -- `WavWriter::getAvailableFormats()` probes writer availability at runtime. -- GUI export dropdown reflects available codecs and provides diagnostics for unavailable formats. -- MP3 export now supports two fallback layers: - - JUCE MP3/LAME encoder integration when enabled at build-time. - - External `lame` CLI encoding fallback when JUCE MP3 writer creation is unavailable. - - Cross-platform on-demand LAME downloader fallback when no local `lame` binary is present. -- Runtime availability marks MP3 as available if either writer path is valid. - -3. External limiter contract validation -- `ExternalLimiterRenderer::validateBinary()` performs `--validate` handshake, timeout handling, schema checks, and capability parsing. -- `RendererRegistry` now validates external renderers during discovery and marks invalid binaries unavailable with diagnostics. -- Formal schema added: `docs/external_limiter_contract.schema.json`. -- New tooling command: `automix_dev_tools validate-external-limiter --binary `. - -4. AI runtime throughput improvements -- ONNX adapter includes graph optimization policy (`ORT_ENABLE_ALL` semantics), warmup, preallocated scratch buffers, batch run support, and quantized variant preference (`*_int8.onnx`, `*_fp16.onnx`). -- ONNX backend diagnostics now expose runtime counters and timing telemetry: - - `calls`, `batches`, `provider_fallbacks`, `avg_inference_ms`, `warmup_ms`. -- Model metadata now supports specialization fields consumed by ONNX runtime setup: - - `preferredPrecision`, `providerAffinity`, `defaultIntraOpThreads`, `defaultInterOpThreads`, `enableProfiling`. -- Build toggles for RTNeural acceleration options are exposed: `RTNEURAL_XSIMD`, `RTNEURAL_USE_AVX`. - -5. Real-time transport preview -- `TransportController` with play/pause/stop/seek/progress state broadcasting. -- Waveform preview component with playhead cursor. -- GUI transport slider plus stem solo/mute preview routing. - -6. Multiband dynamics in mastering -- Added `dsp::MultibandProcessor`. -- `MasterPlan` supports `enableMultibandCompressor` and `multibandSettings`. -- Heuristic mastering chain inserts multiband stage before limiter when enabled. - -7. Platform loudness presets -- Added `MasterPreset` values: `Spotify`, `AppleMusic`, `YouTube`, `AmazonMusic`, `Tidal`, `BroadcastEbuR128`. -- Data-driven preset overrides loaded from `assets/mastering/platform_presets.json`. -- GUI now has a platform preset selector. - -8. Stem separation integration path -- Optional `StemSeparator` integrated into import flow for single mixed-file imports when `AI-separated stems` is enabled. -- Implements model-backed overlap-add separation when a separator model is available. -- Includes deterministic overlap-add fallback and residual-safe reconstruction when model output is unavailable. -- Per-stem `separationConfidence` and `separationArtifactRisk` are now attached to imported stems and serialized in sessions. - -9. Advanced spectral analysis and ML features -- Analysis now includes multi-resolution STFT summaries, spectral flux, onset strength, crest factor, MFCCs, and constant-Q bins. -- Feature schema expanded in both runtime and training export: - - `src/ai/FeatureSchema.cpp` - - `tools/training/feature_schema_v1.json` - -10. UI responsiveness and task scheduling -- `Auto Mix` and `Auto Master` now execute on background worker threads and report progress back to the JUCE message thread. -- UI remains responsive during intensive operations; cancel remains available. -- Preview rebuilding from Auto Mix now runs asynchronously to avoid post-task UI stalls. +This branch implements the **v2 roadmap** in `docs/roadmap.md` including native ONNX session support (when linked), advanced separation variants, precision transport UX, trust-policy renderer discovery, and expanded developer tooling for evaluation, catalog processing, and collaboration. + +## Since Last Published `ai_dev` Commit + +Compared to `origin/ai_dev` commit `7311aa8`, this working tree adds: + +1. Native ONNX Runtime session wiring +- CMake now auto-detects ONNX Runtime headers/libs when `ENABLE_ONNX=ON` and enables true native sessions (`AUTOMIX_HAS_NATIVE_ORT=1`) when found. +- Runtime provider canonicalization/tuning and native profiling artifact capture are now included. + +2. Advanced separator variants + QA bundle +- `StemSeparator` now supports 2/4/6 stem variants with per-run `targetStemCount`, `gpuMemoryBudgetMb`, and `maxStreams` controls. +- Variant discovery supports `separator_pack.json` and fallback model filenames. +- Separation runs now emit `separation_qa_report.json` with `energyLeakage`, `residualDistortion`, and `transientRetention`. + +3. Session/profile/trust-policy data model expansion +- Added project profile catalog support and default profile loader: `assets/profiles/project_profiles.json`. +- Added renderer trust policy support: `assets/renderers/trust_policy.json`. +- Session serialization now includes timeline state, profile/safety identifiers, and MP3 mode fields. + +4. UI/transport and responsiveness hardening +- Main window is now resizable (`1280x720` default). +- Added loop in/out controls, timeline zoom, fine-scrub behavior, and loop overlay in waveform preview. +- Session loading, stem import/separation, and export preflight checks are pushed off the UI thread with stale-result guards. +- Task center history and progress updates are throttled to reduce UI stalls. + +5. Render/export pipeline upgrades +- Added `Final` / `Balanced` / `Quick` export speed modes with Quick mode defaults for fast turnaround. +- MP3 now supports CBR and true VBR paths (`mp3UseVbr`, `mp3VbrQuality`), including external LAME VBR mode. +- Export metadata is preserved from original mix (or stem fallback) and mapped to MP3 ID3 tags. +- Offline render pipeline adds stem/raw mix caching and adaptive block-sizing for repeated renders. + +6. Renderer trust/compliance/reporting parity +- External descriptor trust evaluation supports signature metadata (`fnv1a64`) and optional signed-only enforcement. +- Discovered external renderers now emit capability snapshots (`*.capabilities.snapshot.json`). +- `ExternalLimiter` and `PhaseLimiter` now apply original-mix soft-target guidance when no master plan is pinned, matching built-in behavior. +- Render reports now include plan-source and decision-log provenance metadata. + +7. Dev tools and test coverage expansion +- `automix_dev_tools` now includes comparator, catalog processing, session diff/merge, external compatibility, golden eval, plan diff, model/limiter catalog installers, and LAME fallback installer. +- Added/expanded tests for project profile loading, trust-policy enforcement, transport looping, separator variants/QA output, and session schema fields. + +## Implemented Scope (v2) + +1. Native ONNX runtime sessions (Phase G) +- `OnnxModelInference` supports real ONNX Runtime C++ sessions when `ENABLE_ONNX=ON` and runtime headers/libs are present. +- Provider-aware tuning matrix (CPU/CUDA/DirectML/CoreML), thread controls, and optional profiling artifact export. +- Deterministic adapter fallback remains available when native runtime is unavailable. + +2. Advanced separation model workflow (Phase H) +- Multi-variant separation packs (2/4/6 stem targets) with automatic selection and override support. +- Chunked overlap-add processing with GPU memory budget and stream scheduling controls. +- Separation QA report bundle with `energyLeakage`, `residualDistortion`, and `transientRetention`. + +3. Precision UX and timeline state (Phase I) +- Loop in/out markers, looped transport playback, timeline zoom, and fine-scrub behavior. +- Timeline loop/zoom/fine-scrub state persists in session JSON. +- Task center panel with timestamped background-task history. + +4. External DSP marketplace foundation (Phase J) +- External renderer descriptor signature metadata and trust policy loading. +- Signed/unsigned policy handling and trusted-signer filtering. +- Capability snapshot artifacts written for discovered external renderers. + +5. Evaluation/training operations (Phase K) +- Golden corpus evaluator command backed by regression harness baselines. +- Heuristic vs model plan diff report generation. +- Dataset lineage/manifest output for feature export workflows. + +## Product Suggestions Implemented + +1. Project Profiles +- Data-driven profile bundles (platform target, renderer, codec, model packs, safety policy, preferred stem count). +- Profile selector in UI with auto-application to render/model settings. +- Strict safety policy pinning blocks export when renderer is not profile-pinned. + +2. Multi-Render Comparator +- `automix_dev_tools compare-renders` renders multiple targets and ranks by loudness/compliance/artifact risk score. +- JSON and CSV comparator reports are generated. + +3. Stem Health Assistant +- Pre-export diagnostics for masking, pumping, harshness, and mono risk. +- Integrated into app export flow and available via CLI (`stem-health`). + +4. Catalog Processing Mode +- `catalog-process` headless queue runner with JSON/CSV deliverables. +- Resumable checkpoints via `--checkpoint` and `--resume`. +- Per-item failure handling (unreadable files are marked failed, not fatal to whole run). + +5. Creator Collaboration Mode +- `session-diff` for deterministic JSON patch output. +- `session-merge` three-way deterministic merge with conflict reporting and left/right preference policy. ## Build @@ -77,6 +106,8 @@ cmake -S . -B build-codex -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_ cmake --build build-codex --parallel ``` +When `ENABLE_ONNX=ON`, CMake attempts to locate native ONNX Runtime headers/library and enables native session support automatically when found. + ### Configure + build (Windows, VS 2026) Windows Visual Studio builds are pinned to the VS 2026 generator (`Visual Studio 18 2026`). @@ -100,72 +131,94 @@ Built executable: ### Tests ```bash -TMPDIR=/tmp ctest --test-dir build-codex --output-on-failure -j4 +ctest --test-dir build-codex --output-on-failure -j4 ``` -As of **February 14, 2026**, this branch passes all 50 tests in `automix_tests`. +As of **February 15, 2026**, this branch passes **55/55 tests**. +Coverage includes project profile loading, renderer trust-policy enforcement, transport loop behavior, separator variant/QA outputs, and session schema round-trip fields. ## GUI Workflow 1. Import stems (or one mixed track with `AI-separated stems` enabled). 2. Optional: load original mix for A/B and residual blend. -3. Choose AI packs and ML provider (`auto/cpu/directml/coreml/cuda`). +3. Select project profile, AI packs, and ML provider (`auto/cpu/directml/coreml/cuda`). 4. Run `Auto Mix`. -5. Run `Auto Master` and choose master/platform presets. -6. Optional: set stem solo/mute and audition with transport controls. -7. Choose renderer + export format. -8. Export single song or batch folder. +5. Run `Auto Master` and choose master/platform preset. +6. Use transport/loop/zoom/fine-scrub for preview. +7. Review task center and stem health diagnostics. +8. Export single song or batch folder (`Quick` mode for speed, strict safety policy can block non-pinned renderers). Outputs: - `.` - `..report.json` +## Session Schema Additions + +- `renderSettings`: `exportSpeedMode`, `mp3UseVbr`, `mp3VbrQuality` +- `timeline`: `loopEnabled`, `loopInSeconds`, `loopOutSeconds`, `zoom`, `fineScrub` +- `session`: `projectProfileId`, `safetyPolicyId`, `preferredStemCount` + ## Renderers - `BuiltIn`: always available in-process renderer. - `PhaseLimiter`: external binary discovery with fallback-safe behavior. -- External limiters from descriptor `renderer.json` files. +- Descriptor-driven external renderers from: + - `assets/limiters/**/renderer.json` + - `assets/renderers/**/renderer.json` + - `Assets/Limiters/**/renderer.json` -External descriptor scan roots: -- `assets/limiters/**/renderer.json` -- `assets/renderers/**/renderer.json` -- `Assets/Limiters/**/renderer.json` +Trust policy candidates: +- `assets/renderers/trust_policy.json` +- `assets/limiters/trust_policy.json` -External renderers are now validated at discovery time via the `--validate` contract. +Discovery side-effect: +- External renderer validation writes capability snapshots next to binaries as `.capabilities.snapshot.json`. ## Codec Availability -Export codec availability is probed at runtime and surfaced in the GUI. +Runtime export codec probing is surfaced in the GUI. Supported format targets: - Lossless: `wav`, `aiff`, `flac` - Lossy: `mp3`, `ogg` -Key build option: -- `ENABLE_BUNDLED_LAME=ON|OFF` (default: `ON`) +MP3 export modes: +- `CBR`: target bitrate via `lossyBitrateKbps` (default `320 kbps`) +- `VBR`: quality ladder via `mp3VbrQuality` (0 best .. 9 smallest) + +Export speed modes: +- `Final`: default quality path (`blockSize=1024`, `24-bit`) +- `Balanced`: moderate throughput (`blockSize=2048`, `24-bit`) +- `Quick`: fast-turnaround preset (`blockSize=4096`, `16-bit`) with default codec target `MP3 VBR` + - Stem-health preflight is skipped in Quick mode to reduce turnaround time. + - If MP3 is unavailable at runtime, Quick mode falls back to the first available codec. -MP3 fallback discovery order: -- Bundled lookup roots (when `ENABLE_BUNDLED_LAME=ON`) from current working directory and executable-relative ancestors. -- `lame(.exe)` beside the app executable (`./`, `./bin`, `./lame`). -- `LAME_BIN` environment variable. -- `PATH` entries (quoted entries are supported). -- Downloaded fallback cache (`/AutoMixMaster/codecs/lame//`). +MP3 fallback order: +- JUCE MP3/LAME writer path (when available) +- External `lame` binary path discovery (`LAME_BIN`, app-adjacent, `PATH`) +- Optional on-demand LAME downloader cache -MP3 downloader controls: -- `AUTOMIX_LAME_SKIP_DOWNLOAD=1` to disable downloader fallback. -- `AUTOMIX_LAME_FORCE_DOWNLOAD=1` to force re-download. -- `AUTOMIX_LAME_VERSION=` to override default (`3.100`). -- `AUTOMIX_LAME_DOWNLOAD_URL=` to override source with a direct archive/binary URL. +Metadata retention: +- Exports preserve source metadata from the original mix (or first valid stem fallback when original mix is not set). +- External LAME MP3 export maps common metadata to ID3 tags (`title`, `artist`, `album`, `year`, `track`, `genre`, `comment`). ## Developer Tools (`automix_dev_tools`) Built when `-DBUILD_TOOLS=ON`. ```bash -automix_dev_tools export-features --session --out +automix_dev_tools export-features --session --out [--manifest ] [--dataset-id ] [--source-tag ] [--lineage-parents ] automix_dev_tools export-segments --session --out-dir [--segment-seconds ] automix_dev_tools validate-modelpack --pack automix_dev_tools validate-external-limiter --binary [--json] +automix_dev_tools stem-health --session [--out ] [--json] +automix_dev_tools compare-renders --session [--renderers ] [--out-dir ] [--format ] [--external-binary ] [--json] +automix_dev_tools catalog-process --input --output [--checkpoint ] [--resume] [--renderer ] [--format ] [--analysis-threads ] [--render-parallelism ] [--csv ] [--json ] +automix_dev_tools session-diff --base --head [--out ] [--summary] +automix_dev_tools session-merge --base --left --right --out [--prefer ] [--report ] [--json] +automix_dev_tools external-limiter-compat --binary [--timeout-ms ] [--required-features ] [--out ] [--json] +automix_dev_tools golden-eval [--baseline ] [--work-dir ] [--out ] [--json] +automix_dev_tools plan-diff --session [--mix-model ] [--master-model ] [--out ] [--json] automix_dev_tools list-supported-models automix_dev_tools install-supported-model --id [--dest ] automix_dev_tools list-supported-limiters @@ -178,28 +231,27 @@ automix_dev_tools install-lame-fallback [--force] [--json] Schema: - `docs/external_limiter_contract.schema.json` -Validation behavior: -- The renderer sends a minimal validation request using `--validate --request `. -- Expected response fields: - - `schemaVersion` (major version 1) - - `version` (string) - - `supportedFeatures` (string array) -- Validation now emits explicit error taxonomy codes (for tooling and registry diagnostics), including: - - `binary_missing`, `launch_failed`, `timeout`, `exit_code`, `invalid_json`, `missing_version`, `missing_supported_features`, `schema_incompatible`. -- Invalid binaries are surfaced as unavailable in renderer discovery and fallback to BuiltIn at render time. +Validation taxonomy: +- `binary_missing` +- `launch_failed` +- `timeout` +- `exit_code` +- `invalid_json` +- `missing_version` +- `missing_supported_features` +- `schema_incompatible` ## AI Model Packs -`ModelManager` scans from: +`ModelManager` scans: - configured roots - `assets/models` - `assets/modelpacks` - `assets/ModelPacks` - `Assets/ModelPacks` -- env var `AUTOMIX_MODELPACK_PATHS` +- `AUTOMIX_MODELPACK_PATHS` -Packs are schema-gated and validated for required metadata (`license`, `source`, `feature_schema_version`) and model-file presence/checksum. -Additional optional runtime specialization metadata is supported: +Runtime specialization metadata supported: - `preferredPrecision` - `providerAffinity` - `defaultIntraOpThreads` @@ -217,16 +269,17 @@ Additional optional runtime specialization metadata is supported: - `ENABLE_LIBEBUR128=ON|OFF` - `ENABLE_PHASELIMITER=ON|OFF` - `ENABLE_EXTERNAL_TOOL_SUPPORT=ON|OFF` -- `ENABLE_BUNDLED_LAME=ON|OFF` (default: `ON`) +- `ENABLE_BUNDLED_LAME=ON|OFF` - `DISTRIBUTION_MODE=OSS|PROPRIETARY` ## Known Limits -- ONNX inference remains a deterministic adapter layer in this branch (not a linked native ONNX Runtime session integration). -- External limiter contract still enforces major schema compatibility (`1.x`) rather than strict minor-version negotiation. +- Native ONNX sessions require external ONNX Runtime headers/library at configure time; otherwise the deterministic adapter path is used. +- Signature verification currently uses descriptor-embedded `fnv1a64` checks (policy and signer lists are data-driven but not PKI-backed yet). +- Collaboration merge is deterministic and semantic for core session structures, but not yet CRDT-based real-time merge. ## License AutoMixMaster is GPLv3 in OSS mode (`DISTRIBUTION_MODE=OSS`). -Third-party dependencies include JUCE, nlohmann/json, Catch2, and libebur128, each under their respective licenses. +Third-party dependencies include JUCE, nlohmann/json, Catch2, and libebur128 under their respective licenses. diff --git a/assets/profiles/project_profiles.json b/assets/profiles/project_profiles.json new file mode 100644 index 0000000..19ee1bb --- /dev/null +++ b/assets/profiles/project_profiles.json @@ -0,0 +1,53 @@ +[ + { + "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, + "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, + "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, + "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/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index cbc2838..7c344bb 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -1,16 +1,30 @@ #include "ai/OnnxModelInference.h" #include -#include #include -#include #include +#include +#include +#include #include #include +#include #include +#include +#include +#include +#include #include +#ifndef AUTOMIX_HAS_NATIVE_ORT +#define AUTOMIX_HAS_NATIVE_ORT 0 +#endif + +#if AUTOMIX_HAS_NATIVE_ORT +#include +#endif + namespace automix::ai { namespace { @@ -27,6 +41,23 @@ std::string toLower(std::string value) { return 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"; @@ -83,8 +114,135 @@ std::filesystem::path pickQuantizedVariant(const std::filesystem::path& modelPat 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& modelPath) { @@ -96,6 +254,7 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { 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(); @@ -104,15 +263,18 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { intraOpThreads_ = 0; interOpThreads_ = 0; profilingEnabled_ = false; - inferenceCalls_.store(0); - batchCalls_.store(0); - providerFallbacks_.store(0); - cumulativeInferenceMicros_.store(0); - warmupDurationMillis_.store(0); + 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(); @@ -121,6 +283,8 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { 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) { @@ -171,6 +335,21 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { 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>(); + } } } @@ -183,7 +362,7 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { } for (auto& provider : availableExecutionProviders_) { - provider = toLower(provider); + provider = canonicalProviderName(provider); } std::sort(availableExecutionProviders_.begin(), availableExecutionProviders_.end()); @@ -200,24 +379,139 @@ bool OnnxModelInference::loadModel(const std::filesystem::path& modelPath) { activeExecutionProvider_ = resolveExecutionProvider(); loaded_ = true; warmupRan_ = false; - inferenceCalls_.store(0); - batchCalls_.store(0); - providerFallbacks_.store(0); - cumulativeInferenceMicros_.store(0); - warmupDurationMillis_.store(0); + 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"); - std::ostringstream os; - os << "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) { - os << "; quantized_variant=" << selectedModelPath.filename().string(); + diagnostics << "; quantized_variant=" << selectedModelPath.filename().string(); } - diagnostics_ = os.str(); +#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; } @@ -255,6 +549,196 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { 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; { @@ -276,10 +760,10 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { } } + InferenceResult result; result.usedModel = true; - result.logMessage = "ONNX inference executed for task '" + request.task + "' using provider '" + - activeExecutionProvider_ + "' (" + (graphOptimizationEnabled_ ? "ORT_ENABLE_ALL" : "graph-opt-off") + - ", precision=" + preferredPrecision_ + ")."; + 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)); @@ -289,7 +773,6 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { {"global_gain_db", std::clamp(-mean * 0.08, -4.0, 4.0)}, {"global_pan_bias", std::clamp(mean * 0.002, -0.2, 0.2)}, }; - finalizeMetrics(); return result; } @@ -301,7 +784,6 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { {"limiter_ceiling_db", -1.0}, {"glue_ratio", std::clamp(2.0 + rms * 0.02, 1.2, 4.0)}, }; - finalizeMetrics(); return result; } @@ -318,14 +800,12 @@ InferenceResult OnnxModelInference::run(const InferenceRequest& request) const { {"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)}, }; - finalizeMetrics(); return result; } result.outputs = { {"confidence", confidence}, }; - finalizeMetrics(); return result; } @@ -340,7 +820,7 @@ std::vector OnnxModelInference::runBatch(const std::vector(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 fe892c0..42f21b6 100644 --- a/src/ai/OnnxModelInference.h +++ b/src/ai/OnnxModelInference.h @@ -2,8 +2,11 @@ #include #include +#include +#include #include #include +#include #include #include "ai/IModelInference.h" @@ -12,6 +15,8 @@ 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; @@ -28,20 +33,33 @@ class OnnxModelInference final : public IModelInference { [[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"; @@ -51,8 +69,11 @@ class OnnxModelInference final : public IModelInference { 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}; diff --git a/src/ai/StemSeparator.cpp b/src/ai/StemSeparator.cpp index fc1c136..22f0e20 100644 --- a/src/ai/StemSeparator.cpp +++ b/src/ai/StemSeparator.cpp @@ -2,12 +2,18 @@ #include #include +#include #include +#include +#include #include #include #include +#include #include +#include + #include "ai/OnnxModelInference.h" #include "domain/StemOrigin.h" #include "domain/StemRole.h" @@ -18,11 +24,27 @@ namespace automix::ai { namespace { constexpr double kPi = 3.14159265358979323846; -constexpr int kStemCount = 4; -constexpr int kBassStem = 0; -constexpr int kVocalsStem = 1; -constexpr int kDrumsStem = 2; -constexpr int kMusicStem = 3; + +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); @@ -32,6 +54,29 @@ 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); @@ -69,35 +114,53 @@ void applyOnePoleHighPass(engine::AudioBuffer& buffer, const double cutoffHz) { } engine::AudioBuffer makeResidual(const engine::AudioBuffer& source, - const engine::AudioBuffer& bass, - const engine::AudioBuffer& vocals, - const engine::AudioBuffer& drums) { + 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) { - const double value = source.getSample(ch, i) - bass.getSample(ch, i) - vocals.getSample(ch, i) - drums.getSample(ch, 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; } -domain::Stem makeStem(const std::string& id, - const std::string& name, - const std::filesystem::path& path, - const domain::StemRole role, - const double confidence, - const double artifactRisk) { - domain::Stem stem; - stem.id = id; - stem.name = name; - 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; +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) { @@ -207,22 +270,44 @@ std::optional findOutputValue(const InferenceResult& result, const std:: return std::nullopt; } -std::array defaultWeights(const std::vector& features) { +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::array weights { - 0.55 + low * 0.45 - high * 0.2, - 0.45 + mid * 0.6 - low * 0.15, - 0.35 + high * 0.4 + flux * 0.25, - 0.25 + mid * 0.25 + high * 0.15, - }; - - for (double& value : weights) { - value = std::max(0.01, value); + 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); @@ -230,20 +315,51 @@ std::array defaultWeights(const std::vector& feature return weights; } -std::array weightsFromInference(const InferenceResult& result, - const std::array& fallback) { +std::vector weightsFromInference(const InferenceResult& result, + const std::vector& fallback, + const std::vector& roles) { auto weights = fallback; bool anyExplicitWeight = false; - const std::array, kStemCount> keyOptions = { - std::vector{"bass_weight", "stem0_weight", "source0_weight", "mask_bass"}, - std::vector{"vocals_weight", "stem1_weight", "source1_weight", "mask_vocals"}, - std::vector{"drums_weight", "stem2_weight", "source2_weight", "mask_drums"}, - std::vector{"music_weight", "other_weight", "stem3_weight", "source3_weight", "mask_other"}, - }; - for (int index = 0; index < kStemCount; ++index) { - if (const auto value = findOutputValue(result, keyOptions[static_cast(index)]); value.has_value()) { - weights[static_cast(index)] = std::max(0.0, value.value()); + 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; } } @@ -262,17 +378,195 @@ std::array weightsFromInference(const InferenceResult& resul return weights; } -struct OverlapAddResult { - bool success = false; - bool usedModel = false; - std::array stems; - std::array confidence {}; - std::array artifactRisk {}; - std::string logMessage; -}; +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; + }; -OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, const std::filesystem::path& modelPath) { + 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"); @@ -280,7 +574,7 @@ OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, inference.setWarmupEnabled(true); inference.setPreferQuantizedVariants(true); - if (!inference.loadModel(modelPath)) { + if (!inference.loadModel(variant.modelPath)) { result.logMessage = "Separator model exists but failed to load."; return result; } @@ -292,73 +586,96 @@ OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, return result; } - for (auto& stem : result.stems) { - stem = engine::AudioBuffer(channels, samples, mixBuffer.getSampleRate()); + 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::array confidenceAccumulator {}; - std::array confidenceWeight {}; - std::array artifactAccumulator {}; - std::array artifactWeight {}; + 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 frameStart = 0; frameStart < samples; frameStart += hopSize) { - const auto features = extractFrameFeatures(mixBuffer, frameStart, frameSize); - const auto fallbackWeights = defaultWeights(features); - auto weights = fallbackWeights; - double confidence = 0.45; - - InferenceRequest request; - request.task = "stem_separation"; - request.features = features; - const auto inferenceResult = inference.run(request); - if (inferenceResult.usedModel) { - weights = weightsFromInference(inferenceResult, fallbackWeights); - 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); - } + 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 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 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 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 < kStemCount; ++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)])); + 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 < kStemCount; ++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)]; - } + 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; + ++processedFrames; + } } if (processedFrames == 0) { @@ -368,7 +685,7 @@ OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, 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 < kStemCount; ++stemIndex) { + 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)); @@ -376,18 +693,23 @@ OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, } } - // Lock "music" to residual so the separated stems remain phase-consistent with the source. - for (int ch = 0; ch < channels; ++ch) { - for (int i = 0; i < samples; ++i) { - const double value = static_cast(mixBuffer.getSample(ch, i)) - - static_cast(result.stems[kBassStem].getSample(ch, i)) - - static_cast(result.stems[kVocalsStem].getSample(ch, i)) - - static_cast(result.stems[kDrumsStem].getSample(ch, i)); - result.stems[kMusicStem].setSample(ch, i, static_cast(clampSample(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 < kStemCount; ++stemIndex) { + 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)] = @@ -398,36 +720,256 @@ OverlapAddResult runModelBackedOverlapAdd(const engine::AudioBuffer& mixBuffer, result.success = true; if (result.usedModel) { - result.logMessage = "Model-backed overlap-add separation completed (" + std::to_string(modelFrames) + " model frames)."; + 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; used overlap-add fallback weights."; + 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) { +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); - result.stems[kBassStem] = mixBuffer; - applyOnePoleLowPass(result.stems[kBassStem], 180.0); + 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; + }; - result.stems[kVocalsStem] = mixBuffer; - applyOnePoleHighPass(result.stems[kVocalsStem], 180.0); - applyOnePoleLowPass(result.stems[kVocalsStem], 3500.0); + 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; + } - result.stems[kDrumsStem] = mixBuffer; - applyOnePoleHighPass(result.stems[kDrumsStem], 3500.0); + auto bass = mixBuffer; + applyOnePoleLowPass(bass, 180.0); + setStem(domain::StemRole::Bass, bass); - result.stems[kMusicStem] = makeResidual(mixBuffer, result.stems[kBassStem], result.stems[kVocalsStem], result.stems[kDrumsStem]); + 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; + } - result.confidence = {0.45, 0.45, 0.45, 0.45}; - result.artifactRisk = {0.58, 0.58, 0.58, 0.58}; - result.logMessage = "No separator model installed; used deterministic frequency splitter."; + 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)) {} @@ -449,11 +991,16 @@ std::filesystem::path StemSeparator::resolveModelPath() const { } 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 { + const std::filesystem::path& outputDir, + const SeparationOptions& options) const { SeparationResult result; try { @@ -466,59 +1013,64 @@ StemSeparator::SeparationResult StemSeparator::separate(const std::filesystem::p 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; - const auto modelPath = resolveModelPath(); - if (!modelPath.empty()) { - separated = runModelBackedOverlapAdd(mixBuffer, modelPath); + std::optional selectedVariant; + if (!variants.empty()) { + selectedVariant = pickModelVariant(variants, mixBuffer, options); + } + + if (selectedVariant.has_value()) { + separated = runModelBackedOverlapAdd(mixBuffer, selectedVariant.value(), options); if (!separated.success) { - separated = runDeterministicFallback(mixBuffer); + 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); + separated = runDeterministicFallback(mixBuffer, options.targetStemCount.value_or(4)); } util::WavWriter writer; - const auto bassPath = outputDir / "stem_bass.wav"; - const auto vocalsPath = outputDir / "stem_vocals.wav"; - const auto drumsPath = outputDir / "stem_drums.wav"; - const auto musicPath = outputDir / "stem_music.wav"; - - writer.write(bassPath, separated.stems[kBassStem], 24); - writer.write(vocalsPath, separated.stems[kVocalsStem], 24); - writer.write(drumsPath, separated.stems[kDrumsStem], 24); - writer.write(musicPath, separated.stems[kMusicStem], 24); - - result.generatedFiles = {bassPath, vocalsPath, drumsPath, musicPath}; - result.stems = { - makeStem("sep_bass", - "Separated Bass", - bassPath, - domain::StemRole::Bass, - separated.confidence[kBassStem], - separated.artifactRisk[kBassStem]), - makeStem("sep_vocals", - "Separated Vocals", - vocalsPath, - domain::StemRole::Vocals, - separated.confidence[kVocalsStem], - separated.artifactRisk[kVocalsStem]), - makeStem("sep_drums", - "Separated Drums", - drumsPath, - domain::StemRole::Drums, - separated.confidence[kDrumsStem], - separated.artifactRisk[kDrumsStem]), - makeStem("sep_music", - "Separated Music", - musicPath, - domain::StemRole::Music, - separated.confidence[kMusicStem], - separated.artifactRisk[kMusicStem]), - }; + 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) { diff --git a/src/ai/StemSeparator.h b/src/ai/StemSeparator.h index bcd242b..d3e16ee 100644 --- a/src/ai/StemSeparator.h +++ b/src/ai/StemSeparator.h @@ -10,18 +10,35 @@ 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; + SeparationResult separate(const std::filesystem::path& mixPath, + const std::filesystem::path& outputDir, + const SeparationOptions& options = {}) const; private: [[nodiscard]] std::filesystem::path resolveModelPath() const; 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 990b7f3..f0f8b50 100644 --- a/src/app/MainComponent.cpp +++ b/src/app/MainComponent.cpp @@ -2,11 +2,14 @@ #include #include +#include #include #include #include #include +#include #include +#include #include #include @@ -17,6 +20,7 @@ #include "ai/RtNeuralInference.h" #include "ai/StemSeparator.h" #include "automaster/OriginalMixReference.h" +#include "analysis/StemHealthAssistant.h" #include "engine/AudioFileIO.h" #include "engine/AudioResampler.h" #include "engine/BatchQueueRunner.h" @@ -77,6 +81,10 @@ std::string extensionForFormat(const std::string& format) { return ".wav"; } +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; @@ -149,6 +157,58 @@ std::string formatDuration(const double seconds) { return output.str(); } +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 buildExportHealthCacheKey(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 MainComponent::AnalysisTableModel::setEntries(const std::vector* entries) { @@ -217,6 +277,8 @@ void MainComponent::AnalysisTableModel::paintCell(juce::Graphics& g, MainComponent::MainComponent() { addAndMakeVisible(importButton_); addAndMakeVisible(originalMixButton_); + addAndMakeVisible(clearOriginalMixButton_); + addAndMakeVisible(regenerateCacheButton_); addAndMakeVisible(saveSessionButton_); addAndMakeVisible(loadSessionButton_); addAndMakeVisible(autoMixButton_); @@ -226,6 +288,9 @@ MainComponent::MainComponent() { addAndMakeVisible(previewRenderedButton_); addAndMakeVisible(playPauseButton_); addAndMakeVisible(stopButton_); + addAndMakeVisible(loopInButton_); + addAndMakeVisible(loopOutButton_); + addAndMakeVisible(clearLoopButton_); addAndMakeVisible(addExternalRendererButton_); addAndMakeVisible(prefetchLameButton_); addAndMakeVisible(exportButton_); @@ -236,8 +301,16 @@ MainComponent::MainComponent() { 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_); @@ -249,6 +322,9 @@ MainComponent::MainComponent() { addAndMakeVisible(muteStemLabel_); addAndMakeVisible(muteStemBox_); addAndMakeVisible(transportSlider_); + addAndMakeVisible(zoomLabel_); + addAndMakeVisible(zoomSlider_); + addAndMakeVisible(fineScrubToggle_); addAndMakeVisible(aiModelsLabel_); addAndMakeVisible(roleModelBox_); addAndMakeVisible(mixModelBox_); @@ -260,9 +336,13 @@ MainComponent::MainComponent() { 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); autoMixButton_.addListener(this); @@ -272,6 +352,9 @@ MainComponent::MainComponent() { 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); @@ -279,6 +362,9 @@ MainComponent::MainComponent() { rendererBox_.addListener(this); exportFormatBox_.addListener(this); + exportSpeedModeBox_.addListener(this); + mp3ModeBox_.addListener(this); + projectProfileBox_.addListener(this); gpuProviderBox_.addListener(this); masterPresetBox_.addListener(this); platformPresetBox_.addListener(this); @@ -290,7 +376,10 @@ MainComponent::MainComponent() { residualBlendSlider_.addListener(this); exportBitrateSlider_.addListener(this); + mp3VbrSlider_.addListener(this); transportSlider_.addListener(this); + zoomSlider_.addListener(this); + fineScrubToggle_.addListener(this); residualBlendLabel_.setJustificationType(juce::Justification::centredLeft); residualBlendSlider_.setSliderStyle(juce::Slider::LinearHorizontal); @@ -299,12 +388,30 @@ MainComponent::MainComponent() { 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(192.0, juce::dontSendNotification); + 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); @@ -323,6 +430,14 @@ MainComponent::MainComponent() { 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 model"); @@ -349,26 +464,48 @@ 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(); + 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() { 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); autoMixButton_.removeListener(this); @@ -378,6 +515,9 @@ MainComponent::~MainComponent() { 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); @@ -385,6 +525,9 @@ MainComponent::~MainComponent() { rendererBox_.removeListener(this); exportFormatBox_.removeListener(this); + exportSpeedModeBox_.removeListener(this); + mp3ModeBox_.removeListener(this); + projectProfileBox_.removeListener(this); gpuProviderBox_.removeListener(this); masterPresetBox_.removeListener(this); platformPresetBox_.removeListener(this); @@ -396,7 +539,10 @@ MainComponent::~MainComponent() { residualBlendSlider_.removeListener(this); exportBitrateSlider_.removeListener(this); + mp3VbrSlider_.removeListener(this); transportSlider_.removeListener(this); + zoomSlider_.removeListener(this); + fineScrubToggle_.removeListener(this); } void MainComponent::resized() { @@ -411,22 +557,28 @@ void MainComponent::resized() { 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(98).reduced(2)); - originalMixButton_.setBounds(top.removeFromLeft(112).reduced(2)); - saveSessionButton_.setBounds(top.removeFromLeft(112).reduced(2)); - loadSessionButton_.setBounds(top.removeFromLeft(112).reduced(2)); - autoMixButton_.setBounds(top.removeFromLeft(95).reduced(2)); - autoMasterButton_.setBounds(top.removeFromLeft(110).reduced(2)); - batchImportButton_.setBounds(top.removeFromLeft(112).reduced(2)); - exportButton_.setBounds(top.removeFromLeft(90).reduced(2)); - cancelButton_.setBounds(top.removeFromLeft(85).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)); + 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)); 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)); @@ -439,12 +591,20 @@ void MainComponent::resized() { residualBlendLabel_.setBounds(blendRow.removeFromLeft(132).reduced(2)); residualBlendSlider_.setBounds(blendRow.removeFromLeft(250).reduced(2)); - exportFormatLabel_.setBounds(exportRow.removeFromLeft(52).reduced(2)); - exportFormatBox_.setBounds(exportRow.removeFromLeft(190).reduced(2)); - exportBitrateLabel_.setBounds(exportRow.removeFromLeft(90).reduced(2)); - exportBitrateSlider_.setBounds(exportRow.removeFromLeft(220).reduced(2)); - gpuProviderLabel_.setBounds(exportRow.removeFromLeft(84).reduced(2)); - gpuProviderBox_.setBounds(exportRow.removeFromLeft(150).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)); @@ -462,10 +622,15 @@ void MainComponent::resized() { masterModelBox_.setBounds(modelRow.removeFromLeft(250).reduced(2)); 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)); - analysisTable_.setBounds(area.removeFromTop(180)); + 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)); } @@ -480,6 +645,16 @@ void MainComponent::buttonClicked(juce::Button* button) { return; } + if (button == &clearOriginalMixButton_) { + onClearOriginalMix(); + return; + } + + if (button == ®enerateCacheButton_) { + onRegenerateCachedRenders(); + return; + } + if (button == &saveSessionButton_) { onSaveSession(); return; @@ -520,6 +695,7 @@ void MainComponent::buttonClicked(juce::Button* button) { transportController_.pause(); previewEngine_.stop(); } else { + playbackCursorSamples_.store(transportController_.positionSamples()); transportController_.play(); previewEngine_.play(); } @@ -530,10 +706,47 @@ void MainComponent::buttonClicked(juce::Button* button) { 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; @@ -573,6 +786,30 @@ void MainComponent::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) { 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: @@ -613,9 +850,7 @@ void MainComponent::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) { return it != availability.end() && it->available; }; - const bool lossy = util::WavWriter::isLossyFormat(format); - exportBitrateSlider_.setEnabled(lossy); - exportBitrateLabel_.setEnabled(lossy); + updateExportCodecControls(); if (!isAvailable(format)) { for (const auto& [comboId, formatName] : codecFormatByComboId_) { @@ -637,6 +872,12 @@ void MainComponent::comboBoxChanged(juce::ComboBox* comboBoxThatHasChanged) { return; } + if (comboBoxThatHasChanged == &mp3ModeBox_) { + session_.renderSettings.mp3UseVbr = mp3ModeBox_.getSelectedId() == 2; + updateExportCodecControls(); + return; + } + if (comboBoxThatHasChanged == &soloStemBox_ || comboBoxThatHasChanged == &muteStemBox_) { rebuildPreviewBuffers(); return; @@ -649,20 +890,40 @@ void MainComponent::sliderValueChanged(juce::Slider* slider) { 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_) { - transportController_.seekToFraction(transportSlider_.getValue()); + 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(); - const auto totalSeconds = transportController_.totalSeconds(); - if (totalSamples > 0 && totalSeconds > 0.0) { - const auto sampleRate = static_cast(totalSamples) / totalSeconds; - const int increment = std::max(1, static_cast(std::lround(sampleRate / 20.0))); - transportController_.advance(increment); + if (totalSamples > 0) { + transportController_.seekToSample(std::clamp(currentCursor, 0, totalSamples)); } } @@ -675,6 +936,75 @@ void MainComponent::changeListenerCallback(juce::ChangeBroadcaster* source) { } } +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(); @@ -830,13 +1160,199 @@ void MainComponent::refreshCodecAvailability() { 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); - exportBitrateSlider_.setEnabled(lossy); - exportBitrateLabel_.setEnabled(lossy); + 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.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() { @@ -882,63 +1398,102 @@ void MainComponent::refreshStemRoutingSelectors() { } void MainComponent::rebuildPreviewBuffers() { - if (session_.stems.empty()) { - waveformPreview_.setBuffer(engine::AudioBuffer{}); - transportController_.setTimeline(0, 44100.0); - return; - } + rebuildPreviewBuffersAsync(); +} - try { - auto previewSession = session_; +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; + } - 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(); + 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); - 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; - } - } + int exportModeSelectedId = 1; + for (const auto& [comboId, mode] : exportSpeedModeByComboId_) { + if (mode == session_.renderSettings.exportSpeedMode) { + exportModeSelectedId = comboId; + break; } + } + exportSpeedModeBox_.setSelectedId(exportModeSelectedId, juce::dontSendNotification); - const auto previousProgress = transportController_.progress(); - const auto settings = buildCurrentRenderSettings(""); + zoomSlider_.setValue(session_.timeline.zoom, juce::dontSendNotification); + fineScrubToggle_.setToggleState(session_.timeline.fineScrub, juce::dontSendNotification); - engine::OfflineRenderPipeline pipeline; - const auto raw = pipeline.renderRawMix(previewSession, settings, {}, nullptr); - if (raw.cancelled || raw.mixBuffer.getNumSamples() == 0) { - return; - } + 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); + } - auto mastered = raw.mixBuffer; - if (session_.masterPlan.has_value()) { - mastered = autoMasterStrategy_.applyPlan(raw.mixBuffer, session_.masterPlan.value(), nullptr); + 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; } + } - previewEngine_.setBuffers(raw.mixBuffer, mastered); - const auto preview = previewEngine_.buildCrossfadedPreview(1024); - updateTransportFromBuffer(preview); - transportController_.seekToFraction(previousProgress); - } catch (const std::exception& error) { - reportEditor_.setText(reportEditor_.getText() + "\nPreview rebuild skipped: " + juce::String(error.what())); + 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()); @@ -948,6 +1503,7 @@ void MainComponent::rebuildPreviewBuffersAsync() { juce::Component::SafePointer safeThis(this); std::thread([safeThis, + generation, previewSession = std::move(previewSession), soloStemId, muteStemId, @@ -986,26 +1542,42 @@ void MainComponent::rebuildPreviewBuffersAsync() { 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, rawMix = raw.mixBuffer, mastered = std::move(mastered), preview = std::move(preview), previousProgress]() mutable { + [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, message]() { + 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); }); } @@ -1013,10 +1585,19 @@ void MainComponent::rebuildPreviewBuffersAsync() { } 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); @@ -1031,6 +1612,7 @@ void MainComponent::updateTransportDisplay() { ignoreTransportSliderChange_ = false; waveformPreview_.setPlayheadProgress(progress); + updateTransportLoopAndZoomUI(); if (transportController_.state() == engine::TransportController::State::Playing) { playPauseButton_.setButtonText("Pause"); @@ -1040,7 +1622,47 @@ void MainComponent::updateTransportDisplay() { const auto positionText = formatDuration(transportController_.positionSeconds()); const auto totalText = formatDuration(transportController_.totalSeconds()); - transportSlider_.setTooltip(positionText + " / " + totalText); + 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() { @@ -1097,21 +1719,42 @@ domain::MasterPreset MainComponent::selectedPlatformPreset() const { 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; const auto formatIt = codecFormatByComboId_.find(exportFormatBox_.getSelectedId()); settings.outputFormat = formatIt != codecFormatByComboId_.end() ? formatIt->second : "wav"; - if (!util::WavWriter::isFormatAvailable(settings.outputFormat)) { - settings.outputFormat = "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: @@ -1158,9 +1801,15 @@ domain::RenderSettings MainComponent::buildCurrentRenderSettings(const std::stri 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 | @@ -1174,66 +1823,100 @@ void MainComponent::onImport() { return; } - session_.stems.clear(); - std::vector importLines; + std::vector selectedFiles; + selectedFiles.reserve(static_cast(files.size())); + for (int i = 0; i < files.size(); ++i) { + selectedFiles.push_back(files.getReference(i)); + } - if (files.size() == 1 && separatedStemsToggle_.getToggleState()) { - try { - const auto selected = files.getReference(0); - const auto mixPath = std::filesystem::path(selected.getFullPathName().toStdString()); - const auto outputDir = mixPath.parent_path() / (mixPath.stem().string() + "_separated"); - - ai::StemSeparator separator; - const auto separationResult = separator.separate(mixPath, outputDir); - if (separationResult.success) { - session_.stems = separationResult.stems; - importLines.push_back("Separated import from: " + mixPath.string()); - 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()); + const bool useSeparation = separatedStemsToggle_.getToggleState(); + const int preferredStemCount = session_.preferredStemCount; + statusLabel_.setText("Importing files...", juce::dontSendNotification); + appendTaskHistory("Import started"); + + juce::Component::SafePointer safeThis(this); + std::thread([safeThis, selectedFiles = std::move(selectedFiles), useSeparation, preferredStemCount]() mutable { + std::vector importedStems; + std::vector importLines; + + if (selectedFiles.size() == 1 && useSeparation) { + try { + const auto mixPath = std::filesystem::path(selectedFiles.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 (stem.separationArtifactRisk.has_value()) { - line += " artifactRisk=" + std::to_string(stem.separationArtifactRisk.value()); + if (!separationResult.qaReportPath.empty()) { + importLines.push_back("Separation QA report: " + separationResult.qaReportPath.string()); } - importLines.push_back(line); + 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); } - 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"); } - } catch (const std::exception& error) { - importLines.push_back("Separation error: " + std::string(error.what())); } - } - if (session_.stems.empty()) { - for (int i = 0; i < files.size(); ++i) { - const auto& file = files.getReference(i); + if (importedStems.empty()) { + for (size_t i = 0; i < selectedFiles.size(); ++i) { + const auto& file = selectedFiles[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; - stem.enabled = true; - session_.stems.push_back(stem); + 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); + importLines.push_back(stem.name + " -> " + stem.filePath); + } } - } - statusLabel_.setText("Imported " + juce::String(static_cast(session_.stems.size())) + " stems", - juce::dontSendNotification); + juce::MessageManager::callAsync( + [safeThis, importedStems = std::move(importedStems), importLines = std::move(importLines)]() mutable { + if (safeThis == nullptr) { + return; + } + + safeThis->session_.stems = std::move(importedStems); + safeThis->statusLabel_.setText( + "Imported " + juce::String(static_cast(safeThis->session_.stems.size())) + " stems", + juce::dontSendNotification); + safeThis->appendTaskHistory("Imported " + juce::String(static_cast(safeThis->session_.stems.size())) + " stems"); - analysisEntries_.clear(); - analysisTableModel_.setEntries(&analysisEntries_); - analysisTable_.updateContent(); + safeThis->analysisEntries_.clear(); + safeThis->analysisTableModel_.setEntries(&safeThis->analysisEntries_); + safeThis->analysisTable_.updateContent(); - refreshStemRoutingSelectors(); - reportEditor_.setText(juce::String("Imported files:\n") + toJuceText(importLines)); - rebuildPreviewBuffers(); + safeThis->refreshStemRoutingSelectors(); + safeThis->reportEditor_.setText(juce::String("Imported files:\n") + toJuceText(importLines)); + safeThis->rebuildPreviewBuffersAsync(); + }); + }).detach(); importChooser_.reset(); }); @@ -1255,12 +1938,43 @@ void MainComponent::onImportOriginalMix() { } 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(); + { + std::scoped_lock lock(exportHealthCacheMutex()); + exportHealthCache().reset(); + } + + 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"); @@ -1277,11 +1991,18 @@ void MainComponent::onSaveSession() { 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(); @@ -1299,52 +2020,38 @@ void MainComponent::onLoadSession() { return; } - try { - session_ = sessionRepository_.load(selected.getFullPathName().toStdString()); - residualBlendSlider_.setValue(session_.residualBlend, juce::dontSendNotification); - exportBitrateSlider_.setValue(session_.renderSettings.lossyBitrateKbps, 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); - } + const auto selectedPath = selected.getFullPathName().toStdString(); + statusLabel_.setText("Loading session...", juce::dontSendNotification); + appendTaskHistory("Session load started: " + selected.getFullPathName()); - refreshRenderers(); - refreshCodecAvailability(); - refreshStemRoutingSelectors(); + juce::Component::SafePointer safeThis(this); + std::thread([safeThis, selectedPath]() { + engine::SessionRepository repository; + std::optional loadedSession; + juce::String errorMessage; - for (const auto& [comboId, rendererId] : rendererIdByComboId_) { - if (rendererId == session_.renderSettings.rendererName) { - rendererBox_.setSelectedId(comboId, juce::dontSendNotification); - break; - } + try { + loadedSession = repository.load(selectedPath); + } catch (const std::exception& error) { + errorMessage = error.what(); + } catch (...) { + errorMessage = "Unknown session load error"; } - for (const auto& [comboId, format] : codecFormatByComboId_) { - if (toLower(format) == toLower(session_.renderSettings.outputFormat)) { - exportFormatBox_.setSelectedId(comboId, juce::dontSendNotification); - break; + juce::MessageManager::callAsync([safeThis, selectedPath, loadedSession = std::move(loadedSession), errorMessage]() mutable { + if (safeThis == nullptr) { + return; } - } - analysisEntries_.clear(); - analysisTableModel_.setEntries(&analysisEntries_); - analysisTable_.updateContent(); - - statusLabel_.setText("Session loaded", juce::dontSendNotification); - reportEditor_.setText("Loaded session: " + selected.getFullPathName()); - rebuildPreviewBuffers(); - } catch (const std::exception& error) { - statusLabel_.setText("Load failed", juce::dontSendNotification); - reportEditor_.setText("Session load error:\n" + juce::String(error.what())); - } + 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"); + } + }); + }).detach(); loadSessionChooser_.reset(); }); @@ -1352,7 +2059,9 @@ void MainComponent::onLoadSession() { void MainComponent::onPreviewOriginal() { if (transportController_.totalSamples() == 0) { - rebuildPreviewBuffers(); + rebuildPreviewBuffersAsync(); + statusLabel_.setText("Building preview...", juce::dontSendNotification); + return; } const auto progress = transportController_.progress(); @@ -1360,15 +2069,19 @@ void MainComponent::onPreviewOriginal() { 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) { - rebuildPreviewBuffers(); + rebuildPreviewBuffersAsync(); + statusLabel_.setText("Building preview...", juce::dontSendNotification); + return; } const auto progress = transportController_.progress(); @@ -1376,10 +2089,12 @@ void MainComponent::onPreviewRendered() { 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() { @@ -1393,23 +2108,39 @@ void MainComponent::onAddExternalRenderer() { return; } - renderers::ExternalRendererConfig config; - config.id = "ExternalUserUI" + std::to_string(userExternalRendererConfigs_.size() + 1); - config.name = selected.getFileName().toStdString(); - config.binaryPath = selected.getFullPathName().toStdString(); - config.licenseId = "User-supplied"; - const auto validation = renderers::ExternalLimiterRenderer::validateBinary(config.binaryPath); - userExternalRendererConfigs_.push_back(config); + 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)); - refreshRenderers(); + juce::Component::SafePointer safeThis(this); + std::thread([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."); + }); + }).detach(); - const juce::String statusText = validation.valid ? "External renderer added" : "External renderer added (validation failed)"; - statusLabel_.setText(statusText, juce::dontSendNotification); - reportEditor_.setText(reportEditor_.getText() + - "\nAdded external renderer: " + selected.getFullPathName() + - "\nValidation: " + juce::String(validation.valid ? "passed" : "failed") + - " (" + juce::String(validation.diagnostics) + ")" + - "\nLicense note: user-supplied tool is not distributed by this app."); externalRendererChooser_.reset(); }); } @@ -1436,8 +2167,10 @@ void MainComponent::onPrefetchLame() { 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(); @@ -1479,6 +2212,7 @@ void MainComponent::onAutoMix() { taskRunning_.store(true); cancelButton_.setEnabled(true); statusLabel_.setText("Auto Mix started", juce::dontSendNotification); + appendTaskHistory("Auto Mix started"); const auto sessionSnapshot = session_; std::optional mixPack; @@ -1503,6 +2237,7 @@ void MainComponent::onAutoMix() { return; } safeThis->statusLabel_.setText(text, juce::dontSendNotification); + safeThis->appendTaskHistory(text); }); }; @@ -1567,11 +2302,13 @@ void MainComponent::onAutoMix() { if (!errorText.isEmpty()) { safeThis->statusLabel_.setText("Auto Mix failed", juce::dontSendNotification); safeThis->reportEditor_.setText(errorText); + safeThis->appendTaskHistory("Auto Mix failed"); return; } if (cancelled || safeThis->cancelRender_.load()) { safeThis->statusLabel_.setText("Auto Mix cancelled", juce::dontSendNotification); + safeThis->appendTaskHistory("Auto Mix cancelled"); return; } @@ -1588,6 +2325,7 @@ void MainComponent::onAutoMix() { } safeThis->statusLabel_.setText("Auto Mix plan generated", juce::dontSendNotification); + safeThis->appendTaskHistory("Auto Mix completed"); safeThis->rebuildPreviewBuffersAsync(); }); }).detach(); @@ -1608,6 +2346,7 @@ void MainComponent::onAutoMaster() { 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) { @@ -1637,21 +2376,59 @@ void MainComponent::onAutoMaster() { if (safeThis != nullptr) { cancelPtr = &safeThis->cancelRender_; } + std::mutex progressMutex; + auto lastProgressEmit = std::chrono::steady_clock::time_point {}; + double lastProgressFraction = -1.0; + std::string lastProgressStage; const auto rawMix = pipeline.renderRawMix( sessionSnapshot, settings, - [safeThis](const engine::RenderProgress& progress) { + [safeThis, &progressMutex, &lastProgressEmit, &lastProgressFraction, &lastProgressStage](const engine::RenderProgress& progress) { if (safeThis == nullptr) { return; } + 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; + } + juce::MessageManager::callAsync([safeThis, progress]() { if (safeThis == nullptr) { return; } + const bool cacheHit = progress.stage == "Mix render cache hit"; + if (cacheHit) { + safeThis->statusLabel_.setText("Auto Master: Using cached mix render (fast path)", + juce::dontSendNotification); + safeThis->appendTaskHistory("Auto Master using cached mix render"); + return; + } + safeThis->statusLabel_.setText("Auto Master: " + juce::String(progress.stage) + " " + juce::String(progress.fraction * 100.0, 1) + "%", juce::dontSendNotification); + if (progress.fraction >= 0.999 || progress.stage != "Summing stem buses") { + safeThis->appendTaskHistory("Auto Master " + juce::String(progress.stage) + " " + + juce::String(progress.fraction * 100.0, 1) + "%"); + } }); }, cancelPtr); @@ -1705,9 +2482,10 @@ void MainComponent::onAutoMaster() { } } - previewMaster = autoMasterStrategy.applyPlan(rawMixBuffer, masterPlan, &previewReport); if (masterInference != nullptr) { previewMaster = aiMaster.applyPlan(rawMixBuffer, masterPlan, autoMasterStrategy, &previewReport); + } else { + previewMaster = autoMasterStrategy.applyPlan(rawMixBuffer, masterPlan, &previewReport); } reportAppend += "\nMaster decisions:\n" + toJuceText(masterPlan.decisionLog); @@ -1736,11 +2514,13 @@ void MainComponent::onAutoMaster() { if (!errorText.isEmpty()) { safeThis->statusLabel_.setText("Auto Master failed", juce::dontSendNotification); safeThis->reportEditor_.setText(errorText); + safeThis->appendTaskHistory("Auto Master failed"); return; } if (cancelled) { safeThis->statusLabel_.setText("Auto Master cancelled", juce::dontSendNotification); + safeThis->appendTaskHistory("Auto Master cancelled"); return; } @@ -1752,6 +2532,7 @@ void MainComponent::onAutoMaster() { safeThis->updateMeterPanel(previewReport); safeThis->statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); + safeThis->appendTaskHistory("Auto Master completed"); if (!reportAppend.isEmpty()) { safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + reportAppend); } @@ -1775,29 +2556,60 @@ void MainComponent::onBatchImport() { return; } + 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 std::filesystem::path outputFolder = inputFolder / "automix_batch_exports"; - std::filesystem::create_directories(outputFolder); const auto baseRenderSettings = buildCurrentRenderSettings(""); + juce::Component::SafePointer safeThis(this); - cancelRender_.store(false); - cancelButton_.setEnabled(true); - taskRunning_.store(true); + std::thread([safeThis, inputFolder, outputFolder, baseRenderSettings]() mutable { + 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"; + } - engine::BatchQueueRunner batchQueueRunner; - auto items = batchQueueRunner.buildItemsFromFolder(inputFolder, outputFolder); - if (items.empty()) { - statusLabel_.setText("Batch folder has no supported audio files", juce::dontSendNotification); - taskRunning_.store(false); - cancelButton_.setEnabled(false); - batchImportChooser_.reset(); - return; - } + if (safeThis == nullptr) { + return; + } - statusLabel_.setText("Batch started", juce::dontSendNotification); - juce::Component::SafePointer safeThis(this); + if (!prepError.isEmpty() || items.empty()) { + juce::MessageManager::callAsync([safeThis, prepError]() { + if (safeThis == nullptr) { + return; + } + safeThis->taskRunning_.store(false); + safeThis->cancelButton_.setEnabled(false); + if (!prepError.isEmpty()) { + safeThis->statusLabel_.setText("Batch preparation failed", juce::dontSendNotification); + safeThis->reportEditor_.setText("Batch preparation error:\n" + prepError); + safeThis->appendTaskHistory("Batch preparation failed"); + } else { + safeThis->statusLabel_.setText("Batch folder has no supported audio files", juce::dontSendNotification); + safeThis->appendTaskHistory("Batch preparation found no supported files"); + } + }); + return; + } + + juce::MessageManager::callAsync([safeThis]() { + if (safeThis == nullptr) { + return; + } + safeThis->statusLabel_.setText("Batch started", juce::dontSendNotification); + }); - std::thread([safeThis, items = std::move(items), outputFolder, baseRenderSettings]() mutable { domain::BatchJob job; job.items = std::move(items); job.settings.outputFolder = outputFolder; @@ -1812,13 +2624,43 @@ void MainComponent::onBatchImport() { if (safeThis != nullptr) { cancelPtr = &safeThis->cancelRender_; } + 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; const auto result = runner.process( job, - [safeThis](const size_t itemIndex, const double progress, const std::string& stage) { + [safeThis, &progressMutex, &lastProgressEmit, &lastItemIndex, &lastProgress, &lastStage](const size_t itemIndex, + const double progress, + const std::string& stage) { if (safeThis == nullptr) { return; } + 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; + } + juce::MessageManager::callAsync([safeThis, itemIndex, progress, stage]() { if (safeThis == nullptr) { return; @@ -1826,6 +2668,8 @@ void MainComponent::onBatchImport() { safeThis->statusLabel_.setText("Batch item " + juce::String(static_cast(itemIndex + 1)) + " " + stage + " (" + juce::String(progress * 100.0, 1) + "%)", juce::dontSendNotification); + safeThis->appendTaskHistory("Batch item " + juce::String(static_cast(itemIndex + 1)) + + " " + stage + " " + juce::String(progress * 100.0, 1) + "%"); }); }, cancelPtr); @@ -1857,6 +2701,7 @@ void MainComponent::onBatchImport() { safeThis->cancelButton_.setEnabled(false); safeThis->statusLabel_.setText("Batch complete", juce::dontSendNotification); safeThis->reportEditor_.setText(summary); + safeThis->appendTaskHistory("Batch completed"); }); }).detach(); @@ -1875,6 +2720,25 @@ void MainComponent::onExport() { 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), @@ -1892,35 +2756,126 @@ void MainComponent::onExport() { auto settings = buildCurrentRenderSettings(selected.getFullPathName().toStdString()); const auto sessionCopy = session_; + const auto analysisSnapshot = analysisEntries_; + const bool quickExportMode = toLower(settings.exportSpeedMode) == kExportSpeedModeQuick; cancelRender_.store(false); taskRunning_.store(true); cancelButton_.setEnabled(true); statusLabel_.setText("Export started", juce::dontSendNotification); + appendTaskHistory("Export started: " + selected.getFullPathName()); juce::Component::SafePointer safeThis(this); - std::thread([safeThis, sessionCopy, settings]() mutable { + std::thread([safeThis, sessionCopy, settings, quickExportMode, analysisSnapshot = std::move(analysisSnapshot)]() mutable { renderers::RenderResult renderResult; juce::String crashMessage; + juce::String healthText; + std::vector analysisEntriesLocal = std::move(analysisSnapshot); + bool healthHasCriticalIssues = false; + size_t healthIssueCount = 0; try { + if (quickExportMode) { + healthText = "Quick export mode: stem-health preflight skipped for faster turnaround."; + } else { + if (analysisEntriesLocal.empty()) { + if (safeThis != nullptr) { + juce::MessageManager::callAsync([safeThis]() { + if (safeThis == nullptr) { + return; + } + safeThis->statusLabel_.setText("Export: analyzing stems", juce::dontSendNotification); + }); + } + + analysis::StemAnalyzer analyzer; + analysisEntriesLocal = analyzer.analyzeSession(sessionCopy); + } + const auto healthCacheKey = buildExportHealthCacheKey(sessionCopy, analysisEntriesLocal); + 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(sessionCopy, analysisEntriesLocal); + 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::atomic_bool* cancelPtr = nullptr; if (safeThis != nullptr) { cancelPtr = &safeThis->cancelRender_; } + + std::mutex progressMutex; + auto lastProgressEmit = std::chrono::steady_clock::time_point {}; + double lastProgressFraction = -1.0; + std::string lastProgressStage; + renderResult = renderer->render( sessionCopy, settings, - [safeThis](const double progress, const std::string& stage) { + [safeThis, &progressMutex, &lastProgressEmit, &lastProgressFraction, &lastProgressStage](const double progress, + const std::string& stage) { if (safeThis == nullptr) { return; } + 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; + } + juce::MessageManager::callAsync([safeThis, progress, stage]() { if (safeThis == nullptr) { return; } + if (stage == "Mix render cache hit") { + safeThis->statusLabel_.setText("Export: Using cached mix render (fast path)", + juce::dontSendNotification); + safeThis->appendTaskHistory("Export using cached mix render"); + return; + } safeThis->statusLabel_.setText("Export: " + juce::String(stage) + " (" + juce::String(progress * 100.0, 1) + "%)", juce::dontSendNotification); + if (progress >= 0.999 || stage != "Summing stem buses") { + safeThis->appendTaskHistory("Export " + juce::String(stage) + " " + + juce::String(progress * 100.0, 1) + "%"); + } }); }, cancelPtr); @@ -1930,31 +2885,65 @@ void MainComponent::onExport() { crashMessage = "Export exception:\nUnknown error"; } - juce::MessageManager::callAsync([safeThis, renderResult, crashMessage]() { + const auto exportSpeedMode = settings.exportSpeedMode; + juce::MessageManager::callAsync([safeThis, + renderResult, + crashMessage, + analysisEntriesLocal = std::move(analysisEntriesLocal), + quickExportMode, + exportSpeedMode, + healthText, + healthHasCriticalIssues, + healthIssueCount]() mutable { if (safeThis == nullptr) { return; } safeThis->taskRunning_.store(false); safeThis->cancelButton_.setEnabled(false); + if (!analysisEntriesLocal.empty()) { + safeThis->analysisEntries_ = std::move(analysisEntriesLocal); + safeThis->analysisTableModel_.setEntries(&safeThis->analysisEntries_); + safeThis->analysisTable_.updateContent(); + } if (!crashMessage.isEmpty()) { safeThis->statusLabel_.setText("Export crashed", juce::dontSendNotification); safeThis->reportEditor_.setText(crashMessage); + safeThis->appendTaskHistory("Export crashed"); return; } if (renderResult.cancelled) { safeThis->statusLabel_.setText("Export cancelled", juce::dontSendNotification); + safeThis->appendTaskHistory("Export cancelled"); return; } + if (quickExportMode) { + safeThis->appendTaskHistory("Quick export mode active: stem-health preflight skipped"); + } else if (healthIssueCount > 0) { + safeThis->appendTaskHistory("Stem health check found " + juce::String(static_cast(healthIssueCount)) + " issue(s)"); + } else { + safeThis->appendTaskHistory("Stem health check passed"); + } + safeThis->statusLabel_.setText(renderResult.success ? "Export complete" : "Export failed", juce::dontSendNotification); - safeThis->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)); + if (healthHasCriticalIssues && renderResult.success) { + safeThis->statusLabel_.setText("Export complete with critical stem health warnings", juce::dontSendNotification); + } + safeThis->appendTaskHistory(renderResult.success ? "Export completed" : "Export failed"); + juce::String report = juce::String("Renderer: ") + juce::String(renderResult.rendererName) + + juce::String("\nExport mode: ") + juce::String(exportSpeedMode) + + juce::String("\nOutput: ") + juce::String(renderResult.outputAudioPath) + + juce::String("\nReport: ") + juce::String(renderResult.reportPath) + + juce::String("\n\nLogs:\n") + toJuceText(renderResult.logs); + if (!healthText.isEmpty()) { + report += "\n\n"; + report += healthText; + } + safeThis->reportEditor_.setText(report); }); }).detach(); diff --git a/src/app/MainComponent.h b/src/app/MainComponent.h index 4d789af..1b01beb 100644 --- a/src/app/MainComponent.h +++ b/src/app/MainComponent.h @@ -1,11 +1,14 @@ #pragma once #include +#include #include #include +#include #include #include +#include #include #include "analysis/StemAnalyzer.h" @@ -14,6 +17,7 @@ #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" @@ -27,7 +31,8 @@ class MainComponent final : public juce::Component, private juce::ComboBox::Listener, private juce::Slider::Listener, private juce::Timer, - private juce::ChangeListener { + private juce::ChangeListener, + private juce::AudioIODeviceCallback { public: MainComponent(); ~MainComponent() override; @@ -60,9 +65,19 @@ class MainComponent final : public juce::Component, 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(); @@ -79,11 +94,20 @@ class MainComponent final : public juce::Component, 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; @@ -94,6 +118,8 @@ class MainComponent final : public juce::Component, 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 autoMixButton_ {"Auto Mix"}; @@ -103,6 +129,9 @@ class MainComponent final : public juce::Component, 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"}; @@ -113,8 +142,16 @@ class MainComponent final : public juce::Component, 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"}; @@ -126,6 +163,9 @@ class MainComponent final : public juce::Component, 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_; @@ -138,6 +178,8 @@ class MainComponent final : public juce::Component, AnalysisTableModel analysisTableModel_; juce::TableListBox analysisTable_; juce::TextEditor reportEditor_; + juce::Label taskCenterLabel_ {"taskCenterLabel", "Task Center"}; + juce::TextEditor taskCenterEditor_; domain::Session session_; analysis::StemAnalyzer analyzer_; @@ -147,6 +189,7 @@ class MainComponent final : public juce::Component, engine::AudioPreviewEngine previewEngine_; engine::TransportController transportController_; ai::ModelManager modelManager_; + juce::AudioDeviceManager audioDeviceManager_; std::vector analysisEntries_; std::vector rendererInfos_; std::vector userExternalRendererConfigs_; @@ -157,11 +200,18 @@ class MainComponent final : public juce::Component, std::map masterPresetByComboId_; std::map platformPresetByComboId_; std::map codecFormatByComboId_; + std::map exportSpeedModeByComboId_; std::map stemIdBySoloComboId_; std::map stemIdByMuteComboId_; + std::map projectProfileIdByComboId_; 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_; @@ -169,6 +219,8 @@ class MainComponent final : public juce::Component, std::unique_ptr saveSessionChooser_; std::unique_ptr loadSessionChooser_; std::unique_ptr externalRendererChooser_; + std::vector taskHistoryLines_; + std::vector projectProfiles_; }; } // namespace automix::app diff --git a/src/app/WaveformPreviewComponent.cpp b/src/app/WaveformPreviewComponent.cpp index e97e370..09f0389 100644 --- a/src/app/WaveformPreviewComponent.cpp +++ b/src/app/WaveformPreviewComponent.cpp @@ -14,7 +14,7 @@ void WaveformPreviewComponent::setBuffer(const engine::AudioBuffer& buffer) { return; } - const int columns = std::max(128, getWidth() > 0 ? getWidth() : 512); + 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); @@ -42,6 +42,24 @@ void WaveformPreviewComponent::setPlayheadProgress(const double progress) { 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)); @@ -54,16 +72,60 @@ void WaveformPreviewComponent::paint(juce::Graphics& g) { 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)); - const int columns = static_cast(waveform_.size()); - for (int x = 0; x < columns; ++x) { - const float value = std::clamp(waveform_[static_cast(x)], 0.0f, 1.0f); + 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(x, centerY - h, centerY + h); + 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))); } - const int playheadX = static_cast(std::round(playheadProgress_ * static_cast(columns - 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); diff --git a/src/app/WaveformPreviewComponent.h b/src/app/WaveformPreviewComponent.h index 066a28e..84ab8ff 100644 --- a/src/app/WaveformPreviewComponent.h +++ b/src/app/WaveformPreviewComponent.h @@ -12,12 +12,19 @@ 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/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index 6a75b0c..2faf8f3 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -70,9 +70,12 @@ void to_json(Json& j, const RenderSettings& value) { {"outputBitDepth", value.outputBitDepth}, {"outputPath", value.outputPath}, {"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}, {"rendererName", value.rendererName}, @@ -86,9 +89,17 @@ void from_json(const Json& j, RenderSettings& value) { 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", 192), 48, 512); + 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.rendererName = j.value("rendererName", "BuiltIn"); @@ -238,7 +249,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(); @@ -258,6 +280,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), 2, 6); + + 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/ProjectProfile.cpp b/src/domain/ProjectProfile.cpp new file mode 100644 index 0000000..7b4f125 --- /dev/null +++ b/src/domain/ProjectProfile.cpp @@ -0,0 +1,180 @@ +#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), 2, 6); + 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, + .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, + .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, + .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..af2bc31 --- /dev/null +++ b/src/domain/ProjectProfile.h @@ -0,0 +1,32 @@ +#pragma once + +#include +#include +#include +#include + +namespace automix::domain { + +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::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 28bb15b..1b97fe0 100644 --- a/src/domain/RenderSettings.h +++ b/src/domain/RenderSettings.h @@ -10,9 +10,12 @@ struct RenderSettings { int outputBitDepth = 24; std::string outputPath; std::string outputFormat = "auto"; + std::string exportSpeedMode = "final"; std::string gpuExecutionProvider = "auto"; - int lossyBitrateKbps = 192; + int lossyBitrateKbps = 320; int lossyQuality = 7; + bool mp3UseVbr = false; + int mp3VbrQuality = 4; int processingThreads = 0; bool preferHardwareAcceleration = true; std::string rendererName = "BuiltIn"; 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/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/BatchQueueRunner.cpp b/src/engine/BatchQueueRunner.cpp index 3526949..bb76c24 100644 --- a/src/engine/BatchQueueRunner.cpp +++ b/src/engine/BatchQueueRunner.cpp @@ -187,7 +187,15 @@ domain::BatchResult BatchQueueRunner::process(domain::BatchJob& job, } if (item.status == domain::BatchItemStatus::Pending) { - runAnalysisForItem(item); + 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."; + } } } }); @@ -203,7 +211,15 @@ domain::BatchResult BatchQueueRunner::process(domain::BatchJob& job, continue; } if (item.status == domain::BatchItemStatus::Pending) { - runAnalysisForItem(item); + 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."; + } } } } diff --git a/src/engine/OfflineRenderPipeline.cpp b/src/engine/OfflineRenderPipeline.cpp index 51f2ee6..6ca6edb 100644 --- a/src/engine/OfflineRenderPipeline.cpp +++ b/src/engine/OfflineRenderPipeline.cpp @@ -4,10 +4,16 @@ #include #include #include +#include +#include +#include +#include +#include #include #include -#include +#include #include +#include #include #include @@ -38,10 +44,265 @@ struct BiquadCoefficients { }; struct StemRenderNode { - AudioBuffer buffer; + 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(); +} + +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 {}; + } + + 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; +} + +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); @@ -286,8 +547,22 @@ int effectiveThreadCount(const domain::RenderSettings& settings, const int taskC return 1; } const int hardwareThreads = static_cast(std::max(1u, std::thread::hardware_concurrency())); - const int requested = settings.processingThreads > 0 ? settings.processingThreads : hardwareThreads; - return std::clamp(requested, 1, std::max(1, taskCount)); + 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, @@ -304,7 +579,14 @@ void addBlock(AudioBuffer& destination, for (int ch = 0; ch < channels; ++ch) { float* dst = destination.getWritePointer(ch); const float* src = source.getReadPointer(ch); - for (int i = 0; i < numSamples; ++i) { + 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]; } } @@ -312,11 +594,46 @@ void addBlock(AudioBuffer& destination, } // 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; @@ -370,17 +687,22 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s const auto& stem = session.stems[stemIndex]; try { - AudioBuffer buffer = workerFileIO.readAudioFile(stem.filePath); - if (buffer.getSampleRate() != static_cast(settings.outputSampleRate)) { - buffer = workerResampler.resampleLinear(buffer, static_cast(settings.outputSampleRate)); - } - 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 = processStemBuffer(buffer, decision, dryWet, 2), + .buffer = processed, .busId = busId, }; } catch (const std::exception& error) { @@ -434,7 +756,7 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s int maxSamples = 0; for (const auto& node : stemNodes) { - maxSamples = std::max(maxSamples, node.buffer.getNumSamples()); + maxSamples = std::max(maxSamples, node.buffer->getNumSamples()); } if (maxSamples == 0) { @@ -456,8 +778,9 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s 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()) { @@ -475,14 +798,14 @@ OfflineRenderResult OfflineRenderPipeline::renderRawMix(const domain::Session& s continue; } auto& busBuffer = busIt->second; - if (start >= node.buffer.getNumSamples()) { + if (start >= node.buffer->getNumSamples()) { continue; } - const int blockSamples = std::min(end, node.buffer.getNumSamples()) - start; - addBlock(busBuffer, node.buffer, start, blockSamples); + const int blockSamples = std::min(end, node.buffer->getNumSamples()) - start; + addBlock(busBuffer, *node.buffer, start, blockSamples); } - if (onProgress) { + 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"}); } @@ -553,6 +876,13 @@ 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"}); } 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 index 6ca1406..b687f27 100644 --- a/src/engine/TransportController.cpp +++ b/src/engine/TransportController.cpp @@ -1,6 +1,7 @@ #include "engine/TransportController.h" #include +#include namespace automix::engine { @@ -10,8 +11,16 @@ void TransportController::setTimeline(const int64_t totalSamples, const double s 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; } } @@ -56,6 +65,9 @@ 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; } @@ -80,9 +92,20 @@ void TransportController::advance(const int numSamples) { if (state_ != State::Playing || totalSamples_ <= 0 || numSamples <= 0) { return; } - positionSamples_ = std::min(totalSamples_, positionSamples_ + static_cast(numSamples)); + 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 (positionSamples_ >= totalSamples_) { + if (!loopEnabled_ && positionSamples_ >= totalSamples_) { state_ = State::Paused; } } @@ -92,6 +115,35 @@ void TransportController::advance(const int numSamples) { } } +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_; @@ -136,4 +188,41 @@ double TransportController::progress() const { 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 index 523fe88..60711d4 100644 --- a/src/engine/TransportController.h +++ b/src/engine/TransportController.h @@ -22,6 +22,8 @@ class TransportController final : public juce::ChangeBroadcaster { 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; @@ -30,11 +32,19 @@ class TransportController final : public juce::ChangeBroadcaster { [[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; }; diff --git a/src/renderers/BuiltInRenderer.cpp b/src/renderers/BuiltInRenderer.cpp index c70a178..4ac6f4e 100644 --- a/src/renderers/BuiltInRenderer.cpp +++ b/src/renderers/BuiltInRenderer.cpp @@ -2,6 +2,8 @@ #include #include +#include +#include #include @@ -14,6 +16,43 @@ #include "util/WavWriter.h" namespace automix::renderers { +namespace { + +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 bool BuiltInRenderer::isAvailable() const { return true; } @@ -47,8 +86,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,13 +109,25 @@ 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())); + } + } const std::filesystem::path outputPath = settings.outputPath.empty() ? "export_master.wav" : settings.outputPath; writer.write(outputPath, mastered, settings.outputBitDepth, settings.outputFormat, settings.lossyBitrateKbps, - settings.lossyQuality); + settings.lossyQuality, + settings.mp3UseVbr, + settings.mp3VbrQuality, + sourceMetadata); const std::filesystem::path reportPath = outputPath.string() + ".report.json"; nlohmann::json report = { @@ -93,9 +145,14 @@ RenderResult BuiltInRenderer::render(const domain::Session& session, {"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}, {"targetLufs", plan.targetLufs}, {"targetTruePeakDbtp", plan.truePeakDbtp}, {"limiterCeilingDb", plan.limiterCeilingDb}, diff --git a/src/renderers/ExternalLimiterRenderer.cpp b/src/renderers/ExternalLimiterRenderer.cpp index 5654db8..da205ad 100644 --- a/src/renderers/ExternalLimiterRenderer.cpp +++ b/src/renderers/ExternalLimiterRenderer.cpp @@ -5,8 +5,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -14,7 +16,9 @@ #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/WavWriter.h" @@ -22,6 +26,40 @@ namespace automix::renderers { namespace { +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; +} + RenderResult fallbackToBuiltIn(const domain::Session& session, const domain::RenderSettings& settings, const IRenderer::ProgressCallback& onProgress, @@ -264,9 +302,26 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, util::WavWriter writer; writer.write(tempInputPath, rawResult.mixBuffer, settings.outputBitDepth); - const auto plan = session.masterPlan.has_value() ? session.masterPlan.value() - : automaster::HeuristicAutoMasterStrategy().buildPlan( - domain::MasterPreset::DefaultStreaming, rawResult.mixBuffer); + 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()}, @@ -282,8 +337,15 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, {"limiterReleaseMs", plan.limiterReleaseMs}, {"preGainDb", plan.preGainDb}, {"outputFormat", outputFormat}, + {"exportSpeedMode", settings.exportSpeedMode}, {"lossyBitrateKbps", settings.lossyBitrateKbps}, {"lossyQuality", settings.lossyQuality}, + {"gpuExecutionProvider", settings.gpuExecutionProvider}, + {"preferHardwareAcceleration", settings.preferHardwareAcceleration}, + {"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); @@ -337,9 +399,16 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, engine::AudioFileIO fileIo; auto mastered = fileIo.readAudioFile(tempLimiterOutputPath); - automaster::HeuristicAutoMasterStrategy strategy; 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())); + } + } const auto boundedPlan = compliance.enforcePlanBounds(plan); const auto checked = compliance.enforceOutput(mastered, boundedPlan, strategy, &complianceReport); @@ -349,9 +418,11 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, settings.outputBitDepth, settings.outputFormat, settings.lossyBitrateKbps, - settings.lossyQuality); + settings.lossyQuality, + settings.mp3UseVbr, + settings.mp3VbrQuality, + sourceMetadata); - analysis::StemAnalyzer analyzer; const auto spectrum = analyzer.analyzeBuffer(mastered); const std::filesystem::path reportPath = outputPath.string() + ".report.json"; @@ -372,9 +443,16 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, {"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}, {"targetLufs", boundedPlan.targetLufs}, {"targetTruePeakDbtp", boundedPlan.truePeakDbtp}, {"limiterCeilingDb", boundedPlan.limiterCeilingDb}, diff --git a/src/renderers/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index 7908c6a..6e81864 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -15,6 +16,7 @@ #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" @@ -34,6 +36,40 @@ bool pathExists(const std::filesystem::path& path) { return std::filesystem::exists(path, error); } +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; +} + class CurrentWorkingDirectoryGuard final { public: explicit CurrentWorkingDirectoryGuard(const std::filesystem::path& path) @@ -147,9 +183,26 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, } automaster::HeuristicAutoMasterStrategy strategy; - const auto plan = session.masterPlan.has_value() - ? session.masterPlan.value() - : strategy.buildPlan(domain::MasterPreset::DefaultStreaming, rawMix); + 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); @@ -223,6 +276,14 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, engine::AudioFileIO fileIO; 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())); + } + } ai::MasteringCompliance compliance; const auto boundedPlan = compliance.enforcePlanBounds(plan); @@ -233,9 +294,11 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, settings.outputBitDepth, settings.outputFormat, settings.lossyBitrateKbps, - settings.lossyQuality); + settings.lossyQuality, + settings.mp3UseVbr, + settings.mp3VbrQuality, + sourceMetadata); - analysis::StemAnalyzer analyzer; const auto spectrumMetrics = analyzer.analyzeBuffer(mastered); const std::filesystem::path reportPath = outputPath.string() + ".report.json"; @@ -253,9 +316,16 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, {"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}, {"preGainDb", boundedPlan.preGainDb}, {"targetLufs", boundedPlan.targetLufs}, {"targetTruePeakDbtp", boundedPlan.truePeakDbtp}, diff --git a/src/renderers/RendererRegistry.cpp b/src/renderers/RendererRegistry.cpp index 6d5e492..83d5833 100644 --- a/src/renderers/RendererRegistry.cpp +++ b/src/renderers/RendererRegistry.cpp @@ -1,9 +1,13 @@ #include "renderers/RendererRegistry.h" #include +#include #include #include #include +#include +#include +#include #include @@ -13,11 +17,122 @@ 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); } +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::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 = toHex(fnv1a64(canonical.dump())); + return digest == signatureValue; +} + RendererInfo makeBuiltInInfo() { RendererInfo info; info.id = "BuiltIn"; @@ -28,6 +143,7 @@ RendererInfo makeBuiltInInfo() { info.bundledByDefault = true; info.available = true; info.discovery = "Always available (core renderer)."; + info.trustPolicyStatus = "trusted:built-in"; return info; } @@ -45,6 +161,7 @@ RendererInfo makePhaseLimiterInfo() { 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; @@ -53,6 +170,35 @@ RendererInfo makePhaseLimiterInfo() { 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; @@ -62,6 +208,8 @@ RendererInfo makeExternalInfo(const ExternalRendererConfig& config) { 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; @@ -75,16 +223,25 @@ RendererInfo makeExternalInfo(const ExternalRendererConfig& config) { 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) { +std::optional loadExternalRendererDescriptor(const std::filesystem::path& descriptorPath, + const TrustPolicy& trustPolicy) { try { std::ifstream in(descriptorPath); if (!in.is_open()) { @@ -108,9 +265,49 @@ std::optional loadExternalRendererDescriptor(const std:: 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; @@ -137,7 +334,7 @@ std::vector assetLimiterRoots() { return roots; } -std::vector discoverAssetExternalRenderers() { +std::vector discoverAssetExternalRenderers(const TrustPolicy& trustPolicy) { std::vector configs; std::set seenDescriptors; std::error_code error; @@ -162,8 +359,15 @@ std::vector discoverAssetExternalRenderers() { continue; } - const auto config = loadExternalRendererDescriptor(entry.path()); + 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()); } } @@ -175,8 +379,10 @@ std::vector discoverAssetExternalRenderers() { } // namespace std::vector RendererRegistry::list(const std::vector& externalConfigs) const { + const auto trustPolicy = loadTrustPolicy(); + std::vector infos; - const auto assetConfigs = discoverAssetExternalRenderers(); + const auto assetConfigs = discoverAssetExternalRenderers(trustPolicy); infos.reserve(2 + externalConfigs.size() + assetConfigs.size()); infos.push_back(makeBuiltInInfo()); infos.push_back(makePhaseLimiterInfo()); @@ -189,7 +395,12 @@ std::vector RendererRegistry::list(const std::vector pinnedProfileIds; + std::filesystem::path capabilitySnapshotPath; }; struct ExternalRendererConfig { @@ -30,6 +33,13 @@ struct ExternalRendererConfig { 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 { diff --git a/src/util/WavWriter.cpp b/src/util/WavWriter.cpp index b0a2ee7..43deb17 100644 --- a/src/util/WavWriter.cpp +++ b/src/util/WavWriter.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,91 @@ int qualityIndexFromRequested(const juce::StringArray& options, const int reques return std::clamp(requestedQuality, 0, options.size() - 1); } +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))); + } + } + 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"}; @@ -273,9 +359,9 @@ std::unique_ptr createWriterForFormat(const std::string const int outputBitDepth, const int bitrate, const int quality, + const std::map& sourceMetadata, std::string* detail) { - juce::StringPairArray metadata; - metadata.set("bitrate", juce::String(bitrate)); + const auto metadata = buildWriterMetadata(bitrate, sourceMetadata); std::unique_ptr writer; const auto normalized = toLower(format); @@ -405,14 +491,62 @@ bool encodeMp3WithExternalLame(const std::filesystem::path& lamePath, 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"); - command.add("-b"); - command.add(std::to_string(std::clamp(bitrateKbps, 48, 320))); + 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()); @@ -495,7 +629,7 @@ std::vector WavWriter::getAvailableFormats() { 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); + auto writer = createWriterForFormat(format, stream.get(), 44100.0, 2, 24, 192, 7, {}, &detail); const bool knownByManager = formatExistsInManager(manager, extension); bool available = writer != nullptr; @@ -551,7 +685,10 @@ void WavWriter::write(const std::filesystem::path& path, const int bitDepth, const std::string& preferredFormat, const int lossyBitrateKbps, - const int lossyQuality) const { + 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); @@ -567,14 +704,20 @@ void WavWriter::write(const std::filesystem::path& path, } std::string detail; - auto writer = createWriterForFormat(format, - stream.get(), - buffer.getSampleRate(), - buffer.getNumChannels(), - outputBitDepth, - bitrate, - quality, - &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(); @@ -596,7 +739,15 @@ void WavWriter::write(const std::filesystem::path& path, try { write(tempWavPath, buffer, outputBitDepth, "wav", bitrate, quality); - if (encodeMp3WithExternalLame(*lamePath, tempWavPath, path, bitrate, quality, &lameDetail)) { + if (encodeMp3WithExternalLame(*lamePath, + tempWavPath, + path, + bitrate, + quality, + mp3UseVbr, + mp3VbrQuality, + sourceMetadata, + &lameDetail)) { std::filesystem::remove(tempWavPath, error); return; } diff --git a/src/util/WavWriter.h b/src/util/WavWriter.h index 8ea8eec..87ccca8 100644 --- a/src/util/WavWriter.h +++ b/src/util/WavWriter.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -20,8 +21,11 @@ class WavWriter { const engine::AudioBuffer& buffer, int bitDepth, const std::string& preferredFormat = "auto", - int lossyBitrateKbps = 192, - int lossyQuality = 7) const; + 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); diff --git a/tests/unit/ProjectProfileTests.cpp b/tests/unit/ProjectProfileTests.cpp new file mode 100644 index 0000000..ce6e4c5 --- /dev/null +++ b/tests/unit/ProjectProfileTests.cpp @@ -0,0 +1,66 @@ +#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}, + {"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->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/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 0c93599..72c13a4 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -13,10 +13,21 @@ TEST_CASE("Session serialization round trip preserves required fields", "[sessio 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.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"; @@ -44,9 +55,20 @@ TEST_CASE("Session serialization round trip preserves required fields", "[sessio 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.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]") { @@ -64,9 +86,17 @@ TEST_CASE("Session deserialization handles missing optional fields", "[session]" REQUIRE(decoded.residualBlend == Catch::Approx(0.0)); REQUIRE(decoded.renderSettings.blockSize == 1024); REQUIRE(decoded.renderSettings.outputFormat == "auto"); - REQUIRE(decoded.renderSettings.lossyBitrateKbps == 192); + 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.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 index 7ca8477..7adc9b8 100644 --- a/tests/unit/StemSeparatorTests.cpp +++ b/tests/unit/StemSeparatorTests.cpp @@ -58,6 +58,12 @@ TEST_CASE("Stem separator uses model-backed overlap-add when model metadata is p 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)); @@ -71,3 +77,30 @@ TEST_CASE("Stem separator uses model-backed overlap-add when model metadata is p 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 index 7ad30a7..eba72f8 100644 --- a/tests/unit/TransportControllerTests.cpp +++ b/tests/unit/TransportControllerTests.cpp @@ -30,3 +30,23 @@ TEST_CASE("TransportController tracks play/seek/advance state", "[transport]") { 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/dev_tools.cpp b/tools/dev_tools.cpp index a1a66fd..48c6c78 100644 --- a/tools/dev_tools.cpp +++ b/tools/dev_tools.cpp @@ -1,33 +1,57 @@ +#include #include +#include #include +#include +#include +#include #include #include #include +#include #include +#include #include +#include +#include #include #include #include #include +#include #include #include #include "ai/IModelInference.h" #include "ai/FeatureSchema.h" +#include "ai/AutoMasterStrategyAI.h" +#include "ai/AutoMixStrategyAI.h" #include "ai/ModelPackLoader.h" #include "ai/OnnxModelInference.h" #include "ai/RtNeuralInference.h" +#include "analysis/StemHealthAssistant.h" #include "analysis/StemAnalyzer.h" +#include "automaster/HeuristicAutoMasterStrategy.h" +#include "automix/HeuristicAutoMixStrategy.h" +#include "domain/MasterPlan.h" +#include "domain/ProjectProfile.h" +#include "domain/Session.h" +#include "domain/JsonSerialization.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/BuiltInRenderer.h" +#include "renderers/RendererFactory.h" +#include "renderers/RendererRegistry.h" #include "util/LameDownloader.h" #include "util/WavWriter.h" #include "renderers/ExternalLimiterRenderer.h" +#include "RegressionHarness.h" namespace { @@ -57,6 +81,151 @@ bool hasFlag(const std::vector& args, const std::string& key) { return std::find(args.begin(), args.end(), key) != args.end(); } +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::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; +} + +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(); +} + +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; + } +} + +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::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; +} + +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); +} + automix::engine::AudioBuffer sliceBuffer(const automix::engine::AudioBuffer& input, const int maxSamples) { const int outputSamples = std::max(0, std::min(input.getNumSamples(), maxSamples)); @@ -154,6 +323,374 @@ void copyDirectory(const std::filesystem::path& source, const std::filesystem::p } } +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) { + 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); +} + +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; +} + +class DeterministicPlanDiffInference final : public automix::ai::IModelInference { + public: + bool isAvailable() const override { return true; } + + bool loadModel(const std::filesystem::path&) override { + loaded_ = true; + return true; + } + + automix::ai::InferenceResult run(const automix::ai::InferenceRequest& request) const override { + 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; + } + + private: + bool loaded_ = true; +}; + +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(); +} + +struct JsonMergeTelemetry { + bool preferRight = true; + size_t conflictCount = 0; + std::vector conflictPaths; +}; + +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 mergeJsonNode(const std::optional& base, + const std::optional& left, + const std::optional& right, + const std::string& path, + JsonMergeTelemetry* telemetry); + +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 = mergeJsonNode(baseItem, + leftItem, + rightItem, + path + "/" + keyField + "=" + key, + telemetry); + if (mergedItem.has_value()) { + merged.push_back(mergedItem.value()); + } + } + + return merged; +} + +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; +} + int commandListSupportedModels() { std::cout << "Supported model packs:\n"; for (const auto& pack : supportedModelPacks()) { @@ -287,6 +824,10 @@ int commandExportFeatures(const std::vector& args) { 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); @@ -354,6 +895,45 @@ int commandExportFeatures(const std::vector& args) { } 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; } @@ -528,12 +1108,804 @@ int commandValidateExternalLimiter(const std::vector& args) { return validation.valid ? 0 : 1; } +int commandStemHealth(const std::vector& 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 commandCompareRenders(const std::vector& 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 std::vector& 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 commandSessionDiff(const std::vector& 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 std::vector& 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 commandExternalLimiterCompat(const std::vector& 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 commandGoldenEval(const std::vector& 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 std::vector& 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; +} + void printUsage() { std::cout << "Usage:\n"; - std::cout << " automix_dev_tools export-features --session --out \n"; + std::cout << " automix_dev_tools export-features --session --out [--manifest ] [--dataset-id ] [--source-tag ] [--lineage-parents ]\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 << " automix_dev_tools validate-external-limiter --binary [--json]\n"; + std::cout << " automix_dev_tools stem-health --session [--out ] [--json]\n"; + std::cout << " automix_dev_tools compare-renders --session [--renderers ] [--out-dir ] [--format ] [--external-binary ] [--json]\n"; + std::cout << " automix_dev_tools catalog-process --input --output [--checkpoint ] [--resume] [--renderer ] [--format ] [--analysis-threads ] [--render-parallelism ] [--csv ] [--json ]\n"; + std::cout << " automix_dev_tools session-diff --base --head [--out ] [--summary]\n"; + std::cout << " automix_dev_tools session-merge --base --left --right --out [--prefer ] [--report ] [--json]\n"; + std::cout << " automix_dev_tools external-limiter-compat --binary [--timeout-ms ] [--required-features ] [--out ] [--json]\n"; + std::cout << " automix_dev_tools golden-eval [--baseline ] [--work-dir ] [--out ] [--json]\n"; + std::cout << " automix_dev_tools plan-diff --session [--mix-model ] [--master-model ] [--out ] [--json]\n"; std::cout << " automix_dev_tools list-supported-models\n"; std::cout << " automix_dev_tools install-supported-model --id [--dest ]\n"; std::cout << " automix_dev_tools list-supported-limiters\n"; @@ -569,6 +1941,30 @@ int main(int argc, char** argv) { if (command == "validate-external-limiter") { return commandValidateExternalLimiter(args); } + if (command == "stem-health") { + return commandStemHealth(args); + } + if (command == "compare-renders") { + return commandCompareRenders(args); + } + if (command == "catalog-process") { + return commandCatalogProcess(args); + } + if (command == "session-diff") { + return commandSessionDiff(args); + } + if (command == "session-merge") { + return commandSessionMerge(args); + } + if (command == "external-limiter-compat") { + return commandExternalLimiterCompat(args); + } + if (command == "golden-eval") { + return commandGoldenEval(args); + } + if (command == "plan-diff") { + return commandPlanDiff(args); + } if (command == "list-supported-models") { return commandListSupportedModels(); } From aea6c68b75a899b08813060f22568d3b9e1cf943 Mon Sep 17 00:00:00 2001 From: Soficis Date: Sun, 15 Feb 2026 19:07:28 -0600 Subject: [PATCH 04/20] feat: add HF model hub workflows, metadata policy controls, and ops automation - Add Hugging Face model hub integration: - new hub service for discovery/install/update checks - new GUI Models menu actions - new CLI commands: model-browse/model-install/model-health - Add metadata policy engine and wire it through: - project profiles, render settings, JSON serialization - built-in/external/phaselimiter renderer export metadata paths - unit tests for metadata policy behavior - Expand dev tooling: - profile-export/profile-import - adaptive-assistant and session-review - eval-trend and batch-studio-api launcher - add tools/batch_studio_api.py - Add nightly CI workflow for golden eval trend artifacts - Apply repository-wide EOL/style normalization across source/config/tests/assets --- .github/workflows/nightly_golden_eval.yml | 41 ++ CMakeLists.txt | 3 + README.md | 146 +++-- assets/profiles/project_profiles.json | 6 + dumpbin_members.err | 0 src/ai/HuggingFaceModelHub.cpp | 706 ++++++++++++++++++++++ src/ai/HuggingFaceModelHub.h | 64 ++ src/app/MainComponent.cpp | 320 ++++++++++ src/app/MainComponent.h | 8 + src/domain/JsonSerialization.cpp | 11 + src/domain/ProjectProfile.cpp | 17 + src/domain/ProjectProfile.h | 3 + src/domain/RenderSettings.h | 3 + src/renderers/BuiltInRenderer.cpp | 10 +- src/renderers/ExternalLimiterRenderer.cpp | 12 +- src/renderers/PhaseLimiterRenderer.cpp | 10 +- src/util/MetadataPolicy.cpp | 95 +++ src/util/MetadataPolicy.h | 15 + tests/unit/MetadataPolicyTests.cpp | 45 ++ tests/unit/ProjectProfileTests.cpp | 4 + tests/unit/SessionSerializationTests.cpp | 6 + tools/batch_studio_api.py | 264 ++++++++ tools/dev_tools.cpp | 623 +++++++++++++++++++ 23 files changed, 2353 insertions(+), 59 deletions(-) create mode 100644 .github/workflows/nightly_golden_eval.yml create mode 100644 dumpbin_members.err create mode 100644 src/ai/HuggingFaceModelHub.cpp create mode 100644 src/ai/HuggingFaceModelHub.h create mode 100644 src/util/MetadataPolicy.cpp create mode 100644 src/util/MetadataPolicy.h create mode 100644 tests/unit/MetadataPolicyTests.cpp create mode 100644 tools/batch_studio_api.py 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/CMakeLists.txt b/CMakeLists.txt index 97fbb62..c4e13d7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,6 +110,7 @@ add_library(automix_core 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 @@ -122,6 +123,7 @@ add_library(automix_core src/ai/StemSeparator.cpp src/ai/StemRoleClassifierAI.cpp src/util/LameDownloader.cpp + src/util/MetadataPolicy.cpp src/util/WavWriter.cpp ) @@ -274,6 +276,7 @@ if(BUILD_TESTING) 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 diff --git a/README.md b/README.md index 522c991..86a3d93 100644 --- a/README.md +++ b/README.md @@ -4,47 +4,52 @@ AutoMixMaster is a JUCE/CMake desktop app for deterministic stem mixing and mast `Analysis -> MixPlan -> MasterPlan -> Renderer -> Audio + JSON report` -This branch implements the **v2 roadmap** in `docs/roadmap.md` including native ONNX session support (when linked), advanced separation variants, precision transport UX, trust-policy renderer discovery, and expanded developer tooling for evaluation, catalog processing, and collaboration. +This branch implements the **v2 roadmap** in `docs/roadmap.md` and follow-up Phase V/Future Suggestion work, including an automated Hugging Face model browser/downloader, metadata policy profiles, and expanded automation APIs/tooling. ## Since Last Published `ai_dev` Commit -Compared to `origin/ai_dev` commit `7311aa8`, this working tree adds: - -1. Native ONNX Runtime session wiring -- CMake now auto-detects ONNX Runtime headers/libs when `ENABLE_ONNX=ON` and enables true native sessions (`AUTOMIX_HAS_NATIVE_ORT=1`) when found. -- Runtime provider canonicalization/tuning and native profiling artifact capture are now included. - -2. Advanced separator variants + QA bundle -- `StemSeparator` now supports 2/4/6 stem variants with per-run `targetStemCount`, `gpuMemoryBudgetMb`, and `maxStreams` controls. -- Variant discovery supports `separator_pack.json` and fallback model filenames. -- Separation runs now emit `separation_qa_report.json` with `energyLeakage`, `residualDistortion`, and `transientRetention`. - -3. Session/profile/trust-policy data model expansion -- Added project profile catalog support and default profile loader: `assets/profiles/project_profiles.json`. -- Added renderer trust policy support: `assets/renderers/trust_policy.json`. -- Session serialization now includes timeline state, profile/safety identifiers, and MP3 mode fields. - -4. UI/transport and responsiveness hardening -- Main window is now resizable (`1280x720` default). -- Added loop in/out controls, timeline zoom, fine-scrub behavior, and loop overlay in waveform preview. -- Session loading, stem import/separation, and export preflight checks are pushed off the UI thread with stale-result guards. -- Task center history and progress updates are throttled to reduce UI stalls. - -5. Render/export pipeline upgrades -- Added `Final` / `Balanced` / `Quick` export speed modes with Quick mode defaults for fast turnaround. -- MP3 now supports CBR and true VBR paths (`mp3UseVbr`, `mp3VbrQuality`), including external LAME VBR mode. -- Export metadata is preserved from original mix (or stem fallback) and mapped to MP3 ID3 tags. -- Offline render pipeline adds stem/raw mix caching and adaptive block-sizing for repeated renders. - -6. Renderer trust/compliance/reporting parity -- External descriptor trust evaluation supports signature metadata (`fnv1a64`) and optional signed-only enforcement. -- Discovered external renderers now emit capability snapshots (`*.capabilities.snapshot.json`). -- `ExternalLimiter` and `PhaseLimiter` now apply original-mix soft-target guidance when no master plan is pinned, matching built-in behavior. -- Render reports now include plan-source and decision-log provenance metadata. - -7. Dev tools and test coverage expansion -- `automix_dev_tools` now includes comparator, catalog processing, session diff/merge, external compatibility, golden eval, plan diff, model/limiter catalog installers, and LAME fallback installer. -- Added/expanded tests for project profile loading, trust-policy enforcement, transport looping, separator variants/QA output, and session schema fields. +Compared to published `origin/ai_dev` commit `a6b7173`, this working tree adds: + +1. Automatic AI model browser + downloader (Hugging Face) +- New app `Models` menu with: + - `Browse & Download Models` + - `Installed Models` + - `Check Updates` + - `Integrity & Licenses` + - `Open Model Hub Folder` +- New `src/ai/HuggingFaceModelHub.*` service for discovery, model-info fetch, revision-pinned install, install registry/logging, and update checks. +- Discovery is dynamic (no fixed manual catalog), focused on proven music-audio model families (`demucs`, `mdx23c`, `bs-roformer`, `mel-band-roformer`, `open-unmix`, `clap`, `panns`, `basic-pitch`). + +2. Secure Hugging Face token handling +- Gated/private access supports env-based token resolution: + - `AUTOMIX_HF_TOKEN` + - `HF_TOKEN` + - `HUGGINGFACE_TOKEN` + - `HUGGINGFACE_HUB_TOKEN` +- Tokens are not persisted in config files/logs; install metadata stores only `tokenUsed` boolean. + +3. Future suggestions implemented end-to-end +- Profile sharing simplified to import/export workflows (`profile-export`, `profile-import`). +- Adaptive fix-chain assistant (`adaptive-assistant`). +- Guided collaboration review (`session-review`). +- Batch Studio remote API (`batch-studio-api` launcher + `tools/batch_studio_api.py`). +- Continuous eval trend automation (`eval-trend`) and nightly CI workflow. +- Metadata policy profiles fully wired through domain, renderer pipeline, and reports. + +4. Metadata policy profile system +- Added profile/session/render settings fields: + - `metadataPolicy` + - `metadataTemplate` +- Implemented policy engine with `copy_all`, `copy_common`/`copy_common_only`, `strip`, and `override_template`. +- Built-in and external renderer paths now apply policy before writing exports. + +5. CI + test coverage updates +- Added nightly trend workflow: `.github/workflows/nightly_golden_eval.yml`. +- Added metadata policy unit tests and expanded profile/session serialization coverage. + +6. Non-functional repository normalization +- Large repository-wide line-ending/style normalization touched many files (source, config, tests, and bundled PhaseLimiter license/resource files). +- Effective logic changes remain concentrated in the files listed above; most other touched files are formatting/EOL-only churn. ## Implemented Scope (v2) @@ -75,27 +80,29 @@ Compared to `origin/ai_dev` commit `7311aa8`, this working tree adds: ## Product Suggestions Implemented -1. Project Profiles -- Data-driven profile bundles (platform target, renderer, codec, model packs, safety policy, preferred stem count). -- Profile selector in UI with auto-application to render/model settings. -- Strict safety policy pinning blocks export when renderer is not profile-pinned. +1. Profile Sharing (import/export) +- Simplified marketplace scope to direct sharing workflows. +- Added `automix_dev_tools profile-export` and `profile-import`. -2. Multi-Render Comparator -- `automix_dev_tools compare-renders` renders multiple targets and ranks by loudness/compliance/artifact risk score. -- JSON and CSV comparator reports are generated. +2. Adaptive Assistant +- Added `automix_dev_tools adaptive-assistant` to generate fix chains from stem health + optional comparator context. -3. Stem Health Assistant -- Pre-export diagnostics for masking, pumping, harshness, and mono risk. -- Integrated into app export flow and available via CLI (`stem-health`). +3. Continuous Evaluation +- Added `automix_dev_tools eval-trend`. +- Added nightly CI workflow for golden-eval trend artifacts. -4. Catalog Processing Mode -- `catalog-process` headless queue runner with JSON/CSV deliverables. -- Resumable checkpoints via `--checkpoint` and `--resume`. -- Per-item failure handling (unreadable files are marked failed, not fatal to whole run). +4. Batch Studio API +- Added `tools/batch_studio_api.py` (`/health`, `/v1/catalog/process`, `/v1/reports/ingest`). +- Added launcher command `automix_dev_tools batch-studio-api`. -5. Creator Collaboration Mode -- `session-diff` for deterministic JSON patch output. -- `session-merge` three-way deterministic merge with conflict reporting and left/right preference policy. +5. Guided Collaboration Review +- Added `automix_dev_tools session-review` with semantic highlights over session diffs. + +6. Metadata Policy Profiles +- Added profile-level metadata policy + template support through serialization, profiles, and renderers. + +7. Asynchronous Mastering Engine +- Long-running render/master tasks run in worker threads with cancellation checkpoints and adaptive chunk sizing. ## Build @@ -134,7 +141,7 @@ Built executable: ctest --test-dir build-codex --output-on-failure -j4 ``` -As of **February 15, 2026**, this branch passes **55/55 tests**. +As of **February 15, 2026**, this branch passes **58/58 tests**. Coverage includes project profile loading, renderer trust-policy enforcement, transport loop behavior, separator variant/QA outputs, and session schema round-trip fields. ## GUI Workflow @@ -216,9 +223,18 @@ automix_dev_tools compare-renders --session [--renderers --output [--checkpoint ] [--resume] [--renderer ] [--format ] [--analysis-threads ] [--render-parallelism ] [--csv ] [--json ] automix_dev_tools session-diff --base --head [--out ] [--summary] automix_dev_tools session-merge --base --left --right --out [--prefer ] [--report ] [--json] +automix_dev_tools profile-export --out [--id ] +automix_dev_tools profile-import --in [--out ] +automix_dev_tools adaptive-assistant --session [--compare-report ] [--out ] [--json] +automix_dev_tools session-review --base --head [--out ] [--json] +automix_dev_tools model-browse [--limit ] [--token-env ] [--out ] [--json] +automix_dev_tools model-install --repo [--dest ] [--token-env ] [--force] [--out ] [--json] +automix_dev_tools model-health [--root ] [--out ] [--json] automix_dev_tools external-limiter-compat --binary [--timeout-ms ] [--required-features ] [--out ] [--json] automix_dev_tools golden-eval [--baseline ] [--work-dir ] [--out ] [--json] +automix_dev_tools eval-trend [--baseline ] [--work-dir ] [--trend ] [--out ] [--json] automix_dev_tools plan-diff --session [--mix-model ] [--master-model ] [--out ] [--json] +automix_dev_tools batch-studio-api [--host ] [--port ] [--automix-bin ] [--output-root ] [--api-key ] automix_dev_tools list-supported-models automix_dev_tools install-supported-model --id [--dest ] automix_dev_tools list-supported-limiters @@ -258,6 +274,24 @@ Runtime specialization metadata supported: - `defaultInterOpThreads` - `enableProfiling` +## Hugging Face Model Hub + +Model browser/downloader installs into `assets/modelhub` and tracks: +- per-model metadata: `/modelhub.json` +- install registry: `assets/modelhub/install_registry.json` +- append-only install log: `assets/modelhub/install_log.jsonl` + +Token support for gated models: +- `AUTOMIX_HF_TOKEN` +- `HF_TOKEN` +- `HUGGINGFACE_TOKEN` +- `HUGGINGFACE_HUB_TOKEN` + +Security notes: +- Tokens are read from environment variables at runtime. +- Raw token values are not written to disk by AutoMixMaster. +- Model metadata records only whether a token was used (`tokenUsed`). + ## Key CMake Options - `BUILD_TESTING=ON|OFF` diff --git a/assets/profiles/project_profiles.json b/assets/profiles/project_profiles.json index 19ee1bb..9ca9859 100644 --- a/assets/profiles/project_profiles.json +++ b/assets/profiles/project_profiles.json @@ -14,6 +14,8 @@ "masterModelPackId": "none", "safetyPolicyId": "balanced", "preferredStemCount": 4, + "metadataPolicy": "copy_common", + "metadataTemplate": {}, "pinnedRendererIds": ["BuiltIn"] }, { @@ -31,6 +33,8 @@ "masterModelPackId": "demo-master-v1", "safetyPolicyId": "strict", "preferredStemCount": 4, + "metadataPolicy": "copy_common", + "metadataTemplate": {}, "pinnedRendererIds": ["BuiltIn", "PhaseLimiter"] }, { @@ -48,6 +52,8 @@ "masterModelPackId": "none", "safetyPolicyId": "balanced", "preferredStemCount": 2, + "metadataPolicy": "strip", + "metadataTemplate": {}, "pinnedRendererIds": ["BuiltIn"] } ] diff --git a/dumpbin_members.err b/dumpbin_members.err new file mode 100644 index 0000000..e69de29 diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp new file mode 100644 index 0000000..0da5640 --- /dev/null +++ b/src/ai/HuggingFaceModelHub.cpp @@ -0,0 +1,706 @@ +#include "ai/HuggingFaceModelHub.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +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; +} + +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; +} + +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; +} + +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); + } + } + } + + 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; + } + 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()}, + {"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..7654d5b --- /dev/null +++ b/src/ai/HuggingFaceModelHub.h @@ -0,0 +1,64 @@ +#pragma once + +#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; +}; + +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/app/MainComponent.cpp b/src/app/MainComponent.cpp index f0f8b50..2af65b2 100644 --- a/src/app/MainComponent.cpp +++ b/src/app/MainComponent.cpp @@ -7,12 +7,15 @@ #include #include #include +#include #include #include #include #include #include +#include + #include "ai/AutoMasterStrategyAI.h" #include "ai/AutoMixStrategyAI.h" #include "ai/IModelInference.h" @@ -209,6 +212,33 @@ std::string buildExportHealthCacheKey(const domain::Session& session, return key.str(); } +std::filesystem::path modelHubRootPath() { + 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(); + } +} + +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) { @@ -281,6 +311,7 @@ MainComponent::MainComponent() { addAndMakeVisible(regenerateCacheButton_); addAndMakeVisible(saveSessionButton_); addAndMakeVisible(loadSessionButton_); + addAndMakeVisible(modelsMenuButton_); addAndMakeVisible(autoMixButton_); addAndMakeVisible(autoMasterButton_); addAndMakeVisible(batchImportButton_); @@ -345,6 +376,7 @@ MainComponent::MainComponent() { regenerateCacheButton_.addListener(this); saveSessionButton_.addListener(this); loadSessionButton_.addListener(this); + modelsMenuButton_.addListener(this); autoMixButton_.addListener(this); autoMasterButton_.addListener(this); batchImportButton_.addListener(this); @@ -508,6 +540,7 @@ MainComponent::~MainComponent() { regenerateCacheButton_.removeListener(this); saveSessionButton_.removeListener(this); loadSessionButton_.removeListener(this); + modelsMenuButton_.removeListener(this); autoMixButton_.removeListener(this); autoMasterButton_.removeListener(this); batchImportButton_.removeListener(this); @@ -566,6 +599,7 @@ void MainComponent::resized() { 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)); @@ -665,6 +699,11 @@ void MainComponent::buttonClicked(juce::Button* button) { return; } + if (button == &modelsMenuButton_) { + onModelsMenu(); + return; + } + if (button == &autoMixButton_) { onAutoMix(); return; @@ -1309,6 +1348,8 @@ void MainComponent::applyProjectProfile(const domain::ProjectProfile& profile) { 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(); @@ -1731,6 +1772,8 @@ domain::RenderSettings MainComponent::buildCurrentRenderSettings(const std::stri } 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"; @@ -2190,6 +2233,283 @@ void MainComponent::onPrefetchLame() { }).detach(); } +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->browseAndDownloadModels(); + break; + case 2: + safeThis->showInstalledModels(); + break; + case 3: + safeThis->checkModelUpdates(); + break; + case 4: + safeThis->showModelIntegrityAndLicenses(); + break; + case 5: { + const auto folder = juce::File(modelHubRootPath().string()); + folder.createDirectory(); + folder.revealToUser(); + break; + } + default: + break; + } + }); +} + +void MainComponent::browseAndDownloadModels() { + statusLabel_.setText("Models: fetching Hugging Face catalog...", juce::dontSendNotification); + appendTaskHistory("Models catalog fetch started"); + + juce::Component::SafePointer safeThis(this); + std::thread([safeThis]() { + ai::HuggingFaceModelHub hub; + ai::HubModelQueryOptions options; + options.maxResultsPerQuery = 6; + auto models = hub.discoverRecommended(options); + if (models.size() > 20) { + models.resize(20); + } + + juce::MessageManager::callAsync([safeThis, models = std::move(models)]() mutable { + if (safeThis == nullptr) { + return; + } + + if (models.empty()) { + safeThis->statusLabel_.setText("Models: no catalog results", juce::dontSendNotification); + safeThis->appendTaskHistory("Models catalog returned no results"); + safeThis->reportEditor_.setText( + safeThis->reportEditor_.getText() + + "\nModel browser: no public compatible entries returned by Hugging Face queries."); + return; + } + + safeThis->discoveredHubModels_ = models; + juce::PopupMenu modelMenu; + int itemId = 1000; + for (const auto& model : safeThis->discoveredHubModels_) { + modelMenu.addItem(itemId++, modelLabel(model)); + } + modelMenu.addSeparator(); + modelMenu.addItem(1900, "Refresh"); + + safeThis->statusLabel_.setText("Models: select model to download", juce::dontSendNotification); + safeThis->appendTaskHistory("Models catalog loaded (" + + juce::String(static_cast(safeThis->discoveredHubModels_.size())) + " entries)"); + + juce::Component::SafePointer nestedSafe = safeThis; + modelMenu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&safeThis->modelsMenuButton_), + [nestedSafe](const int selection) { + if (nestedSafe == nullptr) { + return; + } + if (selection == 1900) { + nestedSafe->browseAndDownloadModels(); + return; + } + if (selection < 1000 || + selection >= 1000 + static_cast(nestedSafe->discoveredHubModels_.size())) { + return; + } + + const auto model = nestedSafe->discoveredHubModels_[static_cast(selection - 1000)]; + nestedSafe->statusLabel_.setText("Models: installing " + juce::String(model.repoId), + juce::dontSendNotification); + nestedSafe->appendTaskHistory("Model install started: " + juce::String(model.repoId)); + + juce::Component::SafePointer installSafe = nestedSafe; + std::thread([installSafe, model]() { + ai::HuggingFaceModelHub hub; + ai::HubInstallOptions installOptions; + installOptions.destinationRoot = modelHubRootPath(); + const auto install = hub.installModel(model.repoId, installOptions); + + juce::MessageManager::callAsync([installSafe, model, install]() { + if (installSafe == nullptr) { + return; + } + + if (install.success) { + installSafe->statusLabel_.setText("Model installed: " + juce::String(model.repoId), + juce::dontSendNotification); + installSafe->appendTaskHistory("Model installed: " + juce::String(model.repoId)); + } else { + installSafe->statusLabel_.setText("Model install failed", juce::dontSendNotification); + installSafe->appendTaskHistory("Model install failed: " + juce::String(model.repoId)); + } + + juce::String report = installSafe->reportEditor_.getText(); + if (!report.isEmpty()) { + report += "\n"; + } + report += "Model install: " + juce::String(model.repoId) + "\n"; + report += "Revision: " + juce::String(install.revision) + "\n"; + report += "Result: " + juce::String(install.success ? "success" : "failed") + "\n"; + report += "Detail: " + juce::String(install.message) + "\n"; + if (!install.installPath.empty()) { + report += "Path: " + juce::String(install.installPath.string()) + "\n"; + } + report += "Token usage: env-based token is supported via AUTOMIX_HF_TOKEN/HF_TOKEN/HUGGINGFACE_TOKEN.\n"; + installSafe->reportEditor_.setText(report); + }); + }).detach(); + }); + }); + }).detach(); +} + +void MainComponent::showInstalledModels() { + const auto registryPath = modelHubRootPath() / "install_registry.json"; + const auto registry = loadJsonIfPresent(registryPath); + if (!registry.is_array() || registry.empty()) { + statusLabel_.setText("Models: no installed hub models", juce::dontSendNotification); + reportEditor_.setText(reportEditor_.getText() + "\nNo installed modelhub entries found at " + + juce::String(registryPath.string())); + return; + } + + juce::String report = "Installed Models\n"; + report += "Registry: " + juce::String(registryPath.string()) + "\n\n"; + for (const auto& item : registry) { + if (!item.is_object()) { + continue; + } + report += "- " + juce::String(item.value("repoId", "")) + + " rev=" + juce::String(item.value("revision", "")) + + " useCase=" + juce::String(item.value("useCase", "")) + + " license=" + juce::String(item.value("license", "")) + "\n"; + } + + statusLabel_.setText("Models: installed list loaded", juce::dontSendNotification); + appendTaskHistory("Loaded installed model list"); + reportEditor_.setText(report); +} + +void MainComponent::checkModelUpdates() { + const auto registryPath = modelHubRootPath() / "install_registry.json"; + const auto registry = loadJsonIfPresent(registryPath); + if (!registry.is_array() || registry.empty()) { + statusLabel_.setText("Models: no installed hub models", juce::dontSendNotification); + return; + } + + statusLabel_.setText("Models: checking updates...", juce::dontSendNotification); + appendTaskHistory("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); + } + } + } + + juce::Component::SafePointer safeThis(this); + std::thread([safeThis, repoIds = std::move(repoIds), registryPath]() { + ai::HuggingFaceModelHub hub; + juce::String report = "Model Update Check\n"; + report += "Registry: " + juce::String(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 += "- " + juce::String(repoId) + ": unable to fetch remote metadata\n"; + continue; + } + + const bool changed = !localRevision.empty() && localRevision != remote->revision; + if (changed) { + ++updatesAvailable; + } + report += "- " + juce::String(repoId) + + " local=" + juce::String(localRevision) + + " remote=" + juce::String(remote->revision) + + " status=" + juce::String(changed ? "update-available" : "up-to-date") + "\n"; + } + + juce::MessageManager::callAsync([safeThis, report, updatesAvailable]() { + if (safeThis == nullptr) { + return; + } + safeThis->statusLabel_.setText("Models: update check complete (" + juce::String(updatesAvailable) + " updates)", + juce::dontSendNotification); + safeThis->appendTaskHistory("Model update check completed"); + safeThis->reportEditor_.setText(report); + }); + }).detach(); +} + +void MainComponent::showModelIntegrityAndLicenses() { + const auto registryPath = modelHubRootPath() / "install_registry.json"; + const auto registry = loadJsonIfPresent(registryPath); + if (!registry.is_array() || registry.empty()) { + statusLabel_.setText("Models: no installed hub models", juce::dontSendNotification); + return; + } + + juce::String report = "Model Integrity & Licenses\n"; + report += "Registry: " + juce::String(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 += "- " + juce::String(repoId) + + " integrity=" + juce::String(present ? "ok" : "missing") + + " license=" + juce::String(item.value("license", "unknown")) + + " source=" + juce::String(item.value("sourceUrl", "")) + "\n"; + } + + statusLabel_.setText("Models: integrity check complete", juce::dontSendNotification); + appendTaskHistory("Model integrity check completed"); + report += "\nSummary: ok=" + juce::String(validCount) + " missing=" + juce::String(missingCount) + "\n"; + reportEditor_.setText(report); +} + 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); diff --git a/src/app/MainComponent.h b/src/app/MainComponent.h index 1b01beb..28fa7a4 100644 --- a/src/app/MainComponent.h +++ b/src/app/MainComponent.h @@ -13,6 +13,7 @@ #include "analysis/StemAnalyzer.h" #include "ai/ModelManager.h" +#include "ai/HuggingFaceModelHub.h" #include "app/WaveformPreviewComponent.h" #include "automaster/HeuristicAutoMasterStrategy.h" #include "automix/HeuristicAutoMixStrategy.h" @@ -89,6 +90,7 @@ class MainComponent final : public juce::Component, void onPreviewRendered(); void onAddExternalRenderer(); void onPrefetchLame(); + void onModelsMenu(); void updateMeterPanel(const automaster::MasteringReport& report); void refreshModelPacks(); @@ -109,6 +111,10 @@ class MainComponent final : public juce::Component, void appendTaskHistory(const juce::String& line); void applyProjectProfile(const domain::ProjectProfile& profile); void populateMasterPresetSelectors(); + void browseAndDownloadModels(); + void showInstalledModels(); + void checkModelUpdates(); + void showModelIntegrityAndLicenses(); domain::MasterPreset selectedMasterPreset() const; domain::MasterPreset selectedPlatformPreset() const; @@ -122,6 +128,7 @@ class MainComponent final : public juce::Component, 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"}; @@ -221,6 +228,7 @@ class MainComponent final : public juce::Component, std::unique_ptr externalRendererChooser_; std::vector taskHistoryLines_; std::vector projectProfiles_; + std::vector discoveredHubModels_; }; } // namespace automix::app diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index 2faf8f3..ca354f5 100644 --- a/src/domain/JsonSerialization.cpp +++ b/src/domain/JsonSerialization.cpp @@ -78,6 +78,8 @@ void to_json(Json& j, const RenderSettings& value) { {"mp3VbrQuality", value.mp3VbrQuality}, {"processingThreads", value.processingThreads}, {"preferHardwareAcceleration", value.preferHardwareAcceleration}, + {"metadataPolicy", value.metadataPolicy}, + {"metadataTemplate", value.metadataTemplate}, {"rendererName", value.rendererName}, {"externalRendererPath", value.externalRendererPath}, {"externalRendererTimeoutMs", value.externalRendererTimeoutMs}}; @@ -102,6 +104,15 @@ void from_json(const Json& j, RenderSettings& value) { 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); diff --git a/src/domain/ProjectProfile.cpp b/src/domain/ProjectProfile.cpp index 7b4f125..efb2680 100644 --- a/src/domain/ProjectProfile.cpp +++ b/src/domain/ProjectProfile.cpp @@ -25,6 +25,17 @@ ProjectProfile profileFromJson(const nlohmann::json& json) { profile.masterModelPackId = json.value("masterModelPackId", "none"); profile.safetyPolicyId = json.value("safetyPolicyId", "balanced"); profile.preferredStemCount = std::clamp(json.value("preferredStemCount", 4), 2, 6); + 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>(); } @@ -86,6 +97,8 @@ std::vector defaultProjectProfiles() { .masterModelPackId = "none", .safetyPolicyId = "balanced", .preferredStemCount = 4, + .metadataPolicy = "copy_common", + .metadataTemplate = {}, .pinnedRendererIds = {"BuiltIn"}, }, ProjectProfile{ @@ -103,6 +116,8 @@ std::vector defaultProjectProfiles() { .masterModelPackId = "demo-master-v1", .safetyPolicyId = "strict", .preferredStemCount = 4, + .metadataPolicy = "copy_common", + .metadataTemplate = {}, .pinnedRendererIds = {"BuiltIn", "PhaseLimiter"}, }, ProjectProfile{ @@ -120,6 +135,8 @@ std::vector defaultProjectProfiles() { .masterModelPackId = "none", .safetyPolicyId = "balanced", .preferredStemCount = 2, + .metadataPolicy = "strip", + .metadataTemplate = {}, .pinnedRendererIds = {"BuiltIn"}, }, }; diff --git a/src/domain/ProjectProfile.h b/src/domain/ProjectProfile.h index af2bc31..638a2f1 100644 --- a/src/domain/ProjectProfile.h +++ b/src/domain/ProjectProfile.h @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include #include @@ -22,6 +23,8 @@ struct ProjectProfile { std::string masterModelPackId = "none"; std::string safetyPolicyId = "balanced"; int preferredStemCount = 4; + std::string metadataPolicy = "copy_common"; + std::map metadataTemplate; std::vector pinnedRendererIds; }; diff --git a/src/domain/RenderSettings.h b/src/domain/RenderSettings.h index 1b97fe0..d99696e 100644 --- a/src/domain/RenderSettings.h +++ b/src/domain/RenderSettings.h @@ -1,5 +1,6 @@ #pragma once +#include #include namespace automix::domain { @@ -18,6 +19,8 @@ struct RenderSettings { 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; diff --git a/src/renderers/BuiltInRenderer.cpp b/src/renderers/BuiltInRenderer.cpp index 4ac6f4e..75d534b 100644 --- a/src/renderers/BuiltInRenderer.cpp +++ b/src/renderers/BuiltInRenderer.cpp @@ -13,6 +13,7 @@ #include "engine/AudioFileIO.h" #include "engine/AudioResampler.h" #include "engine/OfflineRenderPipeline.h" +#include "util/MetadataPolicy.h" #include "util/WavWriter.h" namespace automix::renderers { @@ -118,6 +119,12 @@ RenderResult BuiltInRenderer::render(const domain::Session& session, 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, @@ -127,7 +134,7 @@ RenderResult BuiltInRenderer::render(const domain::Session& session, settings.lossyQuality, settings.mp3UseVbr, settings.mp3VbrQuality, - sourceMetadata); + exportMetadata); const std::filesystem::path reportPath = outputPath.string() + ".report.json"; nlohmann::json report = { @@ -153,6 +160,7 @@ RenderResult BuiltInRenderer::render(const domain::Session& session, {"lossyQuality", settings.lossyQuality}, {"mp3Mode", settings.mp3UseVbr ? "vbr" : "cbr"}, {"mp3VbrQuality", settings.mp3VbrQuality}, + {"metadataPolicy", settings.metadataPolicy}, {"targetLufs", plan.targetLufs}, {"targetTruePeakDbtp", plan.truePeakDbtp}, {"limiterCeilingDb", plan.limiterCeilingDb}, diff --git a/src/renderers/ExternalLimiterRenderer.cpp b/src/renderers/ExternalLimiterRenderer.cpp index da205ad..bff1c49 100644 --- a/src/renderers/ExternalLimiterRenderer.cpp +++ b/src/renderers/ExternalLimiterRenderer.cpp @@ -21,6 +21,7 @@ #include "engine/AudioResampler.h" #include "engine/OfflineRenderPipeline.h" #include "renderers/BuiltInRenderer.h" +#include "util/MetadataPolicy.h" #include "util/WavWriter.h" namespace automix::renderers { @@ -342,6 +343,8 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, {"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}, @@ -409,6 +412,12 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, 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); @@ -421,7 +430,7 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, settings.lossyQuality, settings.mp3UseVbr, settings.mp3VbrQuality, - sourceMetadata); + exportMetadata); const auto spectrum = analyzer.analyzeBuffer(mastered); @@ -453,6 +462,7 @@ RenderResult ExternalLimiterRenderer::render(const domain::Session& session, {"lossyQuality", settings.lossyQuality}, {"mp3Mode", settings.mp3UseVbr ? "vbr" : "cbr"}, {"mp3VbrQuality", settings.mp3VbrQuality}, + {"metadataPolicy", settings.metadataPolicy}, {"targetLufs", boundedPlan.targetLufs}, {"targetTruePeakDbtp", boundedPlan.truePeakDbtp}, {"limiterCeilingDb", boundedPlan.limiterCeilingDb}, diff --git a/src/renderers/PhaseLimiterRenderer.cpp b/src/renderers/PhaseLimiterRenderer.cpp index 6e81864..b8425e9 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -22,6 +22,7 @@ #include "engine/OfflineRenderPipeline.h" #include "renderers/BuiltInRenderer.h" #include "renderers/PhaseLimiterDiscovery.h" +#include "util/MetadataPolicy.h" #include "util/WavWriter.h" namespace automix::renderers { @@ -284,6 +285,12 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, 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); @@ -297,7 +304,7 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, settings.lossyQuality, settings.mp3UseVbr, settings.mp3VbrQuality, - sourceMetadata); + exportMetadata); const auto spectrumMetrics = analyzer.analyzeBuffer(mastered); @@ -326,6 +333,7 @@ RenderResult PhaseLimiterRenderer::render(const domain::Session& session, {"lossyQuality", settings.lossyQuality}, {"mp3Mode", settings.mp3UseVbr ? "vbr" : "cbr"}, {"mp3VbrQuality", settings.mp3VbrQuality}, + {"metadataPolicy", settings.metadataPolicy}, {"preGainDb", boundedPlan.preGainDb}, {"targetLufs", boundedPlan.targetLufs}, {"targetTruePeakDbtp", boundedPlan.truePeakDbtp}, diff --git a/src/util/MetadataPolicy.cpp b/src/util/MetadataPolicy.cpp new file mode 100644 index 0000000..b109bf5 --- /dev/null +++ b/src/util/MetadataPolicy.cpp @@ -0,0 +1,95 @@ +#include "util/MetadataPolicy.h" + +#include +#include +#include + +namespace automix::util { +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; +} + +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/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/ProjectProfileTests.cpp b/tests/unit/ProjectProfileTests.cpp index ce6e4c5..1f6acae 100644 --- a/tests/unit/ProjectProfileTests.cpp +++ b/tests/unit/ProjectProfileTests.cpp @@ -41,6 +41,8 @@ TEST_CASE("Project profile loader merges asset profiles with defaults", "[profil {"masterModelPackId", "demo-master-v1"}, {"safetyPolicyId", "strict"}, {"preferredStemCount", 12}, + {"metadataPolicy", "override_template"}, + {"metadataTemplate", nlohmann::json{{"album", "AutoMix Album"}}}, {"pinnedRendererIds", nlohmann::json::array({"BuiltIn", "PhaseLimiter"})}, }); @@ -57,6 +59,8 @@ TEST_CASE("Project profile loader merges asset profiles with defaults", "[profil 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"); diff --git a/tests/unit/SessionSerializationTests.cpp b/tests/unit/SessionSerializationTests.cpp index 72c13a4..04a6891 100644 --- a/tests/unit/SessionSerializationTests.cpp +++ b/tests/unit/SessionSerializationTests.cpp @@ -20,6 +20,8 @@ TEST_CASE("Session serialization round trip preserves required fields", "[sessio 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; @@ -61,6 +63,8 @@ TEST_CASE("Session serialization round trip preserves required fields", "[sessio 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)); @@ -91,6 +95,8 @@ TEST_CASE("Session deserialization handles missing optional fields", "[session]" 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)); diff --git a/tools/batch_studio_api.py b/tools/batch_studio_api.py new file mode 100644 index 0000000..f43d8de --- /dev/null +++ b/tools/batch_studio_api.py @@ -0,0 +1,264 @@ +#!/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 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 + + 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: + 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/dev_tools.cpp b/tools/dev_tools.cpp index 48c6c78..d25a8d0 100644 --- a/tools/dev_tools.cpp +++ b/tools/dev_tools.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +29,7 @@ #include "ai/AutoMasterStrategyAI.h" #include "ai/AutoMixStrategyAI.h" #include "ai/ModelPackLoader.h" +#include "ai/HuggingFaceModelHub.h" #include "ai/OnnxModelInference.h" #include "ai/RtNeuralInference.h" #include "analysis/StemHealthAssistant.h" @@ -154,6 +156,18 @@ std::optional parseDoubleArg(const std::vector& args, const } } +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") { @@ -817,6 +831,579 @@ int commandInstallLameFallback(const std::vector& args) { return result.success ? 0 : 1; } +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), 2, 6); + 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; +} + +int commandProfileExport(const std::vector& 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 std::vector& 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 commandAdaptiveAssistant(const std::vector& 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; +} + +int commandSessionReview(const std::vector& 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 commandModelBrowse(const std::vector& 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 std::vector& 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 std::vector& 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; +} + +int commandEvalTrend(const std::vector& 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; +} + +int commandBatchStudioApi(const std::vector& 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()); +} + int commandExportFeatures(const std::vector& args) { const auto sessionPathArg = argValue(args, "--session"); const auto outPathArg = argValue(args, "--out"); @@ -1911,6 +2498,15 @@ void printUsage() { std::cout << " automix_dev_tools list-supported-limiters\n"; std::cout << " automix_dev_tools install-supported-limiter --id [--dest ]\n"; std::cout << " automix_dev_tools install-lame-fallback [--force] [--json]\n"; + std::cout << " automix_dev_tools profile-export --out [--id ]\n"; + std::cout << " automix_dev_tools profile-import --in [--out ]\n"; + std::cout << " automix_dev_tools adaptive-assistant --session [--compare-report ] [--out ] [--json]\n"; + std::cout << " automix_dev_tools session-review --base --head [--out ] [--json]\n"; + std::cout << " automix_dev_tools model-browse [--limit ] [--token-env ] [--out ] [--json]\n"; + std::cout << " automix_dev_tools model-install --repo [--dest ] [--token-env ] [--force] [--out ] [--json]\n"; + std::cout << " automix_dev_tools model-health [--root ] [--out ] [--json]\n"; + std::cout << " automix_dev_tools eval-trend [--baseline ] [--work-dir ] [--trend ] [--out ] [--json]\n"; + std::cout << " automix_dev_tools batch-studio-api [--host ] [--port ] [--automix-bin ] [--output-root ] [--api-key ]\n"; } } // namespace @@ -1980,6 +2576,33 @@ int main(int argc, char** argv) { if (command == "install-lame-fallback") { return commandInstallLameFallback(args); } + if (command == "profile-export") { + return commandProfileExport(args); + } + if (command == "profile-import") { + return commandProfileImport(args); + } + if (command == "adaptive-assistant") { + return commandAdaptiveAssistant(args); + } + if (command == "session-review") { + return commandSessionReview(args); + } + if (command == "model-browse") { + return commandModelBrowse(args); + } + if (command == "model-install") { + return commandModelInstall(args); + } + if (command == "model-health") { + return commandModelHealth(args); + } + if (command == "eval-trend") { + return commandEvalTrend(args); + } + if (command == "batch-studio-api") { + return commandBatchStudioApi(args); + } printUsage(); return 2; From 284d09ee45cc92ef103bb89d71e089cf0bfc6cea Mon Sep 17 00:00:00 2001 From: Soficis Date: Mon, 16 Feb 2026 00:01:10 -0600 Subject: [PATCH 05/20] feat: complete B4+B5 expansion with HF hub, metadata policy, batch API, and modular CLI refactor This update integrates the HuggingFace model hub and metadata trust workflows, introduces the Batch Studio REST API, and completes a major refactor of the developer tools into a modular command-based registry system. Includes core hardening for model fetching and export engines. --- CMakeLists.txt | 12 +- src/ai/HuggingFaceModelHub.cpp | 147 +- src/ai/HuggingFaceModelHub.h | 2 + src/ai/ModelManager.cpp | 9 +- src/ai/OnnxModelInference.cpp | 11 +- src/ai/StemRoleClassifierAI.cpp | 7 +- src/app/MainComponent.cpp | 1423 +++------- src/app/MainComponent.h | 14 +- src/app/controllers/ExportController.cpp | 245 ++ src/app/controllers/ExportController.h | 54 + src/app/controllers/ImportController.cpp | 115 + src/app/controllers/ImportController.h | 38 + src/app/controllers/ModelController.cpp | 307 +++ src/app/controllers/ModelController.h | 48 + src/app/controllers/ProcessingController.cpp | 507 ++++ src/app/controllers/ProcessingController.h | 77 + src/engine/BatchQueueRunner.cpp | 29 +- src/engine/OfflineRenderPipeline.cpp | 9 +- src/renderers/BuiltInRenderer.cpp | 35 +- src/renderers/ExternalLimiterRenderer.cpp | 35 +- src/renderers/PhaseLimiterDiscovery.cpp | 16 +- src/renderers/PhaseLimiterRenderer.cpp | 37 +- src/util/FileUtils.h | 13 + src/util/LameDownloader.cpp | 38 +- src/util/MetadataPolicy.cpp | 9 +- src/util/MetadataSourceResolver.h | 45 + src/util/StringUtils.h | 55 + src/util/WavWriter.cpp | 15 +- tools/batch_studio_api.py | 35 + tools/commands/BatchCommands.cpp | 47 + tools/commands/CommandRegistry.h | 41 + tools/commands/Commands.h | 11 + tools/commands/DevToolsUtils.cpp | 718 +++++ tools/commands/DevToolsUtils.h | 138 + tools/commands/EvalCommands.cpp | 287 ++ tools/commands/ModelCommands.cpp | 511 ++++ tools/commands/RenderCommands.cpp | 562 ++++ tools/commands/SessionCommands.cpp | 448 +++ tools/dev_tools.cpp | 2606 +----------------- 39 files changed, 4824 insertions(+), 3932 deletions(-) create mode 100644 src/app/controllers/ExportController.cpp create mode 100644 src/app/controllers/ExportController.h create mode 100644 src/app/controllers/ImportController.cpp create mode 100644 src/app/controllers/ImportController.h create mode 100644 src/app/controllers/ModelController.cpp create mode 100644 src/app/controllers/ModelController.h create mode 100644 src/app/controllers/ProcessingController.cpp create mode 100644 src/app/controllers/ProcessingController.h create mode 100644 src/util/FileUtils.h create mode 100644 src/util/MetadataSourceResolver.h create mode 100644 src/util/StringUtils.h create mode 100644 tools/commands/BatchCommands.cpp create mode 100644 tools/commands/CommandRegistry.h create mode 100644 tools/commands/Commands.h create mode 100644 tools/commands/DevToolsUtils.cpp create mode 100644 tools/commands/DevToolsUtils.h create mode 100644 tools/commands/EvalCommands.cpp create mode 100644 tools/commands/ModelCommands.cpp create mode 100644 tools/commands/RenderCommands.cpp create mode 100644 tools/commands/SessionCommands.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c4e13d7..9e682d6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -225,6 +225,10 @@ 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) @@ -246,9 +250,15 @@ target_compile_definitions(AutoMixMasterApp PRIVATE if(BUILD_TOOLS) 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 tests/regression) + 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() diff --git a/src/ai/HuggingFaceModelHub.cpp b/src/ai/HuggingFaceModelHub.cpp index 0da5640..7012ccc 100644 --- a/src/ai/HuggingFaceModelHub.cpp +++ b/src/ai/HuggingFaceModelHub.cpp @@ -13,34 +13,13 @@ #include #include +#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; -} - -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; -} +using ::automix::util::toLower; +using ::automix::util::trim; std::optional readEnvironment(const char* key) { #if defined(_WIN32) @@ -189,6 +168,102 @@ bool downloadToFile(const std::string& url, 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); @@ -528,6 +603,12 @@ std::optional HuggingFaceModelHub::modelInfo(const std::string& re 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; + } + } } } } @@ -661,6 +742,21 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& repoId, co 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) { @@ -688,6 +784,7 @@ HubInstallResult HuggingFaceModelHub::installModel(const std::string& repoId, co {"likes", info->likes}, {"installedAtUtc", iso8601NowUtc()}, {"primaryFile", primaryPath.filename().string()}, + {"primaryFileSha256", computeFileSha256(primaryPath)}, {"hasOnnx", info->hasOnnx}, {"tokenUsed", !effectiveToken.empty()}, {"availableFiles", info->files}, diff --git a/src/ai/HuggingFaceModelHub.h b/src/ai/HuggingFaceModelHub.h index 7654d5b..8f9a49f 100644 --- a/src/ai/HuggingFaceModelHub.h +++ b/src/ai/HuggingFaceModelHub.h @@ -3,6 +3,7 @@ #include #include #include +#include #include namespace automix::ai { @@ -25,6 +26,7 @@ struct HubModelInfo { std::string primaryFile; std::vector tags; std::vector files; + std::unordered_map fileSha256; }; struct HubModelQueryOptions { diff --git a/src/ai/ModelManager.cpp b/src/ai/ModelManager.cpp index 6fce126..303372b 100644 --- a/src/ai/ModelManager.cpp +++ b/src/ai/ModelManager.cpp @@ -7,15 +7,12 @@ #include #include +#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; std::vector defaultRoots() { return { diff --git a/src/ai/OnnxModelInference.cpp b/src/ai/OnnxModelInference.cpp index 7c344bb..531c2f5 100644 --- a/src/ai/OnnxModelInference.cpp +++ b/src/ai/OnnxModelInference.cpp @@ -17,6 +17,8 @@ #include +#include "util/StringUtils.h" + #ifndef AUTOMIX_HAS_NATIVE_ORT #define AUTOMIX_HAS_NATIVE_ORT 0 #endif @@ -28,19 +30,14 @@ 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 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 canonicalProviderName(const std::string& rawProvider) { const auto provider = toLower(rawProvider); if (provider.find("cpu") != std::string::npos) { diff --git a/src/ai/StemRoleClassifierAI.cpp b/src/ai/StemRoleClassifierAI.cpp index 21ff8f9..120d479 100644 --- a/src/ai/StemRoleClassifierAI.cpp +++ b/src/ai/StemRoleClassifierAI.cpp @@ -5,15 +5,12 @@ #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); diff --git a/src/app/MainComponent.cpp b/src/app/MainComponent.cpp index 2af65b2..5637381 100644 --- a/src/app/MainComponent.cpp +++ b/src/app/MainComponent.cpp @@ -16,26 +16,32 @@ #include -#include "ai/AutoMasterStrategyAI.h" -#include "ai/AutoMixStrategyAI.h" -#include "ai/IModelInference.h" -#include "ai/OnnxModelInference.h" -#include "ai/RtNeuralInference.h" -#include "ai/StemSeparator.h" -#include "automaster/OriginalMixReference.h" -#include "analysis/StemHealthAssistant.h" -#include "engine/AudioFileIO.h" -#include "engine/AudioResampler.h" -#include "engine/BatchQueueRunner.h" #include "engine/OfflineRenderPipeline.h" #include "renderers/ExternalLimiterRenderer.h" -#include "renderers/RendererFactory.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) { @@ -57,33 +63,6 @@ std::vector splitDelimited(const std::string& text, const char deli return out; } -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 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"; -} - constexpr const char* kExportSpeedModeFinal = "final"; constexpr const char* kExportSpeedModeBalanced = "balanced"; constexpr const char* kExportSpeedModeQuick = "quick"; @@ -100,52 +79,6 @@ const ai::ModelPack* findPackById(const ai::ModelManager& manager, const std::st return nullptr; } -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; -} - std::string formatDuration(const double seconds) { const auto clamped = std::max(0.0, seconds); const int total = static_cast(std::lround(clamped)); @@ -160,76 +93,6 @@ std::string formatDuration(const double seconds) { return output.str(); } -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 buildExportHealthCacheKey(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(); -} - -std::filesystem::path modelHubRootPath() { - 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(); - } -} - juce::String modelLabel(const ai::HubModelInfo& model) { juce::String label = model.repoId + " [" + model.useCase + "]"; label += " dls=" + juce::String(model.downloads); @@ -516,6 +379,276 @@ MainComponent::MainComponent() { 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()) { @@ -530,6 +663,9 @@ MainComponent::MainComponent() { } MainComponent::~MainComponent() { + cancelRender_.store(true); + backgroundPool_.removeAllJobs(true, 5000); + stopTimer(); audioDeviceManager_.removeAudioCallback(this); transportController_.removeChangeListener(this); @@ -1543,7 +1679,7 @@ void MainComponent::rebuildPreviewBuffersAsync() { const auto previousProgress = transportController_.progress(); juce::Component::SafePointer safeThis(this); - std::thread([safeThis, + backgroundPool_.addJob(new BackgroundJob([safeThis, generation, previewSession = std::move(previewSession), soloStemId, @@ -1622,7 +1758,7 @@ void MainComponent::rebuildPreviewBuffersAsync() { safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + "\nPreview rebuild skipped: " + message); }); } - }).detach(); + }), true); } void MainComponent::updateTransportFromBuffer(const engine::AudioBuffer& buffer) { @@ -1872,95 +2008,9 @@ void MainComponent::onImport() { selectedFiles.push_back(files.getReference(i)); } - const bool useSeparation = separatedStemsToggle_.getToggleState(); - const int preferredStemCount = session_.preferredStemCount; - statusLabel_.setText("Importing files...", juce::dontSendNotification); - appendTaskHistory("Import started"); - - juce::Component::SafePointer safeThis(this); - std::thread([safeThis, selectedFiles = std::move(selectedFiles), useSeparation, preferredStemCount]() mutable { - std::vector importedStems; - std::vector importLines; - - if (selectedFiles.size() == 1 && useSeparation) { - try { - const auto mixPath = std::filesystem::path(selectedFiles.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 < selectedFiles.size(); ++i) { - const auto& file = selectedFiles[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); - } - } - - juce::MessageManager::callAsync( - [safeThis, importedStems = std::move(importedStems), importLines = std::move(importLines)]() mutable { - if (safeThis == nullptr) { - return; - } - - safeThis->session_.stems = std::move(importedStems); - safeThis->statusLabel_.setText( - "Imported " + juce::String(static_cast(safeThis->session_.stems.size())) + " stems", - juce::dontSendNotification); - safeThis->appendTaskHistory("Imported " + juce::String(static_cast(safeThis->session_.stems.size())) + " stems"); - - safeThis->analysisEntries_.clear(); - safeThis->analysisTableModel_.setEntries(&safeThis->analysisEntries_); - safeThis->analysisTable_.updateContent(); - - safeThis->refreshStemRoutingSelectors(); - safeThis->reportEditor_.setText(juce::String("Imported files:\n") + toJuceText(importLines)); - safeThis->rebuildPreviewBuffersAsync(); - }); - }).detach(); - + importController_->importFiles(std::move(selectedFiles), + separatedStemsToggle_.getToggleState(), + session_.preferredStemCount); importChooser_.reset(); }); } @@ -2005,10 +2055,7 @@ void MainComponent::onClearOriginalMix() { void MainComponent::onRegenerateCachedRenders() { engine::OfflineRenderPipeline::clearCaches(); - { - std::scoped_lock lock(exportHealthCacheMutex()); - exportHealthCache().reset(); - } + ExportController::clearHealthCache(); statusLabel_.setText("Render caches cleared", juce::dontSendNotification); appendTaskHistory("Render caches cleared; next render will regenerate intermediates"); @@ -2068,7 +2115,7 @@ void MainComponent::onLoadSession() { appendTaskHistory("Session load started: " + selected.getFullPathName()); juce::Component::SafePointer safeThis(this); - std::thread([safeThis, selectedPath]() { + backgroundPool_.addJob(new BackgroundJob([safeThis, selectedPath]() { engine::SessionRepository repository; std::optional loadedSession; juce::String errorMessage; @@ -2094,7 +2141,7 @@ void MainComponent::onLoadSession() { safeThis->appendTaskHistory("Session load failed"); } }); - }).detach(); + }), true); loadSessionChooser_.reset(); }); @@ -2157,7 +2204,7 @@ void MainComponent::onAddExternalRenderer() { appendTaskHistory("External renderer validation started: " + juce::String(selectedName)); juce::Component::SafePointer safeThis(this); - std::thread([safeThis, selectedPath, selectedName]() { + backgroundPool_.addJob(new BackgroundJob([safeThis, selectedPath, selectedName]() { const auto validation = renderers::ExternalLimiterRenderer::validateBinary(selectedPath); juce::MessageManager::callAsync([safeThis, selectedPath, selectedName, validation]() { if (safeThis == nullptr) { @@ -2182,7 +2229,7 @@ void MainComponent::onAddExternalRenderer() { " (" + juce::String(validation.diagnostics) + ")" + "\nLicense note: user-supplied tool is not distributed by this app."); }); - }).detach(); + }), true); externalRendererChooser_.reset(); }); @@ -2198,7 +2245,7 @@ void MainComponent::onPrefetchLame() { statusLabel_.setText("Prefetching LAME...", juce::dontSendNotification); juce::Component::SafePointer safeThis(this); - std::thread([safeThis]() { + backgroundPool_.addJob(new BackgroundJob([safeThis]() { const auto result = util::LameDownloader::ensureAvailable(); juce::MessageManager::callAsync([safeThis, result]() { if (safeThis == nullptr) { @@ -2230,7 +2277,7 @@ void MainComponent::onPrefetchLame() { } safeThis->reportEditor_.setText(report); }); - }).detach(); + }), true); } void MainComponent::onModelsMenu() { @@ -2250,19 +2297,20 @@ void MainComponent::onModelsMenu() { } switch (result) { case 1: - safeThis->browseAndDownloadModels(); + safeThis->modelController_->fetchCatalog(); break; case 2: - safeThis->showInstalledModels(); + safeThis->modelController_->showInstalled(); break; case 3: - safeThis->checkModelUpdates(); + safeThis->modelController_->checkUpdates(); break; case 4: - safeThis->showModelIntegrityAndLicenses(); + safeThis->modelController_->verifyIntegrity(); break; case 5: { - const auto folder = juce::File(modelHubRootPath().string()); + const auto folder = juce::File( + (std::filesystem::path("assets") / "modelhub").string()); folder.createDirectory(); folder.revealToUser(); break; @@ -2273,243 +2321,6 @@ void MainComponent::onModelsMenu() { }); } -void MainComponent::browseAndDownloadModels() { - statusLabel_.setText("Models: fetching Hugging Face catalog...", juce::dontSendNotification); - appendTaskHistory("Models catalog fetch started"); - - juce::Component::SafePointer safeThis(this); - std::thread([safeThis]() { - ai::HuggingFaceModelHub hub; - ai::HubModelQueryOptions options; - options.maxResultsPerQuery = 6; - auto models = hub.discoverRecommended(options); - if (models.size() > 20) { - models.resize(20); - } - - juce::MessageManager::callAsync([safeThis, models = std::move(models)]() mutable { - if (safeThis == nullptr) { - return; - } - - if (models.empty()) { - safeThis->statusLabel_.setText("Models: no catalog results", juce::dontSendNotification); - safeThis->appendTaskHistory("Models catalog returned no results"); - safeThis->reportEditor_.setText( - safeThis->reportEditor_.getText() + - "\nModel browser: no public compatible entries returned by Hugging Face queries."); - return; - } - - safeThis->discoveredHubModels_ = models; - juce::PopupMenu modelMenu; - int itemId = 1000; - for (const auto& model : safeThis->discoveredHubModels_) { - modelMenu.addItem(itemId++, modelLabel(model)); - } - modelMenu.addSeparator(); - modelMenu.addItem(1900, "Refresh"); - - safeThis->statusLabel_.setText("Models: select model to download", juce::dontSendNotification); - safeThis->appendTaskHistory("Models catalog loaded (" + - juce::String(static_cast(safeThis->discoveredHubModels_.size())) + " entries)"); - - juce::Component::SafePointer nestedSafe = safeThis; - modelMenu.showMenuAsync(juce::PopupMenu::Options().withTargetComponent(&safeThis->modelsMenuButton_), - [nestedSafe](const int selection) { - if (nestedSafe == nullptr) { - return; - } - if (selection == 1900) { - nestedSafe->browseAndDownloadModels(); - return; - } - if (selection < 1000 || - selection >= 1000 + static_cast(nestedSafe->discoveredHubModels_.size())) { - return; - } - - const auto model = nestedSafe->discoveredHubModels_[static_cast(selection - 1000)]; - nestedSafe->statusLabel_.setText("Models: installing " + juce::String(model.repoId), - juce::dontSendNotification); - nestedSafe->appendTaskHistory("Model install started: " + juce::String(model.repoId)); - - juce::Component::SafePointer installSafe = nestedSafe; - std::thread([installSafe, model]() { - ai::HuggingFaceModelHub hub; - ai::HubInstallOptions installOptions; - installOptions.destinationRoot = modelHubRootPath(); - const auto install = hub.installModel(model.repoId, installOptions); - - juce::MessageManager::callAsync([installSafe, model, install]() { - if (installSafe == nullptr) { - return; - } - - if (install.success) { - installSafe->statusLabel_.setText("Model installed: " + juce::String(model.repoId), - juce::dontSendNotification); - installSafe->appendTaskHistory("Model installed: " + juce::String(model.repoId)); - } else { - installSafe->statusLabel_.setText("Model install failed", juce::dontSendNotification); - installSafe->appendTaskHistory("Model install failed: " + juce::String(model.repoId)); - } - - juce::String report = installSafe->reportEditor_.getText(); - if (!report.isEmpty()) { - report += "\n"; - } - report += "Model install: " + juce::String(model.repoId) + "\n"; - report += "Revision: " + juce::String(install.revision) + "\n"; - report += "Result: " + juce::String(install.success ? "success" : "failed") + "\n"; - report += "Detail: " + juce::String(install.message) + "\n"; - if (!install.installPath.empty()) { - report += "Path: " + juce::String(install.installPath.string()) + "\n"; - } - report += "Token usage: env-based token is supported via AUTOMIX_HF_TOKEN/HF_TOKEN/HUGGINGFACE_TOKEN.\n"; - installSafe->reportEditor_.setText(report); - }); - }).detach(); - }); - }); - }).detach(); -} - -void MainComponent::showInstalledModels() { - const auto registryPath = modelHubRootPath() / "install_registry.json"; - const auto registry = loadJsonIfPresent(registryPath); - if (!registry.is_array() || registry.empty()) { - statusLabel_.setText("Models: no installed hub models", juce::dontSendNotification); - reportEditor_.setText(reportEditor_.getText() + "\nNo installed modelhub entries found at " + - juce::String(registryPath.string())); - return; - } - - juce::String report = "Installed Models\n"; - report += "Registry: " + juce::String(registryPath.string()) + "\n\n"; - for (const auto& item : registry) { - if (!item.is_object()) { - continue; - } - report += "- " + juce::String(item.value("repoId", "")) + - " rev=" + juce::String(item.value("revision", "")) + - " useCase=" + juce::String(item.value("useCase", "")) + - " license=" + juce::String(item.value("license", "")) + "\n"; - } - - statusLabel_.setText("Models: installed list loaded", juce::dontSendNotification); - appendTaskHistory("Loaded installed model list"); - reportEditor_.setText(report); -} - -void MainComponent::checkModelUpdates() { - const auto registryPath = modelHubRootPath() / "install_registry.json"; - const auto registry = loadJsonIfPresent(registryPath); - if (!registry.is_array() || registry.empty()) { - statusLabel_.setText("Models: no installed hub models", juce::dontSendNotification); - return; - } - - statusLabel_.setText("Models: checking updates...", juce::dontSendNotification); - appendTaskHistory("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); - } - } - } - - juce::Component::SafePointer safeThis(this); - std::thread([safeThis, repoIds = std::move(repoIds), registryPath]() { - ai::HuggingFaceModelHub hub; - juce::String report = "Model Update Check\n"; - report += "Registry: " + juce::String(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 += "- " + juce::String(repoId) + ": unable to fetch remote metadata\n"; - continue; - } - - const bool changed = !localRevision.empty() && localRevision != remote->revision; - if (changed) { - ++updatesAvailable; - } - report += "- " + juce::String(repoId) + - " local=" + juce::String(localRevision) + - " remote=" + juce::String(remote->revision) + - " status=" + juce::String(changed ? "update-available" : "up-to-date") + "\n"; - } - - juce::MessageManager::callAsync([safeThis, report, updatesAvailable]() { - if (safeThis == nullptr) { - return; - } - safeThis->statusLabel_.setText("Models: update check complete (" + juce::String(updatesAvailable) + " updates)", - juce::dontSendNotification); - safeThis->appendTaskHistory("Model update check completed"); - safeThis->reportEditor_.setText(report); - }); - }).detach(); -} - -void MainComponent::showModelIntegrityAndLicenses() { - const auto registryPath = modelHubRootPath() / "install_registry.json"; - const auto registry = loadJsonIfPresent(registryPath); - if (!registry.is_array() || registry.empty()) { - statusLabel_.setText("Models: no installed hub models", juce::dontSendNotification); - return; - } - - juce::String report = "Model Integrity & Licenses\n"; - report += "Registry: " + juce::String(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 += "- " + juce::String(repoId) + - " integrity=" + juce::String(present ? "ok" : "missing") + - " license=" + juce::String(item.value("license", "unknown")) + - " source=" + juce::String(item.value("sourceUrl", "")) + "\n"; - } - - statusLabel_.setText("Models: integrity check complete", juce::dontSendNotification); - appendTaskHistory("Model integrity check completed"); - report += "\nSummary: ok=" + juce::String(validCount) + " missing=" + juce::String(missingCount) + "\n"; - reportEditor_.setText(report); -} - 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); @@ -2534,121 +2345,12 @@ void MainComponent::onAutoMix() { statusLabel_.setText("Auto Mix started", juce::dontSendNotification); appendTaskHistory("Auto Mix started"); - const auto sessionSnapshot = session_; std::optional mixPack; if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("mix")); selected != nullptr) { mixPack = *selected; } - juce::Component::SafePointer safeThis(this); - std::thread([safeThis, sessionSnapshot, mixPack]() mutable { - std::vector analysisEntries; - std::optional plan; - juce::String reportText; - juce::String errorText; - bool cancelled = false; - - auto updateStatus = [safeThis](const juce::String& text) { - if (safeThis == nullptr) { - return; - } - juce::MessageManager::callAsync([safeThis, text]() { - if (safeThis == nullptr) { - return; - } - safeThis->statusLabel_.setText(text, juce::dontSendNotification); - safeThis->appendTaskHistory(text); - }); - }; - - try { - updateStatus("Auto Mix: analyzing..."); - analysis::StemAnalyzer analyzer; - analysisEntries = analyzer.analyzeSession(sessionSnapshot); - - if (safeThis != nullptr && safeThis->cancelRender_.load()) { - cancelled = true; - } - - if (!cancelled) { - updateStatus("Auto Mix: building plan..."); - automix::HeuristicAutoMixStrategy heuristicMix; - const auto heuristicPlan = heuristicMix.buildPlan(sessionSnapshot, analysisEntries, 1.0); - plan = heuristicPlan; - - ai::AutoMixStrategyAI aiMix; - std::string backendDiagnostics; - std::unique_ptr inference; - if (mixPack.has_value()) { - inference = createInferenceBackend(&mixPack.value(), sessionSnapshot.renderSettings.gpuExecutionProvider, &backendDiagnostics); - } - - if (inference != nullptr) { - auto aiPlan = aiMix.buildPlan(sessionSnapshot, 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"; - } - - juce::MessageManager::callAsync( - [safeThis, - analysisEntries = std::move(analysisEntries), - plan = std::move(plan), - reportText, - errorText, - cancelled]() mutable { - if (safeThis == nullptr) { - return; - } - - safeThis->taskRunning_.store(false); - safeThis->cancelButton_.setEnabled(false); - - if (!errorText.isEmpty()) { - safeThis->statusLabel_.setText("Auto Mix failed", juce::dontSendNotification); - safeThis->reportEditor_.setText(errorText); - safeThis->appendTaskHistory("Auto Mix failed"); - return; - } - - if (cancelled || safeThis->cancelRender_.load()) { - safeThis->statusLabel_.setText("Auto Mix cancelled", juce::dontSendNotification); - safeThis->appendTaskHistory("Auto Mix cancelled"); - return; - } - - safeThis->analysisEntries_ = std::move(analysisEntries); - safeThis->analysisTableModel_.setEntries(&safeThis->analysisEntries_); - safeThis->analysisTable_.updateContent(); - - if (plan.has_value()) { - safeThis->session_.mixPlan = plan.value(); - } - - if (!reportText.isEmpty()) { - safeThis->reportEditor_.setText(reportText); - } - - safeThis->statusLabel_.setText("Auto Mix plan generated", juce::dontSendNotification); - safeThis->appendTaskHistory("Auto Mix completed"); - safeThis->rebuildPreviewBuffersAsync(); - }); - }).detach(); + processingController_->runAutoMix(session_, mixPack, cancelRender_); } void MainComponent::onAutoMaster() { @@ -2674,190 +2376,12 @@ void MainComponent::onAutoMaster() { } const auto settings = buildCurrentRenderSettings(""); - const auto sessionSnapshot = session_; std::optional masterPack; if (const auto* selected = findPackById(modelManager_, modelManager_.activePackId("master")); selected != nullptr) { masterPack = *selected; } - juce::Component::SafePointer safeThis(this); - std::thread([safeThis, sessionSnapshot, settings, preset, masterPack]() mutable { - 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::atomic_bool* cancelPtr = nullptr; - if (safeThis != nullptr) { - cancelPtr = &safeThis->cancelRender_; - } - std::mutex progressMutex; - auto lastProgressEmit = std::chrono::steady_clock::time_point {}; - double lastProgressFraction = -1.0; - std::string lastProgressStage; - - const auto rawMix = pipeline.renderRawMix( - sessionSnapshot, - settings, - [safeThis, &progressMutex, &lastProgressEmit, &lastProgressFraction, &lastProgressStage](const engine::RenderProgress& progress) { - if (safeThis == nullptr) { - return; - } - 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; - } - - juce::MessageManager::callAsync([safeThis, progress]() { - if (safeThis == nullptr) { - return; - } - const bool cacheHit = progress.stage == "Mix render cache hit"; - if (cacheHit) { - safeThis->statusLabel_.setText("Auto Master: Using cached mix render (fast path)", - juce::dontSendNotification); - safeThis->appendTaskHistory("Auto Master using cached mix render"); - return; - } - - safeThis->statusLabel_.setText("Auto Master: " + juce::String(progress.stage) + " " + - juce::String(progress.fraction * 100.0, 1) + "%", - juce::dontSendNotification); - if (progress.fraction >= 0.999 || progress.stage != "Summing stem buses") { - safeThis->appendTaskHistory("Auto Master " + juce::String(progress.stage) + " " + - juce::String(progress.fraction * 100.0, 1) + "%"); - } - }); - }, - cancelPtr); - - if (rawMix.cancelled) { - cancelled = true; - } else { - rawMixBuffer = rawMix.mixBuffer; - } - - if (!cancelled) { - automaster::HeuristicAutoMasterStrategy autoMasterStrategy; - analysis::StemAnalyzer analyzer; - masterPlan = autoMasterStrategy.buildPlan(preset, rawMixBuffer); - - if (sessionSnapshot.originalMixPath.has_value()) { - try { - engine::AudioFileIO fileIO; - engine::AudioResampler resampler; - auto originalMix = fileIO.readAudioFile(sessionSnapshot.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"; - } - - juce::MessageManager::callAsync([safeThis, - masterPlan = std::move(masterPlan), - rawMixBuffer = std::move(rawMixBuffer), - previewMaster = std::move(previewMaster), - previewReport, - reportAppend, - errorText, - cancelled]() mutable { - if (safeThis == nullptr) { - return; - } - - safeThis->taskRunning_.store(false); - safeThis->cancelButton_.setEnabled(false); - - if (!errorText.isEmpty()) { - safeThis->statusLabel_.setText("Auto Master failed", juce::dontSendNotification); - safeThis->reportEditor_.setText(errorText); - safeThis->appendTaskHistory("Auto Master failed"); - return; - } - - if (cancelled) { - safeThis->statusLabel_.setText("Auto Master cancelled", juce::dontSendNotification); - safeThis->appendTaskHistory("Auto Master cancelled"); - return; - } - - safeThis->session_.masterPlan = std::move(masterPlan); - safeThis->previewEngine_.setBuffers(rawMixBuffer, previewMaster); - safeThis->previewEngine_.setSource(engine::PreviewSource::OriginalMix); - safeThis->previewEngine_.stop(); - safeThis->updateTransportFromBuffer(safeThis->previewEngine_.buildCrossfadedPreview(1024)); - safeThis->updateMeterPanel(previewReport); - - safeThis->statusLabel_.setText("Auto Master plan generated", juce::dontSendNotification); - safeThis->appendTaskHistory("Auto Master completed"); - if (!reportAppend.isEmpty()) { - safeThis->reportEditor_.setText(safeThis->reportEditor_.getText() + reportAppend); - } - }); - }).detach(); + processingController_->runAutoMaster(session_, settings, preset, masterPack, cancelRender_); } void MainComponent::onBatchImport() { @@ -2883,147 +2407,9 @@ void MainComponent::onBatchImport() { appendTaskHistory("Batch started: " + folder.getFullPathName()); const std::filesystem::path inputFolder(folder.getFullPathName().toStdString()); - const std::filesystem::path outputFolder = inputFolder / "automix_batch_exports"; const auto baseRenderSettings = buildCurrentRenderSettings(""); - juce::Component::SafePointer safeThis(this); - - std::thread([safeThis, inputFolder, outputFolder, baseRenderSettings]() mutable { - 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 (safeThis == nullptr) { - return; - } - - if (!prepError.isEmpty() || items.empty()) { - juce::MessageManager::callAsync([safeThis, prepError]() { - if (safeThis == nullptr) { - return; - } - safeThis->taskRunning_.store(false); - safeThis->cancelButton_.setEnabled(false); - if (!prepError.isEmpty()) { - safeThis->statusLabel_.setText("Batch preparation failed", juce::dontSendNotification); - safeThis->reportEditor_.setText("Batch preparation error:\n" + prepError); - safeThis->appendTaskHistory("Batch preparation failed"); - } else { - safeThis->statusLabel_.setText("Batch folder has no supported audio files", juce::dontSendNotification); - safeThis->appendTaskHistory("Batch preparation found no supported files"); - } - }); - return; - } - - juce::MessageManager::callAsync([safeThis]() { - if (safeThis == nullptr) { - return; - } - safeThis->statusLabel_.setText("Batch started", juce::dontSendNotification); - }); - - 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 = baseRenderSettings; - - engine::BatchQueueRunner runner; - std::atomic_bool* cancelPtr = nullptr; - if (safeThis != nullptr) { - cancelPtr = &safeThis->cancelRender_; - } - 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; - - const auto result = runner.process( - job, - [safeThis, &progressMutex, &lastProgressEmit, &lastItemIndex, &lastProgress, &lastStage](const size_t itemIndex, - const double progress, - const std::string& stage) { - if (safeThis == nullptr) { - return; - } - 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; - } - - juce::MessageManager::callAsync([safeThis, itemIndex, progress, stage]() { - if (safeThis == nullptr) { - return; - } - safeThis->statusLabel_.setText("Batch item " + juce::String(static_cast(itemIndex + 1)) + - " " + stage + " (" + juce::String(progress * 100.0, 1) + "%)", - juce::dontSendNotification); - safeThis->appendTaskHistory("Batch item " + juce::String(static_cast(itemIndex + 1)) + - " " + stage + " " + juce::String(progress * 100.0, 1) + "%"); - }); - }, - cancelPtr); - - if (safeThis == nullptr) { - return; - } - - juce::String summary; - summary << "Batch completed\n"; - summary << "Completed: " << result.completed << "\n"; - summary << "Failed: " << result.failed << "\n"; - summary << "Cancelled: " << result.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"; - } - - juce::MessageManager::callAsync([safeThis, summary]() { - if (safeThis == nullptr) { - return; - } - safeThis->taskRunning_.store(false); - safeThis->cancelButton_.setEnabled(false); - safeThis->statusLabel_.setText("Batch complete", juce::dontSendNotification); - safeThis->reportEditor_.setText(summary); - safeThis->appendTaskHistory("Batch completed"); - }); - }).detach(); + processingController_->runBatch(inputFolder, baseRenderSettings, cancelRender_); batchImportChooser_.reset(); }); @@ -3075,9 +2461,6 @@ void MainComponent::onExport() { } auto settings = buildCurrentRenderSettings(selected.getFullPathName().toStdString()); - const auto sessionCopy = session_; - const auto analysisSnapshot = analysisEntries_; - const bool quickExportMode = toLower(settings.exportSpeedMode) == kExportSpeedModeQuick; cancelRender_.store(false); taskRunning_.store(true); @@ -3085,187 +2468,7 @@ void MainComponent::onExport() { statusLabel_.setText("Export started", juce::dontSendNotification); appendTaskHistory("Export started: " + selected.getFullPathName()); - juce::Component::SafePointer safeThis(this); - std::thread([safeThis, sessionCopy, settings, quickExportMode, analysisSnapshot = std::move(analysisSnapshot)]() mutable { - renderers::RenderResult renderResult; - juce::String crashMessage; - juce::String healthText; - std::vector analysisEntriesLocal = std::move(analysisSnapshot); - bool healthHasCriticalIssues = false; - size_t healthIssueCount = 0; - try { - if (quickExportMode) { - healthText = "Quick export mode: stem-health preflight skipped for faster turnaround."; - } else { - if (analysisEntriesLocal.empty()) { - if (safeThis != nullptr) { - juce::MessageManager::callAsync([safeThis]() { - if (safeThis == nullptr) { - return; - } - safeThis->statusLabel_.setText("Export: analyzing stems", juce::dontSendNotification); - }); - } - - analysis::StemAnalyzer analyzer; - analysisEntriesLocal = analyzer.analyzeSession(sessionCopy); - } - const auto healthCacheKey = buildExportHealthCacheKey(sessionCopy, analysisEntriesLocal); - 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(sessionCopy, analysisEntriesLocal); - 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::atomic_bool* cancelPtr = nullptr; - if (safeThis != nullptr) { - cancelPtr = &safeThis->cancelRender_; - } - - std::mutex progressMutex; - auto lastProgressEmit = std::chrono::steady_clock::time_point {}; - double lastProgressFraction = -1.0; - std::string lastProgressStage; - - renderResult = renderer->render( - sessionCopy, - settings, - [safeThis, &progressMutex, &lastProgressEmit, &lastProgressFraction, &lastProgressStage](const double progress, - const std::string& stage) { - if (safeThis == nullptr) { - return; - } - 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; - } - - juce::MessageManager::callAsync([safeThis, progress, stage]() { - if (safeThis == nullptr) { - return; - } - if (stage == "Mix render cache hit") { - safeThis->statusLabel_.setText("Export: Using cached mix render (fast path)", - juce::dontSendNotification); - safeThis->appendTaskHistory("Export using cached mix render"); - return; - } - safeThis->statusLabel_.setText("Export: " + juce::String(stage) + " (" + juce::String(progress * 100.0, 1) + "%)", - juce::dontSendNotification); - if (progress >= 0.999 || stage != "Summing stem buses") { - safeThis->appendTaskHistory("Export " + juce::String(stage) + " " + - juce::String(progress * 100.0, 1) + "%"); - } - }); - }, - cancelPtr); - } catch (const std::exception& error) { - crashMessage = "Export exception:\n" + juce::String(error.what()); - } catch (...) { - crashMessage = "Export exception:\nUnknown error"; - } - - const auto exportSpeedMode = settings.exportSpeedMode; - juce::MessageManager::callAsync([safeThis, - renderResult, - crashMessage, - analysisEntriesLocal = std::move(analysisEntriesLocal), - quickExportMode, - exportSpeedMode, - healthText, - healthHasCriticalIssues, - healthIssueCount]() mutable { - if (safeThis == nullptr) { - return; - } - - safeThis->taskRunning_.store(false); - safeThis->cancelButton_.setEnabled(false); - if (!analysisEntriesLocal.empty()) { - safeThis->analysisEntries_ = std::move(analysisEntriesLocal); - safeThis->analysisTableModel_.setEntries(&safeThis->analysisEntries_); - safeThis->analysisTable_.updateContent(); - } - - if (!crashMessage.isEmpty()) { - safeThis->statusLabel_.setText("Export crashed", juce::dontSendNotification); - safeThis->reportEditor_.setText(crashMessage); - safeThis->appendTaskHistory("Export crashed"); - return; - } - - if (renderResult.cancelled) { - safeThis->statusLabel_.setText("Export cancelled", juce::dontSendNotification); - safeThis->appendTaskHistory("Export cancelled"); - return; - } - - if (quickExportMode) { - safeThis->appendTaskHistory("Quick export mode active: stem-health preflight skipped"); - } else if (healthIssueCount > 0) { - safeThis->appendTaskHistory("Stem health check found " + juce::String(static_cast(healthIssueCount)) + " issue(s)"); - } else { - safeThis->appendTaskHistory("Stem health check passed"); - } - - safeThis->statusLabel_.setText(renderResult.success ? "Export complete" : "Export failed", - juce::dontSendNotification); - if (healthHasCriticalIssues && renderResult.success) { - safeThis->statusLabel_.setText("Export complete with critical stem health warnings", juce::dontSendNotification); - } - safeThis->appendTaskHistory(renderResult.success ? "Export completed" : "Export failed"); - juce::String report = juce::String("Renderer: ") + juce::String(renderResult.rendererName) + - juce::String("\nExport mode: ") + juce::String(exportSpeedMode) + - juce::String("\nOutput: ") + juce::String(renderResult.outputAudioPath) + - juce::String("\nReport: ") + juce::String(renderResult.reportPath) + - juce::String("\n\nLogs:\n") + toJuceText(renderResult.logs); - if (!healthText.isEmpty()) { - report += "\n\n"; - report += healthText; - } - safeThis->reportEditor_.setText(report); - }); - }).detach(); + exportController_->runExport(session_, settings, analysisEntries_, cancelRender_); exportChooser_.reset(); }); diff --git a/src/app/MainComponent.h b/src/app/MainComponent.h index 28fa7a4..a752521 100644 --- a/src/app/MainComponent.h +++ b/src/app/MainComponent.h @@ -23,6 +23,10 @@ #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 { @@ -111,10 +115,6 @@ class MainComponent final : public juce::Component, void appendTaskHistory(const juce::String& line); void applyProjectProfile(const domain::ProjectProfile& profile); void populateMasterPresetSelectors(); - void browseAndDownloadModels(); - void showInstalledModels(); - void checkModelUpdates(); - void showModelIntegrityAndLicenses(); domain::MasterPreset selectedMasterPreset() const; domain::MasterPreset selectedPlatformPreset() const; @@ -211,6 +211,7 @@ class MainComponent final : public juce::Component, 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}; @@ -228,7 +229,10 @@ class MainComponent final : public juce::Component, std::unique_ptr externalRendererChooser_; std::vector taskHistoryLines_; std::vector projectProfiles_; - std::vector discoveredHubModels_; + std::unique_ptr modelController_; + std::unique_ptr importController_; + std::unique_ptr exportController_; + std::unique_ptr processingController_; }; } // 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/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/engine/BatchQueueRunner.cpp b/src/engine/BatchQueueRunner.cpp index bb76c24..325dae1 100644 --- a/src/engine/BatchQueueRunner.cpp +++ b/src/engine/BatchQueueRunner.cpp @@ -10,43 +10,20 @@ #include "analysis/StemAnalyzer.h" #include "automix/HeuristicAutoMixStrategy.h" #include "renderers/RendererFactory.h" +#include "util/StringUtils.h" #include "util/WavWriter.h" namespace automix::engine { namespace { -std::string toLower(std::string text) { - std::transform(text.begin(), text.end(), text.begin(), [](const unsigned char c) { - return static_cast(std::tolower(c)); - }); - return text; -} +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"; } -std::string extensionForFormat(const std::string& format) { - const auto normalized = toLower(format); - if (normalized == "wav") { - return ".wav"; - } - if (normalized == "aiff" || normalized == "aif") { - return ".aiff"; - } - if (normalized == "flac") { - return ".flac"; - } - if (normalized == "ogg" || normalized == "vorbis") { - return ".ogg"; - } - if (normalized == "mp3") { - return ".mp3"; - } - return ".wav"; -} - void runAnalysisForItem(domain::BatchItem& item) { item.status = domain::BatchItemStatus::Analyzing; analysis::StemAnalyzer analyzer; diff --git a/src/engine/OfflineRenderPipeline.cpp b/src/engine/OfflineRenderPipeline.cpp index 6ca6edb..90b7f0e 100644 --- a/src/engine/OfflineRenderPipeline.cpp +++ b/src/engine/OfflineRenderPipeline.cpp @@ -21,10 +21,13 @@ #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; @@ -501,12 +504,6 @@ AudioBuffer processStemBuffer(const AudioBuffer& input, return output; } -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 defaultBusIdForStem(const domain::Stem& stem) { if (stem.busId.has_value() && !stem.busId->empty()) { return stem.busId.value(); diff --git a/src/renderers/BuiltInRenderer.cpp b/src/renderers/BuiltInRenderer.cpp index 75d534b..06d947e 100644 --- a/src/renderers/BuiltInRenderer.cpp +++ b/src/renderers/BuiltInRenderer.cpp @@ -14,44 +14,13 @@ #include "engine/AudioResampler.h" #include "engine/OfflineRenderPipeline.h" #include "util/MetadataPolicy.h" +#include "util/MetadataSourceResolver.h" #include "util/WavWriter.h" namespace automix::renderers { namespace { -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; -} +using ::automix::util::metadataSourcePath; } // namespace diff --git a/src/renderers/ExternalLimiterRenderer.cpp b/src/renderers/ExternalLimiterRenderer.cpp index bff1c49..425eb22 100644 --- a/src/renderers/ExternalLimiterRenderer.cpp +++ b/src/renderers/ExternalLimiterRenderer.cpp @@ -22,44 +22,13 @@ #include "engine/OfflineRenderPipeline.h" #include "renderers/BuiltInRenderer.h" #include "util/MetadataPolicy.h" +#include "util/MetadataSourceResolver.h" #include "util/WavWriter.h" namespace automix::renderers { namespace { -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; -} +using ::automix::util::metadataSourcePath; RenderResult fallbackToBuiltIn(const domain::Session& session, const domain::RenderSettings& settings, 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 b8425e9..d354c5c 100644 --- a/src/renderers/PhaseLimiterRenderer.cpp +++ b/src/renderers/PhaseLimiterRenderer.cpp @@ -23,11 +23,14 @@ #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; @@ -37,40 +40,6 @@ bool pathExists(const std::filesystem::path& path) { return std::filesystem::exists(path, error); } -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; -} - class CurrentWorkingDirectoryGuard final { public: explicit CurrentWorkingDirectoryGuard(const std::filesystem::path& path) 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/LameDownloader.cpp b/src/util/LameDownloader.cpp index 8f38dee..7ddeade 100644 --- a/src/util/LameDownloader.cpp +++ b/src/util/LameDownloader.cpp @@ -18,9 +18,16 @@ #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; @@ -60,32 +67,6 @@ struct TempDirectory { std::filesystem::path path; }; -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 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; -} - std::string joinLines(const std::vector& lines) { std::ostringstream os; for (size_t i = 0; i < lines.size(); ++i) { @@ -135,11 +116,6 @@ bool flagEnabled(const char* key) { return lower == "1" || lower == "true" || lower == "yes" || lower == "on"; } -bool isRegularFile(const std::filesystem::path& path) { - std::error_code error; - return std::filesystem::is_regular_file(path, error) && !error; -} - std::string binaryName() { #if defined(_WIN32) return "lame.exe"; diff --git a/src/util/MetadataPolicy.cpp b/src/util/MetadataPolicy.cpp index b109bf5..b628b7e 100644 --- a/src/util/MetadataPolicy.cpp +++ b/src/util/MetadataPolicy.cpp @@ -4,15 +4,12 @@ #include #include +#include "util/StringUtils.h" + namespace automix::util { 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; std::string normalizeKey(const std::string& key) { std::string normalized; 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 43deb17..765569b 100644 --- a/src/util/WavWriter.cpp +++ b/src/util/WavWriter.cpp @@ -16,17 +16,15 @@ #include #include +#include "util/FileUtils.h" #include "util/LameDownloader.h" +#include "util/StringUtils.h" namespace automix::util { 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; +using ::automix::util::isRegularFile; int qualityIndexFromRequested(const juce::StringArray& options, const int requestedQuality) { if (options.isEmpty()) { @@ -128,11 +126,6 @@ std::vector lameExecutableNames() { #endif } -bool isRegularFile(const std::filesystem::path& path) { - std::error_code error; - return std::filesystem::is_regular_file(path, error) && !error; -} - 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; diff --git a/tools/batch_studio_api.py b/tools/batch_studio_api.py index f43d8de..15457d4 100644 --- a/tools/batch_studio_api.py +++ b/tools/batch_studio_api.py @@ -50,6 +50,19 @@ def _append_jsonl(path: pathlib.Path, payload: Dict[str, Any]) -> None: 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, @@ -98,6 +111,21 @@ def _handle_catalog_process(self) -> None: ) 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) @@ -187,6 +215,13 @@ def _handle_report_ingest(self) -> 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(): 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..9659027 --- /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), 2, 6); + 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 d25a8d0..073ba1d 100644 --- a/tools/dev_tools.cpp +++ b/tools/dev_tools.cpp @@ -1,2518 +1,34 @@ -#include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include #include -#include -#include -#include #include -#include +#include "commands/CommandRegistry.h" +#include "commands/Commands.h" -#include "ai/IModelInference.h" -#include "ai/FeatureSchema.h" -#include "ai/AutoMasterStrategyAI.h" -#include "ai/AutoMixStrategyAI.h" -#include "ai/ModelPackLoader.h" -#include "ai/HuggingFaceModelHub.h" -#include "ai/OnnxModelInference.h" -#include "ai/RtNeuralInference.h" -#include "analysis/StemHealthAssistant.h" -#include "analysis/StemAnalyzer.h" -#include "automaster/HeuristicAutoMasterStrategy.h" -#include "automix/HeuristicAutoMixStrategy.h" -#include "domain/MasterPlan.h" -#include "domain/ProjectProfile.h" -#include "domain/Session.h" -#include "domain/JsonSerialization.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/BuiltInRenderer.h" -#include "renderers/RendererFactory.h" -#include "renderers/RendererRegistry.h" -#include "util/LameDownloader.h" -#include "util/WavWriter.h" -#include "renderers/ExternalLimiterRenderer.h" -#include "RegressionHarness.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; -} - -bool hasFlag(const std::vector& args, const std::string& key) { - return std::find(args.begin(), args.end(), key) != args.end(); -} - -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::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; -} - -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(); -} - -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; - } -} - -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::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; -} - -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); -} - -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"; -} - -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() { - 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; -} - -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()); - } -} - -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) { - 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); -} - -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; -} - -class DeterministicPlanDiffInference final : public automix::ai::IModelInference { - public: - bool isAvailable() const override { return true; } - - bool loadModel(const std::filesystem::path&) override { - loaded_ = true; - return true; - } - - automix::ai::InferenceResult run(const automix::ai::InferenceRequest& request) const override { - 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; - } - - private: - bool loaded_ = true; -}; - -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(); -} - -struct JsonMergeTelemetry { - bool preferRight = true; - size_t conflictCount = 0; - std::vector conflictPaths; -}; - -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 mergeJsonNode(const std::optional& base, - const std::optional& left, - const std::optional& right, - const std::string& path, - JsonMergeTelemetry* telemetry); - -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 = mergeJsonNode(baseItem, - leftItem, - rightItem, - path + "/" + keyField + "=" + key, - telemetry); - if (mergedItem.has_value()) { - merged.push_back(mergedItem.value()); - } - } - - return merged; -} - -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; -} - -int commandListSupportedModels() { - 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 std::vector& 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() { - std::cout << "Supported limiters:\n"; - for (const auto& limiter : supportedLimiters()) { - std::cout << " - " << limiter.id << " [" << limiter.name << "] " << limiter.description << "\n"; - } - return 0; -} - -int commandInstallSupportedLimiter(const std::vector& 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 std::vector& 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; -} - -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), 2, 6); - 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; -} - -int commandProfileExport(const std::vector& 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 std::vector& 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 commandAdaptiveAssistant(const std::vector& 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; -} - -int commandSessionReview(const std::vector& 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 commandModelBrowse(const std::vector& 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 std::vector& 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 std::vector& 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; -} - -int commandEvalTrend(const std::vector& 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; -} - -int commandBatchStudioApi(const std::vector& 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()); -} - -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; - } - 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; -} - -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); - } - - 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") { - 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 std::vector& 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 commandStemHealth(const std::vector& 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 commandCompareRenders(const std::vector& 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 std::vector& 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 commandSessionDiff(const std::vector& 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 std::vector& 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 commandExternalLimiterCompat(const std::vector& 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 commandGoldenEval(const std::vector& 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 std::vector& 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}, - }}, - }; +namespace { - 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"; +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"; } - - return 0; -} - -void printUsage() { - std::cout << "Usage:\n"; - std::cout << " automix_dev_tools export-features --session --out [--manifest ] [--dataset-id ] [--source-tag ] [--lineage-parents ]\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 << " automix_dev_tools validate-external-limiter --binary [--json]\n"; - std::cout << " automix_dev_tools stem-health --session [--out ] [--json]\n"; - std::cout << " automix_dev_tools compare-renders --session [--renderers ] [--out-dir ] [--format ] [--external-binary ] [--json]\n"; - std::cout << " automix_dev_tools catalog-process --input --output [--checkpoint ] [--resume] [--renderer ] [--format ] [--analysis-threads ] [--render-parallelism ] [--csv ] [--json ]\n"; - std::cout << " automix_dev_tools session-diff --base --head [--out ] [--summary]\n"; - std::cout << " automix_dev_tools session-merge --base --left --right --out [--prefer ] [--report ] [--json]\n"; - std::cout << " automix_dev_tools external-limiter-compat --binary [--timeout-ms ] [--required-features ] [--out ] [--json]\n"; - std::cout << " automix_dev_tools golden-eval [--baseline ] [--work-dir ] [--out ] [--json]\n"; - std::cout << " automix_dev_tools plan-diff --session [--mix-model ] [--master-model ] [--out ] [--json]\n"; - std::cout << " automix_dev_tools list-supported-models\n"; - std::cout << " automix_dev_tools install-supported-model --id [--dest ]\n"; - std::cout << " automix_dev_tools list-supported-limiters\n"; - std::cout << " automix_dev_tools install-supported-limiter --id [--dest ]\n"; - std::cout << " automix_dev_tools install-lame-fallback [--force] [--json]\n"; - std::cout << " automix_dev_tools profile-export --out [--id ]\n"; - std::cout << " automix_dev_tools profile-import --in [--out ]\n"; - std::cout << " automix_dev_tools adaptive-assistant --session [--compare-report ] [--out ] [--json]\n"; - std::cout << " automix_dev_tools session-review --base --head [--out ] [--json]\n"; - std::cout << " automix_dev_tools model-browse [--limit ] [--token-env ] [--out ] [--json]\n"; - std::cout << " automix_dev_tools model-install --repo [--dest ] [--token-env ] [--force] [--out ] [--json]\n"; - std::cout << " automix_dev_tools model-health [--root ] [--out ] [--json]\n"; - std::cout << " automix_dev_tools eval-trend [--baseline ] [--work-dir ] [--trend ] [--out ] [--json]\n"; - std::cout << " automix_dev_tools batch-studio-api [--host ] [--port ] [--automix-bin ] [--output-root ] [--api-key ]\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) { @@ -2520,92 +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); - } - if (command == "validate-external-limiter") { - return commandValidateExternalLimiter(args); - } - if (command == "stem-health") { - return commandStemHealth(args); - } - if (command == "compare-renders") { - return commandCompareRenders(args); - } - if (command == "catalog-process") { - return commandCatalogProcess(args); - } - if (command == "session-diff") { - return commandSessionDiff(args); - } - if (command == "session-merge") { - return commandSessionMerge(args); - } - if (command == "external-limiter-compat") { - return commandExternalLimiterCompat(args); - } - if (command == "golden-eval") { - return commandGoldenEval(args); - } - if (command == "plan-diff") { - return commandPlanDiff(args); - } - if (command == "list-supported-models") { - return commandListSupportedModels(); - } - if (command == "install-supported-model") { - return commandInstallSupportedModel(args); - } - if (command == "list-supported-limiters") { - return commandListSupportedLimiters(); - } - if (command == "install-supported-limiter") { - return commandInstallSupportedLimiter(args); - } - if (command == "install-lame-fallback") { - return commandInstallLameFallback(args); - } - if (command == "profile-export") { - return commandProfileExport(args); - } - if (command == "profile-import") { - return commandProfileImport(args); - } - if (command == "adaptive-assistant") { - return commandAdaptiveAssistant(args); - } - if (command == "session-review") { - return commandSessionReview(args); - } - if (command == "model-browse") { - return commandModelBrowse(args); - } - if (command == "model-install") { - return commandModelInstall(args); - } - if (command == "model-health") { - return commandModelHealth(args); - } - if (command == "eval-trend") { - return commandEvalTrend(args); - } - if (command == "batch-studio-api") { - return commandBatchStudioApi(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; From c1d72daf552dc78ac0ff50c38886ce0a1b643901 Mon Sep 17 00:00:00 2001 From: Soficis Date: Tue, 17 Feb 2026 16:32:55 -0600 Subject: [PATCH 06/20] refactor: split MainComponent orchestration into backend/controller modules - add `MainComponentBackend` and `MainComponentControllers` to move orchestration and callback wiring out of the UI shell - add focused controllers for session/original-mix/preview/profile workflows - add shared utilities: `BackgroundJob`, `CallbackDispatch`, and `HashUtils` - add tests: `tests/unit/ControllerTests.cpp` and `tests/python/test_batch_studio_api.py` - update model/renderer integration points (`ModelPackLoader`, `RendererRegistry`) - update README visuals and include `assets/AutoMixMaster.jpg` - refresh `.gitignore` patterns for local build and temp artifacts --- .gitignore | 69 +- README.md | 320 +---- assets/AutoMixMaster.jpg | Bin 0 -> 369585 bytes src/ai/ModelPackLoader.cpp | 21 +- src/app/MainComponentBackend.cpp | 1272 +++++++++++++++++ src/app/MainComponentControllers.cpp | 455 ++++++ src/app/controllers/OriginalMixController.cpp | 34 + src/app/controllers/OriginalMixController.h | 32 + src/app/controllers/PreviewController.cpp | 98 ++ src/app/controllers/PreviewController.h | 55 + src/app/controllers/ProfileController.cpp | 47 + src/app/controllers/ProfileController.h | 30 + src/app/controllers/SessionController.cpp | 182 +++ src/app/controllers/SessionController.h | 48 + src/renderers/RendererRegistry.cpp | 20 +- src/util/BackgroundJob.h | 24 + src/util/CallbackDispatch.h | 18 + src/util/HashUtils.h | 42 + tests/python/test_batch_studio_api.py | 65 + tests/unit/ControllerTests.cpp | 440 ++++++ 20 files changed, 2856 insertions(+), 416 deletions(-) create mode 100644 assets/AutoMixMaster.jpg create mode 100644 src/app/MainComponentBackend.cpp create mode 100644 src/app/MainComponentControllers.cpp create mode 100644 src/app/controllers/OriginalMixController.cpp create mode 100644 src/app/controllers/OriginalMixController.h create mode 100644 src/app/controllers/PreviewController.cpp create mode 100644 src/app/controllers/PreviewController.h create mode 100644 src/app/controllers/ProfileController.cpp create mode 100644 src/app/controllers/ProfileController.h create mode 100644 src/app/controllers/SessionController.cpp create mode 100644 src/app/controllers/SessionController.h create mode 100644 src/util/BackgroundJob.h create mode 100644 src/util/CallbackDispatch.h create mode 100644 src/util/HashUtils.h create mode 100644 tests/python/test_batch_studio_api.py create mode 100644 tests/unit/ControllerTests.cpp diff --git a/.gitignore b/.gitignore index 5e97dd7..4affaba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,68 +1 @@ -# Build artifacts -build*/ -build_clean/ - -# CMake generated files -CMakeCache.txt -CMakeFiles/ -cmake_install.cmake -CTestTestfile.cmake -DartConfiguration.tcl - -# IDE and editor files -.vscode/ -.vs/ -*.vcxproj.user -*.slnx.user - -# Analysis and cache files -.cache/ -.clangd/ -*.sarif -*.analysis -assets/**/analysis_data/ - -# Documentation -docs/ - -# Personal data and sensitive files -.env -personal/ -*.key -*.pem -.ssh/ -*.id_rsa -*.id_dsa -config.ini - -# Temporary files and directories -tmp/ -tmpclaude-*/ -tmpclaude-* -nul -*.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 +# === Build & Output === build*/ out/ bin/ _deps/ *.exe *.dll *.lib *.pdb *.ilk *.obj *.o *.exp artefacts/ *artefacts/ ntml docs/ # === CMake === CMakeCache.txt CMakeFiles/ cmake_install.cmake CTestTestfile.cmake DartConfiguration.tcl CMakeUserPresets.json # === IDE & Editors === .vs/ .vscode/ .idea/ *.vcxproj.user *.slnx.user *.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 # === Testing === Testing/ *.xml tests/regression/output/ # === Personal & Security === .env personal/ *.key *.pem .ssh/ config.ini \ No newline at end of file diff --git a/README.md b/README.md index 86a3d93..39553cb 100644 --- a/README.md +++ b/README.md @@ -1,319 +1 @@ -# AutoMixMaster - -AutoMixMaster is a JUCE/CMake desktop app for deterministic stem mixing and mastering: - -`Analysis -> MixPlan -> MasterPlan -> Renderer -> Audio + JSON report` - -This branch implements the **v2 roadmap** in `docs/roadmap.md` and follow-up Phase V/Future Suggestion work, including an automated Hugging Face model browser/downloader, metadata policy profiles, and expanded automation APIs/tooling. - -## Since Last Published `ai_dev` Commit - -Compared to published `origin/ai_dev` commit `a6b7173`, this working tree adds: - -1. Automatic AI model browser + downloader (Hugging Face) -- New app `Models` menu with: - - `Browse & Download Models` - - `Installed Models` - - `Check Updates` - - `Integrity & Licenses` - - `Open Model Hub Folder` -- New `src/ai/HuggingFaceModelHub.*` service for discovery, model-info fetch, revision-pinned install, install registry/logging, and update checks. -- Discovery is dynamic (no fixed manual catalog), focused on proven music-audio model families (`demucs`, `mdx23c`, `bs-roformer`, `mel-band-roformer`, `open-unmix`, `clap`, `panns`, `basic-pitch`). - -2. Secure Hugging Face token handling -- Gated/private access supports env-based token resolution: - - `AUTOMIX_HF_TOKEN` - - `HF_TOKEN` - - `HUGGINGFACE_TOKEN` - - `HUGGINGFACE_HUB_TOKEN` -- Tokens are not persisted in config files/logs; install metadata stores only `tokenUsed` boolean. - -3. Future suggestions implemented end-to-end -- Profile sharing simplified to import/export workflows (`profile-export`, `profile-import`). -- Adaptive fix-chain assistant (`adaptive-assistant`). -- Guided collaboration review (`session-review`). -- Batch Studio remote API (`batch-studio-api` launcher + `tools/batch_studio_api.py`). -- Continuous eval trend automation (`eval-trend`) and nightly CI workflow. -- Metadata policy profiles fully wired through domain, renderer pipeline, and reports. - -4. Metadata policy profile system -- Added profile/session/render settings fields: - - `metadataPolicy` - - `metadataTemplate` -- Implemented policy engine with `copy_all`, `copy_common`/`copy_common_only`, `strip`, and `override_template`. -- Built-in and external renderer paths now apply policy before writing exports. - -5. CI + test coverage updates -- Added nightly trend workflow: `.github/workflows/nightly_golden_eval.yml`. -- Added metadata policy unit tests and expanded profile/session serialization coverage. - -6. Non-functional repository normalization -- Large repository-wide line-ending/style normalization touched many files (source, config, tests, and bundled PhaseLimiter license/resource files). -- Effective logic changes remain concentrated in the files listed above; most other touched files are formatting/EOL-only churn. - -## Implemented Scope (v2) - -1. Native ONNX runtime sessions (Phase G) -- `OnnxModelInference` supports real ONNX Runtime C++ sessions when `ENABLE_ONNX=ON` and runtime headers/libs are present. -- Provider-aware tuning matrix (CPU/CUDA/DirectML/CoreML), thread controls, and optional profiling artifact export. -- Deterministic adapter fallback remains available when native runtime is unavailable. - -2. Advanced separation model workflow (Phase H) -- Multi-variant separation packs (2/4/6 stem targets) with automatic selection and override support. -- Chunked overlap-add processing with GPU memory budget and stream scheduling controls. -- Separation QA report bundle with `energyLeakage`, `residualDistortion`, and `transientRetention`. - -3. Precision UX and timeline state (Phase I) -- Loop in/out markers, looped transport playback, timeline zoom, and fine-scrub behavior. -- Timeline loop/zoom/fine-scrub state persists in session JSON. -- Task center panel with timestamped background-task history. - -4. External DSP marketplace foundation (Phase J) -- External renderer descriptor signature metadata and trust policy loading. -- Signed/unsigned policy handling and trusted-signer filtering. -- Capability snapshot artifacts written for discovered external renderers. - -5. Evaluation/training operations (Phase K) -- Golden corpus evaluator command backed by regression harness baselines. -- Heuristic vs model plan diff report generation. -- Dataset lineage/manifest output for feature export workflows. - -## Product Suggestions Implemented - -1. Profile Sharing (import/export) -- Simplified marketplace scope to direct sharing workflows. -- Added `automix_dev_tools profile-export` and `profile-import`. - -2. Adaptive Assistant -- Added `automix_dev_tools adaptive-assistant` to generate fix chains from stem health + optional comparator context. - -3. Continuous Evaluation -- Added `automix_dev_tools eval-trend`. -- Added nightly CI workflow for golden-eval trend artifacts. - -4. Batch Studio API -- Added `tools/batch_studio_api.py` (`/health`, `/v1/catalog/process`, `/v1/reports/ingest`). -- Added launcher command `automix_dev_tools batch-studio-api`. - -5. Guided Collaboration Review -- Added `automix_dev_tools session-review` with semantic highlights over session diffs. - -6. Metadata Policy Profiles -- Added profile-level metadata policy + template support through serialization, profiles, and renderers. - -7. Asynchronous Mastering Engine -- Long-running render/master tasks run in worker threads with cancellation checkpoints and adaptive chunk sizing. - -## Build - -### Configure + build (Linux/WSL/macOS) - -```bash -cmake -S . -B build-codex -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON -DBUILD_TOOLS=ON -cmake --build build-codex --parallel -``` - -When `ENABLE_ONNX=ON`, CMake attempts to locate native ONNX Runtime headers/library and enables native session support automatically when found. - -### Configure + build (Windows, VS 2026) - -Windows Visual Studio builds are pinned to the VS 2026 generator (`Visual Studio 18 2026`). - -```powershell -cmake -S . -B build_win_vs2026_release -G "Visual Studio 18 2026" -A x64 -DBUILD_TESTING=OFF -DBUILD_TOOLS=OFF -DBUILD_SHARED_LIBS=OFF -cmake --build build_win_vs2026_release --config Release --parallel -``` - -Built executable: -- `build_win_vs2026_release/AutoMixMasterApp_artefacts/Release/AutoMixMaster.exe` - -## Run - -### App - -```bash -./build-codex/AutoMixMasterApp_artefacts/AutoMixMaster -``` - -### Tests - -```bash -ctest --test-dir build-codex --output-on-failure -j4 -``` - -As of **February 15, 2026**, this branch passes **58/58 tests**. -Coverage includes project profile loading, renderer trust-policy enforcement, transport loop behavior, separator variant/QA outputs, and session schema round-trip fields. - -## GUI Workflow - -1. Import stems (or one mixed track with `AI-separated stems` enabled). -2. Optional: load original mix for A/B and residual blend. -3. Select project profile, AI packs, and ML provider (`auto/cpu/directml/coreml/cuda`). -4. Run `Auto Mix`. -5. Run `Auto Master` and choose master/platform preset. -6. Use transport/loop/zoom/fine-scrub for preview. -7. Review task center and stem health diagnostics. -8. Export single song or batch folder (`Quick` mode for speed, strict safety policy can block non-pinned renderers). - -Outputs: -- `.` -- `..report.json` - -## Session Schema Additions - -- `renderSettings`: `exportSpeedMode`, `mp3UseVbr`, `mp3VbrQuality` -- `timeline`: `loopEnabled`, `loopInSeconds`, `loopOutSeconds`, `zoom`, `fineScrub` -- `session`: `projectProfileId`, `safetyPolicyId`, `preferredStemCount` - -## Renderers - -- `BuiltIn`: always available in-process renderer. -- `PhaseLimiter`: external binary discovery with fallback-safe behavior. -- Descriptor-driven external renderers from: - - `assets/limiters/**/renderer.json` - - `assets/renderers/**/renderer.json` - - `Assets/Limiters/**/renderer.json` - -Trust policy candidates: -- `assets/renderers/trust_policy.json` -- `assets/limiters/trust_policy.json` - -Discovery side-effect: -- External renderer validation writes capability snapshots next to binaries as `.capabilities.snapshot.json`. - -## Codec Availability - -Runtime export codec probing is surfaced in the GUI. - -Supported format targets: -- Lossless: `wav`, `aiff`, `flac` -- Lossy: `mp3`, `ogg` - -MP3 export modes: -- `CBR`: target bitrate via `lossyBitrateKbps` (default `320 kbps`) -- `VBR`: quality ladder via `mp3VbrQuality` (0 best .. 9 smallest) - -Export speed modes: -- `Final`: default quality path (`blockSize=1024`, `24-bit`) -- `Balanced`: moderate throughput (`blockSize=2048`, `24-bit`) -- `Quick`: fast-turnaround preset (`blockSize=4096`, `16-bit`) with default codec target `MP3 VBR` - - Stem-health preflight is skipped in Quick mode to reduce turnaround time. - - If MP3 is unavailable at runtime, Quick mode falls back to the first available codec. - -MP3 fallback order: -- JUCE MP3/LAME writer path (when available) -- External `lame` binary path discovery (`LAME_BIN`, app-adjacent, `PATH`) -- Optional on-demand LAME downloader cache - -Metadata retention: -- Exports preserve source metadata from the original mix (or first valid stem fallback when original mix is not set). -- External LAME MP3 export maps common metadata to ID3 tags (`title`, `artist`, `album`, `year`, `track`, `genre`, `comment`). - -## Developer Tools (`automix_dev_tools`) - -Built when `-DBUILD_TOOLS=ON`. - -```bash -automix_dev_tools export-features --session --out [--manifest ] [--dataset-id ] [--source-tag ] [--lineage-parents ] -automix_dev_tools export-segments --session --out-dir [--segment-seconds ] -automix_dev_tools validate-modelpack --pack -automix_dev_tools validate-external-limiter --binary [--json] -automix_dev_tools stem-health --session [--out ] [--json] -automix_dev_tools compare-renders --session [--renderers ] [--out-dir ] [--format ] [--external-binary ] [--json] -automix_dev_tools catalog-process --input --output [--checkpoint ] [--resume] [--renderer ] [--format ] [--analysis-threads ] [--render-parallelism ] [--csv ] [--json ] -automix_dev_tools session-diff --base --head [--out ] [--summary] -automix_dev_tools session-merge --base --left --right --out [--prefer ] [--report ] [--json] -automix_dev_tools profile-export --out [--id ] -automix_dev_tools profile-import --in [--out ] -automix_dev_tools adaptive-assistant --session [--compare-report ] [--out ] [--json] -automix_dev_tools session-review --base --head [--out ] [--json] -automix_dev_tools model-browse [--limit ] [--token-env ] [--out ] [--json] -automix_dev_tools model-install --repo [--dest ] [--token-env ] [--force] [--out ] [--json] -automix_dev_tools model-health [--root ] [--out ] [--json] -automix_dev_tools external-limiter-compat --binary [--timeout-ms ] [--required-features ] [--out ] [--json] -automix_dev_tools golden-eval [--baseline ] [--work-dir ] [--out ] [--json] -automix_dev_tools eval-trend [--baseline ] [--work-dir ] [--trend ] [--out ] [--json] -automix_dev_tools plan-diff --session [--mix-model ] [--master-model ] [--out ] [--json] -automix_dev_tools batch-studio-api [--host ] [--port ] [--automix-bin ] [--output-root ] [--api-key ] -automix_dev_tools list-supported-models -automix_dev_tools install-supported-model --id [--dest ] -automix_dev_tools list-supported-limiters -automix_dev_tools install-supported-limiter --id [--dest ] -automix_dev_tools install-lame-fallback [--force] [--json] -``` - -## External Limiter Contract - -Schema: -- `docs/external_limiter_contract.schema.json` - -Validation taxonomy: -- `binary_missing` -- `launch_failed` -- `timeout` -- `exit_code` -- `invalid_json` -- `missing_version` -- `missing_supported_features` -- `schema_incompatible` - -## AI Model Packs - -`ModelManager` scans: -- configured roots -- `assets/models` -- `assets/modelpacks` -- `assets/ModelPacks` -- `Assets/ModelPacks` -- `AUTOMIX_MODELPACK_PATHS` - -Runtime specialization metadata supported: -- `preferredPrecision` -- `providerAffinity` -- `defaultIntraOpThreads` -- `defaultInterOpThreads` -- `enableProfiling` - -## Hugging Face Model Hub - -Model browser/downloader installs into `assets/modelhub` and tracks: -- per-model metadata: `/modelhub.json` -- install registry: `assets/modelhub/install_registry.json` -- append-only install log: `assets/modelhub/install_log.jsonl` - -Token support for gated models: -- `AUTOMIX_HF_TOKEN` -- `HF_TOKEN` -- `HUGGINGFACE_TOKEN` -- `HUGGINGFACE_HUB_TOKEN` - -Security notes: -- Tokens are read from environment variables at runtime. -- Raw token values are not written to disk by AutoMixMaster. -- Model metadata records only whether a token was used (`tokenUsed`). - -## Key CMake Options - -- `BUILD_TESTING=ON|OFF` -- `BUILD_TOOLS=ON|OFF` -- `ENABLE_ONNX=ON|OFF` -- `ENABLE_RTNEURAL=ON|OFF` -- `RTNEURAL_XSIMD=ON|OFF` -- `RTNEURAL_USE_AVX=ON|OFF` -- `ENABLE_LIBEBUR128=ON|OFF` -- `ENABLE_PHASELIMITER=ON|OFF` -- `ENABLE_EXTERNAL_TOOL_SUPPORT=ON|OFF` -- `ENABLE_BUNDLED_LAME=ON|OFF` -- `DISTRIBUTION_MODE=OSS|PROPRIETARY` - -## Known Limits - -- Native ONNX sessions require external ONNX Runtime headers/library at configure time; otherwise the deterministic adapter path is used. -- Signature verification currently uses descriptor-embedded `fnv1a64` checks (policy and signer lists are data-driven but not PKI-backed yet). -- Collaboration merge is deterministic and semantic for core session structures, but not yet CRDT-based real-time merge. - -## License - -AutoMixMaster is GPLv3 in OSS mode (`DISTRIBUTION_MODE=OSS`). - -Third-party dependencies include JUCE, nlohmann/json, Catch2, and libebur128 under their respective licenses. +
``` _____________________________________________________________________________ [ SYSTEM: AUTOMIXMASTER ] [ VERSION: 0.2.0 ] [ STATUS: OPERATIONAL ] _____________________________________________________________________________ _ __ __ _ __ __ _ /\ | | | \/ (_) | \/ | | | / \ _ _| |_ ___ | \ / |_ _ _| \ / | __ _ ___| |_ ___ _ __ / /\ \| | | | __/ _ \| |\/| | \ \/ / |\/| |/ _` / __| __/ _ \ '__| / ____ \ |_| | || (_) | | | | |> <| | | | (_| \__ \ || __/ | /_/ \_\__,_|\__\___/|_| |_|_/_/\_\_| |_|\__,_|____\__\___|_| ----------------------------------------------------------------------------- ``` Application Interface ``` ---------Automatic mixing and mastering for music stems--------- ```
--- ### [01] Introduction AutoMixMaster is an automated utility designed for amateur music producers and hobbyists to manage repetitive audio tasks. It provides a fixed algorithmic workflow for balancing levels and gain staging music stems. This application is built for personal experimentation, allowing users to process raw multi-track stems or AI-generated seeds (such as those from Udio or Suno) through set mathematical rules rather than manual mixing adjustments. --- ### [02] Functional Workflows **AI Seed Processing** Processes separated audio stems from AI generation platforms. The utility applies standard level balancing to help hobbyists hear their generations with consistent gain staging. **Songwriter Prototyping** A simple method for moving from raw multi-track recordings to a basic reference balance. It automates technical level adjustments so creators can listen back to their ideas without manual fader manipulation. **Bulk Folder Processing** Processes directories of audio files to a set loudness target. Useful for organizing personal catalogs or ensuring a collection of tracks shares a similar base volume level. --- ### [03] Feature Set * **Auto Mix**: Applies fixed algorithmic rules for level balancing and basic spatial positioning. * **Auto Master**: Performs automated gain staging and applies peak limiting to the final output. * **Batch Mode**: Sequentially processes multiple tracks or music folders. * **Analysis Tools**: Visual monitoring for LUFS (loudness) and peak measurements. --- ### [04] Installation Protocols #### Environment: Windows (Visual Studio 2026) 1. **Configuration**: `cmake -S . -B build -G "Visual Studio 18 2026" -A x64` 2. **Compilation**: `cmake --build build --config Release --parallel` #### Environment: Ubuntu Linux (24.04+) 1. **Dependencies**: `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` 2. **Compilation**: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel` --- ### [05] System Architecture and Licensing AutoMixMaster is distributed under the **GNU General Public License v3 (GPLv3)**. | 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 | ``` _____________________________________________________________________________ [ THIS APPLICATION IS A WORK IN PROGRESS ] _____________________________________________________________________________ ``` \ No newline at end of file diff --git a/assets/AutoMixMaster.jpg b/assets/AutoMixMaster.jpg new file mode 100644 index 0000000000000000000000000000000000000000..95e3e73d8051174986517fb689f36d8ba11648cf GIT binary patch literal 369585 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

$TLx1uK)}+}@L4tsQvT=yRAq@sZ>km>L`NugZF_ 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

  • v8UNGK5uq-7l@% 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

    6QEudce@uT>{t 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

    9oyOP0~A9gM#r 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 #include -#include #include #include "ai/FeatureSchema.h" +#include "util/HashUtils.h" namespace automix::ai { namespace { @@ -126,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/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/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/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/renderers/RendererRegistry.cpp b/src/renderers/RendererRegistry.cpp index 83d5833..1a43c82 100644 --- a/src/renderers/RendererRegistry.cpp +++ b/src/renderers/RendererRegistry.cpp @@ -5,7 +5,6 @@ #include #include #include -#include #include #include @@ -13,6 +12,7 @@ #include "renderers/ExternalLimiterRenderer.h" #include "renderers/PhaseLimiterDiscovery.h" +#include "util/HashUtils.h" namespace automix::renderers { namespace { @@ -28,22 +28,6 @@ bool hasBinary(const std::filesystem::path& path) { return std::filesystem::is_regular_file(path, error); } -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::vector trustPolicyCandidates() { std::vector candidates; std::error_code error; @@ -129,7 +113,7 @@ bool verifyDescriptorSignature(const nlohmann::json& descriptor, auto canonical = descriptor; canonical.erase("signature"); - const auto digest = toHex(fnv1a64(canonical.dump())); + const auto digest = util::toHex(util::fnv1a64(canonical.dump())); return digest == signatureValue; } 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/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/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/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"); +} From 67bdde9fe2a7bedcf81cf57c815a0f4846c3e963 Mon Sep 17 00:00:00 2001 From: Soficis Date: Tue, 17 Feb 2026 20:07:50 -0600 Subject: [PATCH 07/20] fix: normalize .gitignore formatting and keep docs ignored - restore multiline ignore formatting - keep `docs/` ignored in repository status --- .gitignore | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4affaba..762993c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,79 @@ -# === Build & Output === build*/ out/ bin/ _deps/ *.exe *.dll *.lib *.pdb *.ilk *.obj *.o *.exp artefacts/ *artefacts/ ntml docs/ # === CMake === CMakeCache.txt CMakeFiles/ cmake_install.cmake CTestTestfile.cmake DartConfiguration.tcl CMakeUserPresets.json # === IDE & Editors === .vs/ .vscode/ .idea/ *.vcxproj.user *.slnx.user *.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 # === Testing === Testing/ *.xml tests/regression/output/ # === Personal & Security === .env personal/ *.key *.pem .ssh/ config.ini \ No newline at end of file +# === Build & Output === +build*/ +out/ +bin/ +_deps/ +*.exe +*.dll +*.lib +*.pdb +*.ilk +*.obj +*.o +*.exp +artefacts/ +*artefacts/ +ntml +docs/ + +# === CMake === +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +CTestTestfile.cmake +DartConfiguration.tcl +CMakeUserPresets.json + +# === IDE & Editors === +.vs/ +.vscode/ +.idea/ +*.vcxproj.user +*.slnx.user +*.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 + +# === Testing === +Testing/ +*.xml +tests/regression/output/ + +# === Personal & Security === +.env +personal/ +*.key +*.pem +.ssh/ +config.ini From f9fd18571a2c89c3b48babee919b7256ed6b7557 Mon Sep 17 00:00:00 2001 From: Soficis Date: Tue, 17 Feb 2026 20:51:50 -0600 Subject: [PATCH 08/20] docs: restore README formatting from pre-rebase snapshot --- README.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 39553cb..5289cb2 100644 --- a/README.md +++ b/README.md @@ -1 +1,88 @@ -
    ``` _____________________________________________________________________________ [ SYSTEM: AUTOMIXMASTER ] [ VERSION: 0.2.0 ] [ STATUS: OPERATIONAL ] _____________________________________________________________________________ _ __ __ _ __ __ _ /\ | | | \/ (_) | \/ | | | / \ _ _| |_ ___ | \ / |_ _ _| \ / | __ _ ___| |_ ___ _ __ / /\ \| | | | __/ _ \| |\/| | \ \/ / |\/| |/ _` / __| __/ _ \ '__| / ____ \ |_| | || (_) | | | | |> <| | | | (_| \__ \ || __/ | /_/ \_\__,_|\__\___/|_| |_|_/_/\_\_| |_|\__,_|____\__\___|_| ----------------------------------------------------------------------------- ``` Application Interface ``` ---------Automatic mixing and mastering for music stems--------- ```
    --- ### [01] Introduction AutoMixMaster is an automated utility designed for amateur music producers and hobbyists to manage repetitive audio tasks. It provides a fixed algorithmic workflow for balancing levels and gain staging music stems. This application is built for personal experimentation, allowing users to process raw multi-track stems or AI-generated seeds (such as those from Udio or Suno) through set mathematical rules rather than manual mixing adjustments. --- ### [02] Functional Workflows **AI Seed Processing** Processes separated audio stems from AI generation platforms. The utility applies standard level balancing to help hobbyists hear their generations with consistent gain staging. **Songwriter Prototyping** A simple method for moving from raw multi-track recordings to a basic reference balance. It automates technical level adjustments so creators can listen back to their ideas without manual fader manipulation. **Bulk Folder Processing** Processes directories of audio files to a set loudness target. Useful for organizing personal catalogs or ensuring a collection of tracks shares a similar base volume level. --- ### [03] Feature Set * **Auto Mix**: Applies fixed algorithmic rules for level balancing and basic spatial positioning. * **Auto Master**: Performs automated gain staging and applies peak limiting to the final output. * **Batch Mode**: Sequentially processes multiple tracks or music folders. * **Analysis Tools**: Visual monitoring for LUFS (loudness) and peak measurements. --- ### [04] Installation Protocols #### Environment: Windows (Visual Studio 2026) 1. **Configuration**: `cmake -S . -B build -G "Visual Studio 18 2026" -A x64` 2. **Compilation**: `cmake --build build --config Release --parallel` #### Environment: Ubuntu Linux (24.04+) 1. **Dependencies**: `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` 2. **Compilation**: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel` --- ### [05] System Architecture and Licensing AutoMixMaster is distributed under the **GNU General Public License v3 (GPLv3)**. | 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 | ``` _____________________________________________________________________________ [ THIS APPLICATION IS A WORK IN PROGRESS ] _____________________________________________________________________________ ``` \ No newline at end of file +
    + +``` + _____________________________________________________________________________ + [ SYSTEM: AUTOMIXMASTER ] [ VERSION: 0.2.0 ] [ STATUS: OPERATIONAL ] + _____________________________________________________________________________ + + _ __ __ _ __ __ _ + /\ | | | \/ (_) | \/ | | | + / \ _ _| |_ ___ | \ / |_ _ _| \ / | __ _ ___| |_ ___ _ __ + / /\ \| | | | __/ _ \| |\/| | \ \/ / |\/| |/ _` / __| __/ _ \ '__| + / ____ \ |_| | || (_) | | | | |> <| | | | (_| \__ \ || __/ | + /_/ \_\__,_|\__\___/|_| |_|_/_/\_\_| |_|\__,_|____\__\___|_| + + ----------------------------------------------------------------------------- +``` + +Application Interface + +``` +---------Automatic mixing and mastering for music stems--------- +``` + +
    + +--- + +### [01] Introduction + +AutoMixMaster is an automated utility designed for amateur music producers and hobbyists to manage repetitive audio tasks. It provides a fixed algorithmic workflow for balancing levels and gain staging music stems. + +This application is built for personal experimentation, allowing users to process raw multi-track stems or AI-generated seeds (such as those from Udio or Suno) through set mathematical rules rather than manual mixing adjustments. + +--- + +### [02] Functional Workflows + +**AI Seed Processing** +Processes separated audio stems from AI generation platforms. The utility applies standard level balancing to help hobbyists hear their generations with consistent gain staging. + +**Songwriter Prototyping** +A simple method for moving from raw multi-track recordings to a basic reference balance. It automates technical level adjustments so creators can listen back to their ideas without manual fader manipulation. + +**Bulk Folder Processing** +Processes directories of audio files to a set loudness target. Useful for organizing personal catalogs or ensuring a collection of tracks shares a similar base volume level. + +--- + +### [03] Feature Set + +* **Auto Mix**: Applies fixed algorithmic rules for level balancing and basic spatial positioning. +* **Auto Master**: Performs automated gain staging and applies peak limiting to the final output. +* **Batch Mode**: Sequentially processes multiple tracks or music folders. +* **Analysis Tools**: Visual monitoring for LUFS (loudness) and peak measurements. + +--- + +### [04] Installation Protocols + +#### Environment: Windows (Visual Studio 2026) + +1. **Configuration**: `cmake -S . -B build -G "Visual Studio 18 2026" -A x64` +2. **Compilation**: `cmake --build build --config Release --parallel` + +#### Environment: Ubuntu Linux (24.04+) + +1. **Dependencies**: `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` +2. **Compilation**: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel` + +--- + +### [05] System Architecture and Licensing + +AutoMixMaster is distributed under the **GNU General Public License v3 (GPLv3)**. + +| 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 | + +``` + _____________________________________________________________________________ + [ THIS APPLICATION IS A WORK IN PROGRESS ] + _____________________________________________________________________________ +``` From 701342da575d8bdbbc2ed8205b8a55404938d632 Mon Sep 17 00:00:00 2001 From: Soficis Date: Tue, 17 Feb 2026 21:07:49 -0600 Subject: [PATCH 09/20] docs: refresh README visual layout and structure --- README.md | 130 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 86 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index 5289cb2..a26dd58 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,130 @@
    ``` - _____________________________________________________________________________ + ______________________________________________________________________________ [ SYSTEM: AUTOMIXMASTER ] [ VERSION: 0.2.0 ] [ STATUS: OPERATIONAL ] - _____________________________________________________________________________ + ______________________________________________________________________________ - _ __ __ _ __ __ _ - /\ | | | \/ (_) | \/ | | | - / \ _ _| |_ ___ | \ / |_ _ _| \ / | __ _ ___| |_ ___ _ __ - / /\ \| | | | __/ _ \| |\/| | \ \/ / |\/| |/ _` / __| __/ _ \ '__| - / ____ \ |_| | || (_) | | | | |> <| | | | (_| \__ \ || __/ | - /_/ \_\__,_|\__\___/|_| |_|_/_/\_\_| |_|\__,_|____\__\___|_| + _ __ __ _ __ __ _ + /\ | | | \/ (_) | \/ | | | + / \ _ _| |_ ___ | \ / |_ _ _| \ / | __ _ ___| |_ ___ _ __ + / /\ \| | | | __/ _ \| |\/| | \ \/ / |\/| |/ _` / __| __/ _ \ '__| + / ____ \ |_| | || (_) | | | | |> <| | | | (_| \__ \ || __/ | + /_/ \_\__,_|\__\___/|_| |_|_/_/\_\_| |_|\__,_|___/\__\___|_| - ----------------------------------------------------------------------------- + ------------------------------------------------------------------------------ ``` -Application Interface +AutoMixMaster application interface ``` ----------Automatic mixing and mastering for music stems--------- + -------- FIXED-RULE AUDIO WORKFLOW FOR MIXING AND MASTERING MUSIC STEMS -------- ``` +**Designed for amateur music producers and hobbyists** +
    +
    + --- -### [01] Introduction +## Overview -AutoMixMaster is an automated utility designed for amateur music producers and hobbyists to manage repetitive audio tasks. It provides a fixed algorithmic workflow for balancing levels and gain staging music stems. +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. -This application is built for personal experimentation, allowing users to process raw multi-track stems or AI-generated seeds (such as those from Udio or Suno) through set mathematical rules rather than manual mixing adjustments. +The project is aimed at personal experimentation and iteration speed, especially for creators working with raw multitrack material or AI-generated stems. --- -### [02] Functional Workflows +## Workflows -**AI Seed Processing** -Processes separated audio stems from AI generation platforms. The utility applies standard level balancing to help hobbyists hear their generations with consistent gain staging. +| 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 | + +--- -**Songwriter Prototyping** -A simple method for moving from raw multi-track recordings to a basic reference balance. It automates technical level adjustments so creators can listen back to their ideas without manual fader manipulation. +## Feature Set -**Bulk Folder Processing** -Processes directories of audio files to a set loudness target. Useful for organizing personal catalogs or ensuring a collection of tracks shares a similar base volume level. +| 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 | --- -### [03] Feature Set +## Build + Install -* **Auto Mix**: Applies fixed algorithmic rules for level balancing and basic spatial positioning. -* **Auto Master**: Performs automated gain staging and applies peak limiting to the final output. -* **Batch Mode**: Sequentially processes multiple tracks or music folders. -* **Analysis Tools**: Visual monitoring for LUFS (loudness) and peak measurements. +### Windows (Visual Studio 2026) ---- +1. Configure + +```bash +cmake -S . -B build -G "Visual Studio 18 2026" -A x64 +``` + +2. Build + +```bash +cmake --build build --config Release --parallel +``` + +### Ubuntu Linux (24.04+) -### [04] Installation Protocols +1. Install dependencies -#### Environment: Windows (Visual Studio 2026) +```bash +sudo apt-get install -y \ + libasound2-dev libfreetype6-dev libx11-dev libxcomposite-dev \ + libxcursor-dev libxext-dev libxinerama-dev libxrandr-dev \ + libxrender-dev libwebkit2gtk-4.0-dev libglu1-mesa-dev mesa-common-dev +``` -1. **Configuration**: `cmake -S . -B build -G "Visual Studio 18 2026" -A x64` -2. **Compilation**: `cmake --build build --config Release --parallel` +2. Configure + build + +```bash +cmake -S . -B build -DCMAKE_BUILD_TYPE=Release +cmake --build build --parallel +``` -#### Environment: Ubuntu Linux (24.04+) +3. Run tests (optional but recommended) -1. **Dependencies**: `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` -2. **Compilation**: `cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build --parallel` +```bash +ctest --test-dir build --output-on-failure +``` --- -### [05] System Architecture and Licensing +## Licensing -AutoMixMaster is distributed under the **GNU General Public License v3 (GPLv3)**. +AutoMixMaster is distributed under **GNU General Public License v3 (GPLv3)**. | 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 | +| 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 | + +
    ``` - _____________________________________________________________________________ - [ THIS APPLICATION IS A WORK IN PROGRESS ] - _____________________________________________________________________________ + ______________________________________________________________________________ + [ THIS APPLICATION IS A WORK IN PROGRESS ] + ______________________________________________________________________________ ``` + +
    From 30afb78e565dcff78a8cf2338cbffed1399a3e0e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:48:40 +0000 Subject: [PATCH 10/20] Initial plan From 03b5119ef4619b325474dfd6913fe957f159fc38 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:49:09 +0000 Subject: [PATCH 11/20] Initial plan From 66083f29c42002b6050f048bebfd0cb3d99ee72a Mon Sep 17 00:00:00 2001 From: soficis <107279009+soficis@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:49:20 -0600 Subject: [PATCH 12/20] Update src/ai/ModelManager.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/ai/ModelManager.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ai/ModelManager.cpp b/src/ai/ModelManager.cpp index 303372b..83c1c87 100644 --- a/src/ai/ModelManager.cpp +++ b/src/ai/ModelManager.cpp @@ -31,15 +31,16 @@ std::vector parseEnvRoots() { return roots; } + constexpr char kPathDelimiter = #if defined(_WIN32) - constexpr char delimiter = ';'; + ';'; #else - constexpr char delimiter = ':'; + ':'; #endif std::stringstream stream(raw); std::string token; - while (std::getline(stream, token, delimiter)) { + while (std::getline(stream, token, kPathDelimiter)) { if (!token.empty()) { roots.emplace_back(token); } From 3a11196ce204bb264b050724ca3cfa1bc9aa33ca Mon Sep 17 00:00:00 2001 From: soficis <107279009+soficis@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:50:17 -0600 Subject: [PATCH 13/20] Update src/analysis/ArtifactRiskEstimator.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/analysis/ArtifactRiskEstimator.cpp | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/analysis/ArtifactRiskEstimator.cpp b/src/analysis/ArtifactRiskEstimator.cpp index f781f59..91c1f72 100644 --- a/src/analysis/ArtifactRiskEstimator.cpp +++ b/src/analysis/ArtifactRiskEstimator.cpp @@ -45,8 +45,21 @@ ArtifactProfile ArtifactRiskEstimator::profile(const engine::AudioBuffer& buffer 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)); - const double noiseDominance = clamp01(metrics.highEnergy * 0.30 + spectralFlatness * 0.35 + normalizedRoughness * 0.15 + - normalizedFluxInstability * 0.10 + spectralFluxNorm * 0.10); + + // 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; + + 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); From cb118d544b197dcf5bc3e14813b95a78d9bcc72c Mon Sep 17 00:00:00 2001 From: soficis <107279009+soficis@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:51:49 -0600 Subject: [PATCH 14/20] Update src/ai/MasteringCompliance.cpp Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/ai/MasteringCompliance.cpp | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ai/MasteringCompliance.cpp b/src/ai/MasteringCompliance.cpp index cb566d8..6e0603a 100644 --- a/src/ai/MasteringCompliance.cpp +++ b/src/ai/MasteringCompliance.cpp @@ -8,7 +8,14 @@ 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); } From 9cf3cdf652a2a8b4e83e1c4b9e20b0edc9edd317 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:51:55 +0000 Subject: [PATCH 15/20] Define named constants for preferredStemCount limits with documentation Co-authored-by: soficis <107279009+soficis@users.noreply.github.com> --- src/domain/JsonSerialization.cpp | 4 +++- src/domain/ProjectProfile.cpp | 2 +- src/domain/ProjectProfile.h | 6 ++++++ tools/commands/DevToolsUtils.cpp | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/domain/JsonSerialization.cpp b/src/domain/JsonSerialization.cpp index ca354f5..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) { @@ -293,7 +295,7 @@ void from_json(const Json& j, Session& value) { 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), 2, 6); + 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); diff --git a/src/domain/ProjectProfile.cpp b/src/domain/ProjectProfile.cpp index efb2680..c6ad4c7 100644 --- a/src/domain/ProjectProfile.cpp +++ b/src/domain/ProjectProfile.cpp @@ -24,7 +24,7 @@ ProjectProfile profileFromJson(const nlohmann::json& json) { 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), 2, 6); + 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" && diff --git a/src/domain/ProjectProfile.h b/src/domain/ProjectProfile.h index 638a2f1..501dc0f 100644 --- a/src/domain/ProjectProfile.h +++ b/src/domain/ProjectProfile.h @@ -8,6 +8,12 @@ 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; diff --git a/tools/commands/DevToolsUtils.cpp b/tools/commands/DevToolsUtils.cpp index 9659027..86a03c7 100644 --- a/tools/commands/DevToolsUtils.cpp +++ b/tools/commands/DevToolsUtils.cpp @@ -693,7 +693,7 @@ std::optional projectProfileFromJson(const nloh 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), 2, 6); + 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" && From f1182f8c32e1fc2c44cefec3ae856a05b10a70a2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:52:20 +0000 Subject: [PATCH 16/20] Initial plan From 2eec35b2e8ae6057e3d5df1b39c0eba4a82790e4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:52:32 +0000 Subject: [PATCH 17/20] Initial plan From c797662711961f2d1bc3235a587355997ba3105c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:53:03 +0000 Subject: [PATCH 18/20] feat: implement semantic version comparison for FeatureSchemaV1 Co-authored-by: soficis <107279009+soficis@users.noreply.github.com> --- src/ai/FeatureSchema.cpp | 77 ++++++++++++++++++++++++++++++++- tests/unit/AiExtensionTests.cpp | 34 +++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/src/ai/FeatureSchema.cpp b/src/ai/FeatureSchema.cpp index aa661c8..5f6c6c3 100644 --- a/src/ai/FeatureSchema.cpp +++ b/src/ai/FeatureSchema.cpp @@ -9,6 +9,55 @@ double vectorValueOrDefault(const std::vector& values, const size_t inde 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) { + version.major = std::stoi(versionStr); + version.valid = true; + 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) { + version.minor = std::stoi(versionStr.substr(pos)); + version.valid = true; + 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() { @@ -83,7 +132,33 @@ const std::vector& FeatureSchemaV1::names() { return kNames; } -bool FeatureSchemaV1::isCompatible(const std::string& version) { return version == kVersion; } +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(); } diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index 485d11d..3d32738 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -161,6 +161,40 @@ 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 handled + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1")); + REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0")); + REQUIRE(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(); From 260c47060b26e9932d99594d498ee82a8afdbe0d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:55:20 +0000 Subject: [PATCH 19/20] Format JSON metadata in AiExtensionTests.cpp for better readability Co-authored-by: soficis <107279009+soficis@users.noreply.github.com> --- tests/unit/AiExtensionTests.cpp | 48 ++++++++++++++++++++++++++++++--- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index 485d11d..461f233 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -53,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","license":"MIT","source":"unit-test","feature_schema_version":"1.0.0","output_schema":{"target_lufs":"float"}})"; + 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); @@ -76,13 +90,33 @@ 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","license":"MIT","source":"unit-test","feature_schema_version":"1.0.0","output_schema":{"prob_vocals":"float"}})"; + 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","license":"MIT","source":"unit-test","feature_schema_version":"1.0.0","output_schema":{"target_lufs":"float"}})"; + 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); @@ -114,7 +148,13 @@ TEST_CASE("Model pack loader rejects packs missing licensing metadata", "[ai]") 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"})"; + 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; From 679756d5b4c25ceeec74aab6d6183c26ae666b49 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 03:55:23 +0000 Subject: [PATCH 20/20] fix: require strict semver format (major.minor.patch) and reject partial versions Co-authored-by: soficis <107279009+soficis@users.noreply.github.com> --- src/ai/FeatureSchema.cpp | 6 ++---- tests/unit/AiExtensionTests.cpp | 8 ++++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/ai/FeatureSchema.cpp b/src/ai/FeatureSchema.cpp index 5f6c6c3..05e0a17 100644 --- a/src/ai/FeatureSchema.cpp +++ b/src/ai/FeatureSchema.cpp @@ -29,8 +29,7 @@ SemanticVersion parseVersion(const std::string& versionStr) { try { // Parse major version if (dotPos == std::string::npos) { - version.major = std::stoi(versionStr); - version.valid = true; + // Partial version with only major - not valid for strict semver return version; } @@ -40,8 +39,7 @@ SemanticVersion parseVersion(const std::string& versionStr) { // Parse minor version dotPos = versionStr.find('.', pos); if (dotPos == std::string::npos) { - version.minor = std::stoi(versionStr.substr(pos)); - version.valid = true; + // Partial version with major.minor only - not valid for strict semver return version; } diff --git a/tests/unit/AiExtensionTests.cpp b/tests/unit/AiExtensionTests.cpp index 3d32738..d0fd135 100644 --- a/tests/unit/AiExtensionTests.cpp +++ b/tests/unit/AiExtensionTests.cpp @@ -187,10 +187,10 @@ TEST_CASE("Feature schema version compatibility uses semantic versioning", "[ai] REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("1.x.0")); REQUIRE_FALSE(automix::ai::FeatureSchemaV1::isCompatible("a.b.c")); - // Partial versions (missing components) should be handled - REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1")); - REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.0")); - REQUIRE(automix::ai::FeatureSchemaV1::isCompatible("1.1")); + // 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")); }

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